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
146
apps/examples/e2e/baselines/fps-baselines.json
Normal file
@@ -0,0 +1,146 @@
|
||||
{
|
||||
"baselines": {
|
||||
"linux-1280x720": {
|
||||
"rotate_shapes": {
|
||||
"avgFps": 25,
|
||||
"minFps": 8,
|
||||
"maxFps": 58,
|
||||
"timestamp": "2025-12-06T22:18:16.000Z",
|
||||
"environment": {
|
||||
"platform": "linux",
|
||||
"viewport": "1280x720",
|
||||
"browser": "chromium"
|
||||
}
|
||||
},
|
||||
"drag_shapes": {
|
||||
"avgFps": 54,
|
||||
"minFps": 26,
|
||||
"maxFps": 69,
|
||||
"timestamp": "2025-07-21T14:28:04.232Z",
|
||||
"environment": {
|
||||
"platform": "linux",
|
||||
"viewport": "1280x720",
|
||||
"browser": "chromium"
|
||||
}
|
||||
},
|
||||
"resize_shapes": {
|
||||
"avgFps": 22,
|
||||
"minFps": 18,
|
||||
"maxFps": 67,
|
||||
"timestamp": "2025-07-21T14:28:23.924Z",
|
||||
"environment": {
|
||||
"platform": "linux",
|
||||
"viewport": "1280x720",
|
||||
"browser": "chromium"
|
||||
}
|
||||
},
|
||||
"canvas_panning": {
|
||||
"avgFps": 38,
|
||||
"minFps": 28,
|
||||
"maxFps": 70,
|
||||
"timestamp": "2025-07-21T14:28:13.302Z",
|
||||
"environment": {
|
||||
"platform": "linux",
|
||||
"viewport": "1280x720",
|
||||
"browser": "chromium"
|
||||
}
|
||||
},
|
||||
"canvas_zooming": {
|
||||
"avgFps": 41,
|
||||
"minFps": 19,
|
||||
"maxFps": 67,
|
||||
"timestamp": "2025-07-21T14:28:23.924Z",
|
||||
"environment": {
|
||||
"platform": "linux",
|
||||
"viewport": "1280x720",
|
||||
"browser": "chromium"
|
||||
}
|
||||
}
|
||||
},
|
||||
"darwin-1280x720": {
|
||||
"rotate_shapes": {
|
||||
"avgFps": 60,
|
||||
"minFps": 47,
|
||||
"maxFps": 65,
|
||||
"timestamp": "2025-11-26T22:53:39.487Z",
|
||||
"environment": {
|
||||
"platform": "darwin",
|
||||
"viewport": "1280x720",
|
||||
"browser": "chromium"
|
||||
}
|
||||
},
|
||||
"drag_shapes": {
|
||||
"avgFps": 60,
|
||||
"minFps": 48,
|
||||
"maxFps": 67,
|
||||
"timestamp": "2025-11-26T22:53:50.324Z",
|
||||
"environment": {
|
||||
"platform": "darwin",
|
||||
"viewport": "1280x720",
|
||||
"browser": "chromium"
|
||||
}
|
||||
},
|
||||
"resize_shapes": {
|
||||
"avgFps": 58,
|
||||
"minFps": 49,
|
||||
"maxFps": 71,
|
||||
"timestamp": "2025-11-26T22:54:01.975Z",
|
||||
"environment": {
|
||||
"platform": "darwin",
|
||||
"viewport": "1280x720",
|
||||
"browser": "chromium"
|
||||
}
|
||||
},
|
||||
"canvas_panning": {
|
||||
"avgFps": 60,
|
||||
"minFps": 49,
|
||||
"maxFps": 70,
|
||||
"timestamp": "2025-11-26T22:54:09.322Z",
|
||||
"environment": {
|
||||
"platform": "darwin",
|
||||
"viewport": "1280x720",
|
||||
"browser": "chromium"
|
||||
}
|
||||
},
|
||||
"canvas_zooming": {
|
||||
"avgFps": 59,
|
||||
"minFps": 35,
|
||||
"maxFps": 63,
|
||||
"timestamp": "2025-11-26T22:54:23.386Z",
|
||||
"environment": {
|
||||
"platform": "darwin",
|
||||
"viewport": "1280x720",
|
||||
"browser": "chromium"
|
||||
}
|
||||
},
|
||||
"create_text_shapes": {
|
||||
"avgFps": 117,
|
||||
"minFps": 72,
|
||||
"maxFps": 137,
|
||||
"timestamp": "2026-06-22T09:19:08.333Z",
|
||||
"environment": {
|
||||
"platform": "darwin",
|
||||
"viewport": "1280x720",
|
||||
"browser": "chromium"
|
||||
}
|
||||
}
|
||||
},
|
||||
"darwin-393x727": {
|
||||
"drag_shapes": {
|
||||
"avgFps": 60,
|
||||
"minFps": 58,
|
||||
"maxFps": 69,
|
||||
"timestamp": "2025-11-26T22:54:24.557Z",
|
||||
"environment": {
|
||||
"platform": "darwin",
|
||||
"viewport": "393x727",
|
||||
"browser": "chromium"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"lastUpdated": "2026-06-22T09:19:08.334Z",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
}
|
||||
178
apps/examples/e2e/fixtures/analytics-reporter.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { PerformanceTestResult } from './perf-utils'
|
||||
|
||||
export interface AnalyticsConfig {
|
||||
enabled: boolean
|
||||
projectKey?: string
|
||||
apiHost?: string
|
||||
}
|
||||
|
||||
export class PerformanceAnalyticsReporter {
|
||||
private config: AnalyticsConfig
|
||||
|
||||
constructor(config: AnalyticsConfig = { enabled: false }) {
|
||||
this.config = config
|
||||
}
|
||||
|
||||
async reportPerformanceMetrics(
|
||||
results: PerformanceTestResult[],
|
||||
testContext: {
|
||||
testName: string
|
||||
timestamp: string
|
||||
gitCommit?: string
|
||||
branch?: string
|
||||
ciEnvironment?: string
|
||||
}
|
||||
): Promise<void> {
|
||||
if (!this.config.enabled) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
'Analytics reporting disabled - would have sent:',
|
||||
results.length,
|
||||
'performance results'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = this.formatPayload(results, testContext)
|
||||
// See https://eu.posthog.com/project/45972/insights/ZdpGyj1r
|
||||
await this.sendToPostHog(payload)
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Sent ${results.length} performance metrics to PostHog`)
|
||||
} catch (error) {
|
||||
console.error('Failed to send performance metrics:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private formatPayload(results: PerformanceTestResult[], testContext: any) {
|
||||
return results.map((result) => ({
|
||||
event: 'performance_test_result',
|
||||
properties: {
|
||||
// Test identification
|
||||
test_name: testContext.testName,
|
||||
interaction: result.interaction,
|
||||
timestamp: testContext.timestamp,
|
||||
|
||||
// Environment
|
||||
platform: result.environment.platform,
|
||||
viewport: result.environment.viewport,
|
||||
browser: result.environment.browser,
|
||||
|
||||
// Performance metrics
|
||||
avg_fps: result.metrics.averageFps,
|
||||
min_fps: result.metrics.minFps,
|
||||
max_fps: result.metrics.maxFps,
|
||||
total_frames: result.metrics.totalFrames,
|
||||
duration_seconds: result.metrics.duration,
|
||||
sample_count: result.metrics.samples.length,
|
||||
|
||||
// Comparison results
|
||||
baseline_avg_fps: result.comparison.baseline.avgFps,
|
||||
fps_change_percent: result.comparison.avgFpsChange,
|
||||
is_regression: result.comparison.isRegression,
|
||||
is_improvement: result.comparison.isImprovement,
|
||||
status: result.comparison.status,
|
||||
|
||||
// CI/Git context
|
||||
git_commit: testContext.gitCommit,
|
||||
git_branch: testContext.branch,
|
||||
ci_environment: testContext.ciEnvironment,
|
||||
|
||||
// Additional metadata
|
||||
fps_samples: result.metrics.samples.slice(0, 10), // First 10 samples for analysis
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
private async sendToPostHog(events: any[]): Promise<void> {
|
||||
if (!this.config.projectKey) {
|
||||
throw new Error('PostHog project key not configured')
|
||||
}
|
||||
|
||||
const url = `${this.config.apiHost || 'https://analytics.tldraw.com'}/capture/`
|
||||
|
||||
for (const event of events) {
|
||||
const payload = {
|
||||
api_key: this.config.projectKey,
|
||||
event: event.event,
|
||||
properties: {
|
||||
...event.properties,
|
||||
distinct_id: `e2e-test-${Date.now()}`, // Anonymous test identifier
|
||||
$lib: 'tldraw-e2e-perf',
|
||||
$lib_version: '1.0.0',
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`PostHog API error: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async reportRegressionAlert(result: PerformanceTestResult, testContext: any): Promise<void> {
|
||||
if (!this.config.enabled || result.comparison.status !== 'fail') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const alertPayload = {
|
||||
event: 'performance_regression_alert',
|
||||
properties: {
|
||||
test_name: testContext.testName,
|
||||
interaction: result.interaction,
|
||||
avg_fps_drop: Math.abs(result.comparison.avgFpsChange),
|
||||
current_fps: result.metrics.averageFps,
|
||||
baseline_fps: result.comparison.baseline.avgFps,
|
||||
platform: result.environment.platform,
|
||||
browser: result.environment.browser,
|
||||
timestamp: testContext.timestamp,
|
||||
git_commit: testContext.gitCommit,
|
||||
git_branch: testContext.branch,
|
||||
alert_message: result.comparison.message,
|
||||
severity: result.comparison.avgFpsChange < -20 ? 'high' : 'medium',
|
||||
},
|
||||
}
|
||||
|
||||
await this.sendToPostHog([alertPayload])
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Sent performance regression alert for ${result.interaction}`)
|
||||
} catch (error) {
|
||||
console.error('Failed to send regression alert:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Factory function to create analytics reporter with environment-based config
|
||||
export function createAnalyticsReporter(): PerformanceAnalyticsReporter {
|
||||
const config: AnalyticsConfig = {
|
||||
enabled: process.env.PERFORMANCE_ANALYTICS_ENABLED === 'true',
|
||||
projectKey: 'phc_i8oKgMzgV38sn3GfjswW9mevQ3gFlo7bJXekZFeDN6',
|
||||
apiHost: process.env.POSTHOG_API_HOST || 'https://analytics.tldraw.com/ingest',
|
||||
}
|
||||
|
||||
return new PerformanceAnalyticsReporter(config)
|
||||
}
|
||||
|
||||
// Helper to collect test context from environment
|
||||
export function getTestContext(testName: string) {
|
||||
return {
|
||||
testName,
|
||||
timestamp: new Date().toISOString(),
|
||||
gitCommit: process.env.GIT_COMMIT || process.env.GITHUB_SHA,
|
||||
branch: process.env.GIT_BRANCH || process.env.GITHUB_REF_NAME,
|
||||
ciEnvironment: process.env.CI
|
||||
? process.env.GITHUB_ACTIONS
|
||||
? 'github-actions'
|
||||
: 'ci'
|
||||
: 'local',
|
||||
}
|
||||
}
|
||||
261
apps/examples/e2e/fixtures/baseline-manager.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { FPSMetrics } from './fps-tracker'
|
||||
|
||||
export interface PerformanceBaseline {
|
||||
avgFps: number
|
||||
minFps: number
|
||||
maxFps: number
|
||||
timestamp: string
|
||||
environment: {
|
||||
platform: string
|
||||
viewport: string
|
||||
browser: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface BaselineData {
|
||||
baselines: {
|
||||
[platform: string]: {
|
||||
[interaction: string]: PerformanceBaseline
|
||||
}
|
||||
}
|
||||
metadata: {
|
||||
lastUpdated: string
|
||||
version: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface ComparisonResult {
|
||||
interaction: string
|
||||
current: FPSMetrics
|
||||
baseline: PerformanceBaseline
|
||||
avgFpsChange: number // Percentage change
|
||||
minFpsChange: number
|
||||
isRegression: boolean
|
||||
isImprovement: boolean
|
||||
status: 'pass' | 'warning' | 'fail'
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface Environment {
|
||||
platform: string
|
||||
viewport: string
|
||||
browser: string
|
||||
}
|
||||
|
||||
const __dirname = path.dirname(new URL(import.meta.url).pathname)
|
||||
|
||||
export class BaselineManager {
|
||||
private baselineFilePath: string
|
||||
private regressionThreshold = 15 // Percentage
|
||||
private warningThreshold = 10 // Percentage
|
||||
|
||||
constructor(baselineDir = path.join(__dirname, '..', 'baselines')) {
|
||||
// Ensure baseline directory exists
|
||||
if (!fs.existsSync(baselineDir)) {
|
||||
fs.mkdirSync(baselineDir, { recursive: true })
|
||||
}
|
||||
this.baselineFilePath = path.join(baselineDir, 'fps-baselines.json')
|
||||
}
|
||||
|
||||
private loadBaselines(): BaselineData {
|
||||
if (!fs.existsSync(this.baselineFilePath)) {
|
||||
return {
|
||||
baselines: {},
|
||||
metadata: {
|
||||
lastUpdated: new Date().toISOString(),
|
||||
version: '1.0.0',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(this.baselineFilePath, 'utf-8')
|
||||
return JSON.parse(content)
|
||||
} catch (error) {
|
||||
console.warn('Failed to load baseline file, creating new one:', error)
|
||||
return {
|
||||
baselines: {},
|
||||
metadata: {
|
||||
lastUpdated: new Date().toISOString(),
|
||||
version: '1.0.0',
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private saveBaselines(data: BaselineData): void {
|
||||
try {
|
||||
const content = JSON.stringify(data, null, 2)
|
||||
fs.writeFileSync(this.baselineFilePath, content, 'utf-8')
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to save baselines: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
private getPlatformKey(env: Environment): string {
|
||||
return `${env.platform}-${env.viewport}`
|
||||
}
|
||||
|
||||
private calculatePercentageChange(current: number, baseline: number): number {
|
||||
if (baseline === 0) return 0
|
||||
return ((current - baseline) / baseline) * 100
|
||||
}
|
||||
|
||||
updateBaseline(
|
||||
interaction: string,
|
||||
metrics: FPSMetrics,
|
||||
environment: Environment,
|
||||
force = false
|
||||
): void {
|
||||
const data = this.loadBaselines()
|
||||
const platformKey = this.getPlatformKey(environment)
|
||||
|
||||
if (!data.baselines[platformKey]) {
|
||||
data.baselines[platformKey] = {}
|
||||
}
|
||||
|
||||
const existingBaseline = data.baselines[platformKey][interaction]
|
||||
const newBaseline: PerformanceBaseline = {
|
||||
avgFps: metrics.averageFps,
|
||||
minFps: metrics.minFps,
|
||||
maxFps: metrics.maxFps,
|
||||
timestamp: new Date().toISOString(),
|
||||
environment,
|
||||
}
|
||||
|
||||
// Only update if it's an improvement, forced, or no existing baseline
|
||||
if (!existingBaseline || force || metrics.averageFps > existingBaseline.avgFps) {
|
||||
data.baselines[platformKey][interaction] = newBaseline
|
||||
data.metadata.lastUpdated = new Date().toISOString()
|
||||
this.saveBaselines(data)
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Updated baseline for ${interaction} on ${platformKey}:`, newBaseline)
|
||||
}
|
||||
}
|
||||
|
||||
compareWithBaseline(
|
||||
interaction: string,
|
||||
metrics: FPSMetrics,
|
||||
environment: Environment,
|
||||
autoCreateBaseline = true
|
||||
): ComparisonResult {
|
||||
const data = this.loadBaselines()
|
||||
const platformKey = this.getPlatformKey(environment)
|
||||
const baseline = data.baselines[platformKey]?.[interaction]
|
||||
|
||||
if (!baseline) {
|
||||
if (autoCreateBaseline) {
|
||||
// Automatically create baseline on first run
|
||||
this.updateBaseline(interaction, metrics, environment, true)
|
||||
return {
|
||||
interaction,
|
||||
current: metrics,
|
||||
baseline: {
|
||||
avgFps: metrics.averageFps,
|
||||
minFps: metrics.minFps,
|
||||
maxFps: metrics.maxFps,
|
||||
timestamp: new Date().toISOString(),
|
||||
environment,
|
||||
},
|
||||
avgFpsChange: 0,
|
||||
minFpsChange: 0,
|
||||
isRegression: false,
|
||||
isImprovement: false,
|
||||
status: 'pass',
|
||||
message: `New baseline created for ${interaction} on ${platformKey}. Average FPS: ${metrics.averageFps}`,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
interaction,
|
||||
current: metrics,
|
||||
baseline: {
|
||||
avgFps: 0,
|
||||
minFps: 0,
|
||||
maxFps: 0,
|
||||
timestamp: '',
|
||||
environment,
|
||||
},
|
||||
avgFpsChange: 0,
|
||||
minFpsChange: 0,
|
||||
isRegression: false,
|
||||
isImprovement: false,
|
||||
status: 'warning',
|
||||
message: `No baseline found for ${interaction} on ${platformKey}. Consider setting a baseline first.`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const avgFpsChange = this.calculatePercentageChange(metrics.averageFps, baseline.avgFps)
|
||||
const minFpsChange = this.calculatePercentageChange(metrics.minFps, baseline.minFps)
|
||||
|
||||
const isRegression = avgFpsChange < -this.regressionThreshold
|
||||
const isImprovement = avgFpsChange > this.warningThreshold
|
||||
|
||||
let status: 'pass' | 'warning' | 'fail' = 'pass'
|
||||
let message = `Performance stable. Average FPS: ${metrics.averageFps} (${avgFpsChange.toFixed(1)}% change)`
|
||||
|
||||
if (isRegression) {
|
||||
status = 'fail'
|
||||
message = `Performance regression detected! Average FPS dropped by ${Math.abs(avgFpsChange).toFixed(1)}% (${baseline.avgFps} → ${metrics.averageFps})`
|
||||
} else if (isImprovement) {
|
||||
status = 'warning' // Warning to indicate a significant change that might warrant baseline update
|
||||
message = `Performance improvement detected! Average FPS improved by ${avgFpsChange.toFixed(1)}% (${baseline.avgFps} → ${metrics.averageFps}). Consider updating baseline.`
|
||||
}
|
||||
|
||||
return {
|
||||
interaction,
|
||||
current: metrics,
|
||||
baseline,
|
||||
avgFpsChange,
|
||||
minFpsChange,
|
||||
isRegression,
|
||||
isImprovement,
|
||||
status,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
getAllBaselines(): BaselineData {
|
||||
return this.loadBaselines()
|
||||
}
|
||||
|
||||
getBaseline(interaction: string, environment: Environment): PerformanceBaseline | null {
|
||||
const data = this.loadBaselines()
|
||||
const platformKey = this.getPlatformKey(environment)
|
||||
return data.baselines[platformKey]?.[interaction] || null
|
||||
}
|
||||
|
||||
deleteBaseline(interaction: string, environment: Environment): boolean {
|
||||
const data = this.loadBaselines()
|
||||
const platformKey = this.getPlatformKey(environment)
|
||||
|
||||
if (data.baselines[platformKey]?.[interaction]) {
|
||||
delete data.baselines[platformKey][interaction]
|
||||
data.metadata.lastUpdated = new Date().toISOString()
|
||||
this.saveBaselines(data)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
setThresholds(regressionThreshold: number, warningThreshold: number): void {
|
||||
if (regressionThreshold <= 0 || warningThreshold <= 0) {
|
||||
throw new Error('Thresholds must be positive numbers')
|
||||
}
|
||||
if (warningThreshold > regressionThreshold) {
|
||||
throw new Error('Warning threshold must be less than or equal to regression threshold')
|
||||
}
|
||||
|
||||
this.regressionThreshold = regressionThreshold
|
||||
this.warningThreshold = warningThreshold
|
||||
}
|
||||
|
||||
getThresholds(): { regressionThreshold: number; warningThreshold: number } {
|
||||
return {
|
||||
regressionThreshold: this.regressionThreshold,
|
||||
warningThreshold: this.warningThreshold,
|
||||
}
|
||||
}
|
||||
}
|
||||
110
apps/examples/e2e/fixtures/fixtures.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Page, test as base } from '@playwright/test'
|
||||
import { EndToEndApi } from '../../src/misc/EndToEndApi'
|
||||
import { ActionsMenu } from './menus/ActionsMenu'
|
||||
import { HelpMenu } from './menus/HelpMenu'
|
||||
import { MainMenu } from './menus/MainMenu'
|
||||
import { MenuClickCapture } from './menus/MenuClickCapture'
|
||||
import { NavigationPanel } from './menus/NavigationPanel'
|
||||
import { PageMenu } from './menus/PageMenu'
|
||||
import { RichTextToolbar } from './menus/RichTextToolbar'
|
||||
import { StylePanel } from './menus/StylePanel'
|
||||
import { Toolbar } from './menus/Toolbar'
|
||||
|
||||
interface Fixtures {
|
||||
menuClickCapture: MenuClickCapture
|
||||
toolbar: Toolbar
|
||||
richTextToolbar: RichTextToolbar
|
||||
stylePanel: StylePanel
|
||||
actionsMenu: ActionsMenu
|
||||
helpMenu: HelpMenu
|
||||
mainMenu: MainMenu
|
||||
pageMenu: PageMenu
|
||||
navigationPanel: NavigationPanel
|
||||
api: ReturnType<typeof makeApiFixture>
|
||||
isMac: boolean
|
||||
}
|
||||
|
||||
export type ApiFixture = {
|
||||
[K in keyof EndToEndApi]: (
|
||||
...args: Parameters<EndToEndApi[K]>
|
||||
) => Promise<ReturnType<EndToEndApi[K]>>
|
||||
}
|
||||
|
||||
function makeApiFixture(keys: { [K in keyof EndToEndApi]: true }, page: Page): ApiFixture {
|
||||
const result = {} as any
|
||||
|
||||
for (const key of Object.keys(keys)) {
|
||||
result[key] = (...args: any[]) => {
|
||||
return page.evaluate(
|
||||
([key, ...args]) => {
|
||||
return (window as any).tldrawApi[key](...args)
|
||||
},
|
||||
[key, ...args]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const test = base.extend<Fixtures>({
|
||||
menuClickCapture: async ({ page }, testUse) => {
|
||||
const toolbar = new MenuClickCapture(page)
|
||||
await testUse(toolbar)
|
||||
},
|
||||
toolbar: async ({ page }, testUse) => {
|
||||
const toolbar = new Toolbar(page)
|
||||
await testUse(toolbar)
|
||||
},
|
||||
richTextToolbar: async ({ page }, testUse) => {
|
||||
const richTextToolbar = new RichTextToolbar(page)
|
||||
await testUse(richTextToolbar)
|
||||
},
|
||||
stylePanel: async ({ page }, testUse) => {
|
||||
const stylePanel = new StylePanel(page)
|
||||
await testUse(stylePanel)
|
||||
},
|
||||
actionsMenu: async ({ page }, testUse) => {
|
||||
const actionsMenu = new ActionsMenu(page)
|
||||
await testUse(actionsMenu)
|
||||
},
|
||||
helpMenu: async ({ page }, testUse) => {
|
||||
const helpMenu = new HelpMenu(page)
|
||||
await testUse(helpMenu)
|
||||
},
|
||||
mainMenu: async ({ page }, testUse) => {
|
||||
const mainMenu = new MainMenu(page)
|
||||
await testUse(mainMenu)
|
||||
},
|
||||
pageMenu: async ({ page }, testUse) => {
|
||||
const pagemenu = new PageMenu(page)
|
||||
await testUse(pagemenu)
|
||||
},
|
||||
navigationPanel: async ({ page }, testUse) => {
|
||||
const navigationPanel = new NavigationPanel(page)
|
||||
await testUse(navigationPanel)
|
||||
},
|
||||
api: async ({ page }, testUse) => {
|
||||
const api = makeApiFixture(
|
||||
{
|
||||
exportAsSvg: true,
|
||||
exportAsFormat: true,
|
||||
createShapeId: true,
|
||||
resetMockShapeIds: true,
|
||||
createMermaidDiagram: true,
|
||||
checkMermaidReadability: true,
|
||||
toRichText: true,
|
||||
preloadFonts: true,
|
||||
markAllArrowBindings: true,
|
||||
b64VecsEncodePoints: true,
|
||||
},
|
||||
page
|
||||
)
|
||||
await testUse(api)
|
||||
},
|
||||
isMac: async ({ page: _ }, testUse) => {
|
||||
await testUse(process.platform === 'darwin')
|
||||
},
|
||||
})
|
||||
|
||||
export default test
|
||||
155
apps/examples/e2e/fixtures/fps-tracker.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { Page } from '@playwright/test'
|
||||
|
||||
export interface FPSMetrics {
|
||||
averageFps: number
|
||||
minFps: number
|
||||
maxFps: number
|
||||
totalFrames: number
|
||||
duration: number
|
||||
samples: number[]
|
||||
}
|
||||
|
||||
export class E2EFpsTracker {
|
||||
private isTracking = false
|
||||
private startTime = 0
|
||||
private frameCount = 0
|
||||
private fpsSamples: number[] = []
|
||||
private rafId: number | null = null
|
||||
private sampleInterval = 100 // Sample FPS every 100ms
|
||||
|
||||
constructor(private page: Page) {}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.isTracking) {
|
||||
throw new Error('FPS tracking is already running')
|
||||
}
|
||||
|
||||
this.isTracking = true
|
||||
this.startTime = 0
|
||||
this.frameCount = 0
|
||||
this.fpsSamples = []
|
||||
this.rafId = null
|
||||
|
||||
// Inject FPS tracking script into the page
|
||||
await this.page.evaluate(
|
||||
({ sampleInterval }) => {
|
||||
;(window as any).__e2eFpsTracker = {
|
||||
isTracking: true,
|
||||
startTime: performance.now(),
|
||||
frameCount: 0,
|
||||
fpsSamples: [],
|
||||
lastSampleTime: performance.now(),
|
||||
sampleInterval,
|
||||
|
||||
trackFrame: function () {
|
||||
if (!this.isTracking) return
|
||||
|
||||
this.frameCount++
|
||||
const now = performance.now()
|
||||
|
||||
// Sample FPS at regular intervals
|
||||
if (now - this.lastSampleTime >= this.sampleInterval) {
|
||||
const duration = (now - this.lastSampleTime) / 1000
|
||||
const framesInPeriod = this.frameCount - (this.lastFrameCount || 0)
|
||||
const currentFps = duration > 0 ? framesInPeriod / duration : 0
|
||||
|
||||
this.fpsSamples.push(Math.round(currentFps))
|
||||
this.lastSampleTime = now
|
||||
this.lastFrameCount = this.frameCount
|
||||
}
|
||||
|
||||
// Continue tracking
|
||||
this.rafId = requestAnimationFrame(() => this.trackFrame())
|
||||
},
|
||||
}
|
||||
|
||||
// Start the tracking loop
|
||||
const tracker = (window as any).__e2eFpsTracker
|
||||
tracker.rafId = requestAnimationFrame(() => tracker.trackFrame())
|
||||
},
|
||||
{ sampleInterval: this.sampleInterval }
|
||||
)
|
||||
}
|
||||
|
||||
async stop(): Promise<FPSMetrics> {
|
||||
if (!this.isTracking) {
|
||||
throw new Error('FPS tracking is not running')
|
||||
}
|
||||
|
||||
// Stop tracking and get final measurements
|
||||
const metrics = await this.page.evaluate(() => {
|
||||
const tracker = (window as any).__e2eFpsTracker
|
||||
if (!tracker) {
|
||||
throw new Error('FPS tracker not found on page')
|
||||
}
|
||||
|
||||
tracker.isTracking = false
|
||||
if (tracker.rafId) {
|
||||
cancelAnimationFrame(tracker.rafId)
|
||||
}
|
||||
|
||||
const totalDuration = (performance.now() - tracker.startTime) / 1000
|
||||
const samples = tracker.fpsSamples.filter((fps: number) => fps > 0) // Filter out invalid samples
|
||||
|
||||
if (samples.length === 0) {
|
||||
return {
|
||||
averageFps: 0,
|
||||
minFps: 0,
|
||||
maxFps: 0,
|
||||
totalFrames: tracker.frameCount,
|
||||
duration: totalDuration,
|
||||
samples: [],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
averageFps: Math.round(samples.reduce((a: number, b: number) => a + b, 0) / samples.length),
|
||||
minFps: Math.min(...samples),
|
||||
maxFps: Math.max(...samples),
|
||||
totalFrames: tracker.frameCount,
|
||||
duration: totalDuration,
|
||||
samples: samples,
|
||||
}
|
||||
})
|
||||
|
||||
this.isTracking = false
|
||||
|
||||
// Clean up the tracker from the page
|
||||
await this.page.evaluate(() => {
|
||||
delete (window as any).__e2eFpsTracker
|
||||
})
|
||||
|
||||
return metrics
|
||||
}
|
||||
|
||||
async measureInteraction<T>(
|
||||
interactionFn: () => Promise<T>,
|
||||
options: { warmupMs?: number; measureMs?: number } = {}
|
||||
): Promise<{ result: T; metrics: FPSMetrics }> {
|
||||
const { warmupMs = 500, measureMs = 3000 } = options
|
||||
|
||||
// Warm up period - let the browser settle
|
||||
if (warmupMs > 0) {
|
||||
await this.page.waitForTimeout(warmupMs)
|
||||
}
|
||||
|
||||
await this.start()
|
||||
|
||||
// Execute the interaction
|
||||
const result = await interactionFn()
|
||||
|
||||
// Measure for the specified duration
|
||||
await this.page.waitForTimeout(measureMs)
|
||||
|
||||
const metrics = await this.stop()
|
||||
|
||||
return { result, metrics }
|
||||
}
|
||||
|
||||
async isTrackingActive(): Promise<boolean> {
|
||||
return this.page.evaluate(() => {
|
||||
const tracker = (window as any).__e2eFpsTracker
|
||||
return tracker ? tracker.isTracking : false
|
||||
})
|
||||
}
|
||||
}
|
||||
304
apps/examples/e2e/fixtures/heavy-board-generator.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import { Page } from '@playwright/test'
|
||||
|
||||
export interface HeavyBoardOptions {
|
||||
shapeCount?: number
|
||||
includeGroups?: boolean
|
||||
includeText?: boolean
|
||||
includeArrows?: boolean
|
||||
canvasWidth?: number
|
||||
canvasHeight?: number
|
||||
seed?: number // For reproducible layouts
|
||||
}
|
||||
|
||||
export class HeavyBoardGenerator {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
async generateHeavyBoard(options: HeavyBoardOptions = {}): Promise<void> {
|
||||
const {
|
||||
shapeCount = 300,
|
||||
includeGroups = true,
|
||||
includeText = true,
|
||||
includeArrows = true,
|
||||
canvasWidth = 2000,
|
||||
canvasHeight = 1500,
|
||||
seed = 12345,
|
||||
} = options
|
||||
|
||||
// Clear existing shapes first
|
||||
await this.page.keyboard.press('Control+a')
|
||||
await this.page.keyboard.press('Backspace')
|
||||
|
||||
// Wait for canvas to be ready
|
||||
await this.page.waitForTimeout(100)
|
||||
|
||||
// Generate shapes using editor API injection
|
||||
await this.page.evaluate(
|
||||
({
|
||||
shapeCount,
|
||||
includeGroups: _includeGroups,
|
||||
includeText,
|
||||
includeArrows,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
seed,
|
||||
}) => {
|
||||
const editor = (window as any).editor
|
||||
if (!editor) {
|
||||
throw new Error('Editor not found on window')
|
||||
}
|
||||
|
||||
// Simple seeded random number generator for reproducible tests
|
||||
class SeededRandom {
|
||||
private seed: number
|
||||
constructor(seed: number) {
|
||||
this.seed = seed
|
||||
}
|
||||
|
||||
next(): number {
|
||||
this.seed = (this.seed * 9301 + 49297) % 233280
|
||||
return this.seed / 233280
|
||||
}
|
||||
|
||||
range(min: number, max: number): number {
|
||||
return Math.floor(this.next() * (max - min + 1)) + min
|
||||
}
|
||||
|
||||
choice<T>(array: T[]): T {
|
||||
return array[this.range(0, array.length - 1)]
|
||||
}
|
||||
}
|
||||
|
||||
const random = new SeededRandom(seed)
|
||||
const shapes: any[] = []
|
||||
|
||||
// Shape types and their weights
|
||||
const shapeTypes = [
|
||||
{ type: 'geo', weight: 50 }, // rectangles, ellipses, etc.
|
||||
{ type: 'text', weight: includeText ? 25 : 0 },
|
||||
{ type: 'arrow', weight: includeArrows ? 25 : 0 },
|
||||
{ type: 'note', weight: 25 },
|
||||
{ type: 'draw', weight: 25 },
|
||||
].filter((s) => s.weight > 0)
|
||||
|
||||
// Generate base shapes
|
||||
for (let i = 0; i < shapeCount; i++) {
|
||||
const x = random.range(0, canvasWidth)
|
||||
const y = random.range(0, canvasHeight)
|
||||
const width = random.range(50, 200)
|
||||
const height = random.range(30, 150)
|
||||
|
||||
// Pick shape type based on weights
|
||||
let weightSum = 0
|
||||
const targetWeight = random.next() * shapeTypes.reduce((sum, s) => sum + s.weight, 0)
|
||||
const shapeType =
|
||||
shapeTypes.find((s) => (weightSum += s.weight) >= targetWeight)?.type || 'geo'
|
||||
|
||||
let shape: any
|
||||
|
||||
switch (shapeType) {
|
||||
case 'geo': {
|
||||
const geoTypes = ['rectangle', 'ellipse', 'triangle', 'diamond', 'hexagon', 'star']
|
||||
shape = {
|
||||
id: `shape:${i}`,
|
||||
type: 'geo',
|
||||
x,
|
||||
y,
|
||||
props: {
|
||||
w: width,
|
||||
h: height,
|
||||
geo: random.choice(geoTypes),
|
||||
color: random.choice([
|
||||
'black',
|
||||
'blue',
|
||||
'green',
|
||||
'red',
|
||||
'yellow',
|
||||
'orange',
|
||||
'violet',
|
||||
]),
|
||||
fill: random.choice(['none', 'semi', 'solid']),
|
||||
size: random.choice(['s', 'm', 'l', 'xl']),
|
||||
},
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'draw': {
|
||||
// Create a simple drawn shape with some points
|
||||
const numPoints = random.range(3, 8)
|
||||
const points: Array<{ x: number; y: number; z: number }> = []
|
||||
for (let p = 0; p < numPoints; p++) {
|
||||
points.push({
|
||||
x: random.range(0, width),
|
||||
y: random.range(0, height),
|
||||
z: 0.5,
|
||||
})
|
||||
}
|
||||
|
||||
// Access b64Vecs from the window.tldrawApi object
|
||||
const tldrawApi = (window as any).tldrawApi
|
||||
const b64VecsEncodePoints = tldrawApi.b64VecsEncodePoints
|
||||
|
||||
shape = {
|
||||
id: `shape:${i}`,
|
||||
type: 'draw',
|
||||
x,
|
||||
y,
|
||||
props: {
|
||||
segments: [
|
||||
{
|
||||
type: 'free',
|
||||
path: b64VecsEncodePoints(points),
|
||||
},
|
||||
],
|
||||
color: random.choice(['black', 'blue', 'green', 'red']),
|
||||
size: random.choice(['s', 'm', 'l']),
|
||||
},
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'text': {
|
||||
const tldrawApi = (window as any).tldrawApi
|
||||
const textSamples = [
|
||||
'Performance Test',
|
||||
'Lorem ipsum dolor sit amet',
|
||||
'Heavy Board Test',
|
||||
'Sample Text ' + i,
|
||||
'Multi-line\nText Content',
|
||||
'Longer text content for testing purposes',
|
||||
]
|
||||
shape = {
|
||||
id: `shape:${i}`,
|
||||
type: 'text',
|
||||
x,
|
||||
y,
|
||||
props: {
|
||||
richText: tldrawApi.toRichText(random.choice(textSamples)),
|
||||
color: random.choice(['black', 'blue', 'green', 'red']),
|
||||
size: random.choice(['s', 'm', 'l', 'xl']),
|
||||
font: 'draw',
|
||||
},
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'arrow': {
|
||||
const endX = x + random.range(-200, 200)
|
||||
const endY = y + random.range(-200, 200)
|
||||
shape = {
|
||||
id: `shape:${i}`,
|
||||
type: 'arrow',
|
||||
x,
|
||||
y,
|
||||
props: {
|
||||
start: { x: 0, y: 0 },
|
||||
end: { x: endX - x, y: endY - y },
|
||||
color: random.choice(['black', 'blue', 'green', 'red']),
|
||||
size: random.choice(['s', 'm', 'l']),
|
||||
arrowheadStart: random.choice(['none', 'arrow', 'triangle']),
|
||||
arrowheadEnd: random.choice(['arrow', 'triangle', 'dot']),
|
||||
},
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'note': {
|
||||
const tldrawApi = (window as any).tldrawApi
|
||||
shape = {
|
||||
id: `shape:${i}`,
|
||||
type: 'note',
|
||||
x,
|
||||
y,
|
||||
props: {
|
||||
richText: tldrawApi.toRichText(`Note ${i}\nPerformance testing note`),
|
||||
color: random.choice(['black', 'blue', 'green', 'red', 'yellow']),
|
||||
size: random.choice(['s', 'm', 'l']),
|
||||
},
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (shape) {
|
||||
shapes.push(shape)
|
||||
}
|
||||
}
|
||||
|
||||
// Create all shapes at once
|
||||
editor.createShapes(shapes)
|
||||
|
||||
// Skip grouping for now - will add back later when debugging shape creation issues
|
||||
// TODO: Re-enable grouping once basic shape generation is working
|
||||
|
||||
// Zoom to fit all content
|
||||
editor.zoomToFit()
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Generated heavy board with ${shapes.length} shapes`)
|
||||
},
|
||||
{ shapeCount, includeGroups, includeText, includeArrows, canvasWidth, canvasHeight, seed }
|
||||
)
|
||||
|
||||
// Wait for rendering to settle
|
||||
await this.page.waitForTimeout(1000)
|
||||
}
|
||||
|
||||
async setupStressTestBoard(shapeCount: number): Promise<void> {
|
||||
// Pre-configured stress test with maximum complexity
|
||||
await this.generateHeavyBoard({
|
||||
shapeCount,
|
||||
includeGroups: true,
|
||||
includeText: true,
|
||||
includeArrows: true,
|
||||
canvasWidth: 3000,
|
||||
canvasHeight: 2500,
|
||||
seed: 42, // Fixed seed for consistent tests
|
||||
})
|
||||
}
|
||||
|
||||
async getShapeCount(): Promise<number> {
|
||||
return await this.page.evaluate(() => {
|
||||
const editor = (window as any).editor
|
||||
return editor ? editor.getCurrentPageShapes().length : 0
|
||||
})
|
||||
}
|
||||
|
||||
async selectRandomShapes(count: number, seed: number = 12345): Promise<void> {
|
||||
await this.page.evaluate(
|
||||
({ count, seed }) => {
|
||||
const editor = (window as any).editor
|
||||
if (!editor) return
|
||||
|
||||
// Simple seeded random number generator for reproducible tests
|
||||
class SeededRandom {
|
||||
private seed: number
|
||||
constructor(seed: number) {
|
||||
this.seed = seed
|
||||
}
|
||||
|
||||
next(): number {
|
||||
this.seed = (this.seed * 9301 + 49297) % 233280
|
||||
return this.seed / 233280
|
||||
}
|
||||
|
||||
range(min: number, max: number): number {
|
||||
return Math.floor(this.next() * (max - min + 1)) + min
|
||||
}
|
||||
}
|
||||
|
||||
const random = new SeededRandom(seed)
|
||||
const allShapes = editor.getCurrentPageShapes()
|
||||
const selectedCount = Math.min(count, allShapes.length)
|
||||
|
||||
// Select random shapes using seeded random
|
||||
const shuffled = allShapes.sort(() => 0.5 - random.next())
|
||||
const selected = shuffled.slice(0, selectedCount)
|
||||
const shapeIds = selected.map((shape: any) => shape.id)
|
||||
|
||||
editor.setSelectedShapes(shapeIds)
|
||||
},
|
||||
{ count, seed }
|
||||
)
|
||||
}
|
||||
}
|
||||
42
apps/examples/e2e/fixtures/menus/ActionsMenu.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class ActionsMenu {
|
||||
readonly quickActions: { [key: string]: Locator }
|
||||
readonly mainActions: { [key: string]: Locator }
|
||||
readonly actionsMenuButton: Locator
|
||||
readonly actionsMenuContent: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.page = page
|
||||
this.actionsMenuButton = this.page.getByTestId('actions-menu.button')
|
||||
this.actionsMenuContent = this.page.getByTestId('actions-menu.content')
|
||||
this.quickActions = {
|
||||
undo: this.page.getByTestId('quick-actions.undo'),
|
||||
redo: this.page.getByTestId('quick-actions.redo'),
|
||||
delete: this.page.getByTestId('quick-actions.delete'),
|
||||
duplicate: this.page.getByTestId('quick-actions.duplicate'),
|
||||
}
|
||||
this.mainActions = {
|
||||
alignLeft: this.page.getByTestId('actions-menu.align-left'),
|
||||
alignCenterHorizontal: this.page.getByTestId('actions-menu.align-center-horizontal'),
|
||||
alignRight: this.page.getByTestId('actions-menu.align-right'),
|
||||
stretchHorizontal: this.page.getByTestId('actions-menu.stretch-horizontal'),
|
||||
alignTop: this.page.getByTestId('actions-menu.align-top'),
|
||||
alignCenterVertical: this.page.getByTestId('actions-menu.align-center-vertical'),
|
||||
alignBottom: this.page.getByTestId('actions-menu.align-bottom'),
|
||||
stretchVertical: this.page.getByTestId('actions-menu.stretch-vertical'),
|
||||
distributeHorizontal: this.page.getByTestId('actions-menu.distribute-horizontal'),
|
||||
distributeVertical: this.page.getByTestId('actions-menu.distribute-vertical'),
|
||||
stackHorizontal: this.page.getByTestId('actions-menu.stack-horizontal'),
|
||||
stackVertical: this.page.getByTestId('actions-menu.stack-vertical'),
|
||||
sendToBack: this.page.getByTestId('actions-menu.send-to-back'),
|
||||
sendBackward: this.page.getByTestId('actions-menu.send-backward'),
|
||||
bringForward: this.page.getByTestId('actions-menu.bring-forward'),
|
||||
bringToFront: this.page.getByTestId('actions-menu.bring-to-front'),
|
||||
rotateCCW: this.page.getByTestId('actions-menu.rotate-ccw'),
|
||||
rotateCW: this.page.getByTestId('actions-menu.rotate-cw'),
|
||||
editLink: this.page.getByTestId('actions-menu.edit-link'),
|
||||
group: this.page.getByTestId('actions-menu.group'),
|
||||
}
|
||||
}
|
||||
}
|
||||
34
apps/examples/e2e/fixtures/menus/HelpMenu.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class HelpMenu {
|
||||
readonly helpMenuButton: Locator
|
||||
readonly languagesButton: Locator
|
||||
readonly languagesContent: Locator
|
||||
readonly keyboardShortcutsMenu: KeyboardShortcutsMenu
|
||||
readonly linkGroup: { [key: string]: Locator }
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.page = page
|
||||
this.helpMenuButton = this.page.getByTestId('help-menu.button')
|
||||
this.languagesButton = this.page.getByTestId('help-menu-sub.language-button')
|
||||
this.languagesContent = this.page.getByTestId('help-menu-sub.language-content')
|
||||
this.keyboardShortcutsMenu = new KeyboardShortcutsMenu(this.page)
|
||||
this.linkGroup = {
|
||||
github: this.page.getByTestId('help-menu.github'),
|
||||
twitter: this.page.getByTestId('help-menu.twitter'),
|
||||
discord: this.page.getByTestId('help-menu.discord'),
|
||||
about: this.page.getByTestId('help-menu.about'),
|
||||
}
|
||||
}
|
||||
}
|
||||
class KeyboardShortcutsMenu {
|
||||
readonly heading: Locator
|
||||
readonly button: Locator
|
||||
readonly closeButton: Locator
|
||||
|
||||
constructor(private readonly page: Page) {
|
||||
this.heading = this.page.getByRole('dialog').getByText('Keyboard shortcuts')
|
||||
this.button = this.page.getByTestId('help-menu.keyboard-shortcuts-button')
|
||||
this.closeButton = this.page.getByRole('dialog').getByTestId('dialog.close')
|
||||
}
|
||||
}
|
||||
62
apps/examples/e2e/fixtures/menus/MainMenu.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class MainMenu {
|
||||
readonly mainMenuButton: Locator
|
||||
readonly buttons: { [key: string]: Locator }
|
||||
readonly subMenus: { [key: string]: Locator[] }
|
||||
constructor(public readonly page: Page) {
|
||||
this.page = page
|
||||
this.mainMenuButton = this.page.getByTestId('main-menu.button')
|
||||
this.buttons = {
|
||||
edit: this.page.getByRole('menuitem').getByText('Edit'),
|
||||
object: this.page.getByRole('menuitem').getByText('Object'),
|
||||
view: this.page.getByRole('menuitem').getByText('View'),
|
||||
insertEmbed: this.page.getByRole('menuitem').getByText('Insert Embed'),
|
||||
uploadMedia: this.page.getByRole('menuitem').getByText('Upload Media'),
|
||||
preferences: this.page.getByRole('menuitem').getByText('Preferences'),
|
||||
}
|
||||
this.subMenus = {
|
||||
editSubmenu: [
|
||||
this.page.getByTestId('main-menu-sub.edit-content').getByText('Undo'),
|
||||
this.page.getByTestId('main-menu-sub.edit-content').getByText('Redo'),
|
||||
this.page.getByTestId('main-menu-sub.edit-content').getByText('Cut'),
|
||||
this.page.getByTestId('main-menu-sub.edit-content').getByText('Copy'),
|
||||
this.page.getByTestId('main-menu-sub.edit-content').getByText('Copy as'),
|
||||
this.page.getByTestId('main-menu-sub.edit-content').getByText('Duplicate'),
|
||||
this.page.getByTestId('main-menu-sub.edit-content').getByText('Paste'),
|
||||
this.page.getByTestId('main-menu-sub.edit-content').getByText('Delete'),
|
||||
this.page.getByTestId('main-menu-sub.edit-content').getByText('Select all'),
|
||||
],
|
||||
objectSubmenu: [
|
||||
this.page.getByRole('menuitem').getByText('Export as'),
|
||||
this.page.getByRole('menuitem').getByText('Group'),
|
||||
this.page.getByRole('menuitem').getByText('Ungroup'),
|
||||
this.page.getByRole('menuitem').getByText('Remove frame'),
|
||||
this.page.getByRole('menuitem').getByText('Fit to content'),
|
||||
this.page.getByRole('menuitem').getByText('Toggle auto size'),
|
||||
this.page.getByRole('menuitem').getByText('Edit link'),
|
||||
this.page.getByRole('menuitem').getByText('Convert to Bookmark'),
|
||||
this.page.getByRole('menuitem').getByText('Convert to Embed'),
|
||||
this.page.getByRole('menuitem').getByText('Toggle locked'),
|
||||
this.page.getByRole('menuitem').getByText('Unlock all'),
|
||||
],
|
||||
viewSubmenu: [
|
||||
this.page.getByTestId('main-menu-sub.view-content').getByText('Zoom in'),
|
||||
this.page.getByTestId('main-menu-sub.view-content').getByText('Zoom out'),
|
||||
this.page.getByTestId('main-menu-sub.view-content').getByText('Zoom to 100%'),
|
||||
this.page.getByTestId('main-menu-sub.view-content').getByText('Zoom to fit'),
|
||||
this.page.getByTestId('main-menu-sub.view-content').getByText('Zoom to selection'),
|
||||
],
|
||||
preferencesSubmenu: [
|
||||
this.page.getByTestId('main-menu-sub.preferences-content').getByText('Always snap'),
|
||||
this.page.getByTestId('main-menu-sub.preferences-content').getByText('Tool lock'),
|
||||
this.page.getByTestId('main-menu-sub.preferences-content').getByText('Show grid'),
|
||||
this.page.getByTestId('main-menu-sub.preferences-content').getByText('Dark mode'),
|
||||
this.page.getByTestId('main-menu-sub.preferences-content').getByText('Focus mode'),
|
||||
this.page.getByTestId('main-menu-sub.preferences-content').getByText('Edge scrolling'),
|
||||
this.page.getByTestId('main-menu-sub.preferences-content').getByText('Reduce motion'),
|
||||
this.page.getByTestId('main-menu-sub.preferences-content').getByText('Language'),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
10
apps/examples/e2e/fixtures/menus/MenuClickCapture.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class MenuClickCapture {
|
||||
readonly content: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.page = page
|
||||
this.content = this.page.getByTestId('menu-click-capture.content')
|
||||
}
|
||||
}
|
||||
21
apps/examples/e2e/fixtures/menus/NavigationPanel.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class NavigationPanel {
|
||||
readonly minimap: Locator
|
||||
readonly zoomMenuButton: Locator
|
||||
readonly toggleButton: Locator
|
||||
readonly zoomMenuItems: { [key: string]: Locator }
|
||||
constructor(private readonly page: Page) {
|
||||
this.page = page
|
||||
this.minimap = page.getByTestId('minimap.canvas')
|
||||
this.zoomMenuButton = page.getByTestId('minimap.zoom-menu-button')
|
||||
this.toggleButton = page.getByTestId('minimap.toggle-button')
|
||||
this.zoomMenuItems = {
|
||||
zoomIn: page.getByRole('menuitem').getByText('Zoom in'),
|
||||
zoomOut: page.getByRole('menuitem').getByText('Zoom out'),
|
||||
zoomToHundred: page.getByRole('menuitem').getByText('Zoom to 100%'),
|
||||
zoomToFit: page.getByRole('menuitem').getByText('Zoom to fit'),
|
||||
zoomToSelection: page.getByRole('menuitem').getByText('Zoom to selection'),
|
||||
}
|
||||
}
|
||||
}
|
||||
27
apps/examples/e2e/fixtures/menus/PageMenu.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class PageMenu {
|
||||
readonly pagemenuButton: Locator
|
||||
readonly header: Locator
|
||||
readonly createButton: Locator
|
||||
readonly pageList: Locator
|
||||
readonly pageItems: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.page = page
|
||||
this.pagemenuButton = this.page.getByTestId('page-menu.button')
|
||||
// The popover no longer has a title row — use the list as the "is the menu open" anchor.
|
||||
this.header = this.page.getByTestId('page-menu.list')
|
||||
this.createButton = this.page.getByTestId('page-menu.create')
|
||||
this.pageList = this.page.getByTestId('page-menu.list')
|
||||
this.pageItems = this.page.getByTestId('page-menu.item')
|
||||
}
|
||||
|
||||
async getPageItem(index: number) {
|
||||
return this.pageItems.nth(index)
|
||||
}
|
||||
|
||||
getPageItemByName(name: string) {
|
||||
return this.page.getByTestId('page-menu.item').filter({ hasText: name })
|
||||
}
|
||||
}
|
||||
60
apps/examples/e2e/fixtures/menus/RichTextToolbar.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Locator, Page, expect } from '@playwright/test'
|
||||
|
||||
export class RichTextToolbar {
|
||||
container: Locator
|
||||
readonly tools: { [key: string]: Locator }
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.container = this.page.getByTestId('contextual-toolbar')
|
||||
this.tools = {
|
||||
bold: this.page.getByTestId('rich-text.bold'),
|
||||
italic: this.page.getByTestId('rich-text.italic'),
|
||||
code: this.page.getByTestId('rich-text.code'),
|
||||
strike: this.page.getByTestId('rich-text.strike'),
|
||||
link: this.page.getByTestId('rich-text.link'),
|
||||
bulletList: this.page.getByTestId('rich-text.bulletList'),
|
||||
highlight: this.page.getByTestId('rich-text.highlight'),
|
||||
linkRemove: this.page.getByTestId('rich-text.link-remove'),
|
||||
heading: this.page.getByTestId('rich-text.heading'),
|
||||
}
|
||||
}
|
||||
|
||||
async clickTool(tool: Locator) {
|
||||
await tool.click()
|
||||
}
|
||||
async isSelected(tool: Locator) {
|
||||
// pseudo elements aren't exposed to the DOM, but we can check the color as a proxy
|
||||
const expectedColor = 'rgb(46, 46, 46)'
|
||||
await expect(tool).toHaveCSS('color', expectedColor)
|
||||
}
|
||||
async isNotSelected(tool: Locator) {
|
||||
const expectedColor = 'rgb(255, 255, 255)'
|
||||
await expect(tool).toHaveCSS('color', expectedColor)
|
||||
}
|
||||
|
||||
async getSelectionBounds() {
|
||||
return this.page.evaluate(() => {
|
||||
const selection = window.getSelection()
|
||||
|
||||
// If there are no selections, don't return a box
|
||||
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return
|
||||
|
||||
// Get a common box from all of the ranges' screen rects
|
||||
let minX = Infinity
|
||||
let minY = Infinity
|
||||
let maxX = -Infinity
|
||||
let maxY = -Infinity
|
||||
|
||||
for (let i = 0; i < selection.rangeCount; i++) {
|
||||
const range = selection.getRangeAt(i)
|
||||
const box = range.getBoundingClientRect()
|
||||
minX = Math.min(minX, box.left)
|
||||
minY = Math.min(minY, box.top)
|
||||
maxX = Math.max(maxX, box.right)
|
||||
maxY = Math.max(maxY, box.bottom)
|
||||
}
|
||||
|
||||
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY }
|
||||
})
|
||||
}
|
||||
}
|
||||
88
apps/examples/e2e/fixtures/menus/StylePanel.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Locator, Page, expect } from '@playwright/test'
|
||||
|
||||
export class StylePanel {
|
||||
readonly stylesArray: string[]
|
||||
readonly colors: { [key: string]: Locator }
|
||||
readonly fill: { [key: string]: Locator }
|
||||
readonly fillExtra: Locator
|
||||
readonly dash: { [key: string]: Locator }
|
||||
readonly size: { [key: string]: Locator }
|
||||
readonly font: { [key: string]: Locator }
|
||||
readonly align: { [key: string]: Locator }
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.page = page
|
||||
this.stylesArray = [
|
||||
'style.color',
|
||||
'style.opacity',
|
||||
'style.fill',
|
||||
'style.dash',
|
||||
'style.size',
|
||||
'style.font',
|
||||
'style.align',
|
||||
]
|
||||
this.colors = {
|
||||
black: this.page.getByTestId('style.color.black'),
|
||||
grey: this.page.getByTestId('style.color.grey'),
|
||||
lightViolet: this.page.getByTestId('style.color.light-violet'),
|
||||
violet: this.page.getByTestId('style.color.violet'),
|
||||
blue: this.page.getByTestId('style.color.blue'),
|
||||
lightBlue: this.page.getByTestId('style.color.light-blue'),
|
||||
yellow: this.page.getByTestId('style.color.yellow'),
|
||||
orange: this.page.getByTestId('style.color.orange'),
|
||||
green: this.page.getByTestId('style.color.green'),
|
||||
lightGreen: this.page.getByTestId('style.color.light-green'),
|
||||
lightRed: this.page.getByTestId('style.color.light-red'),
|
||||
red: this.page.getByTestId('style.color.red'),
|
||||
}
|
||||
this.fill = {
|
||||
none: this.page.getByTestId('style.fill.none'),
|
||||
semi: this.page.getByTestId('style.fill.semi'),
|
||||
solid: this.page.getByTestId('style.fill.solid'),
|
||||
}
|
||||
this.fillExtra = this.page.getByTestId('style.fill-extra')
|
||||
this.dash = {
|
||||
draw: this.page.getByTestId('style.dash.draw'),
|
||||
dashed: this.page.getByTestId('style.dash.dashed'),
|
||||
dotted: this.page.getByTestId('style.dash.dotted'),
|
||||
solid: this.page.getByTestId('style.dash.solid'),
|
||||
}
|
||||
this.size = {
|
||||
s: this.page.getByTestId('style.size.s'),
|
||||
m: this.page.getByTestId('style.size.m'),
|
||||
l: this.page.getByTestId('style.size.l'),
|
||||
xl: this.page.getByTestId('style.size.xl'),
|
||||
}
|
||||
this.font = {
|
||||
draw: this.page.getByTestId('style.font.draw'),
|
||||
sans: this.page.getByTestId('style.font.sans'),
|
||||
serif: this.page.getByTestId('style.font.serif'),
|
||||
mono: this.page.getByTestId('style.font.mono'),
|
||||
}
|
||||
|
||||
this.align = {
|
||||
start: this.page.getByTestId('style.align.start'),
|
||||
middle: this.page.getByTestId('style.align.middle'),
|
||||
end: this.page.getByTestId('style.align.end'),
|
||||
}
|
||||
}
|
||||
async getAfterElementStyle(style: Locator, property: string) {
|
||||
const getStyle = (element: Element, property: string) =>
|
||||
window.getComputedStyle(element, '::after').getPropertyValue(property)
|
||||
return await style.evaluate(getStyle, property)
|
||||
}
|
||||
|
||||
async isActive(style: Locator) {
|
||||
const backgroundColor = await this.getAfterElementStyle(style, 'background-color')
|
||||
return expect(backgroundColor).toBe('rgba(0, 0, 0, 0.055)')
|
||||
}
|
||||
|
||||
async isInactive(style: Locator) {
|
||||
const backgroundColor = await this.getAfterElementStyle(style, 'background-color')
|
||||
// The color is different on mobile
|
||||
return expect(['rgba(0, 0, 0, 0.043)', 'rgba(0, 0, 0, 0)']).toContain(backgroundColor)
|
||||
}
|
||||
getElement() {
|
||||
return this.page.getByTestId('style.panel')
|
||||
}
|
||||
}
|
||||
45
apps/examples/e2e/fixtures/menus/Toolbar.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Locator, Page, expect } from '@playwright/test'
|
||||
|
||||
export class Toolbar {
|
||||
readonly toolLock: Locator
|
||||
readonly moreToolsButton: Locator
|
||||
readonly moreToolsPopover: Locator
|
||||
readonly mobileStylesButton: Locator
|
||||
readonly tools: { [key: string]: Locator }
|
||||
readonly popOverTools: { [key: string]: Locator }
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.page = page
|
||||
this.toolLock = this.page.getByTestId('tool-lock')
|
||||
this.moreToolsButton = this.page.getByTestId('tools.more-button')
|
||||
this.moreToolsPopover = this.page.getByTestId('tools.more-content')
|
||||
this.mobileStylesButton = this.page.getByTestId('mobile-styles.button')
|
||||
this.tools = {
|
||||
select: this.page.getByTestId('tools.select'),
|
||||
draw: this.page.getByTestId('tools.draw'),
|
||||
text: this.page.getByTestId('tools.text'),
|
||||
arrow: this.page.getByTestId('tools.arrow'),
|
||||
cloud: this.page.getByTestId('tools.cloud'),
|
||||
eraser: this.page.getByTestId('tools.eraser'),
|
||||
rectangle: this.page.getByTestId('tools.rectangle'),
|
||||
hand: this.page.getByTestId('tools.hand'),
|
||||
}
|
||||
this.popOverTools = {
|
||||
popoverCloud: this.page.getByTestId('tools.more.cloud'),
|
||||
popoverFrame: this.page.getByTestId('tools.more.frame'),
|
||||
popoverRectangle: this.page.getByTestId('tools.more.rectangle'),
|
||||
}
|
||||
}
|
||||
async clickTool(tool: Locator) {
|
||||
await tool.click()
|
||||
}
|
||||
async isSelected(tool: Locator) {
|
||||
// pseudo elements aren't exposed to the DOM, but we can check the color as a proxy
|
||||
const expectedColor = 'rgb(255, 255, 255)'
|
||||
await expect(tool).toHaveCSS('color', expectedColor)
|
||||
}
|
||||
async isNotSelected(tool: Locator) {
|
||||
const expectedColor = 'rgb(46, 46, 46)'
|
||||
await expect(tool).toHaveCSS('color', expectedColor)
|
||||
}
|
||||
}
|
||||
645
apps/examples/e2e/fixtures/perf-utils.ts
Normal file
@@ -0,0 +1,645 @@
|
||||
import { Page, PlaywrightTestArgs } from '@playwright/test'
|
||||
import { Editor } from 'tldraw'
|
||||
import { BaselineManager, ComparisonResult, Environment } from './baseline-manager'
|
||||
import { E2EFpsTracker, FPSMetrics } from './fps-tracker'
|
||||
import { HeavyBoardGenerator } from './heavy-board-generator'
|
||||
|
||||
export interface PerformanceTestResult {
|
||||
interaction: string
|
||||
metrics: FPSMetrics
|
||||
comparison: ComparisonResult
|
||||
environment: Environment
|
||||
}
|
||||
|
||||
export interface InteractionTestOptions {
|
||||
warmupMs?: number
|
||||
measureMs?: number
|
||||
expectMinFps?: number
|
||||
}
|
||||
|
||||
export class PerformanceTestSuite {
|
||||
private fpsTracker: E2EFpsTracker
|
||||
private baselineManager: BaselineManager
|
||||
private boardGenerator: HeavyBoardGenerator
|
||||
private environment: Environment
|
||||
|
||||
constructor(page: Page, browserName: string) {
|
||||
this.fpsTracker = new E2EFpsTracker(page)
|
||||
this.baselineManager = new BaselineManager()
|
||||
this.boardGenerator = new HeavyBoardGenerator(page)
|
||||
|
||||
// Detect environment
|
||||
this.environment = {
|
||||
platform: process.platform,
|
||||
viewport: '1920x1080', // Will be updated based on actual viewport
|
||||
browser: browserName,
|
||||
}
|
||||
}
|
||||
|
||||
async initialize(page: Page): Promise<void> {
|
||||
// Update viewport information
|
||||
const viewport = page.viewportSize()
|
||||
if (viewport) {
|
||||
this.environment.viewport = `${viewport.width}x${viewport.height}`
|
||||
}
|
||||
|
||||
// Set up the page for performance testing
|
||||
await page.goto('http://localhost:5420/end-to-end')
|
||||
await page.waitForSelector('.tl-canvas')
|
||||
|
||||
// Disable animations for consistent testing
|
||||
await page.evaluate(() => {
|
||||
const editor: Editor = (window as any).editor
|
||||
if (editor) {
|
||||
editor.user.updateUserPreferences({ animationSpeed: 0 })
|
||||
}
|
||||
})
|
||||
|
||||
// Let the editor settle
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
|
||||
async setupHeavyBoard(shapeCount = 200): Promise<void> {
|
||||
await this.boardGenerator.setupStressTestBoard(shapeCount)
|
||||
|
||||
// Ensure the board is fully rendered
|
||||
await this.waitForStableFramerate()
|
||||
}
|
||||
|
||||
private async waitForStableFramerate(maxWaitMs = 3000): Promise<void> {
|
||||
// Wait for the framerate to stabilize after board generation
|
||||
const startTime = Date.now()
|
||||
while (Date.now() - startTime < maxWaitMs) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
// Check if we're getting consistent frame times
|
||||
const isStable = await this.checkFramerateStability()
|
||||
if (isStable) break
|
||||
}
|
||||
}
|
||||
|
||||
private async checkFramerateStability(): Promise<boolean> {
|
||||
// Simple heuristic: check if the page has settled
|
||||
return true // For now, just return true. Could be enhanced with actual stability detection
|
||||
}
|
||||
|
||||
async testShapeRotation(options: InteractionTestOptions = {}): Promise<PerformanceTestResult> {
|
||||
const { warmupMs = 500, measureMs = 10000, expectMinFps } = options // 10 seconds for lots of rotation
|
||||
|
||||
const { metrics } = await this.fpsTracker.measureInteraction(
|
||||
async () => {
|
||||
const page = this.getPage()
|
||||
|
||||
// Select all shapes for rotation
|
||||
await page.keyboard.press('Control+a')
|
||||
|
||||
// Wait a moment for selection
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
// Perform continuous rotations using the editor API
|
||||
await page.evaluate(() => {
|
||||
const editor: Editor = (window as any).editor
|
||||
if (!editor) return
|
||||
|
||||
// Rotate shapes continuously during measurement period
|
||||
// 10 seconds at 60fps with 5° steps = ~8.7 rotations total!
|
||||
let totalRotations = 0
|
||||
const rotateStep = Math.PI / 36 // 5 degrees per step
|
||||
const degreesPerStep = 5
|
||||
|
||||
const rotateInterval = setInterval(() => {
|
||||
const selectedShapes = editor.getSelectedShapes()
|
||||
if (selectedShapes.length > 0) {
|
||||
editor.rotateShapesBy(selectedShapes, rotateStep)
|
||||
totalRotations += degreesPerStep / 360 // Track full rotations
|
||||
;(window as any).__totalRotations = totalRotations
|
||||
}
|
||||
}, 16) // ~60fps rotation updates
|
||||
|
||||
// Store interval ID and stats so we can clean it up
|
||||
;(window as any).__rotationInterval = rotateInterval
|
||||
})
|
||||
},
|
||||
{ warmupMs, measureMs }
|
||||
)
|
||||
|
||||
// Clean up and clear selection
|
||||
const page = this.getPage()
|
||||
|
||||
// Stop the rotation interval and get stats
|
||||
const rotationStats = await page.evaluate(() => {
|
||||
const interval = (window as any).__rotationInterval
|
||||
const totalRotations = (window as any).__totalRotations || 0
|
||||
|
||||
if (interval) {
|
||||
clearInterval(interval)
|
||||
delete (window as any).__rotationInterval
|
||||
delete (window as any).__totalRotations
|
||||
}
|
||||
|
||||
return { totalRotations: Math.round(totalRotations * 10) / 10 } // Round to 1 decimal
|
||||
})
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`🔄 Completed ${rotationStats.totalRotations} rotations during measurement`)
|
||||
|
||||
// Clear selection
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
return this.finalizeTestResult('rotate_shapes', metrics, expectMinFps)
|
||||
}
|
||||
|
||||
async testShapeDragging(options: InteractionTestOptions = {}): Promise<PerformanceTestResult> {
|
||||
const { warmupMs = 500, measureMs = 3000, expectMinFps } = options
|
||||
|
||||
// Select shapes for dragging
|
||||
await this.boardGenerator.selectRandomShapes(5)
|
||||
|
||||
const { metrics } = await this.fpsTracker.measureInteraction(
|
||||
async () => {
|
||||
const page = this.getPage()
|
||||
|
||||
// Select all shapes for rotation
|
||||
await page.keyboard.press('Control+a')
|
||||
|
||||
// Drag shapes around the canvas
|
||||
const startX = 400
|
||||
const startY = 400
|
||||
|
||||
await page.mouse.move(startX, startY)
|
||||
await page.mouse.down()
|
||||
|
||||
// Drag in various directions
|
||||
const movements = [
|
||||
[600, 300],
|
||||
[800, 500],
|
||||
[500, 700],
|
||||
[300, 600],
|
||||
[400, 400],
|
||||
[500, 500],
|
||||
[700, 700],
|
||||
[900, 900],
|
||||
[1100, 1100],
|
||||
[1300, 1300],
|
||||
[200, 200],
|
||||
[324, 324],
|
||||
[448, 448],
|
||||
[572, 572],
|
||||
[696, 696],
|
||||
[820, 820],
|
||||
]
|
||||
|
||||
for (const [x, y] of movements) {
|
||||
await page.mouse.move(x, y, { steps: 10 })
|
||||
await page.waitForTimeout(16) // ~60fps
|
||||
}
|
||||
|
||||
await page.mouse.up()
|
||||
},
|
||||
{ warmupMs, measureMs }
|
||||
)
|
||||
|
||||
// Clear selection
|
||||
const page = this.getPage()
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
return this.finalizeTestResult('drag_shapes', metrics, expectMinFps)
|
||||
}
|
||||
|
||||
async testShapeResizing(options: InteractionTestOptions = {}): Promise<PerformanceTestResult> {
|
||||
const { warmupMs = 500, measureMs = 3000, expectMinFps } = options
|
||||
|
||||
const { metrics } = await this.fpsTracker.measureInteraction(
|
||||
async () => {
|
||||
const page = this.getPage()
|
||||
|
||||
// Select all shapes for resizing
|
||||
await page.keyboard.press('Control+a')
|
||||
|
||||
// Use editor API to programmatically resize selected shapes
|
||||
await page.evaluate(() => {
|
||||
const editor: Editor = (window as any).editor
|
||||
if (!editor) return
|
||||
|
||||
// Simple seeded random number generator for consistent testing
|
||||
let seed = 12345
|
||||
function seededRandom() {
|
||||
seed = (seed * 9301 + 49297) % 233280
|
||||
return seed / 233280
|
||||
}
|
||||
|
||||
let resizeCycle = 0
|
||||
const resizeInterval = setInterval(() => {
|
||||
const selectedShapes = editor.getSelectedShapes()
|
||||
if (selectedShapes.length === 0) return
|
||||
|
||||
// Generate seeded random scale factors
|
||||
let scaleFactor: number
|
||||
if (resizeCycle % 2 === 0) {
|
||||
// Scale up: random between 1.1 and 5
|
||||
scaleFactor = 1.1 + seededRandom() * (2.0 - 1.1)
|
||||
} else {
|
||||
// Scale down: random between 0.2 and 0.9
|
||||
scaleFactor = 0.5 + seededRandom() * (0.9 - 0.5)
|
||||
}
|
||||
|
||||
// Get current bounds for the scale origin
|
||||
const bounds = editor.getSelectionPageBounds()
|
||||
if (bounds) {
|
||||
// Resize each selected shape individually
|
||||
selectedShapes.forEach((shape: any) => {
|
||||
editor.resizeShape(
|
||||
shape.id,
|
||||
{ x: scaleFactor, y: scaleFactor },
|
||||
{
|
||||
scaleOrigin: bounds.center,
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
resizeCycle++
|
||||
}, 16) // Resize every 16ms for ~60fps
|
||||
|
||||
;(window as any).__resizeInterval = resizeInterval
|
||||
})
|
||||
},
|
||||
{ warmupMs, measureMs }
|
||||
)
|
||||
|
||||
// Stop the resize interval
|
||||
const page = this.getPage()
|
||||
await page.evaluate(() => {
|
||||
const interval = (window as any).__resizeInterval
|
||||
if (interval) {
|
||||
clearInterval(interval)
|
||||
delete (window as any).__resizeInterval
|
||||
}
|
||||
})
|
||||
|
||||
// Clear selection
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
return this.finalizeTestResult('resize_shapes', metrics, expectMinFps)
|
||||
}
|
||||
|
||||
async testCanvasPanning(options: InteractionTestOptions = {}): Promise<PerformanceTestResult> {
|
||||
const { warmupMs = 500, measureMs = 3000, expectMinFps } = options
|
||||
|
||||
const { metrics } = await this.fpsTracker.measureInteraction(
|
||||
async () => {
|
||||
const page = this.getPage()
|
||||
|
||||
// Pan around the canvas using Editor camera API
|
||||
await page.evaluate(() => {
|
||||
const editor: Editor = (window as any).editor
|
||||
if (!editor) return
|
||||
|
||||
// Get initial camera position
|
||||
const initialCamera = editor.getCamera()
|
||||
let panCycle = 0
|
||||
|
||||
// Define pan movements as offsets from initial position
|
||||
const panOffsets = [
|
||||
{ x: -1800, y: -200 },
|
||||
{ x: 400, y: -1800 },
|
||||
{ x: 800, y: 1200 },
|
||||
{ x: -900, y: 800 },
|
||||
{ x: 0, y: 0 },
|
||||
{ x: -3600, y: -3600 },
|
||||
{ x: -900, y: -1900 },
|
||||
{ x: -900, y: 1200 },
|
||||
{ x: 300, y: 800 },
|
||||
{ x: -1800, y: -800 },
|
||||
{ x: 400, y: -1800 },
|
||||
{ x: 800, y: 1200 },
|
||||
{ x: -900, y: 800 },
|
||||
{ x: 0, y: 0 },
|
||||
{ x: -3600, y: -3600 },
|
||||
{ x: -900, y: -1900 },
|
||||
{ x: -900, y: 1200 },
|
||||
{ x: 300, y: 800 },
|
||||
]
|
||||
|
||||
const panInterval = setInterval(() => {
|
||||
const offsetIndex = panCycle % panOffsets.length
|
||||
const offset = panOffsets[offsetIndex]
|
||||
|
||||
// Set camera position to initial position plus offset
|
||||
editor.setCamera({
|
||||
x: initialCamera.x + offset.x,
|
||||
y: initialCamera.y + offset.y,
|
||||
z: initialCamera.z,
|
||||
})
|
||||
|
||||
panCycle++
|
||||
}, 16) // Update every 16ms for ~60fps
|
||||
|
||||
;(window as any).__panInterval = panInterval
|
||||
})
|
||||
},
|
||||
{ warmupMs, measureMs }
|
||||
)
|
||||
|
||||
// Stop the pan interval
|
||||
const page = this.getPage()
|
||||
await page.evaluate(() => {
|
||||
const interval = (window as any).__panInterval
|
||||
if (interval) {
|
||||
clearInterval(interval)
|
||||
delete (window as any).__panInterval
|
||||
}
|
||||
})
|
||||
|
||||
return this.finalizeTestResult('canvas_panning', metrics, expectMinFps)
|
||||
}
|
||||
|
||||
async testCanvasZooming(options: InteractionTestOptions = {}): Promise<PerformanceTestResult> {
|
||||
const { warmupMs = 500, measureMs = 10000, expectMinFps } = options // Shorter measure time for zooming
|
||||
|
||||
const { metrics } = await this.fpsTracker.measureInteraction(
|
||||
async () => {
|
||||
const page = this.getPage()
|
||||
|
||||
// Use editor API to zoom in and out continuously
|
||||
await page.evaluate(() => {
|
||||
const editor: Editor = (window as any).editor
|
||||
if (!editor) return
|
||||
|
||||
let zoomCycle = 0
|
||||
const zoomInterval = setInterval(() => {
|
||||
const cycle = Math.floor(zoomCycle / 10) % 2 // Switch every 10 steps
|
||||
if (cycle === 0) {
|
||||
editor.zoomIn() // Zoom in phase
|
||||
} else {
|
||||
editor.zoomOut() // Zoom out phase
|
||||
}
|
||||
zoomCycle++
|
||||
}, 16) // Zoom every 16ms
|
||||
|
||||
;(window as any).__zoomInterval = zoomInterval
|
||||
})
|
||||
},
|
||||
{ warmupMs, measureMs }
|
||||
)
|
||||
|
||||
// Stop the zoom interval
|
||||
const page = this.getPage()
|
||||
await page.evaluate(() => {
|
||||
const interval = (window as any).__zoomInterval
|
||||
if (interval) {
|
||||
clearInterval(interval)
|
||||
delete (window as any).__zoomInterval
|
||||
}
|
||||
})
|
||||
|
||||
return this.finalizeTestResult('canvas_zooming', metrics, expectMinFps)
|
||||
}
|
||||
|
||||
async testTextShapeCreation(
|
||||
options: InteractionTestOptions = {}
|
||||
): Promise<PerformanceTestResult> {
|
||||
const { warmupMs = 500, measureMs = 10000, expectMinFps } = options
|
||||
|
||||
const { metrics } = await this.fpsTracker.measureInteraction(
|
||||
async () => {
|
||||
const page = this.getPage()
|
||||
|
||||
// Repeatedly create text shapes and frames during the measurement
|
||||
// window. Each cycle deletes the previous batch so text layouts
|
||||
// can't be cached, forcing TextManager.measureText /
|
||||
// measureTextSpans to run every time. The samples mix ASCII with
|
||||
// emoji ZWJ sequences, flags, and accents so the grapheme
|
||||
// segmenter path is exercised. See
|
||||
// https://github.com/tldraw/tldraw/issues/9112.
|
||||
await page.evaluate(() => {
|
||||
const editor = (window as any).editor
|
||||
const tldrawApi = (window as any).tldrawApi
|
||||
if (!editor || !tldrawApi) return
|
||||
|
||||
const textSamples = [
|
||||
'Performance text measurement',
|
||||
'Lorem ipsum dolor sit amet, consectetur adipiscing',
|
||||
'Hello 👨👩👧 family',
|
||||
'Flag 🇫🇷 with accents café résumé',
|
||||
'Multi-line\ntext content\nwith line breaks',
|
||||
'Emoji 👍🏽 skin tone modifier 🎉 party',
|
||||
]
|
||||
|
||||
// Seeded RNG for reproducible layouts.
|
||||
let seed = 9112
|
||||
const next = () => {
|
||||
seed = (seed * 9301 + 49297) % 233280
|
||||
return seed / 233280
|
||||
}
|
||||
const range = (min: number, max: number) => Math.floor(next() * (max - min + 1)) + min
|
||||
const choice = <T>(arr: T[]): T => arr[range(0, arr.length - 1)]
|
||||
const sizes = ['s', 'm', 'l', 'xl']
|
||||
|
||||
let batch = 0
|
||||
const createInterval = setInterval(() => {
|
||||
const prev = editor.getCurrentPageShapes()
|
||||
if (prev.length) {
|
||||
editor.deleteShapes(prev.map((s: any) => s.id))
|
||||
}
|
||||
|
||||
const shapes: any[] = []
|
||||
for (let i = 0; i < 25; i++) {
|
||||
const sample = `${choice(textSamples)} ${i}`
|
||||
shapes.push({
|
||||
id: `shape:text-${batch}-${i}`,
|
||||
type: 'text',
|
||||
x: range(0, 1600),
|
||||
y: range(0, 1000),
|
||||
props: {
|
||||
richText: tldrawApi.toRichText(sample),
|
||||
size: choice(sizes),
|
||||
font: 'draw',
|
||||
},
|
||||
})
|
||||
}
|
||||
// Frames whose names end in emoji/accents exercise the
|
||||
// title measurement path from the original bug report.
|
||||
for (let i = 0; i < 5; i++) {
|
||||
shapes.push({
|
||||
id: `shape:frame-${batch}-${i}`,
|
||||
type: 'frame',
|
||||
x: range(0, 1600),
|
||||
y: range(0, 1000),
|
||||
props: { w: 320, h: 220, name: `Frame ${choice(textSamples)}` },
|
||||
})
|
||||
}
|
||||
|
||||
editor.createShapes(shapes)
|
||||
batch++
|
||||
}, 100)
|
||||
|
||||
;(window as any).__textCreateInterval = createInterval
|
||||
})
|
||||
},
|
||||
{ warmupMs, measureMs }
|
||||
)
|
||||
|
||||
// Stop the creation interval and clean up.
|
||||
const page = this.getPage()
|
||||
await page.evaluate(() => {
|
||||
const interval = (window as any).__textCreateInterval
|
||||
if (interval) {
|
||||
clearInterval(interval)
|
||||
delete (window as any).__textCreateInterval
|
||||
}
|
||||
})
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
return this.finalizeTestResult('create_text_shapes', metrics, expectMinFps)
|
||||
}
|
||||
|
||||
async runFullPerformanceTest(): Promise<PerformanceTestResult[]> {
|
||||
const results: PerformanceTestResult[] = []
|
||||
|
||||
// Run all performance tests
|
||||
await this.setupHeavyBoard()
|
||||
results.push(await this.testShapeRotation())
|
||||
await this.setupHeavyBoard()
|
||||
results.push(await this.testShapeDragging())
|
||||
await this.setupHeavyBoard(50)
|
||||
results.push(await this.testShapeResizing())
|
||||
await this.setupHeavyBoard(1000)
|
||||
results.push(await this.testCanvasPanning())
|
||||
await this.setupHeavyBoard()
|
||||
results.push(await this.testCanvasZooming())
|
||||
results.push(await this.testTextShapeCreation())
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
updateBaseline(interaction: string, metrics: FPSMetrics, force = false): void {
|
||||
this.baselineManager.updateBaseline(interaction, metrics, this.environment, force)
|
||||
}
|
||||
|
||||
getBaseline(interaction: string) {
|
||||
return this.baselineManager.getBaseline(interaction, this.environment)
|
||||
}
|
||||
|
||||
setThresholds(regressionThreshold: number, warningThreshold: number): void {
|
||||
this.baselineManager.setThresholds(regressionThreshold, warningThreshold)
|
||||
}
|
||||
|
||||
// Helper method to control baseline auto-creation
|
||||
async runTestWithBaseline(
|
||||
testFn: () => Promise<{ metrics: FPSMetrics }>,
|
||||
interaction: string,
|
||||
autoCreate = true
|
||||
): Promise<PerformanceTestResult> {
|
||||
const { metrics } = await testFn()
|
||||
const comparison = this.baselineManager.compareWithBaseline(
|
||||
interaction,
|
||||
metrics,
|
||||
this.environment,
|
||||
autoCreate
|
||||
)
|
||||
|
||||
return {
|
||||
interaction,
|
||||
metrics,
|
||||
comparison,
|
||||
environment: this.environment,
|
||||
}
|
||||
}
|
||||
|
||||
// Common method for finalizing performance test results
|
||||
private finalizeTestResult(
|
||||
interaction: string,
|
||||
metrics: FPSMetrics,
|
||||
expectMinFps?: number
|
||||
): PerformanceTestResult {
|
||||
const comparison = this.baselineManager.compareWithBaseline(
|
||||
interaction,
|
||||
metrics,
|
||||
this.environment
|
||||
)
|
||||
|
||||
// Validate minimum FPS if specified
|
||||
if (expectMinFps && metrics.minFps < expectMinFps) {
|
||||
comparison.status = 'fail'
|
||||
comparison.message += ` Minimum FPS ${metrics.minFps} below expected ${expectMinFps}.`
|
||||
}
|
||||
|
||||
return {
|
||||
interaction,
|
||||
metrics,
|
||||
comparison,
|
||||
environment: this.environment,
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to get page reference from fps tracker
|
||||
private getPage(): Page {
|
||||
return (this.fpsTracker as any).page as Page
|
||||
}
|
||||
|
||||
// Method to manually create/update baselines
|
||||
async establishBaselines(): Promise<void> {
|
||||
await this.setupHeavyBoard()
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('🔄 Establishing performance baselines...')
|
||||
|
||||
const interactions = [
|
||||
{ name: 'rotate_shapes', testFn: () => this.testShapeRotation() },
|
||||
{ name: 'drag_shapes', testFn: () => this.testShapeDragging() },
|
||||
{ name: 'resize_shapes', testFn: () => this.testShapeResizing() },
|
||||
{ name: 'canvas_panning', testFn: () => this.testCanvasPanning() },
|
||||
{ name: 'canvas_zooming', testFn: () => this.testCanvasZooming() },
|
||||
{ name: 'create_text_shapes', testFn: () => this.testTextShapeCreation() },
|
||||
]
|
||||
|
||||
for (const { name, testFn } of interactions) {
|
||||
const result = await testFn()
|
||||
this.updateBaseline(name, result.metrics, true)
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`✅ Baseline set for ${name}: ${result.metrics.averageFps} FPS`)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('🎉 All baselines established!')
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to set up page for performance testing
|
||||
export async function setupPerformanceTest(
|
||||
{ page }: PlaywrightTestArgs,
|
||||
browserName: string
|
||||
): Promise<PerformanceTestSuite> {
|
||||
const perfSuite = new PerformanceTestSuite(page, browserName)
|
||||
await perfSuite.initialize(page)
|
||||
return perfSuite
|
||||
}
|
||||
|
||||
// Helper function to format performance results for reporting
|
||||
export function formatPerformanceResults(results: PerformanceTestResult[]): string {
|
||||
let report = '\n=== Performance Test Results ===\n'
|
||||
|
||||
for (const result of results) {
|
||||
const { interaction, metrics, comparison } = result
|
||||
const status = comparison.status.toUpperCase()
|
||||
const statusEmoji =
|
||||
comparison.status === 'pass' ? '✅' : comparison.status === 'warning' ? '⚠️' : '❌'
|
||||
|
||||
report += `\n${statusEmoji} ${interaction.replace('_', ' ').toUpperCase()} [${status}]\n`
|
||||
report += ` Average FPS: ${metrics.averageFps} (${comparison.avgFpsChange > 0 ? '+' : ''}${comparison.avgFpsChange.toFixed(1)}%)\n`
|
||||
report += ` Min FPS: ${metrics.minFps}, Max FPS: ${metrics.maxFps}\n`
|
||||
report += ` Samples: ${metrics.samples.length}, Duration: ${metrics.duration.toFixed(1)}s\n`
|
||||
report += ` ${comparison.message}\n`
|
||||
}
|
||||
|
||||
const failCount = results.filter((r) => r.comparison.status === 'fail').length
|
||||
const warningCount = results.filter((r) => r.comparison.status === 'warning').length
|
||||
const passCount = results.filter((r) => r.comparison.status === 'pass').length
|
||||
|
||||
report += `\n=== Summary ===\n`
|
||||
report += ` ✅ Passed: ${passCount}\n`
|
||||
report += ` ⚠️ Warnings: ${warningCount}\n`
|
||||
report += ` ❌ Failed: ${failCount}\n`
|
||||
|
||||
return report
|
||||
}
|
||||
7
apps/examples/e2e/global-setup.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { FullConfig } from '@playwright/test'
|
||||
|
||||
async function globalSetup(config: FullConfig) {
|
||||
return config
|
||||
}
|
||||
|
||||
export default globalSetup
|
||||
8
apps/examples/e2e/global-teardown.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { FullConfig } from '@playwright/test'
|
||||
|
||||
async function globalTeardown(config: FullConfig) {
|
||||
// noop
|
||||
return config
|
||||
}
|
||||
|
||||
export default globalTeardown
|
||||
@@ -0,0 +1,161 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import mermaidDefinitions from '../../src/examples/use-cases/hundred-mermaids/mermaids'
|
||||
import type { MermaidReadabilityFinding } from '../../src/misc/mermaidReadability'
|
||||
import test from '../fixtures/fixtures'
|
||||
import { setupPage } from '../shared-e2e'
|
||||
|
||||
// Converts every diagram in the "Hundreds of Mermaid diagrams" example and compares the result with
|
||||
// mermaid's own rendering (see `src/misc/mermaidReadability.ts`). Not part of `yarn e2e`: CI runs it
|
||||
// only when mermaid conversion changes (`.github/workflows/playwright-mermaid.yml`), and locally it
|
||||
// is `yarn e2e-mermaid-readability` from `apps/examples`.
|
||||
|
||||
// `mermaids.ts` groups its definitions by diagram type, in this order.
|
||||
const DIAGRAM_TYPES = ['flowchart', 'state', 'sequence', 'mindmap'] as const
|
||||
type DiagramType = (typeof DIAGRAM_TYPES)[number]
|
||||
|
||||
// `index` is the diagram's position within its group in `mermaids.ts`.
|
||||
type Finding = { diagram: DiagramType; index: number } & MermaidReadabilityFinding
|
||||
|
||||
function overlap(diagram: DiagramType, index: number, label: string, over: string): Finding {
|
||||
return { diagram, index, check: 'overlap', label, over }
|
||||
}
|
||||
|
||||
function midWordBreak(diagram: DiagramType, index: number, label: string, word: string): Finding {
|
||||
return { diagram, index, check: 'mid-word break', label, word }
|
||||
}
|
||||
|
||||
function missingText(diagram: DiagramType, index: number, text: string): Finding {
|
||||
return { diagram, index, check: 'missing text', text }
|
||||
}
|
||||
|
||||
interface KnownProblem {
|
||||
reason: string
|
||||
findings: Finding[]
|
||||
}
|
||||
|
||||
// Problems that exist today. An entry that stops being found fails the check too, so the list
|
||||
// shrinks as they are fixed. Like the e2e snapshots, it matches chromium on Linux, where CI runs:
|
||||
// text metrics differ by a pixel elsewhere, so a local run on macOS can disagree on an entry that
|
||||
// is a pixel from colliding.
|
||||
const KNOWN_PROBLEMS: KnownProblem[] = [
|
||||
{
|
||||
// Not a bug: a label is part of its arrow, so a self-message's sits on its loop. Placing it
|
||||
// above the loop, as mermaid does, would mean detaching it. Closed as intended in #10796.
|
||||
reason: 'Sequence self-message labels are centered on their loop, over the lifeline',
|
||||
findings: [
|
||||
overlap('sequence', 6, 'Recompute backoff', 'line'),
|
||||
overlap('sequence', 9, 'Recalculate cache', 'line'),
|
||||
overlap('sequence', 11, 'Apply discount', 'opt [Promo code provided]'),
|
||||
overlap('sequence', 24, '9 Apply discount rules', 'line'),
|
||||
overlap('sequence', 24, '9 Apply discount rules', 'rectangle with no text'),
|
||||
overlap('sequence', 24, '9 Apply discount rules', 'opt [Customer included coupon]'),
|
||||
overlap('sequence', 25, 'Recompute eviction policy', 'line'),
|
||||
overlap('sequence', 28, 'Retry parsing malformed input', 'line'),
|
||||
overlap('sequence', 28, 'Retry parsing malformed input', 'rectangle with no text'),
|
||||
overlap(
|
||||
'sequence',
|
||||
29,
|
||||
'perform an unusually long internal bookkeeping step with a very wide label',
|
||||
'line'
|
||||
),
|
||||
overlap(
|
||||
'sequence',
|
||||
32,
|
||||
'parse chunk 3 with a suspiciously long status label for layout testing',
|
||||
'line'
|
||||
),
|
||||
overlap(
|
||||
'sequence',
|
||||
32,
|
||||
'parse chunk 3 with a suspiciously long status label for layout testing',
|
||||
'rectangle with no text'
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
// Listed as a known limitation in #10773.
|
||||
reason: 'A self-loop on the top or bottom of a narrow state node gets too little label width',
|
||||
findings: [midWordBreak('state', 21, 'type character', 'chara/cter')],
|
||||
},
|
||||
{
|
||||
// Mermaid lays edge labels out for a smaller font than the node size they are drawn at, so a
|
||||
// label near a composite state's edge can reach past it. Accepted in #10804, which drew them at
|
||||
// the node size so they read at the same size as the nodes.
|
||||
reason: 'An edge label near a composite state reaches past its edge',
|
||||
findings: [overlap('state', 19, 'manager rejects', 'Review')],
|
||||
},
|
||||
{
|
||||
reason:
|
||||
'Frame titles are drawn at the top left of the frame, where an activation bar runs through them',
|
||||
findings: [
|
||||
'par [Fetch product data]',
|
||||
'[Check stock]',
|
||||
'opt [Customer included coupon]',
|
||||
'critical [Commit purchase]',
|
||||
'[Payment failed]',
|
||||
'par [Notify customer]',
|
||||
'[Track analytics]',
|
||||
].map((label) => overlap('sequence', 24, label, 'rectangle with no text')),
|
||||
},
|
||||
{
|
||||
reason: 'A note in an `option` section is drawn over the section title',
|
||||
findings: [overlap('sequence', 24, 'Stop flow before inventory mutation', '[Payment failed]')],
|
||||
},
|
||||
{
|
||||
reason:
|
||||
"A section title's box is taller than its text, so a frame nested right under it starts inside the box",
|
||||
findings: [overlap('sequence', 22, 'opt [New device detected]', '[Optional alert]')],
|
||||
},
|
||||
{
|
||||
// Not a conversion bug: inside a composite state, mermaid ignores a `<<fork>>` or `<<join>>`
|
||||
// declared after the state is first used and draws a plain named state. The conversion draws
|
||||
// the bar the source asks for.
|
||||
reason: 'Mermaid draws these forks and joins as named states',
|
||||
findings: [
|
||||
missingText('state', 11, 'Fork'),
|
||||
missingText('state', 11, 'Join'),
|
||||
missingText('state', 20, 'F'),
|
||||
missingText('state', 20, 'J'),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
function getKey(finding: Finding) {
|
||||
return JSON.stringify(finding, Object.keys(finding).sort())
|
||||
}
|
||||
|
||||
test.describe('Mermaid readability', () => {
|
||||
test.skip(({ isMobile }) => isMobile, 'Readability is measured at desktop size only')
|
||||
|
||||
DIAGRAM_TYPES.forEach((diagram, group) => {
|
||||
test(`${diagram} diagrams`, async ({ page, api }) => {
|
||||
test.setTimeout(120_000)
|
||||
await setupPage(page)
|
||||
await api.preloadFonts()
|
||||
// A font that fails to load is swapped for a system one, whose metrics wrap text
|
||||
// differently. That would surface below as breaks and overlaps, not as a font problem.
|
||||
const failedFonts = await page.evaluate(() => [
|
||||
...new Set(
|
||||
[...document.fonts].filter((font) => font.status === 'error').map((font) => font.family)
|
||||
),
|
||||
])
|
||||
expect(failedFonts).toEqual([])
|
||||
|
||||
const found: Finding[] = []
|
||||
for (const [index, definition] of mermaidDefinitions[group].entries()) {
|
||||
const findings = await api.checkMermaidReadability(definition)
|
||||
found.push(...findings.map((finding) => ({ diagram, index, ...finding })))
|
||||
}
|
||||
|
||||
const known = KNOWN_PROBLEMS.flatMap((problem) => problem.findings).filter(
|
||||
(finding) => finding.diagram === diagram
|
||||
)
|
||||
const foundKeys = new Set(found.map(getKey))
|
||||
const knownKeys = new Set(known.map(getKey))
|
||||
expect({
|
||||
unexpected: found.filter((finding) => !knownKeys.has(getKey(finding))),
|
||||
noLongerFound: known.filter((finding) => !foundKeys.has(getKey(finding))),
|
||||
}).toEqual({ unexpected: [], noLongerFound: [] })
|
||||
})
|
||||
})
|
||||
})
|
||||
202
apps/examples/e2e/perf/test-perf.spec.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { createAnalyticsReporter, getTestContext } from '../fixtures/analytics-reporter'
|
||||
import test from '../fixtures/fixtures'
|
||||
import {
|
||||
formatPerformanceResults,
|
||||
PerformanceTestResult,
|
||||
setupPerformanceTest,
|
||||
} from '../fixtures/perf-utils'
|
||||
import { setup } from '../shared-e2e'
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
function testOutput(result: PerformanceTestResult) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`${result.interaction} Performance: ${result.metrics.averageFps} avg FPS (min: ${result.metrics.minFps}, max: ${result.metrics.maxFps})\n` +
|
||||
`${result.comparison.message}\n` +
|
||||
'Comparison:' +
|
||||
JSON.stringify(result.comparison, null, 2)
|
||||
)
|
||||
|
||||
// Validate performance
|
||||
expect(result.metrics.averageFps).toBeGreaterThan(18)
|
||||
expect(result.comparison.status).not.toBe('fail')
|
||||
|
||||
// Update baseline if this is a significant improvement
|
||||
if (result.comparison.isImprovement) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Consider updating baseline - performance improved!')
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Performance Tests', () => {
|
||||
test.beforeEach(setup)
|
||||
test.setTimeout(120000) // Increase timeout
|
||||
test.describe.configure({ mode: 'serial' }) // Run tests in series to avoid resource conflicts
|
||||
|
||||
test('Baseline FPS Performance - Desktop', async ({
|
||||
page,
|
||||
context,
|
||||
request,
|
||||
browserName,
|
||||
isMobile,
|
||||
}) => {
|
||||
if (isMobile) return
|
||||
|
||||
const perfSuite = await setupPerformanceTest({ page, context, request }, browserName)
|
||||
const analyticsReporter = createAnalyticsReporter()
|
||||
|
||||
// Run full performance test suite
|
||||
const results = await perfSuite.runFullPerformanceTest()
|
||||
|
||||
// Log detailed results
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(formatPerformanceResults(results))
|
||||
|
||||
// Send analytics
|
||||
const testContext = getTestContext('Baseline FPS Performance - Desktop')
|
||||
await analyticsReporter.reportPerformanceMetrics(results, testContext)
|
||||
|
||||
// Send alerts for any regressions
|
||||
for (const result of results.filter((r) => r.comparison.status === 'fail')) {
|
||||
await analyticsReporter.reportRegressionAlert(result, testContext)
|
||||
}
|
||||
|
||||
// Check for any failures
|
||||
const failedTests = results.filter((result) => result.comparison.status === 'fail')
|
||||
if (failedTests.length > 0) {
|
||||
const failureMessages = failedTests
|
||||
.map((t) => `${t.interaction}: ${t.comparison.message}`)
|
||||
.join('\n')
|
||||
expect(failedTests.length, `Performance tests failed:\n${failureMessages}`).toBe(0)
|
||||
}
|
||||
|
||||
// Warn about any significant changes
|
||||
const warningTests = results.filter((result) => result.comparison.status === 'warning')
|
||||
if (warningTests.length > 0) {
|
||||
console.warn('Performance warnings detected:')
|
||||
warningTests.forEach((t) => {
|
||||
console.warn(` ${t.interaction}: ${t.comparison.message}`)
|
||||
})
|
||||
}
|
||||
|
||||
// Assert overall test health
|
||||
expect(results.length).toBeGreaterThan(0)
|
||||
|
||||
// Ensure all tests have reasonable FPS values
|
||||
results.forEach((result) => {
|
||||
expect(result.metrics.averageFps).toBeGreaterThan(0)
|
||||
expect(result.metrics.averageFps).toBeLessThanOrEqual(60)
|
||||
})
|
||||
})
|
||||
|
||||
test('Shape Rotation Performance', async ({ page, context, request, browserName, isMobile }) => {
|
||||
if (isMobile) return
|
||||
|
||||
const perfSuite = await setupPerformanceTest({ page, context, request }, browserName)
|
||||
await perfSuite.setupHeavyBoard()
|
||||
|
||||
testOutput(await perfSuite.testShapeRotation())
|
||||
})
|
||||
|
||||
test('Shape Dragging Performance', async ({ page, context, request, browserName, isMobile }) => {
|
||||
if (isMobile) return
|
||||
|
||||
const perfSuite = await setupPerformanceTest({ page, context, request }, browserName)
|
||||
await perfSuite.setupHeavyBoard()
|
||||
|
||||
testOutput(await perfSuite.testShapeDragging())
|
||||
})
|
||||
|
||||
test('Shape Resizing Performance', async ({ page, context, request, browserName, isMobile }) => {
|
||||
if (isMobile) return
|
||||
|
||||
const perfSuite = await setupPerformanceTest({ page, context, request }, browserName)
|
||||
await perfSuite.setupHeavyBoard(50)
|
||||
|
||||
testOutput(await perfSuite.testShapeResizing())
|
||||
})
|
||||
|
||||
test('Canvas Panning Performance', async ({ page, context, request, browserName, isMobile }) => {
|
||||
if (isMobile) return
|
||||
|
||||
const perfSuite = await setupPerformanceTest({ page, context, request }, browserName)
|
||||
await perfSuite.setupHeavyBoard(1000)
|
||||
|
||||
testOutput(await perfSuite.testCanvasPanning())
|
||||
})
|
||||
|
||||
test('Canvas Zooming Performance', async ({ page, context, request, browserName, isMobile }) => {
|
||||
if (isMobile) return
|
||||
|
||||
const perfSuite = await setupPerformanceTest({ page, context, request }, browserName)
|
||||
await perfSuite.setupHeavyBoard()
|
||||
|
||||
testOutput(await perfSuite.testCanvasZooming())
|
||||
})
|
||||
|
||||
test('Text Shape Creation Performance', async ({
|
||||
page,
|
||||
context,
|
||||
request,
|
||||
browserName,
|
||||
isMobile,
|
||||
}) => {
|
||||
if (isMobile) return
|
||||
|
||||
// The scenario creates its own text shapes, so no heavy board is needed.
|
||||
const perfSuite = await setupPerformanceTest({ page, context, request }, browserName)
|
||||
|
||||
testOutput(await perfSuite.testTextShapeCreation())
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Baseline Management', () => {
|
||||
test.skip('Establish All Performance Baselines', async ({
|
||||
page,
|
||||
context,
|
||||
request,
|
||||
browserName,
|
||||
}) => {
|
||||
test.setTimeout(120000) // 2 minutes for establishing all baselines
|
||||
|
||||
const perfSuite = await setupPerformanceTest({ page, context, request }, browserName)
|
||||
|
||||
// This will run all performance tests and establish baselines
|
||||
await perfSuite.establishBaselines()
|
||||
|
||||
// Verify baselines were created
|
||||
const rotateBaseline = perfSuite.getBaseline('rotate_shapes')
|
||||
const dragBaseline = perfSuite.getBaseline('drag_shapes')
|
||||
const resizeBaseline = perfSuite.getBaseline('resize_shapes')
|
||||
const panBaseline = perfSuite.getBaseline('canvas_panning')
|
||||
const zoomBaseline = perfSuite.getBaseline('canvas_zooming')
|
||||
|
||||
expect(rotateBaseline).toBeTruthy()
|
||||
expect(dragBaseline).toBeTruthy()
|
||||
expect(resizeBaseline).toBeTruthy()
|
||||
expect(panBaseline).toBeTruthy()
|
||||
expect(zoomBaseline).toBeTruthy()
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('✅ All baselines established successfully!')
|
||||
})
|
||||
|
||||
test.skip('Manual Baseline Update Example', async ({ page, context, request, browserName }) => {
|
||||
const perfSuite = await setupPerformanceTest({ page, context, request }, browserName)
|
||||
await perfSuite.setupHeavyBoard()
|
||||
|
||||
const result = await perfSuite.testShapeRotation()
|
||||
|
||||
// Example of manually updating baseline (use with caution)
|
||||
const existingBaseline = perfSuite.getBaseline('rotate_shapes')
|
||||
if (!existingBaseline || result.metrics.averageFps > existingBaseline.avgFps) {
|
||||
perfSuite.updateBaseline('rotate_shapes', result.metrics, true)
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`📊 Baseline updated: ${result.metrics.averageFps} FPS`)
|
||||
}
|
||||
|
||||
expect(result.comparison).toBeDefined()
|
||||
})
|
||||
})
|
||||
122
apps/examples/e2e/playwright.config.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import path from 'path'
|
||||
import { devices } from '@playwright/test'
|
||||
import type { PlaywrightTestConfig } from '@playwright/test'
|
||||
import { config as _config } from 'dotenv'
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
_config()
|
||||
|
||||
const __dirname = path.dirname(new URL(import.meta.url).pathname)
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
const config: PlaywrightTestConfig = {
|
||||
globalSetup: './global-setup.ts',
|
||||
globalTeardown: './global-teardown.ts',
|
||||
/* Maximum time one test can run for. */
|
||||
timeout: 30 * 1000,
|
||||
expect: {
|
||||
/**
|
||||
* Maximum time expect() should wait for the condition to be met.
|
||||
* For example in `await expect(locator).toHaveText();`
|
||||
*/
|
||||
timeout: 2000,
|
||||
toHaveScreenshot: {
|
||||
maxDiffPixelRatio: 0.0001,
|
||||
threshold: 0.01,
|
||||
},
|
||||
},
|
||||
// Run tests fully in parallel for faster execution.
|
||||
// Clipboard tests are already skipped due to flakiness.
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: false, // !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: process.env.CI ? [['list'], ['github'], ['html', { open: 'never' }]] : 'list',
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
|
||||
actionTimeout: 0,
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
// baseURL: 'http://localhost:5420',
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
headless: true, // !process.env.CI,
|
||||
video: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
},
|
||||
},
|
||||
|
||||
// {
|
||||
// name: 'webkit',
|
||||
// use: {
|
||||
// ...devices['Desktop Safari'],
|
||||
// },
|
||||
// },
|
||||
|
||||
// /* Test against mobile viewports. */
|
||||
{
|
||||
name: 'Mobile Chrome',
|
||||
use: {
|
||||
...devices['Pixel 5'],
|
||||
},
|
||||
},
|
||||
// {
|
||||
// name: 'Mobile Safari',
|
||||
// use: {
|
||||
// ...devices['iPhone 12'],
|
||||
// },
|
||||
// },
|
||||
|
||||
/* Test against branded browsers. */
|
||||
// {
|
||||
// name: 'Microsoft Edge',
|
||||
// use: {
|
||||
// channel: 'msedge',
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// name: 'Google Chrome',
|
||||
// use: {
|
||||
// channel: 'chrome',
|
||||
// },
|
||||
// },
|
||||
],
|
||||
|
||||
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
|
||||
outputDir: './test-results',
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: [
|
||||
{
|
||||
command: process.env.CI ? 'yarn preview' : 'yarn preview-examples',
|
||||
port: 5420,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
cwd: process.env.CI ? path.join(__dirname, '..') : path.join(__dirname, '../../..'),
|
||||
timeout: process.env.CI ? 30_000 : 300_000,
|
||||
},
|
||||
{
|
||||
command: 'yarn dev',
|
||||
port: 8989,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
cwd: path.join(__dirname, '../../../apps/bemo-worker'),
|
||||
timeout: 60_000,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default config
|
||||
163
apps/examples/e2e/shared-e2e.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import {
|
||||
BrowserContext,
|
||||
Locator,
|
||||
Page,
|
||||
PlaywrightTestArgs,
|
||||
PlaywrightWorkerArgs,
|
||||
} from '@playwright/test'
|
||||
import { type Editor } from 'tldraw'
|
||||
|
||||
declare const editor: Editor
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
export async function setup({ page, context }: PlaywrightTestArgs & PlaywrightWorkerArgs) {
|
||||
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
await setupPage(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* Smart setup that navigates on first run, then uses fast reset on subsequent runs.
|
||||
* Use this in beforeEach for optimal test performance with page reuse.
|
||||
*/
|
||||
export async function setupOrReset({ page, context }: { page: Page; context: BrowserContext }) {
|
||||
const url = page.url()
|
||||
if (!url.includes('end-to-end')) {
|
||||
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
await setupPage(page)
|
||||
} else {
|
||||
await hardResetEditor(page)
|
||||
}
|
||||
}
|
||||
|
||||
export async function setupWithShapes({ page }: PlaywrightTestArgs & PlaywrightWorkerArgs) {
|
||||
await setupPage(page)
|
||||
await setupPageWithShapes(page)
|
||||
}
|
||||
|
||||
export async function cleanup({ page }: PlaywrightTestArgs) {
|
||||
await cleanupPage(page)
|
||||
}
|
||||
|
||||
export async function setupPage(page: PlaywrightTestArgs['page']) {
|
||||
await page.goto('http://localhost:5420/end-to-end')
|
||||
await page.waitForSelector('.tl-canvas')
|
||||
await page.evaluate(() => {
|
||||
editor.user.updateUserPreferences({ animationSpeed: 0 })
|
||||
})
|
||||
await page.mouse.move(50, 50)
|
||||
// Ensure the container has focus for keyboard events
|
||||
await page.locator('.tl-container').focus()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fast reset of the editor state without page navigation.
|
||||
* Use this in beforeEach when the page is already set up via beforeAll.
|
||||
*/
|
||||
export async function hardResetEditor(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
// Clear all shapes and reset editor state
|
||||
editor.selectAll().deleteShapes(editor.getSelectedShapeIds())
|
||||
editor.setCurrentTool('select')
|
||||
editor.zoomToFit()
|
||||
editor.resetZoom()
|
||||
})
|
||||
await page.mouse.move(50, 50)
|
||||
// Ensure the container has focus for keyboard events
|
||||
await page.locator('.tl-container').focus()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fast reset and create test shapes without page navigation.
|
||||
* Use this in beforeEach when tests need shapes but want to avoid full setup.
|
||||
*/
|
||||
export async function hardResetWithShapes(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
// Clear all shapes and create test shapes
|
||||
editor.selectAll().deleteShapes(editor.getSelectedShapeIds())
|
||||
editor.createShapes([
|
||||
{ type: 'geo', x: 200, y: 200, props: { w: 100, h: 100, geo: 'rectangle' } },
|
||||
{ type: 'geo', x: 200, y: 250, props: { w: 100, h: 100, geo: 'rectangle' } },
|
||||
{ type: 'geo', x: 250, y: 300, props: { w: 100, h: 100, geo: 'rectangle' } },
|
||||
])
|
||||
editor.selectNone()
|
||||
editor.setCurrentTool('select')
|
||||
})
|
||||
await page.mouse.move(50, 50)
|
||||
// Ensure the container has focus for keyboard events
|
||||
await page.locator('.tl-container').focus()
|
||||
}
|
||||
|
||||
export async function setupPageWithShapes(page: PlaywrightTestArgs['page']) {
|
||||
// delete everything
|
||||
await page.keyboard.press('Control+a')
|
||||
await page.keyboard.press('Backspace')
|
||||
|
||||
// create shapes
|
||||
await page.keyboard.press('r')
|
||||
await page.mouse.click(200, 200)
|
||||
await page.keyboard.press('r')
|
||||
await page.mouse.click(200, 250)
|
||||
await page.keyboard.press('r')
|
||||
await page.mouse.click(250, 300)
|
||||
// deselect everything
|
||||
await page.keyboard.press('Escape')
|
||||
await page.keyboard.press('Escape')
|
||||
}
|
||||
|
||||
export async function cleanupPage(page: PlaywrightTestArgs['page']) {
|
||||
await page.keyboard.press('Control+a')
|
||||
await page.keyboard.press('Delete')
|
||||
}
|
||||
|
||||
export async function getAllShapeLabels(page: PlaywrightTestArgs['page']) {
|
||||
return await page
|
||||
.locator('.tl-shapes')
|
||||
.first()
|
||||
.evaluate((e) => {
|
||||
const labels: { index: string; label: string }[] = []
|
||||
for (const child of e.children) {
|
||||
const index = (child as HTMLDivElement).style.zIndex
|
||||
const label = child.querySelector('.tl-text-content') as HTMLDivElement
|
||||
labels.push({ index, label: label.innerText })
|
||||
}
|
||||
labels.sort((a, b) => (a.index > b.index ? 1 : -1))
|
||||
return labels.map((l) => l.label)
|
||||
})
|
||||
}
|
||||
|
||||
export async function getAllShapeTypes(page: PlaywrightTestArgs['page']) {
|
||||
return await page
|
||||
.locator('.tl-shape')
|
||||
.elementHandles()
|
||||
.then((handles) => Promise.all(handles.map((h) => h.getAttribute('data-shape-type'))))
|
||||
}
|
||||
|
||||
export async function withMenu<T>(page: Page, path: string, cb: (item: Locator) => Promise<T>) {
|
||||
const parts = path.split('.')
|
||||
const lastPartIdx = parts.length - 1
|
||||
for (let i = 0; i < lastPartIdx; i++) {
|
||||
const part = parts[i]
|
||||
if (i === 0) {
|
||||
// context menu should already be open
|
||||
if (part !== 'context-menu') {
|
||||
await page.getByTestId(`${part}.button`).click()
|
||||
}
|
||||
} else {
|
||||
await page.getByTestId(`${parts[0]}-sub.${part}-button`).click()
|
||||
}
|
||||
}
|
||||
|
||||
// last part!
|
||||
return await cb(page.getByTestId(`${parts[0]}.${parts[lastPartIdx]}`))
|
||||
}
|
||||
export async function clickMenu(page: Page, path: string) {
|
||||
await withMenu(page, path, (item) => item.click())
|
||||
}
|
||||
|
||||
// We need a way to wait for the editor to finish a tick
|
||||
export function sleepFrames(frames = 2): Promise<void> {
|
||||
return sleep(frames * (1000 / 60))
|
||||
}
|
||||
38
apps/examples/e2e/tests/README-PERFORMANCE.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Performance Testing
|
||||
|
||||
FPS performance tests for tldraw to detect regressions and track improvements.
|
||||
These are tracked in https://eu.posthog.com/project/45972/insights/ZdpGyj1r
|
||||
|
||||
```bash
|
||||
# Run all performance tests
|
||||
yarn e2e-perf
|
||||
|
||||
# With UI
|
||||
yarn e2e-perf-ui
|
||||
```
|
||||
|
||||
Baselines are automatically created on first run.
|
||||
|
||||
## Regression Detection
|
||||
|
||||
- **Fail**: >10% performance drop
|
||||
- **Warning**: 5-10% change
|
||||
- **Pass**: Performance stable
|
||||
|
||||
Results compared against baselines in `baselines/fps-baselines.json`.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
When `PERFORMANCE_ANALYTICS_ENABLED` enabled, sends performance metrics and regression alerts to PostHog.
|
||||
|
||||
```bash
|
||||
# Analytics (optional)
|
||||
PERFORMANCE_ANALYTICS_ENABLED=true
|
||||
POSTHOG_PROJECT_KEY=your-key
|
||||
|
||||
# CI context
|
||||
GIT_COMMIT=abc123
|
||||
GIT_BRANCH=main
|
||||
```
|
||||
90
apps/examples/e2e/tests/export-mermaid-snapshots.spec.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import assert from 'assert'
|
||||
import { rename, writeFile } from 'fs/promises'
|
||||
import { Page, expect } from '@playwright/test'
|
||||
import { Editor } from 'tldraw'
|
||||
import mermaidDefinitions from '../../src/examples/use-cases/hundred-mermaids/mermaids'
|
||||
import test, { ApiFixture } from '../fixtures/fixtures'
|
||||
import { hardResetEditor, setup } from '../shared-e2e'
|
||||
|
||||
declare const editor: Editor
|
||||
|
||||
interface MermaidSnapshotCase {
|
||||
name: string
|
||||
definition: string
|
||||
}
|
||||
|
||||
const snapshotCases: MermaidSnapshotCase[] = [
|
||||
{ name: 'flowchart decision', definition: mermaidDefinitions[0][0] },
|
||||
{ name: 'flowchart with subgraphs', definition: mermaidDefinitions[0][10] },
|
||||
{ name: 'flowchart with class defs', definition: mermaidDefinitions[0][15] },
|
||||
{ name: 'flowchart with custom edge styles', definition: mermaidDefinitions[0][17] },
|
||||
{ name: 'state diagram basic', definition: mermaidDefinitions[1][1] },
|
||||
{ name: 'state diagram with nested state', definition: mermaidDefinitions[1][8] },
|
||||
{ name: 'state diagram with fork join', definition: mermaidDefinitions[1][11] },
|
||||
{ name: 'sequence diagram basics', definition: mermaidDefinitions[2][0] },
|
||||
{ name: 'sequence diagram with alt', definition: mermaidDefinitions[2][10] },
|
||||
{ name: 'sequence diagram with parallel lanes', definition: mermaidDefinitions[2][12] },
|
||||
]
|
||||
|
||||
test.describe('Mermaid export snapshots', () => {
|
||||
test.skip(({ isMobile }) => isMobile, 'Mermaid export snapshots are desktop-only')
|
||||
|
||||
test.beforeEach(async ({ page, context }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('end-to-end')) {
|
||||
await setup({ page, context } as any)
|
||||
} else {
|
||||
await hardResetEditor(page)
|
||||
}
|
||||
})
|
||||
|
||||
for (const testCase of snapshotCases) {
|
||||
test(`${testCase.name} (light)`, async ({ page, api }) => {
|
||||
await page.evaluate(() => {
|
||||
editor.user.updateUserPreferences({ colorScheme: 'light' })
|
||||
editor
|
||||
.updateInstanceState({ exportBackground: true })
|
||||
.selectAll()
|
||||
.deleteShapes(editor.getSelectedShapeIds())
|
||||
})
|
||||
await api.resetMockShapeIds()
|
||||
await api.createMermaidDiagram(testCase.definition)
|
||||
await page.evaluate(() => {
|
||||
editor.selectAll()
|
||||
})
|
||||
|
||||
await snapshotTest(page, api)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
async function snapshotTest(page: Page, api: ApiFixture) {
|
||||
const downloadAndSnapshot = page.waitForEvent('download').then(async (download) => {
|
||||
const path = (await download.path()) as string
|
||||
assert(path)
|
||||
await rename(path, path + '.svg')
|
||||
await writeFile(
|
||||
path + '.html',
|
||||
`
|
||||
<!DOCTYPE html>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<img src="${path}.svg" />
|
||||
`,
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
await page.goto(`file://${path}.html`)
|
||||
const clip = await page.$eval('img', (img) => img.getBoundingClientRect())
|
||||
await expect(page).toHaveScreenshot({
|
||||
omitBackground: true,
|
||||
clip,
|
||||
fullPage: true,
|
||||
// Rasterizing the exported SVG moves box outlines by a pixel now and then, about 0.2% of the
|
||||
// image (#10758). Mermaid layout is also checked by position in `e2e/mermaid-readability`.
|
||||
maxDiffPixelRatio: 0.003,
|
||||
})
|
||||
})
|
||||
await api.exportAsSvg()
|
||||
await downloadAndSnapshot
|
||||
}
|
||||
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 49 KiB |
582
apps/examples/e2e/tests/export-snapshots-data.tsx
Normal file
@@ -0,0 +1,582 @@
|
||||
/* eslint-disable react/no-string-refs, tldraw/no-internal-imports */
|
||||
import { b64Vecs, degreesToRadians, TLShapeCrop, toRichText, VecModel } from 'tldraw'
|
||||
import { TL } from 'tldraw/src/test/test-jsx'
|
||||
|
||||
const convexPoints: VecModel[] = [
|
||||
{ x: 0, y: 0, z: 0.5 },
|
||||
{ x: 0.1, y: 0, z: 0.5 },
|
||||
{ x: 0.47, y: 0, z: 0.5 },
|
||||
{ x: 1.61, y: 0, z: 0.5 },
|
||||
{ x: 3.9, y: 0, z: 0.5 },
|
||||
{ x: 7.09, y: 0, z: 0.5 },
|
||||
{ x: 10.66, y: 0, z: 0.5 },
|
||||
{ x: 14.15, y: 0, z: 0.5 },
|
||||
{ x: 17.3, y: 0, z: 0.5 },
|
||||
{ x: 20.28, y: 0, z: 0.5 },
|
||||
{ x: 23.43, y: 0.01, z: 0.5 },
|
||||
{ x: 26.21, y: 0.33, z: 0.5 },
|
||||
{ x: 28.78, y: 1.22, z: 0.5 },
|
||||
{ x: 31.52, y: 2.42, z: 0.5 },
|
||||
{ x: 34.2, y: 3.92, z: 0.5 },
|
||||
{ x: 36.52, y: 5.72, z: 0.5 },
|
||||
{ x: 38.36, y: 7.62, z: 0.5 },
|
||||
{ x: 40.26, y: 10.03, z: 0.5 },
|
||||
{ x: 42.18, y: 12.96, z: 0.5 },
|
||||
{ x: 44.04, y: 16.13, z: 0.5 },
|
||||
{ x: 45.89, y: 19.87, z: 0.5 },
|
||||
{ x: 47.5, y: 24.12, z: 0.5 },
|
||||
{ x: 48.77, y: 28.45, z: 0.5 },
|
||||
{ x: 49.75, y: 32.78, z: 0.5 },
|
||||
{ x: 50.36, y: 36.93, z: 0.5 },
|
||||
{ x: 50.72, y: 41.07, z: 0.5 },
|
||||
{ x: 50.84, y: 45.22, z: 0.5 },
|
||||
{ x: 50.84, y: 48.58, z: 0.5 },
|
||||
{ x: 50.84, y: 52.02, z: 0.5 },
|
||||
{ x: 50.79, y: 55.21, z: 0.5 },
|
||||
{ x: 50.45, y: 57.41, z: 0.5 },
|
||||
{ x: 49.83, y: 59.48, z: 0.5 },
|
||||
{ x: 49.09, y: 61.37, z: 0.5 },
|
||||
{ x: 48.22, y: 63.04, z: 0.5 },
|
||||
{ x: 47.28, y: 64.65, z: 0.5 },
|
||||
{ x: 46.22, y: 66.3, z: 0.5 },
|
||||
{ x: 45.03, y: 68.03, z: 0.5 },
|
||||
{ x: 43.81, y: 69.72, z: 0.5 },
|
||||
{ x: 42.48, y: 71.36, z: 0.5 },
|
||||
{ x: 40.85, y: 73.1, z: 0.5 },
|
||||
{ x: 39.24, y: 74.55, z: 0.5 },
|
||||
{ x: 37.4, y: 75.93, z: 0.5 },
|
||||
{ x: 35.5, y: 77.19, z: 0.5 },
|
||||
{ x: 33.64, y: 78.12, z: 0.5 },
|
||||
{ x: 31.66, y: 79.04, z: 0.5 },
|
||||
{ x: 29.82, y: 79.77, z: 0.5 },
|
||||
{ x: 27.93, y: 80.3, z: 0.5 },
|
||||
{ x: 25.85, y: 80.75, z: 0.5 },
|
||||
{ x: 23.56, y: 81.14, z: 0.5 },
|
||||
{ x: 21.35, y: 81.52, z: 0.5 },
|
||||
{ x: 19.18, y: 81.81, z: 0.5 },
|
||||
{ x: 17.08, y: 81.95, z: 0.5 },
|
||||
{ x: 15.18, y: 81.99, z: 0.5 },
|
||||
{ x: 13.43, y: 81.99, z: 0.5 },
|
||||
{ x: 11.91, y: 81.99, z: 0.5 },
|
||||
{ x: 10.63, y: 81.99, z: 0.5 },
|
||||
{ x: 9.47, y: 81.99, z: 0.5 },
|
||||
{ x: 8.43, y: 81.99, z: 0.5 },
|
||||
{ x: 7.57, y: 81.99, z: 0.5 },
|
||||
{ x: 6.81, y: 81.99, z: 0.5 },
|
||||
{ x: 6.05, y: 81.99, z: 0.5 },
|
||||
{ x: 5.31, y: 81.99, z: 0.5 },
|
||||
{ x: 4.67, y: 81.99, z: 0.5 },
|
||||
{ x: 4.17, y: 81.99, z: 0.5 },
|
||||
{ x: 3.71, y: 81.99, z: 0.5 },
|
||||
{ x: 3.26, y: 81.99, z: 0.5 },
|
||||
{ x: 2.85, y: 81.99, z: 0.5 },
|
||||
{ x: 2.46, y: 81.99, z: 0.5 },
|
||||
{ x: 2.13, y: 81.99, z: 0.5 },
|
||||
{ x: 1.77, y: 81.99, z: 0.5 },
|
||||
{ x: 1.37, y: 81.99, z: 0.5 },
|
||||
{ x: 0.96, y: 81.99, z: 0.5 },
|
||||
{ x: 0.46, y: 81.99, z: 0.5 },
|
||||
{ x: -0.03, y: 81.99, z: 0.5 },
|
||||
{ x: -0.37, y: 81.99, z: 0.5 },
|
||||
{ x: -0.62, y: 82.01, z: 0.5 },
|
||||
]
|
||||
|
||||
export const frameContent = (
|
||||
<>
|
||||
<TL.geo
|
||||
richText={toRichText('content')}
|
||||
w={100}
|
||||
h={100}
|
||||
x={50}
|
||||
y={50}
|
||||
rotation={degreesToRadians(35)}
|
||||
fill="solid"
|
||||
color="orange"
|
||||
/>
|
||||
<TL.arrow start={{ x: 50, y: 50 }} end={{ x: 50, y: 20 }} />
|
||||
</>
|
||||
)
|
||||
|
||||
export const manAsset = (
|
||||
<TL.asset.image
|
||||
w={100}
|
||||
h={200}
|
||||
src="/man.png"
|
||||
name="man"
|
||||
isAnimated={false}
|
||||
mimeType="image/png"
|
||||
/>
|
||||
)
|
||||
export const manCrop: TLShapeCrop = {
|
||||
topLeft: { x: 0.25, y: 0.05 },
|
||||
bottomRight: { x: 0.75, y: 0.3 },
|
||||
}
|
||||
export const manCropAsCircle: TLShapeCrop = {
|
||||
topLeft: { x: 0.25, y: 0.05 },
|
||||
bottomRight: { x: 0.75, y: 0.3 },
|
||||
isCircle: true,
|
||||
}
|
||||
export const richText = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'heading',
|
||||
attrs: { dir: 'auto', level: 3 },
|
||||
content: [{ type: 'text', text: 'Headers for things that wrap across multiple lines' }],
|
||||
},
|
||||
{
|
||||
type: 'bulletList',
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
attrs: { dir: 'auto' },
|
||||
content: [{ type: 'text', text: 'testing 123' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
attrs: { dir: 'auto' },
|
||||
content: [
|
||||
{ type: 'text', text: 'lists and ' },
|
||||
{
|
||||
type: 'text',
|
||||
marks: [
|
||||
{
|
||||
type: 'link',
|
||||
attrs: {
|
||||
href: 'https://tldraw.dev',
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer nofollow',
|
||||
class: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
text: 'links',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
attrs: { dir: 'auto' },
|
||||
content: [
|
||||
{ type: 'text', marks: [{ type: 'bold' }], text: 'and' },
|
||||
{ type: 'text', text: ' ' },
|
||||
{ type: 'text', marks: [{ type: 'strike' }], text: 'obvs' },
|
||||
{ type: 'text', text: ' ' },
|
||||
{ type: 'text', marks: [{ type: 'highlight' }], text: 'rich' },
|
||||
{ type: 'text', text: ' ' },
|
||||
{ type: 'text', marks: [{ type: 'code' }], text: 'text' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
attrs: { dir: 'auto' },
|
||||
content: [
|
||||
{ type: 'text', marks: [{ type: 'highlight' }], text: 'highlight ' },
|
||||
{ type: 'text', marks: [{ type: 'highlight' }, { type: 'bold' }], text: 'across' },
|
||||
{ type: 'text', marks: [{ type: 'highlight' }], text: ' styles' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const richTextForArrow = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'heading',
|
||||
attrs: { dir: 'auto', level: 3 },
|
||||
content: [{ type: 'text', text: 'Headers' }],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
attrs: { dir: 'auto' },
|
||||
content: [
|
||||
{ type: 'text', marks: [{ type: 'highlight' }], text: 'rich' },
|
||||
{ type: 'text', text: ' ' },
|
||||
{ type: 'text', marks: [{ type: 'code' }], text: 'text' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const convexDrawShape = (
|
||||
<TL.draw
|
||||
x={0.62}
|
||||
y={0}
|
||||
ref="convex"
|
||||
color="black"
|
||||
fill="none"
|
||||
isClosed={false}
|
||||
isComplete={true}
|
||||
isPen={false}
|
||||
size="m"
|
||||
segments={[
|
||||
{
|
||||
type: 'free',
|
||||
path: b64Vecs.encodePoints(convexPoints),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
|
||||
export const heyDrawShape = (
|
||||
<TL.draw
|
||||
x={0}
|
||||
y={39.65841178508872}
|
||||
ref="hey"
|
||||
color="black"
|
||||
fill="none"
|
||||
isClosed={false}
|
||||
isComplete={true}
|
||||
isPen={false}
|
||||
size="m"
|
||||
segments={[
|
||||
{
|
||||
type: 'free',
|
||||
path: b64Vecs.encodePoints([
|
||||
{ x: 0, y: 0, z: 0.5 },
|
||||
{ x: 0.12, y: 0, z: 0.5 },
|
||||
{ x: 0.91, y: -0.18, z: 0.5 },
|
||||
{ x: 2.51, y: -0.94, z: 0.5 },
|
||||
{ x: 4.65, y: -2.47, z: 0.5 },
|
||||
{ x: 7.11, y: -4.78, z: 0.5 },
|
||||
{ x: 9.84, y: -7.98, z: 0.5 },
|
||||
{ x: 13.03, y: -12.61, z: 0.5 },
|
||||
{ x: 15.78, y: -17.73, z: 0.5 },
|
||||
{ x: 17.93, y: -22.87, z: 0.5 },
|
||||
{ x: 19.42, y: -27.3, z: 0.5 },
|
||||
{ x: 20.11, y: -30.11, z: 0.5 },
|
||||
{ x: 20.53, y: -32.62, z: 0.5 },
|
||||
{ x: 20.66, y: -35.23, z: 0.5 },
|
||||
{ x: 20.54, y: -36.97, z: 0.5 },
|
||||
{ x: 19.97, y: -38.12, z: 0.5 },
|
||||
{ x: 18.95, y: -39.25, z: 0.5 },
|
||||
{ x: 17.82, y: -39.85, z: 0.5 },
|
||||
{ x: 16.59, y: -40.07, z: 0.5 },
|
||||
{ x: 15.25, y: -40.08, z: 0.5 },
|
||||
{ x: 13.84, y: -39.7, z: 0.5 },
|
||||
{ x: 12.48, y: -38.72, z: 0.5 },
|
||||
{ x: 11.21, y: -37.3, z: 0.5 },
|
||||
{ x: 10.07, y: -35.5, z: 0.5 },
|
||||
{ x: 9.14, y: -33.38, z: 0.5 },
|
||||
{ x: 8.51, y: -30.99, z: 0.5 },
|
||||
{ x: 8.19, y: -28.34, z: 0.5 },
|
||||
{ x: 8.11, y: -25.55, z: 0.5 },
|
||||
{ x: 8.11, y: -22.75, z: 0.5 },
|
||||
{ x: 8.31, y: -20.23, z: 0.5 },
|
||||
{ x: 8.83, y: -17.85, z: 0.5 },
|
||||
{ x: 9.49, y: -15.27, z: 0.5 },
|
||||
{ x: 10.15, y: -12.53, z: 0.5 },
|
||||
{ x: 10.77, y: -9.56, z: 0.5 },
|
||||
{ x: 11.29, y: -6.5, z: 0.5 },
|
||||
{ x: 11.74, y: -3.53, z: 0.5 },
|
||||
{ x: 12.19, y: -0.31, z: 0.5 },
|
||||
{ x: 12.52, y: 2.88, z: 0.5 },
|
||||
{ x: 12.75, y: 5.9, z: 0.5 },
|
||||
{ x: 12.95, y: 8.95, z: 0.5 },
|
||||
{ x: 13.03, y: 11.73, z: 0.5 },
|
||||
{ x: 13.03, y: 14.14, z: 0.5 },
|
||||
{ x: 12.83, y: 16.09, z: 0.5 },
|
||||
{ x: 12.23, y: 17.74, z: 0.5 },
|
||||
{ x: 11.4, y: 19.09, z: 0.5 },
|
||||
{ x: 10.36, y: 20.15, z: 0.5 },
|
||||
{ x: 9.08, y: 20.99, z: 0.5 },
|
||||
{ x: 7.73, y: 21.59, z: 0.5 },
|
||||
{ x: 6.27, y: 21.97, z: 0.5 },
|
||||
{ x: 4.82, y: 22.08, z: 0.5 },
|
||||
{ x: 3.52, y: 21.99, z: 0.5 },
|
||||
{ x: 2.39, y: 21.44, z: 0.5 },
|
||||
{ x: 1.49, y: 20.34, z: 0.5 },
|
||||
{ x: 0.89, y: 18.96, z: 0.5 },
|
||||
{ x: 0.6, y: 17.5, z: 0.5 },
|
||||
{ x: 0.54, y: 16.08, z: 0.5 },
|
||||
{ x: 0.62, y: 14.79, z: 0.5 },
|
||||
{ x: 1.13, y: 13.53, z: 0.5 },
|
||||
{ x: 2.13, y: 12.22, z: 0.5 },
|
||||
{ x: 3.56, y: 10.8, z: 0.5 },
|
||||
{ x: 5.39, y: 9.18, z: 0.5 },
|
||||
{ x: 7.52, y: 7.49, z: 0.5 },
|
||||
{ x: 9.94, y: 5.75, z: 0.5 },
|
||||
{ x: 12.46, y: 3.98, z: 0.5 },
|
||||
{ x: 15.02, y: 2.32, z: 0.5 },
|
||||
{ x: 17.66, y: 0.54, z: 0.5 },
|
||||
{ x: 20.2, y: -1.35, z: 0.5 },
|
||||
{ x: 22.87, y: -3.45, z: 0.5 },
|
||||
{ x: 25.3, y: -5.53, z: 0.5 },
|
||||
{ x: 27.23, y: -7.49, z: 0.5 },
|
||||
{ x: 29.05, y: -9.64, z: 0.5 },
|
||||
{ x: 30.87, y: -12.22, z: 0.5 },
|
||||
{ x: 32.68, y: -15.35, z: 0.5 },
|
||||
{ x: 34.02, y: -18.4, z: 0.5 },
|
||||
{ x: 34.93, y: -21.39, z: 0.5 },
|
||||
{ x: 35.63, y: -24.49, z: 0.5 },
|
||||
{ x: 35.99, y: -27.13, z: 0.5 },
|
||||
{ x: 36.04, y: -28.96, z: 0.5 },
|
||||
{ x: 35.83, y: -30.34, z: 0.5 },
|
||||
{ x: 35.35, y: -31.44, z: 0.5 },
|
||||
{ x: 34.79, y: -32.04, z: 0.5 },
|
||||
{ x: 34.24, y: -32.33, z: 0.5 },
|
||||
{ x: 33.61, y: -32.41, z: 0.5 },
|
||||
{ x: 32.88, y: -32.36, z: 0.5 },
|
||||
{ x: 31.96, y: -31.95, z: 0.5 },
|
||||
{ x: 30.84, y: -30.99, z: 0.5 },
|
||||
{ x: 29.69, y: -29.69, z: 0.5 },
|
||||
{ x: 28.62, y: -28.16, z: 0.5 },
|
||||
{ x: 27.7, y: -26.41, z: 0.5 },
|
||||
{ x: 26.93, y: -24.43, z: 0.5 },
|
||||
{ x: 26.3, y: -22.19, z: 0.5 },
|
||||
{ x: 25.81, y: -19.62, z: 0.5 },
|
||||
{ x: 25.34, y: -16.39, z: 0.5 },
|
||||
{ x: 25.02, y: -13.18, z: 0.5 },
|
||||
{ x: 24.83, y: -9.63, z: 0.5 },
|
||||
{ x: 24.69, y: -6.01, z: 0.5 },
|
||||
{ x: 24.69, y: -3.02, z: 0.5 },
|
||||
{ x: 24.69, y: 0, z: 0.5 },
|
||||
{ x: 24.8, y: 2.74, z: 0.5 },
|
||||
{ x: 25.21, y: 5.11, z: 0.5 },
|
||||
{ x: 25.88, y: 7.15, z: 0.5 },
|
||||
{ x: 26.62, y: 8.87, z: 0.5 },
|
||||
{ x: 27.33, y: 10.2, z: 0.5 },
|
||||
{ x: 27.95, y: 11.12, z: 0.5 },
|
||||
{ x: 28.56, y: 11.81, z: 0.5 },
|
||||
{ x: 29.21, y: 12.34, z: 0.5 },
|
||||
{ x: 29.9, y: 12.72, z: 0.5 },
|
||||
{ x: 30.76, y: 13.03, z: 0.5 },
|
||||
{ x: 31.83, y: 13.23, z: 0.5 },
|
||||
{ x: 32.97, y: 13.28, z: 0.5 },
|
||||
{ x: 34.31, y: 13.22, z: 0.5 },
|
||||
{ x: 35.78, y: 12.84, z: 0.5 },
|
||||
{ x: 37.27, y: 12.05, z: 0.5 },
|
||||
{ x: 38.69, y: 11.06, z: 0.5 },
|
||||
{ x: 39.92, y: 9.99, z: 0.5 },
|
||||
{ x: 41.08, y: 8.71, z: 0.5 },
|
||||
{ x: 42.14, y: 7.13, z: 0.5 },
|
||||
{ x: 43.03, y: 5.41, z: 0.5 },
|
||||
{ x: 43.74, y: 3.61, z: 0.5 },
|
||||
{ x: 44.23, y: 1.73, z: 0.5 },
|
||||
{ x: 44.51, y: -0.23, z: 0.5 },
|
||||
{ x: 44.62, y: -2.13, z: 0.5 },
|
||||
{ x: 44.63, y: -3.61, z: 0.5 },
|
||||
{ x: 44.47, y: -4.88, z: 0.5 },
|
||||
{ x: 44.07, y: -5.88, z: 0.5 },
|
||||
{ x: 43.5, y: -6.42, z: 0.5 },
|
||||
{ x: 42.87, y: -6.81, z: 0.5 },
|
||||
{ x: 42.29, y: -7, z: 0.5 },
|
||||
{ x: 41.76, y: -7.06, z: 0.5 },
|
||||
{ x: 41.16, y: -6.99, z: 0.5 },
|
||||
{ x: 40.49, y: -6.64, z: 0.5 },
|
||||
{ x: 39.85, y: -5.97, z: 0.5 },
|
||||
{ x: 39.23, y: -5.13, z: 0.5 },
|
||||
{ x: 38.52, y: -4.11, z: 0.5 },
|
||||
{ x: 37.85, y: -3, z: 0.5 },
|
||||
{ x: 37.29, y: -2, z: 0.5 },
|
||||
{ x: 36.75, y: -1.06, z: 0.5 },
|
||||
{ x: 36.29, y: -0.14, z: 0.5 },
|
||||
{ x: 35.94, y: 0.72, z: 0.5 },
|
||||
{ x: 35.66, y: 1.75, z: 0.5 },
|
||||
{ x: 35.49, y: 2.84, z: 0.5 },
|
||||
{ x: 35.4, y: 3.93, z: 0.5 },
|
||||
{ x: 35.32, y: 5.19, z: 0.5 },
|
||||
{ x: 35.28, y: 6.54, z: 0.5 },
|
||||
{ x: 35.28, y: 7.9, z: 0.5 },
|
||||
{ x: 35.33, y: 9.08, z: 0.5 },
|
||||
{ x: 35.5, y: 10.03, z: 0.5 },
|
||||
{ x: 35.79, y: 10.87, z: 0.5 },
|
||||
{ x: 36.16, y: 11.56, z: 0.5 },
|
||||
{ x: 36.61, y: 12.13, z: 0.5 },
|
||||
{ x: 37.13, y: 12.61, z: 0.5 },
|
||||
{ x: 37.76, y: 13.1, z: 0.5 },
|
||||
{ x: 38.4, y: 13.58, z: 0.5 },
|
||||
{ x: 39.1, y: 13.96, z: 0.5 },
|
||||
{ x: 39.95, y: 14.36, z: 0.5 },
|
||||
{ x: 40.84, y: 14.69, z: 0.5 },
|
||||
{ x: 41.69, y: 14.88, z: 0.5 },
|
||||
{ x: 42.57, y: 15.02, z: 0.5 },
|
||||
{ x: 43.6, y: 15.1, z: 0.5 },
|
||||
{ x: 44.69, y: 15.08, z: 0.5 },
|
||||
{ x: 45.75, y: 14.8, z: 0.5 },
|
||||
{ x: 46.84, y: 14.18, z: 0.5 },
|
||||
{ x: 47.96, y: 13.35, z: 0.5 },
|
||||
{ x: 49.03, y: 12.36, z: 0.5 },
|
||||
{ x: 50, y: 11.23, z: 0.5 },
|
||||
{ x: 50.83, y: 9.93, z: 0.5 },
|
||||
{ x: 51.56, y: 8.45, z: 0.5 },
|
||||
{ x: 52.22, y: 6.81, z: 0.5 },
|
||||
{ x: 52.72, y: 5.09, z: 0.5 },
|
||||
{ x: 53.07, y: 3.35, z: 0.5 },
|
||||
{ x: 53.36, y: 1.6, z: 0.5 },
|
||||
{ x: 53.6, y: -0.35, z: 0.5 },
|
||||
{ x: 53.8, y: -2.37, z: 0.5 },
|
||||
{ x: 53.98, y: -4.06, z: 0.5 },
|
||||
{ x: 54.15, y: -5.39, z: 0.5 },
|
||||
{ x: 54.3, y: -6.4, z: 0.5 },
|
||||
{ x: 54.37, y: -6.95, z: 0.5 },
|
||||
{ x: 54.37, y: -7.18, z: 0.5 },
|
||||
{ x: 54.37, y: -7.21, z: 0.5 },
|
||||
{ x: 54.37, y: -7.12, z: 0.5 },
|
||||
{ x: 54.37, y: -6.92, z: 0.5 },
|
||||
{ x: 54.37, y: -6.43, z: 0.5 },
|
||||
{ x: 54.37, y: -5.71, z: 0.5 },
|
||||
{ x: 54.37, y: -4.8, z: 0.5 },
|
||||
{ x: 54.37, y: -3.66, z: 0.5 },
|
||||
{ x: 54.37, y: -2.34, z: 0.5 },
|
||||
{ x: 54.37, y: -0.89, z: 0.5 },
|
||||
{ x: 54.37, y: 0.58, z: 0.5 },
|
||||
{ x: 54.46, y: 2.04, z: 0.5 },
|
||||
{ x: 54.69, y: 3.49, z: 0.5 },
|
||||
{ x: 55.02, y: 4.89, z: 0.5 },
|
||||
{ x: 55.41, y: 6.29, z: 0.5 },
|
||||
{ x: 55.84, y: 7.62, z: 0.5 },
|
||||
{ x: 56.32, y: 8.89, z: 0.5 },
|
||||
{ x: 56.86, y: 10.2, z: 0.5 },
|
||||
{ x: 57.49, y: 11.48, z: 0.5 },
|
||||
{ x: 58.11, y: 12.51, z: 0.5 },
|
||||
{ x: 58.86, y: 13.46, z: 0.5 },
|
||||
{ x: 59.75, y: 14.33, z: 0.5 },
|
||||
{ x: 60.69, y: 14.83, z: 0.5 },
|
||||
{ x: 61.74, y: 15.08, z: 0.5 },
|
||||
{ x: 62.7, y: 15.21, z: 0.5 },
|
||||
{ x: 63.7, y: 15.24, z: 0.5 },
|
||||
{ x: 64.66, y: 15.06, z: 0.5 },
|
||||
{ x: 65.42, y: 14.5, z: 0.5 },
|
||||
{ x: 66.09, y: 13.69, z: 0.5 },
|
||||
{ x: 66.63, y: 12.77, z: 0.5 },
|
||||
{ x: 67.1, y: 11.64, z: 0.5 },
|
||||
{ x: 67.55, y: 10.06, z: 0.5 },
|
||||
{ x: 68.09, y: 8.03, z: 0.5 },
|
||||
{ x: 68.8, y: 5.7, z: 0.5 },
|
||||
{ x: 69.55, y: 3.22, z: 0.5 },
|
||||
{ x: 70.24, y: 0.97, z: 0.5 },
|
||||
{ x: 70.7, y: -0.61, z: 0.5 },
|
||||
{ x: 71, y: -1.73, z: 0.5 },
|
||||
{ x: 71.23, y: -2.55, z: 0.5 },
|
||||
{ x: 71.36, y: -2.95, z: 0.5 },
|
||||
{ x: 71.42, y: -3.12, z: 0.5 },
|
||||
{ x: 71.44, y: -3.17, z: 0.5 },
|
||||
{ x: 71.46, y: -3.15, z: 0.5 },
|
||||
{ x: 71.49, y: -2.95, z: 0.5 },
|
||||
{ x: 71.53, y: -2.34, z: 0.5 },
|
||||
{ x: 71.53, y: -1.18, z: 0.5 },
|
||||
{ x: 71.53, y: 0.66, z: 0.5 },
|
||||
{ x: 71.5, y: 2.95, z: 0.5 },
|
||||
{ x: 71.33, y: 5.58, z: 0.5 },
|
||||
{ x: 70.99, y: 8.57, z: 0.5 },
|
||||
{ x: 70.53, y: 11.96, z: 0.5 },
|
||||
{ x: 70.11, y: 15.29, z: 0.5 },
|
||||
{ x: 69.82, y: 18.28, z: 0.5 },
|
||||
{ x: 69.7, y: 21.19, z: 0.5 },
|
||||
{ x: 69.69, y: 23.9, z: 0.5 },
|
||||
{ x: 69.69, y: 26.33, z: 0.5 },
|
||||
{ x: 69.69, y: 28.61, z: 0.5 },
|
||||
{ x: 69.69, y: 30.96, z: 0.5 },
|
||||
{ x: 69.67, y: 33.31, z: 0.5 },
|
||||
{ x: 69.49, y: 35.59, z: 0.5 },
|
||||
{ x: 69.12, y: 37.74, z: 0.5 },
|
||||
{ x: 68.62, y: 39.65, z: 0.5 },
|
||||
{ x: 67.98, y: 41.3, z: 0.5 },
|
||||
{ x: 66.98, y: 42.84, z: 0.5 },
|
||||
{ x: 65.6, y: 44.27, z: 0.5 },
|
||||
{ x: 63.92, y: 45.56, z: 0.5 },
|
||||
{ x: 62.03, y: 46.71, z: 0.5 },
|
||||
{ x: 60.18, y: 47.41, z: 0.5 },
|
||||
{ x: 58.36, y: 47.69, z: 0.5 },
|
||||
{ x: 56.55, y: 47.57, z: 0.5 },
|
||||
{ x: 54.75, y: 46.87, z: 0.5 },
|
||||
{ x: 53.04, y: 45.75, z: 0.5 },
|
||||
{ x: 51.51, y: 44.61, z: 0.5 },
|
||||
{ x: 50.46, y: 43.69, z: 0.5 },
|
||||
{ x: 49.77, y: 42.93, z: 0.5 },
|
||||
{ x: 49.16, y: 42.03, z: 0.5 },
|
||||
{ x: 48.84, y: 41.13, z: 0.5 },
|
||||
{ x: 48.78, y: 40.33, z: 0.5 },
|
||||
{ x: 48.78, y: 39.54, z: 0.5 },
|
||||
{ x: 48.86, y: 38.75, z: 0.5 },
|
||||
{ x: 49.28, y: 38.01, z: 0.5 },
|
||||
{ x: 50.19, y: 37.36, z: 0.5 },
|
||||
{ x: 51.56, y: 36.7, z: 0.5 },
|
||||
{ x: 53.41, y: 36.07, z: 0.5 },
|
||||
{ x: 55.71, y: 35.44, z: 0.5 },
|
||||
{ x: 58.21, y: 34.85, z: 0.5 },
|
||||
{ x: 60.57, y: 34.32, z: 0.5 },
|
||||
{ x: 62.93, y: 33.75, z: 0.5 },
|
||||
{ x: 65.29, y: 33.12, z: 0.5 },
|
||||
{ x: 67.46, y: 32.5, z: 0.5 },
|
||||
{ x: 69.41, y: 31.8, z: 0.5 },
|
||||
{ x: 71.38, y: 30.83, z: 0.5 },
|
||||
{ x: 73.7, y: 29.37, z: 0.5 },
|
||||
{ x: 76.04, y: 27.45, z: 0.5 },
|
||||
{ x: 78.48, y: 24.96, z: 0.5 },
|
||||
{ x: 81.07, y: 21.89, z: 0.5 },
|
||||
{ x: 83.32, y: 18.78, z: 0.5 },
|
||||
{ x: 85.52, y: 15.01, z: 0.5 },
|
||||
{ x: 87.47, y: 10.56, z: 0.5 },
|
||||
{ x: 88.82, y: 5.98, z: 0.5 },
|
||||
{ x: 89.82, y: 1.05, z: 0.5 },
|
||||
{ x: 90.62, y: -4.02, z: 0.5 },
|
||||
{ x: 91.26, y: -9.09, z: 0.5 },
|
||||
{ x: 91.6, y: -14.06, z: 0.5 },
|
||||
{ x: 91.65, y: -19.16, z: 0.5 },
|
||||
{ x: 91.29, y: -24.1, z: 0.5 },
|
||||
{ x: 90.46, y: -27.87, z: 0.5 },
|
||||
{ x: 89.23, y: -30.92, z: 0.5 },
|
||||
{ x: 87.67, y: -33.59, z: 0.5 },
|
||||
{ x: 85.98, y: -35.5, z: 0.5 },
|
||||
{ x: 84.22, y: -36.87, z: 0.5 },
|
||||
{ x: 82.48, y: -37.85, z: 0.5 },
|
||||
{ x: 80.81, y: -38.35, z: 0.5 },
|
||||
{ x: 79.14, y: -38.5, z: 0.5 },
|
||||
{ x: 77.52, y: -38.52, z: 0.5 },
|
||||
{ x: 75.92, y: -38.28, z: 0.5 },
|
||||
{ x: 74.29, y: -37.58, z: 0.5 },
|
||||
{ x: 72.65, y: -36.53, z: 0.5 },
|
||||
{ x: 70.95, y: -35.18, z: 0.5 },
|
||||
{ x: 69.35, y: -33.68, z: 0.5 },
|
||||
{ x: 68.08, y: -32.31, z: 0.5 },
|
||||
{ x: 67.09, y: -31.05, z: 0.5 },
|
||||
{ x: 66.32, y: -29.84, z: 0.5 },
|
||||
{ x: 65.83, y: -28.72, z: 0.5 },
|
||||
{ x: 65.59, y: -27.68, z: 0.5 },
|
||||
{ x: 65.52, y: -26.71, z: 0.5 },
|
||||
{ x: 65.52, y: -25.66, z: 0.5 },
|
||||
{ x: 65.54, y: -24.54, z: 0.5 },
|
||||
{ x: 65.78, y: -23.6, z: 0.5 },
|
||||
{ x: 66.37, y: -22.77, z: 0.5 },
|
||||
{ x: 67.14, y: -22.04, z: 0.5 },
|
||||
{ x: 67.87, y: -21.52, z: 0.5 },
|
||||
{ x: 68.61, y: -21.2, z: 0.5 },
|
||||
{ x: 69.37, y: -21.04, z: 0.5 },
|
||||
{ x: 70.19, y: -21, z: 0.5 },
|
||||
{ x: 71.02, y: -21.2, z: 0.5 },
|
||||
{ x: 71.71, y: -21.75, z: 0.5 },
|
||||
{ x: 72.38, y: -22.5, z: 0.5 },
|
||||
{ x: 73.03, y: -23.28, z: 0.5 },
|
||||
{ x: 73.54, y: -24.03, z: 0.5 },
|
||||
{ x: 73.92, y: -24.68, z: 0.5 },
|
||||
{ x: 74.16, y: -25.17, z: 0.5 },
|
||||
{ x: 74.29, y: -25.47, z: 0.5 },
|
||||
{ x: 74.39, y: -25.67, z: 0.5 },
|
||||
{ x: 74.43, y: -25.8, z: 0.5 },
|
||||
{ x: 74.43, y: -25.88, z: 0.5 },
|
||||
{ x: 74.43, y: -25.96, z: 0.5 },
|
||||
{ x: 74.43, y: -26.06, z: 0.5 },
|
||||
{ x: 74.43, y: -26.16, z: 0.5 },
|
||||
{ x: 74.43, y: -26.26, z: 0.5 },
|
||||
{ x: 74.43, y: -26.34, z: 0.5 },
|
||||
{ x: 74.43, y: -26.4, z: 0.5 },
|
||||
]),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
1225
apps/examples/e2e/tests/export-snapshots.spec.tsx
Normal file
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 97 KiB |
328
apps/examples/e2e/tests/test-a11y-style-panel-navigation.spec.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
import { Page, expect } from '@playwright/test'
|
||||
import { Editor } from 'tldraw'
|
||||
import test from '../fixtures/fixtures'
|
||||
import { setupPage } from '../shared-e2e'
|
||||
|
||||
declare const editor: Editor
|
||||
|
||||
test.describe('A11y Style Panel Navigation', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupPage(page)
|
||||
})
|
||||
|
||||
test('can navigate between geo shape label editing and style panel with keyboard', async ({
|
||||
page,
|
||||
isMobile,
|
||||
}) => {
|
||||
if (isMobile) {
|
||||
// Skip on mobile as the keyboard navigation behavior is different
|
||||
test.skip()
|
||||
}
|
||||
|
||||
// Create a geo shape
|
||||
await page.keyboard.press('r') // Rectangle tool
|
||||
await page.mouse.click(200, 200)
|
||||
await page.keyboard.press('v') // Select tool
|
||||
|
||||
// Verify we have a geo shape selected
|
||||
const selectedShapes = await page.evaluate(() => editor.getSelectedShapes())
|
||||
expect(selectedShapes).toHaveLength(1)
|
||||
expect(selectedShapes[0].type).toBe('geo')
|
||||
|
||||
// Step 1: Press Enter to start editing the label
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
// Verify we're in editing mode
|
||||
const isEditingShape = await page.evaluate(() => editor.getEditingShapeId() !== null)
|
||||
expect(isEditingShape).toBe(true)
|
||||
await expect.poll(() => isFocusedOnRichTextEditor(page)).toBe(true)
|
||||
|
||||
// Type some text
|
||||
await page.keyboard.type('Test Label')
|
||||
|
||||
// Step 2: Press Escape to exit editing mode
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
// Verify we're no longer editing
|
||||
const isStillEditing = await page.evaluate(() => editor.getEditingShapeId() !== null)
|
||||
expect(isStillEditing).toBe(false)
|
||||
|
||||
// Step 3: Press Cmd+Enter to focus the style panel
|
||||
await page.keyboard.press('Meta+Enter')
|
||||
|
||||
// Verify that the first button in the style panel is focused
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const activeEl = document.activeElement
|
||||
return activeEl?.closest('.tlui-style-panel') !== null && activeEl?.tagName === 'BUTTON'
|
||||
})
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
// Step 4: Use arrow keys to navigate to a different color (assuming color buttons are first)
|
||||
await page.keyboard.press('ArrowRight')
|
||||
|
||||
// Verify focus has moved to the next button
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const activeEl = document.activeElement
|
||||
return activeEl?.tagName === 'BUTTON' && activeEl?.closest('.tlui-style-panel') !== null
|
||||
})
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
// Step 5: Press Enter to select the color
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
// Step 6: Press Escape to return focus to the shape. The Escape keydown
|
||||
// is what hands focus back to the canvas, and under load that single
|
||||
// keypress can be dropped, so re-send it until focus actually settles.
|
||||
await pressUntilFocusedOnCanvas(page)
|
||||
|
||||
// Step 7: Press Enter again to edit the label. Selecting a colour above makes
|
||||
// the style panel re-render, and its roving-tabindex toolbar can bounce focus
|
||||
// back onto the selected radio asynchronously — after Escape already returned
|
||||
// focus to the canvas. So drive editing through whatever focus state we land
|
||||
// in: Escape back to the canvas if focus bounced into the panel, otherwise
|
||||
// Enter to start editing. The label text is fully replaced below, so a stray
|
||||
// newline from a repeated Enter can't affect the result.
|
||||
await startEditingSelectedShapeViaKeyboard(page)
|
||||
|
||||
// The rich text editor focuses its contenteditable on a deferred timeout, so
|
||||
// wait for it to be the active element before typing or the first keystrokes
|
||||
// get dropped (matching the wait used when first entering editing above).
|
||||
await expect.poll(() => isFocusedOnRichTextEditor(page)).toBe(true)
|
||||
|
||||
// Replace the label text. Even once the contenteditable is focused, the
|
||||
// editor's async setup can still drop the leading keystrokes, so select-all,
|
||||
// type, and verify — retrying the whole select-and-replace until it sticks
|
||||
// rather than asserting a single racy attempt.
|
||||
await replaceSelectedShapeLabel(page, 'Updated Label')
|
||||
|
||||
// Exit editing
|
||||
await page.keyboard.press('Escape')
|
||||
})
|
||||
|
||||
// Skipped: the cmd+enter keyboard event isn't reliably picked up while editing rich text in the
|
||||
// test harness, so the body passes intermittently. Because it was marked `test.fail`, those
|
||||
// intermittent passes were reported as failures and flaked the suite. The behavior works in
|
||||
// practice; re-enable once the event dispatch can be made deterministic (a reliable way to send
|
||||
// the shortcut to the focused contenteditable).
|
||||
test.skip('in a geo shape, cmd+enter returns focus to the canvas', async ({ page, isMobile }) => {
|
||||
if (isMobile) {
|
||||
test.skip()
|
||||
}
|
||||
|
||||
// Create a geo shape
|
||||
await page.keyboard.press('r') // Rectangle tool
|
||||
await page.mouse.click(200, 200)
|
||||
await page.keyboard.press('v') // Select tool
|
||||
|
||||
// press enter
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
await expect.poll(() => isFocusedOnRichTextEditor(page)).toBe(true)
|
||||
|
||||
// expect editor to be in the editing state
|
||||
expect(await page.evaluate(() => editor.isIn('select.editing_shape'))).toBe(true)
|
||||
|
||||
// dispatch cmd+enter to the active element
|
||||
await page.keyboard.press('ControlOrMeta+Enter')
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
expect(await page.evaluate(() => editor.isIn('select.editing_shape'))).toBe(false)
|
||||
|
||||
await expect.poll(() => isFocusedOnCanvas(page)).toBe(true)
|
||||
})
|
||||
|
||||
test('in a note shape, cmd+enter creates the next shape and does not focus on the style panel', async ({
|
||||
page,
|
||||
isMobile,
|
||||
}) => {
|
||||
if (isMobile) {
|
||||
test.skip()
|
||||
}
|
||||
|
||||
// Create a note shape
|
||||
await page.keyboard.press('n') // Note tool
|
||||
await page.mouse.click(200, 200)
|
||||
await page.waitForTimeout(200)
|
||||
|
||||
await expect.poll(() => isFocusedOnRichTextEditor(page)).toBe(true)
|
||||
|
||||
const firstShapeId = await page.evaluate(() => {
|
||||
return editor.getOnlySelectedShape()?.id
|
||||
})
|
||||
expect(firstShapeId).toBeDefined()
|
||||
|
||||
// Press cmd+enter
|
||||
await page.keyboard.press('Meta+Enter')
|
||||
|
||||
// Should be on a new shape now
|
||||
const finalShapeId = await page.evaluate(() => {
|
||||
return editor.getOnlySelectedShape()?.id
|
||||
})
|
||||
expect(finalShapeId).toBeDefined()
|
||||
expect(finalShapeId).not.toBe(firstShapeId)
|
||||
|
||||
await expect.poll(() => isFocusedOnStylePanel(page)).toBe(false)
|
||||
await expect.poll(() => isFocusedOnCanvas(page)).toBe(false)
|
||||
await expect.poll(() => isFocusedOnRichTextEditor(page)).toBe(true)
|
||||
|
||||
// Press escape
|
||||
await pressUntilFocusedOnCanvas(page)
|
||||
})
|
||||
|
||||
test('style panel focus works with different geo shapes', async ({ page, isMobile }) => {
|
||||
if (isMobile) {
|
||||
test.skip()
|
||||
}
|
||||
|
||||
// Test with ellipse
|
||||
await page.keyboard.press('o') // Ellipse tool
|
||||
await page.mouse.click(300, 300)
|
||||
await page.keyboard.press('v') // Select tool
|
||||
|
||||
// Verify we can focus style panel
|
||||
await page.keyboard.press('Meta+Enter')
|
||||
|
||||
await expect.poll(() => isFocusedOnStylePanel(page)).toBe(true)
|
||||
|
||||
// Return to shape and test editing
|
||||
await page.keyboard.press('Escape')
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
const isEditing = await page.evaluate(() => editor.getEditingShapeId() !== null)
|
||||
expect(isEditing).toBe(true)
|
||||
})
|
||||
|
||||
test('style panel focus works with cmd+enter shortcut', async ({ page, isMobile }) => {
|
||||
if (isMobile) {
|
||||
test.skip()
|
||||
}
|
||||
|
||||
// Create a geo shape
|
||||
await page.keyboard.press('r') // Rectangle tool
|
||||
await page.mouse.click(200, 200)
|
||||
await page.keyboard.press('v') // Select tool
|
||||
|
||||
await expect.poll(() => isFocusedOnCanvas(page)).toBe(true)
|
||||
|
||||
// Test that cmd+enter focuses the style panel even without editing first
|
||||
await page.keyboard.press('Meta+Enter')
|
||||
|
||||
await expect.poll(() => isFocusedOnStylePanel(page)).toBe(true)
|
||||
|
||||
// Navigate in the style panel
|
||||
await page.keyboard.press('ArrowRight')
|
||||
|
||||
// Verify focus is still in style panel
|
||||
await expect.poll(() => isFocusedOnStylePanel(page)).toBe(true)
|
||||
|
||||
// Return to canvas. The Escape keydown is what restores focus to the
|
||||
// canvas, and that single keypress can be dropped under load, so re-send
|
||||
// it until focus actually settles instead of asserting once.
|
||||
await pressUntilFocusedOnCanvas(page)
|
||||
})
|
||||
})
|
||||
|
||||
// Escape returns focus to the canvas from wherever it currently is — the style
|
||||
// panel (see DefaultStylePanel's keydown handler, which calls
|
||||
// editor.getContainer().focus() synchronously and stops propagation) or a shape's
|
||||
// rich text editor (Escape exits editing). The transition is instant, so when focus
|
||||
// fails to settle it's because the single Escape keypress was dropped under load
|
||||
// rather than because it was slow — so re-send it until focus returns to the canvas.
|
||||
// The early return means we never press while already on the canvas, so a stray
|
||||
// repeat can't deselect the shape.
|
||||
async function pressUntilFocusedOnCanvas(page: Page) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
if (await isFocusedOnCanvas(page)) return true
|
||||
await page.keyboard.press('Escape')
|
||||
return isFocusedOnCanvas(page)
|
||||
},
|
||||
{ timeout: 5000, intervals: [100, 200, 300, 500] }
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
// Start editing the selected shape's label with the keyboard, tolerating both a
|
||||
// dropped keypress and the style panel's roving-tabindex focus bounce. Each round:
|
||||
// if focus has landed back in the style panel, Escape returns it to the canvas;
|
||||
// otherwise Enter starts editing the selected shape. We only press Enter while not
|
||||
// already editing, so we never inject a stray newline into an active label.
|
||||
async function startEditingSelectedShapeViaKeyboard(page: Page) {
|
||||
const isEditing = () => page.evaluate(() => editor.getEditingShapeId() !== null)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
if (await isEditing()) return true
|
||||
const isInStylePanel = await page.evaluate(
|
||||
() => document.activeElement?.closest('.tlui-style-panel') !== null
|
||||
)
|
||||
await page.keyboard.press(isInStylePanel ? 'Escape' : 'Enter')
|
||||
return isEditing()
|
||||
},
|
||||
{ timeout: 8000, intervals: [100, 200, 300, 500] }
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
// Select all of the editing shape's label text and replace it, tolerating dropped
|
||||
// keystrokes during the editor's async setup. Each attempt re-selects everything
|
||||
// and retypes, so a partially-typed result from a previous attempt is fully
|
||||
// overwritten rather than appended to — the poll converges once nothing drops.
|
||||
// The select-all must be ControlOrMeta: plain Meta+a selects nothing on Linux, so
|
||||
// every retry would append another copy instead of replacing.
|
||||
async function replaceSelectedShapeLabel(page: Page, text: string) {
|
||||
const getText = () =>
|
||||
page.evaluate(() => {
|
||||
const shape = editor.getSelectedShapes()[0] as any
|
||||
return editor.getShapeUtil(shape).getText(shape)
|
||||
})
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await page.keyboard.press('ControlOrMeta+a')
|
||||
await page.keyboard.type(text)
|
||||
return getText()
|
||||
},
|
||||
{ timeout: 8000, intervals: [100, 200, 300, 500] }
|
||||
)
|
||||
.toBe(text)
|
||||
}
|
||||
|
||||
async function isFocusedOnStylePanel(page: Page) {
|
||||
return await page.evaluate(() => {
|
||||
const activeEl = document.activeElement
|
||||
return (
|
||||
// check that the active element is within both the editor container and the style panel
|
||||
activeEl?.role === 'radio' && activeEl?.closest('.tlui-style-panel') !== null
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function isFocusedOnRichTextEditor(page: Page) {
|
||||
return await page.evaluate(() => {
|
||||
const activeEl = document.activeElement
|
||||
return (
|
||||
activeEl?.role === 'textbox' &&
|
||||
activeEl?.closest('.tl-container') !== null &&
|
||||
activeEl?.closest('.tlui-style-panel') === null
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function isFocusedOnCanvas(page: Page) {
|
||||
return await page.evaluate(() => {
|
||||
const activeEl = document.activeElement
|
||||
return (
|
||||
activeEl?.role === 'application' &&
|
||||
activeEl?.closest('.tl-container') !== null &&
|
||||
activeEl?.closest('.tlui-style-panel') === null
|
||||
)
|
||||
})
|
||||
}
|
||||
20
apps/examples/e2e/tests/test-actions-menu.spec.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import test from '../fixtures/fixtures'
|
||||
import { setupOrReset } from '../shared-e2e'
|
||||
|
||||
test.describe('actions menu', () => {
|
||||
test.beforeEach(setupOrReset)
|
||||
|
||||
test('you can open and close the actions menu', async ({ actionsMenu, menuClickCapture }) => {
|
||||
const { actionsMenuButton, actionsMenuContent } = actionsMenu
|
||||
await expect(actionsMenuContent).toBeHidden()
|
||||
await actionsMenuButton.click()
|
||||
await expect(actionsMenuContent).toBeVisible()
|
||||
await menuClickCapture.content.click()
|
||||
await expect(actionsMenuContent).toBeHidden()
|
||||
})
|
||||
|
||||
// ...
|
||||
// More tests here
|
||||
// ...
|
||||
})
|
||||
21
apps/examples/e2e/tests/test-api.spec.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import test from '../fixtures/fixtures'
|
||||
import { hardResetWithShapes, setupWithShapes } from '../shared-e2e'
|
||||
|
||||
test.beforeEach(async ({ page, context }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('end-to-end')) {
|
||||
await setupWithShapes({ page, context } as any)
|
||||
} else {
|
||||
await hardResetWithShapes(page)
|
||||
}
|
||||
})
|
||||
|
||||
test.describe('api', () => {
|
||||
for (const format of ['svg', 'png', 'jpeg', 'webp'] as const) {
|
||||
test(`export as ${format}`, async ({ page, api }) => {
|
||||
const downloadEvent = page.waitForEvent('download')
|
||||
await api.exportAsFormat(format)
|
||||
await downloadEvent
|
||||
})
|
||||
}
|
||||
})
|
||||
256
apps/examples/e2e/tests/test-camera.spec.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { CDPSession, Page, expect } from '@playwright/test'
|
||||
import { type Editor } from 'tldraw'
|
||||
import test from '../fixtures/fixtures'
|
||||
import { setupOrReset, sleep } from '../shared-e2e'
|
||||
|
||||
declare const editor: Editor
|
||||
|
||||
let client: CDPSession
|
||||
|
||||
const dispatchTouch = async (
|
||||
client: CDPSession,
|
||||
type: 'touchStart' | 'touchMove' | 'touchEnd' | 'touchCancel',
|
||||
touchPoints: { x: number; y: number }[]
|
||||
) => {
|
||||
await client.send('Input.dispatchTouchEvent', { type, touchPoints })
|
||||
}
|
||||
|
||||
const multiTouchGesture = async ({
|
||||
start,
|
||||
end,
|
||||
steps,
|
||||
}: {
|
||||
start: { x: number; y: number }[]
|
||||
end: { x: number; y: number }[]
|
||||
steps: number
|
||||
}) => {
|
||||
const finger1 = { x: start[0].x, y: start[0].y }
|
||||
const finger2 = { x: start[1].x, y: start[1].y }
|
||||
const finalTouch = [finger1, finger2]
|
||||
|
||||
await dispatchTouch(client, 'touchStart', [finger1, finger2])
|
||||
await sleep(10)
|
||||
|
||||
// how far should the fingers move each step to reach the end point?
|
||||
const finger1StepDistance = {
|
||||
x: (end[0].x - start[0].x) / steps,
|
||||
y: (end[0].y - start[0].y) / steps,
|
||||
}
|
||||
const finger2StepDistance = {
|
||||
x: (end[1].x - start[1].x) / steps,
|
||||
y: (end[1].y - start[1].y) / steps,
|
||||
}
|
||||
|
||||
for (let i = 1; i < steps; i++) {
|
||||
await dispatchTouch(client, 'touchMove', [
|
||||
{ x: finger1.x + i * finger1StepDistance.x, y: finger1.y + i * finger1StepDistance.y },
|
||||
{ x: finger2.x + i * finger2StepDistance.x, y: finger2.y + i * finger2StepDistance.y },
|
||||
])
|
||||
|
||||
await sleep(10)
|
||||
}
|
||||
|
||||
await dispatchTouch(client, 'touchEnd', finalTouch)
|
||||
await sleep(10)
|
||||
}
|
||||
|
||||
const scrollZoom = async ({
|
||||
page,
|
||||
currentZoomLevel,
|
||||
zoomDirection,
|
||||
steps,
|
||||
}: {
|
||||
page: Page
|
||||
currentZoomLevel: number
|
||||
zoomDirection: 'in' | 'out'
|
||||
steps: number
|
||||
}) => {
|
||||
page.keyboard.down('Control')
|
||||
// zooming with the scroll wheel is capped within the editor to make sure it's consistent between browsers,
|
||||
//scrolling further than 15px per event doesn't have any effect
|
||||
for (let i = 0; i < steps; i++) {
|
||||
await page.mouse.wheel(0, zoomDirection === 'out' ? -15 : 15)
|
||||
}
|
||||
page.keyboard.up('Control')
|
||||
// scrolling 15px on the mouse wheel changes the zoom level by 10%
|
||||
const expectedZoomLevel =
|
||||
zoomDirection === 'out' ? currentZoomLevel * 1.1 ** steps : currentZoomLevel / 1.1 ** steps
|
||||
|
||||
return expectedZoomLevel
|
||||
}
|
||||
|
||||
test.describe('camera', () => {
|
||||
test.beforeEach(setupOrReset)
|
||||
|
||||
test('panning', async ({ isMobile, page }) => {
|
||||
test.skip(!isMobile)
|
||||
client = await page.context().newCDPSession(page)
|
||||
expect(await page.evaluate(() => editor.inputs.getCurrentPagePoint())).toEqual({
|
||||
x: 50,
|
||||
y: 50,
|
||||
z: 0,
|
||||
})
|
||||
|
||||
// the fewer steps you use, the faster the pan. So the ratio of pixels moved is not one-to-one.
|
||||
await multiTouchGesture({
|
||||
start: [
|
||||
{ x: 50, y: 50 },
|
||||
{ x: 100, y: 100 },
|
||||
],
|
||||
end: [
|
||||
{ x: 100, y: 100 },
|
||||
{ x: 150, y: 150 },
|
||||
],
|
||||
steps: 50,
|
||||
})
|
||||
|
||||
// With current panning speed, the above gesture moves the camera by ~36px on each axis.
|
||||
// Use a tolerance to avoid brittleness across engines/emulation.
|
||||
const [x, y] = await page.evaluate(() => {
|
||||
const point = editor.inputs.getCurrentPagePoint()
|
||||
return [point.x, point.y]
|
||||
})
|
||||
expect(x).toBeGreaterThan(80)
|
||||
expect(x).toBeLessThan(95)
|
||||
expect(y).toBeGreaterThan(80)
|
||||
expect(y).toBeLessThan(95)
|
||||
})
|
||||
|
||||
test('pinching on trackpad', async ({ page, isMobile }) => {
|
||||
// Test is flaky, disabling.
|
||||
test.skip(true)
|
||||
|
||||
// pinching on trackpad is the same event as ctrl+scrollwheel
|
||||
test.skip(isMobile)
|
||||
expect(await page.evaluate(() => editor.getZoomLevel())).toBe(1)
|
||||
|
||||
let expectedZoomLevel = await scrollZoom({
|
||||
page,
|
||||
currentZoomLevel: 1,
|
||||
zoomDirection: 'in',
|
||||
steps: 3,
|
||||
})
|
||||
// toBeCloseTo is used to avoid floating point errors
|
||||
expect(await page.evaluate(() => editor.getZoomLevel())).toBeCloseTo(expectedZoomLevel, 1)
|
||||
|
||||
expectedZoomLevel = await scrollZoom({
|
||||
page,
|
||||
currentZoomLevel: expectedZoomLevel,
|
||||
zoomDirection: 'out',
|
||||
steps: 3,
|
||||
})
|
||||
|
||||
expect(await page.evaluate(() => editor.getZoomLevel())).toBeCloseTo(expectedZoomLevel, 1)
|
||||
})
|
||||
|
||||
test('pinching on touchscreen', async ({ page, isMobile }) => {
|
||||
test.skip(!isMobile)
|
||||
|
||||
client = await page.context().newCDPSession(page)
|
||||
|
||||
expect(await page.evaluate(() => editor.getZoomLevel())).toBe(1)
|
||||
|
||||
// zoom out
|
||||
await multiTouchGesture({
|
||||
start: [
|
||||
{ x: 100, y: 100 },
|
||||
{ x: 200, y: 200 },
|
||||
],
|
||||
end: [
|
||||
{ x: 149, y: 149 },
|
||||
{ x: 151, y: 151 },
|
||||
],
|
||||
steps: 50,
|
||||
})
|
||||
expect(await page.evaluate(() => editor.getZoomLevel())).toBe(0.05)
|
||||
|
||||
// now zoom in
|
||||
await multiTouchGesture({
|
||||
start: [
|
||||
{ x: 200, y: 200 },
|
||||
{ x: 200, y: 200 },
|
||||
],
|
||||
end: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 400, y: 400 },
|
||||
],
|
||||
steps: 50,
|
||||
})
|
||||
expect(await page.evaluate(() => editor.getZoomLevel())).toBe(8)
|
||||
})
|
||||
|
||||
test('two-finger pinch does not change the selection', async ({ page, isMobile }) => {
|
||||
test.skip(!isMobile)
|
||||
|
||||
client = await page.context().newCDPSession(page)
|
||||
|
||||
// Finger 1 lands here; finger 2 above it. Both are kept inside the mobile
|
||||
// viewport so the gesture registers.
|
||||
const f1 = { x: 196, y: 470, id: 0 }
|
||||
const f2 = { x: 196, y: 300, id: 1 }
|
||||
|
||||
// Shape "a" (selected, off to the side) plus shape "b" placed directly under
|
||||
// finger 1, so the first finger's pointer_down selects it.
|
||||
await page.evaluate((f) => {
|
||||
const p = editor.screenToPage({ x: f.x, y: f.y })
|
||||
editor.createShapes([
|
||||
{
|
||||
id: 'shape:e2eA',
|
||||
type: 'geo',
|
||||
x: -1000,
|
||||
y: -1000,
|
||||
props: { w: 100, h: 100, fill: 'solid' },
|
||||
},
|
||||
{
|
||||
id: 'shape:e2eB',
|
||||
type: 'geo',
|
||||
x: p.x - 60,
|
||||
y: p.y - 60,
|
||||
props: { w: 120, h: 120, fill: 'solid' },
|
||||
},
|
||||
] as any)
|
||||
editor.setSelectedShapes(['shape:e2eA'] as any)
|
||||
}, f1)
|
||||
|
||||
expect(await page.evaluate(() => editor.getSelectedShapeIds())).toEqual(['shape:e2eA'])
|
||||
|
||||
// Finger 1 down on shape b — through the real touch/pointer pipeline this
|
||||
// changes the selection to b.
|
||||
await client.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [f1] })
|
||||
await sleep(30)
|
||||
expect(await page.evaluate(() => editor.getSelectedShapeIds())).toEqual(['shape:e2eB'])
|
||||
|
||||
// Finger 2 joins, then both fingers move apart to pinch-zoom, then lift.
|
||||
await client.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [f1, f2] })
|
||||
await sleep(30)
|
||||
// The pinch has started, so the incidental selection is already rolled back —
|
||||
// the original selection is shown during the gesture, not only after it ends.
|
||||
expect(await page.evaluate(() => editor.getSelectedShapeIds())).toEqual(['shape:e2eA'])
|
||||
const steps = 12
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
await client.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchMove',
|
||||
touchPoints: [
|
||||
{ x: f1.x, y: f1.y + i * 8, id: 0 }, // finger 1 moves down
|
||||
{ x: f2.x, y: f2.y - i * 8, id: 1 }, // finger 2 moves up
|
||||
],
|
||||
})
|
||||
await sleep(10)
|
||||
}
|
||||
await client.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
|
||||
await sleep(100)
|
||||
|
||||
// The incidental selection of b is rolled back to the pre-gesture selection.
|
||||
expect(await page.evaluate(() => editor.getSelectedShapeIds())).toEqual(['shape:e2eA'])
|
||||
// ...and the pinch really did zoom (sanity check that a pinch happened).
|
||||
expect(await page.evaluate(() => editor.getZoomLevel())).not.toBe(1)
|
||||
})
|
||||
|
||||
test.fixme('minimap', async () => {
|
||||
// todo
|
||||
})
|
||||
|
||||
test.fixme('hand tool', async () => {
|
||||
// todo
|
||||
})
|
||||
})
|
||||
153
apps/examples/e2e/tests/test-canvas-events.spec.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import test, { expect, Page } from '@playwright/test'
|
||||
import { Editor } from 'tldraw'
|
||||
import { setupPage } from '../shared-e2e'
|
||||
|
||||
declare const __tldraw_editor_events: any[]
|
||||
|
||||
// We're just testing the events, not the actual results.
|
||||
|
||||
let page: Page
|
||||
|
||||
declare const editor: Editor
|
||||
|
||||
test.describe('Canvas events', () => {
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage()
|
||||
await setupPage(page)
|
||||
})
|
||||
|
||||
test.describe('pointer events', () => {
|
||||
test('pointer down', async () => {
|
||||
await page.mouse.move(200, 200) // to kill any double clicks
|
||||
await page.mouse.move(100, 100)
|
||||
await page.mouse.down()
|
||||
await page.waitForTimeout(20)
|
||||
expect(await page.evaluate(() => __tldraw_editor_events.at(-1))).toMatchObject({
|
||||
target: 'canvas',
|
||||
type: 'pointer',
|
||||
name: 'pointer_down',
|
||||
})
|
||||
})
|
||||
|
||||
test('pointer move', async () => {
|
||||
await page.mouse.move(200, 200) // to kill any double clicks
|
||||
await page.mouse.move(100, 100)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(101, 101)
|
||||
expect(await page.evaluate(() => __tldraw_editor_events.at(-1))).toMatchObject({
|
||||
target: 'canvas',
|
||||
type: 'pointer',
|
||||
name: 'pointer_move',
|
||||
})
|
||||
})
|
||||
|
||||
test('pointer up', async () => {
|
||||
await page.mouse.move(200, 200) // to kill any double clicks
|
||||
await page.mouse.move(100, 100)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(101, 101)
|
||||
await page.mouse.up()
|
||||
await page.waitForTimeout(20)
|
||||
expect(await page.evaluate(() => __tldraw_editor_events.at(-1))).toMatchObject({
|
||||
target: 'canvas',
|
||||
type: 'pointer',
|
||||
name: 'pointer_up',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('click events', () => {
|
||||
// todo
|
||||
})
|
||||
|
||||
test.describe('pinch events', () => {
|
||||
// todo
|
||||
})
|
||||
|
||||
test.describe('keyboard events', () => {
|
||||
// todo
|
||||
})
|
||||
|
||||
test.fixme('wheel events', async () => {
|
||||
await page.mouse.move(200, 200) // to kill any double clicks
|
||||
await page.mouse.move(100, 100)
|
||||
await page.mouse.wheel(10, 10)
|
||||
expect(await page.evaluate(() => __tldraw_editor_events.at(-1))).toMatchObject({
|
||||
type: 'wheel',
|
||||
name: 'wheel',
|
||||
delta: {
|
||||
x: -20,
|
||||
y: -20,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test.fixme('complete', async () => {
|
||||
await page.evaluate(async () => editor.complete())
|
||||
expect(await page.evaluate(() => __tldraw_editor_events.at(-1))).toMatchObject({
|
||||
type: 'misc',
|
||||
name: 'complete',
|
||||
})
|
||||
})
|
||||
|
||||
test.fixme('cancel', async () => {
|
||||
await page.evaluate(async () => editor.cancel())
|
||||
expect(await page.evaluate(() => __tldraw_editor_events.at(-1))).toMatchObject({
|
||||
type: 'misc',
|
||||
name: 'complete',
|
||||
})
|
||||
})
|
||||
|
||||
test.fixme('interrupt', async () => {
|
||||
await page.evaluate(async () => editor.interrupt())
|
||||
expect(await page.evaluate(() => __tldraw_editor_events.at(-1))).toMatchObject({
|
||||
type: 'misc',
|
||||
name: 'interrupt',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Shape events', () => {
|
||||
test.beforeEach(async ({ browser }) => {
|
||||
page = await browser.newPage()
|
||||
await setupPage(page)
|
||||
await page.keyboard.press('r')
|
||||
await page.mouse.click(150, 150)
|
||||
await page.mouse.move(0, 0)
|
||||
await page.keyboard.press('Escape')
|
||||
})
|
||||
|
||||
test('pointer down', async () => {
|
||||
await page.mouse.move(51, 51)
|
||||
await page.mouse.down()
|
||||
await page.waitForTimeout(20)
|
||||
expect(await page.evaluate(() => __tldraw_editor_events.at(-1))).toMatchObject({
|
||||
target: 'canvas',
|
||||
type: 'pointer',
|
||||
name: 'pointer_down',
|
||||
})
|
||||
})
|
||||
|
||||
test('pointer move', async () => {
|
||||
await page.mouse.move(51, 51)
|
||||
await page.mouse.move(52, 52)
|
||||
await page.waitForTimeout(20)
|
||||
expect(await page.evaluate(() => __tldraw_editor_events.at(-1))).toMatchObject({
|
||||
target: 'canvas',
|
||||
type: 'pointer',
|
||||
name: 'pointer_move',
|
||||
})
|
||||
})
|
||||
|
||||
test('pointer up', async () => {
|
||||
await page.mouse.move(51, 51)
|
||||
await page.mouse.down()
|
||||
await page.mouse.up()
|
||||
await page.waitForTimeout(20)
|
||||
expect(await page.evaluate(() => __tldraw_editor_events.at(-1))).toMatchObject({
|
||||
target: 'canvas',
|
||||
type: 'pointer',
|
||||
name: 'pointer_up',
|
||||
})
|
||||
})
|
||||
})
|
||||
270
apps/examples/e2e/tests/test-clipboard-events.spec.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { type Editor } from 'tldraw'
|
||||
import test from '../fixtures/fixtures'
|
||||
import { sleep } from '../shared-e2e'
|
||||
|
||||
declare const editor: Editor
|
||||
|
||||
test.describe('clipboard event callbacks', () => {
|
||||
test.beforeEach(async ({ page, context }) => {
|
||||
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
await page.goto('http://localhost:5420/clipboard-events/full')
|
||||
await page.waitForSelector('.tl-canvas')
|
||||
await page.evaluate(() => {
|
||||
editor.user.updateUserPreferences({ animationSpeed: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.evaluate(() => {
|
||||
editor.selectAll().deleteShapes(editor.getSelectedShapeIds())
|
||||
;(window as any).__tldraw_clipboard_log = []
|
||||
;(window as any).__tldraw_clipboard_state.disableCopy = false
|
||||
;(window as any).__tldraw_clipboard_state.disablePaste = false
|
||||
;(window as any).__tldraw_clipboard_state.filterRedOnCopy = false
|
||||
;(window as any).__tldraw_clipboard_state.filterRedOnPaste = false
|
||||
;(window as any).__tldraw_clipboard_state.handleRawPaste = false
|
||||
})
|
||||
|
||||
await page.evaluate(() => {
|
||||
editor.createShapes([{ type: 'geo', x: 100, y: 100, props: { w: 100, h: 100 } }])
|
||||
editor.selectAll()
|
||||
})
|
||||
await page.locator('.tl-container').focus()
|
||||
})
|
||||
|
||||
test('onBeforeCopyToClipboard runs on keyboard copy', async ({ page, isMac }) => {
|
||||
const modifier = isMac ? 'Meta' : 'Control'
|
||||
await page.keyboard.down(modifier)
|
||||
await page.keyboard.press('KeyC')
|
||||
await page.keyboard.up(modifier)
|
||||
await sleep(100)
|
||||
|
||||
const log = await page.evaluate(() => (window as any).__tldraw_clipboard_log)
|
||||
expect(log).toHaveLength(1)
|
||||
expect(log[0]).toMatchObject({ action: 'copy', source: 'native', prevented: false })
|
||||
})
|
||||
|
||||
test('onBeforeCopyToClipboard runs on keyboard cut', async ({ page, isMac }) => {
|
||||
const modifier = isMac ? 'Meta' : 'Control'
|
||||
await page.keyboard.down(modifier)
|
||||
await page.keyboard.press('KeyX')
|
||||
await page.keyboard.up(modifier)
|
||||
await sleep(100)
|
||||
|
||||
const log = await page.evaluate(() => (window as any).__tldraw_clipboard_log)
|
||||
expect(log).toHaveLength(1)
|
||||
expect(log[0]).toMatchObject({ action: 'cut', source: 'native', prevented: false })
|
||||
})
|
||||
|
||||
test('onBeforePasteFromClipboard runs on keyboard paste', async ({ page, isMac }) => {
|
||||
const modifier = isMac ? 'Meta' : 'Control'
|
||||
await page.keyboard.down(modifier)
|
||||
await page.keyboard.press('KeyC')
|
||||
await page.keyboard.up(modifier)
|
||||
await sleep(100)
|
||||
|
||||
await page.keyboard.down(modifier)
|
||||
await page.keyboard.press('KeyV')
|
||||
await page.keyboard.up(modifier)
|
||||
await sleep(100)
|
||||
|
||||
const log = await page.evaluate(() => (window as any).__tldraw_clipboard_log)
|
||||
const pasteEntry = log.find((e: any) => e.action === 'paste')
|
||||
expect(pasteEntry).toMatchObject({
|
||||
action: 'paste',
|
||||
source: 'native-event',
|
||||
prevented: false,
|
||||
})
|
||||
})
|
||||
|
||||
test('onBeforeCopyToClipboard can block copy', async ({ page, isMac }) => {
|
||||
await page.evaluate(() => {
|
||||
;(window as any).__tldraw_clipboard_state.disableCopy = true
|
||||
})
|
||||
|
||||
const modifier = isMac ? 'Meta' : 'Control'
|
||||
await page.keyboard.down(modifier)
|
||||
await page.keyboard.press('KeyC')
|
||||
await page.keyboard.up(modifier)
|
||||
await sleep(100)
|
||||
|
||||
const log = await page.evaluate(() => (window as any).__tldraw_clipboard_log)
|
||||
expect(log).toHaveLength(1)
|
||||
expect(log[0]).toMatchObject({ action: 'copy', source: 'native', prevented: true })
|
||||
})
|
||||
|
||||
test('onBeforePasteFromClipboard can block paste', async ({ page, isMac }) => {
|
||||
const modifier = isMac ? 'Meta' : 'Control'
|
||||
|
||||
// Copy normally first
|
||||
await page.keyboard.down(modifier)
|
||||
await page.keyboard.press('KeyC')
|
||||
await page.keyboard.up(modifier)
|
||||
await sleep(100)
|
||||
|
||||
// Now block paste
|
||||
await page.evaluate(() => {
|
||||
;(window as any).__tldraw_clipboard_state.disablePaste = true
|
||||
;(window as any).__tldraw_clipboard_log = []
|
||||
})
|
||||
|
||||
await page.keyboard.down(modifier)
|
||||
await page.keyboard.press('KeyV')
|
||||
await page.keyboard.up(modifier)
|
||||
await sleep(200)
|
||||
|
||||
const log = await page.evaluate(() => (window as any).__tldraw_clipboard_log)
|
||||
const pasteEntry = log.find((e: any) => e.action === 'paste')
|
||||
expect(pasteEntry).toMatchObject({
|
||||
action: 'paste',
|
||||
source: 'native-event',
|
||||
prevented: true,
|
||||
})
|
||||
|
||||
const shapeCount = await page.evaluate(() => editor.getCurrentPageShapes().length)
|
||||
expect(shapeCount).toBe(1)
|
||||
})
|
||||
|
||||
test('onBeforeCopyToClipboard can block cut', async ({ page, isMac }) => {
|
||||
await page.evaluate(() => {
|
||||
;(window as any).__tldraw_clipboard_state.disableCopy = true
|
||||
})
|
||||
|
||||
const modifier = isMac ? 'Meta' : 'Control'
|
||||
await page.keyboard.down(modifier)
|
||||
await page.keyboard.press('KeyX')
|
||||
await page.keyboard.up(modifier)
|
||||
await sleep(100)
|
||||
|
||||
const log = await page.evaluate(() => (window as any).__tldraw_clipboard_log)
|
||||
expect(log).toHaveLength(1)
|
||||
expect(log[0]).toMatchObject({ action: 'cut', source: 'native', prevented: true })
|
||||
|
||||
const shapeCount = await page.evaluate(() => editor.getCurrentPageShapes().length)
|
||||
expect(shapeCount).toBe(1)
|
||||
})
|
||||
|
||||
test('onBeforeCopyToClipboard can filter shapes from copy', async ({ page, isMac }) => {
|
||||
// Create a red shape and a blue shape
|
||||
await page.evaluate(() => {
|
||||
editor.selectAll().deleteShapes(editor.getSelectedShapeIds())
|
||||
editor.createShapes([
|
||||
{ type: 'geo', x: 100, y: 100, props: { w: 100, h: 100, color: 'red' } },
|
||||
{ type: 'geo', x: 300, y: 100, props: { w: 100, h: 100, color: 'blue' } },
|
||||
])
|
||||
editor.selectAll()
|
||||
;(window as any).__tldraw_clipboard_log = []
|
||||
;(window as any).__tldraw_clipboard_state.filterRedOnCopy = true
|
||||
})
|
||||
|
||||
const modifier = isMac ? 'Meta' : 'Control'
|
||||
|
||||
// Copy (should filter out the red shape)
|
||||
await page.keyboard.down(modifier)
|
||||
await page.keyboard.press('KeyC')
|
||||
await page.keyboard.up(modifier)
|
||||
await sleep(200)
|
||||
|
||||
// Delete all shapes, then paste
|
||||
await page.evaluate(() => {
|
||||
editor.selectAll().deleteShapes(editor.getSelectedShapeIds())
|
||||
})
|
||||
|
||||
await page.keyboard.down(modifier)
|
||||
await page.keyboard.press('KeyV')
|
||||
await page.keyboard.up(modifier)
|
||||
await sleep(200)
|
||||
|
||||
// Only the blue shape should have been pasted
|
||||
const shapes = await page.evaluate(() =>
|
||||
editor.getCurrentPageShapes().map((s: any) => s.props.color)
|
||||
)
|
||||
expect(shapes).toEqual(['blue'])
|
||||
|
||||
const log = await page.evaluate(() => (window as any).__tldraw_clipboard_log)
|
||||
const filterEntry = log.find((e: any) => e.action === 'filter-copy')
|
||||
expect(filterEntry).toMatchObject({
|
||||
action: 'filter-copy',
|
||||
detail: 'kept 1/2 shapes',
|
||||
})
|
||||
})
|
||||
|
||||
test('onBeforePasteFromClipboard can filter shapes on paste', async ({ page, isMac }) => {
|
||||
// Create a red shape and a blue shape, then copy them normally
|
||||
await page.evaluate(() => {
|
||||
editor.selectAll().deleteShapes(editor.getSelectedShapeIds())
|
||||
editor.createShapes([
|
||||
{ type: 'geo', x: 100, y: 100, props: { w: 100, h: 100, color: 'red' } },
|
||||
{ type: 'geo', x: 300, y: 100, props: { w: 100, h: 100, color: 'blue' } },
|
||||
])
|
||||
editor.selectAll()
|
||||
;(window as any).__tldraw_clipboard_log = []
|
||||
})
|
||||
|
||||
const modifier = isMac ? 'Meta' : 'Control'
|
||||
|
||||
// Copy both shapes normally
|
||||
await page.keyboard.down(modifier)
|
||||
await page.keyboard.press('KeyC')
|
||||
await page.keyboard.up(modifier)
|
||||
await sleep(200)
|
||||
|
||||
// Enable paste filter and delete all shapes
|
||||
await page.evaluate(() => {
|
||||
;(window as any).__tldraw_clipboard_state.filterRedOnPaste = true
|
||||
;(window as any).__tldraw_clipboard_log = []
|
||||
editor.selectAll().deleteShapes(editor.getSelectedShapeIds())
|
||||
})
|
||||
|
||||
// Paste (should filter out the red shape on the paste side)
|
||||
await page.keyboard.down(modifier)
|
||||
await page.keyboard.press('KeyV')
|
||||
await page.keyboard.up(modifier)
|
||||
await sleep(200)
|
||||
|
||||
const shapes = await page.evaluate(() =>
|
||||
editor.getCurrentPageShapes().map((s: any) => s.props.color)
|
||||
)
|
||||
expect(shapes).toEqual(['blue'])
|
||||
|
||||
const log = await page.evaluate(() => (window as any).__tldraw_clipboard_log)
|
||||
const filterEntry = log.find((e: any) => e.action === 'filter-paste')
|
||||
expect(filterEntry).toMatchObject({
|
||||
action: 'filter-paste',
|
||||
detail: 'kept 1/2 shapes',
|
||||
})
|
||||
})
|
||||
|
||||
test('onClipboardPasteRaw runs before parsed paste and can take over (keyboard)', async ({
|
||||
page,
|
||||
isMac,
|
||||
}) => {
|
||||
await page.evaluate(() => {
|
||||
;(window as any).__tldraw_clipboard_state.handleRawPaste = true
|
||||
;(window as any).__tldraw_clipboard_log = []
|
||||
})
|
||||
|
||||
const modifier = isMac ? 'Meta' : 'Control'
|
||||
await page.keyboard.down(modifier)
|
||||
await page.keyboard.press('KeyC')
|
||||
await page.keyboard.up(modifier)
|
||||
await sleep(100)
|
||||
|
||||
await page.keyboard.down(modifier)
|
||||
await page.keyboard.press('KeyV')
|
||||
await page.keyboard.up(modifier)
|
||||
await sleep(200)
|
||||
|
||||
const log = await page.evaluate(() => (window as any).__tldraw_clipboard_log)
|
||||
const rawEntry = log.find((e: any) => e.action === 'raw-paste')
|
||||
expect(rawEntry).toMatchObject({ source: 'native-event' })
|
||||
expect(rawEntry.detail).toContain('string')
|
||||
|
||||
const parsedPaste = log.find((e: any) => e.action === 'paste' && e.source === 'native-event')
|
||||
expect(parsedPaste).toBeUndefined()
|
||||
|
||||
const shapeCount = await page.evaluate(() => editor.getCurrentPageShapes().length)
|
||||
expect(shapeCount).toBe(1)
|
||||
})
|
||||
})
|
||||
104
apps/examples/e2e/tests/test-clipboard.spec.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { type Editor } from 'tldraw'
|
||||
import test from '../fixtures/fixtures'
|
||||
import { clickMenu, setup, sleep } from '../shared-e2e'
|
||||
|
||||
declare const editor: Editor
|
||||
|
||||
// these are skipped because they're flaky in CI :(
|
||||
test.describe.skip('clipboard tests', () => {
|
||||
test.beforeEach(setup)
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.keyboard.press('r')
|
||||
await page.mouse.move(100, 100)
|
||||
await page.mouse.down()
|
||||
await page.mouse.up()
|
||||
|
||||
expect(await page.evaluate(() => editor.getCurrentPageShapes().length)).toBe(1)
|
||||
expect(await page.evaluate(() => editor.getSelectedShapes().length)).toBe(1)
|
||||
})
|
||||
|
||||
test('copy and paste from keyboard shortcut', async ({ page, isMac }) => {
|
||||
const modifier = isMac ? 'Meta' : 'Control'
|
||||
|
||||
await page.keyboard.down(modifier)
|
||||
await page.keyboard.press('KeyC')
|
||||
await sleep(100)
|
||||
await page.keyboard.press('KeyV')
|
||||
await page.keyboard.up(modifier)
|
||||
|
||||
expect(await page.evaluate(() => editor.getCurrentPageShapes().length)).toBe(2)
|
||||
expect(await page.evaluate(() => editor.getSelectedShapes().length)).toBe(1)
|
||||
})
|
||||
|
||||
test('copy and paste from main menu', async ({ page }) => {
|
||||
await clickMenu(page, 'main-menu.edit.copy')
|
||||
await sleep(100)
|
||||
await clickMenu(page, 'main-menu.edit.paste')
|
||||
|
||||
expect(await page.evaluate(() => editor.getCurrentPageShapes().length)).toBe(2)
|
||||
expect(await page.evaluate(() => editor.getSelectedShapes().length)).toBe(1)
|
||||
})
|
||||
|
||||
test('copy and paste from context menu', async ({ page }) => {
|
||||
await page.mouse.click(100, 100, { button: 'right' })
|
||||
await clickMenu(page, 'context-menu.copy')
|
||||
await sleep(100)
|
||||
await page.mouse.move(200, 200)
|
||||
await page.mouse.click(100, 100, { button: 'right' })
|
||||
await clickMenu(page, 'context-menu.paste')
|
||||
|
||||
expect(await page.evaluate(() => editor.getCurrentPageShapes().length)).toBe(2)
|
||||
expect(await page.evaluate(() => editor.getSelectedShapes().length)).toBe(1)
|
||||
})
|
||||
|
||||
test('copy and paste png from context menu', async ({ page }) => {
|
||||
await page.mouse.click(100, 100, { button: 'right' })
|
||||
await clickMenu(page, 'context-menu.copy-as.copy-as-png')
|
||||
await sleep(100)
|
||||
|
||||
await page.mouse.move(400, 400)
|
||||
await page.mouse.click(100, 100, { button: 'right' })
|
||||
await clickMenu(page, 'context-menu.paste')
|
||||
await sleep(100)
|
||||
|
||||
expect(await page.evaluate(() => editor.getCurrentPageShapes().length)).toBe(2)
|
||||
expect(await page.evaluate(() => editor.getOnlySelectedShape())).toMatchObject({
|
||||
type: 'image',
|
||||
props: { w: 264, h: 264 },
|
||||
})
|
||||
const imageWidth = await page
|
||||
.locator('.tl-image')
|
||||
.evaluate((img) => (img as HTMLImageElement).naturalWidth)
|
||||
expect(imageWidth).toBe(528)
|
||||
})
|
||||
|
||||
test('copy png with context menu, paste with keyboard', async ({ page, isMac }) => {
|
||||
const modifier = isMac ? 'Meta' : 'Control'
|
||||
|
||||
await page.mouse.click(100, 100, { button: 'right' })
|
||||
await clickMenu(page, 'context-menu.copy-as.copy-as-png')
|
||||
await sleep(100)
|
||||
|
||||
await page.keyboard.down(modifier)
|
||||
await page.keyboard.press('KeyV')
|
||||
await sleep(100)
|
||||
await page.keyboard.up(modifier)
|
||||
await sleep(100)
|
||||
|
||||
expect(await page.evaluate(() => editor.getCurrentPageShapes().length)).toBe(2)
|
||||
|
||||
// image should come in at the same size (200x200 + padding)
|
||||
expect(await page.evaluate(() => editor.getOnlySelectedShape())).toMatchObject({
|
||||
type: 'image',
|
||||
props: { w: 264, h: 264 },
|
||||
})
|
||||
|
||||
// but the actual image should be 2x that for retina displays
|
||||
const imageWidth = await page
|
||||
.locator('.tl-image')
|
||||
.evaluate((img) => (img as HTMLImageElement).naturalWidth)
|
||||
expect(imageWidth).toBe(528)
|
||||
})
|
||||
})
|
||||
219
apps/examples/e2e/tests/test-commenting-a11y.spec.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { Page, expect } from '@playwright/test'
|
||||
import test from '../fixtures/fixtures'
|
||||
|
||||
// Pick the comment tool and click the canvas, then wait for the composer to take focus before
|
||||
// returning. Its autofocus runs on the next animation frame, so typing straight after the click
|
||||
// can land before it and be dropped — no text, nothing to post, no pin.
|
||||
async function startComment(page: Page) {
|
||||
await page.getByTestId('quick-actions.comment').click()
|
||||
await page.mouse.click(400, 300)
|
||||
await expect(
|
||||
page.locator('.tlui-cmt-canvas-composer [contenteditable="true"].tlui-cmt-input')
|
||||
).toBeFocused()
|
||||
}
|
||||
|
||||
// Place a comment at the same point, post it, and dismiss the thread the send leaves open.
|
||||
async function placeComment(page: Page, text: string) {
|
||||
await startComment(page)
|
||||
await page.keyboard.type(text)
|
||||
await page.keyboard.press('Enter')
|
||||
await page.keyboard.press('Escape')
|
||||
}
|
||||
|
||||
/**
|
||||
* Canvas comment markers are the only way into a thread, so they have to be reachable without a
|
||||
* pointer. These tests drive the commenting example with the keyboard alone — place a comment with
|
||||
* the mouse (creating one is a canvas gesture), then never touch the mouse again.
|
||||
*/
|
||||
test.describe('commenting a11y', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('http://localhost:5420/commenting/full')
|
||||
await page.waitForSelector('.tl-canvas')
|
||||
await page.locator('.tl-container').focus()
|
||||
})
|
||||
|
||||
test('a comment pin is a labelled button that opens its thread from the keyboard', async ({
|
||||
page,
|
||||
isMobile,
|
||||
}) => {
|
||||
// Keyboard navigation isn't the mobile interaction model.
|
||||
if (isMobile) test.skip()
|
||||
|
||||
// Place a comment: pick the comment tool, click the canvas, type, and send.
|
||||
const commentTool = page.getByTestId('quick-actions.comment')
|
||||
await commentTool.click()
|
||||
await expect(commentTool).toHaveAttribute('aria-pressed', 'true')
|
||||
await expect(commentTool).toHaveAttribute('data-isactive', 'true')
|
||||
await page.mouse.click(400, 300)
|
||||
await expect(
|
||||
page.locator('.tlui-cmt-canvas-composer [contenteditable="true"].tlui-cmt-input')
|
||||
).toBeFocused()
|
||||
await page.keyboard.type('hello from the keyboard')
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
// The pin exists and exposes button semantics with a name, not a bare div.
|
||||
const pin = page.locator('.tlui-cmt-canvas-pin__marker')
|
||||
await expect(pin).toHaveCount(1)
|
||||
await expect(pin).toHaveRole('button')
|
||||
await expect(pin).toHaveAttribute('aria-label', /comment by/i)
|
||||
|
||||
// Dismiss the thread the send left open, so the pin starts closed.
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(pin).toHaveAttribute('aria-expanded', 'false')
|
||||
|
||||
// Focusing the pin and pressing Enter opens the thread — no pointer involved.
|
||||
await pin.focus()
|
||||
await expect(pin).toBeFocused()
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(pin).toHaveAttribute('aria-expanded', 'true')
|
||||
await expect(page.locator('.tlui-cmt-canvas-popover')).toBeVisible()
|
||||
|
||||
// Space toggles it back closed, as a button should.
|
||||
await pin.focus()
|
||||
await page.keyboard.press(' ')
|
||||
await expect(pin).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
|
||||
test('the pin is reachable by tabbing, and the reply box has an accessible name', async ({
|
||||
page,
|
||||
isMobile,
|
||||
}) => {
|
||||
if (isMobile) test.skip()
|
||||
|
||||
await startComment(page)
|
||||
|
||||
// The composer is a contenteditable whose visible placeholder is aria-hidden, so without an
|
||||
// explicit name a screen reader lands on an unlabelled textbox.
|
||||
const composer = page.locator('.tlui-cmt-input').first()
|
||||
await expect(composer).toHaveAttribute('aria-label', /.+/)
|
||||
await expect(composer).toHaveRole('textbox')
|
||||
|
||||
await page.keyboard.type('first')
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
const pin = page.locator('.tlui-cmt-canvas-pin__marker')
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
// The pin participates in the tab order rather than being skipped as a div would be.
|
||||
await page.locator('.tl-container').focus()
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
for (let i = 0; i < 25; i++) {
|
||||
await page.keyboard.press('Tab')
|
||||
if (await pin.evaluate((el) => el === document.activeElement)) return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
{ timeout: 15000 }
|
||||
)
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('a pin comes after the UI, so "move focus to canvas" keeps the first tab stop', async ({
|
||||
page,
|
||||
isMobile,
|
||||
}) => {
|
||||
if (isMobile) test.skip()
|
||||
|
||||
await placeComment(page, 'first')
|
||||
await expect(page.locator('.tlui-cmt-canvas-pin__marker')).toHaveCount(1)
|
||||
|
||||
// The comments layer is portalled into the editor container, and a pin is a real button. Left
|
||||
// where React drops the portal it lands ahead of the UI, taking the first tab stop from the
|
||||
// skip link — which is then unreachable, and with it the keyboard route onto the canvas.
|
||||
const tabOrder = await page.evaluate(() => {
|
||||
const focusable = document.querySelectorAll(
|
||||
'a[href],button,input,select,textarea,[tabindex]:not([tabindex="-1"]),[contenteditable="true"]'
|
||||
)
|
||||
return {
|
||||
firstIsSkipLink: focusable[0]?.classList.contains('tl-skip-to-main-content') ?? false,
|
||||
pinIndex: [...focusable].findIndex((el) =>
|
||||
el.classList.contains('tlui-cmt-canvas-pin__marker')
|
||||
),
|
||||
count: focusable.length,
|
||||
}
|
||||
})
|
||||
expect(tabOrder.firstIsSkipLink).toBe(true)
|
||||
expect(tabOrder.pinIndex).toBe(tabOrder.count - 1)
|
||||
|
||||
// And tabbing into the container really does land on the skip link first.
|
||||
await page.locator('.tl-container').focus()
|
||||
await page.keyboard.press('Tab')
|
||||
await expect(page.locator('.tl-skip-to-main-content')).toBeFocused()
|
||||
})
|
||||
|
||||
test('leaving the edit composer hands focus back to the control that opened it', async ({
|
||||
page,
|
||||
isMobile,
|
||||
}) => {
|
||||
if (isMobile) test.skip()
|
||||
|
||||
await placeComment(page, 'first')
|
||||
|
||||
// Open the thread and spend its one Tab-into-the-reply-box, so nothing is left to pull focus
|
||||
// back into the panel on its own — the state the escape has to survive.
|
||||
await page.locator('.tlui-cmt-canvas-pin__marker').click()
|
||||
await page.locator('.tl-container').focus()
|
||||
await page.keyboard.press('Tab')
|
||||
const reply = page.locator('.tlui-cmt-canvas-popover [contenteditable="true"].tlui-cmt-input')
|
||||
await expect(reply).toBeFocused()
|
||||
|
||||
// Edit from the ⋯ menu: escaping should land back on the ⋯ button that opened it, not on the
|
||||
// editor container, where Tab would walk the app's UI instead of the thread's own controls.
|
||||
const moreButton = page.locator('.tlui-cmt-canvas-popover [data-cmt-more-for]')
|
||||
await moreButton.click()
|
||||
await page.locator('.tlui-cmt-menu-item', { hasText: 'Edit' }).click()
|
||||
await expect(page.locator('.tlui-cmt-editing [contenteditable="true"]')).toBeFocused()
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(page.locator('.tlui-cmt-canvas-popover')).toBeVisible()
|
||||
await expect(moreButton).toBeFocused()
|
||||
|
||||
// Arrow-up-to-edit comes from the reply box, so that's where escaping returns.
|
||||
await reply.focus()
|
||||
await page.keyboard.press('ArrowUp')
|
||||
await expect(page.locator('.tlui-cmt-editing [contenteditable="true"]')).toBeFocused()
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(page.locator('.tlui-cmt-canvas-popover')).toBeVisible()
|
||||
await expect(reply).toBeFocused()
|
||||
})
|
||||
|
||||
test('a stacked card is keyboard-openable without nesting its body links in a button', async ({
|
||||
page,
|
||||
isMobile,
|
||||
}) => {
|
||||
if (isMobile) test.skip()
|
||||
|
||||
// Two comments on the same point stack behind one badge. The first carries a link, which a
|
||||
// comment body renders as a real anchor.
|
||||
await placeComment(page, 'see https://tldraw.dev ')
|
||||
await placeComment(page, 'second')
|
||||
|
||||
// Open the stack list from the keyboard.
|
||||
const badge = page.locator('.tlui-cmt-canvas-stack-badge')
|
||||
await expect(badge).toHaveCount(1)
|
||||
await badge.focus()
|
||||
await page.keyboard.press('Enter')
|
||||
const card = page.locator('.tlui-cmt-stack-list__card').first()
|
||||
await expect(card).toBeVisible()
|
||||
await expect(card.locator('a[href]')).toHaveCount(1)
|
||||
|
||||
// A button can't contain interactive content, so the card's link must not sit inside one —
|
||||
// otherwise activation is unreliable and the control tree assistive tech sees is broken.
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
![...document.querySelectorAll('.tlui-cmt-stack-list__card a[href]')].some((a) =>
|
||||
a.closest('button')
|
||||
)
|
||||
)
|
||||
).toBe(true)
|
||||
|
||||
// The card is still openable by keyboard, through a named button covering its surface.
|
||||
const action = card.locator('.tlui-cmt-stack-list__card-action')
|
||||
await expect(action).toHaveAttribute('aria-label', /comment by/i)
|
||||
await action.focus()
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(page.locator('.tlui-cmt-stack-list__thread')).toHaveCount(1)
|
||||
})
|
||||
})
|
||||
298
apps/examples/e2e/tests/test-context-menu.spec.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
import test, { Page, expect } from '@playwright/test'
|
||||
import { hardResetEditor, setupPage, setupPageWithShapes, withMenu } from '../shared-e2e'
|
||||
|
||||
declare const __tldraw_ui_event: { name: string }
|
||||
// `editor` is a global exposed on the page, not imported at test-runner level.
|
||||
// Importing from 'tldraw' (even as `type`) breaks Node's --no-strip-types because
|
||||
// the tldraw package re-exports PointerEvent from react, which doesn't exist in Node.
|
||||
declare const editor: {
|
||||
inputs: any
|
||||
getCamera(): { x: number; y: number; z: number }
|
||||
getCurrentToolId(): string
|
||||
getPath(): string
|
||||
getSelectedShapeIds(): string[]
|
||||
}
|
||||
|
||||
// We're just testing the events, not the actual results.
|
||||
|
||||
let page: Page
|
||||
|
||||
// Mobile emulation in Playwright dispatches touch events for `mouse.click/down/up`,
|
||||
// so it can't simulate a real right-mouse-button sequence. Tests that depend on
|
||||
// right-click pointer events only run on desktop (Chromium).
|
||||
const isMobileProject = () => test.info().project.name.includes('Mobile')
|
||||
|
||||
test.describe('Context menu', async () => {
|
||||
test.beforeEach(async ({ browser }) => {
|
||||
if (!page) {
|
||||
page = await browser.newPage()
|
||||
await setupPage(page)
|
||||
} else {
|
||||
await hardResetEditor(page)
|
||||
}
|
||||
await setupPageWithShapes(page)
|
||||
})
|
||||
|
||||
test('distribute horizontal', async () => {
|
||||
// distribute horizontal
|
||||
await page.keyboard.press('Control+a')
|
||||
await page.mouse.click(200, 200, { button: 'right' })
|
||||
await withMenu(page, 'context-menu.arrange.distribute-horizontal', (item) => item.focus())
|
||||
await page.keyboard.press('Enter')
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'distribute-shapes',
|
||||
data: { operation: 'horizontal', source: 'context-menu' },
|
||||
})
|
||||
})
|
||||
|
||||
test('distribute vertical', async () => {
|
||||
// distribute vertical — Shift+Alt+V
|
||||
await page.keyboard.press('Control+a')
|
||||
await page.mouse.click(200, 200, { button: 'right' })
|
||||
await withMenu(page, 'context-menu.arrange.distribute-vertical', (item) => item.focus())
|
||||
await page.keyboard.press('Enter')
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'distribute-shapes',
|
||||
data: { operation: 'vertical', source: 'context-menu' },
|
||||
})
|
||||
})
|
||||
|
||||
test('reopens after moving and right-clicking again', async () => {
|
||||
test.skip(isMobileProject(), 'Mobile emulation does not simulate right-click')
|
||||
await page.mouse.click(200, 200, { button: 'right' })
|
||||
await expect(page.getByTestId('context-menu')).toBeVisible()
|
||||
|
||||
await page.mouse.move(600, 400)
|
||||
await page.mouse.down({ button: 'right' })
|
||||
await page.mouse.up({ button: 'right' })
|
||||
|
||||
await expect(page.getByTestId('context-menu')).toBeVisible()
|
||||
expect(
|
||||
await page.evaluate(() => ({
|
||||
isRightPointing: editor.inputs.getIsRightPointing(),
|
||||
isPanning: editor.inputs.getIsPanning(),
|
||||
}))
|
||||
).toMatchObject({
|
||||
isRightPointing: false,
|
||||
isPanning: false,
|
||||
})
|
||||
})
|
||||
|
||||
test('static right-click on plain canvas opens context menu', async () => {
|
||||
test.skip(isMobileProject(), 'Mobile emulation does not simulate right-click')
|
||||
// Right-click on empty canvas (no menu already open)
|
||||
await page.mouse.click(600, 400, { button: 'right' })
|
||||
await expect(page.getByTestId('context-menu')).toBeVisible()
|
||||
|
||||
// Wait for any async events (native contextmenu follows pointerup)
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
// Menu should still be visible — a leaked native contextmenu must not toggle it closed
|
||||
await expect(page.getByTestId('context-menu')).toBeVisible()
|
||||
expect(
|
||||
await page.evaluate(() => ({
|
||||
isRightPointing: editor.inputs.getIsRightPointing(),
|
||||
isPanning: editor.inputs.getIsPanning(),
|
||||
}))
|
||||
).toMatchObject({
|
||||
isRightPointing: false,
|
||||
isPanning: false,
|
||||
})
|
||||
})
|
||||
|
||||
test('right-click drag on plain canvas pans without opening context menu', async () => {
|
||||
const initialCamera = await page.evaluate(() => {
|
||||
const camera = editor.getCamera()
|
||||
return { x: camera.x, y: camera.y, z: camera.z }
|
||||
})
|
||||
|
||||
await page.mouse.move(300, 300)
|
||||
await page.mouse.down({ button: 'right' })
|
||||
await page.mouse.move(500, 500, { steps: 5 })
|
||||
|
||||
expect(
|
||||
await page.evaluate(() => ({
|
||||
isPanning: editor.inputs.getIsPanning(),
|
||||
isRightPointing: editor.inputs.getIsRightPointing(),
|
||||
}))
|
||||
).toMatchObject({
|
||||
isPanning: true,
|
||||
isRightPointing: true,
|
||||
})
|
||||
|
||||
await page.mouse.up({ button: 'right' })
|
||||
|
||||
// Context menu should NOT open after a drag
|
||||
await expect(page.getByTestId('context-menu')).toBeHidden()
|
||||
|
||||
// Camera should have moved
|
||||
expect(
|
||||
await page.evaluate((initialCamera) => {
|
||||
const camera = editor.getCamera()
|
||||
return camera.x !== initialCamera.x || camera.y !== initialCamera.y
|
||||
}, initialCamera)
|
||||
).toBe(true)
|
||||
|
||||
// Input state should be clean
|
||||
expect(
|
||||
await page.evaluate(() => ({
|
||||
isPanning: editor.inputs.getIsPanning(),
|
||||
isRightPointing: editor.inputs.getIsRightPointing(),
|
||||
}))
|
||||
).toMatchObject({
|
||||
isPanning: false,
|
||||
isRightPointing: false,
|
||||
})
|
||||
})
|
||||
|
||||
test('can begin a right-click drag after closing the menu', async () => {
|
||||
test.skip(isMobileProject(), 'Mobile emulation does not simulate right-click')
|
||||
await page.mouse.click(200, 200, { button: 'right' })
|
||||
await expect(page.getByTestId('context-menu')).toBeVisible()
|
||||
|
||||
await page.mouse.move(600, 400)
|
||||
const initialCamera = await page.evaluate(() => {
|
||||
const camera = editor.getCamera()
|
||||
return { x: camera.x, y: camera.y, z: camera.z }
|
||||
})
|
||||
|
||||
await page.mouse.down({ button: 'right' })
|
||||
await expect(page.getByTestId('context-menu')).toBeHidden()
|
||||
|
||||
await page.mouse.move(700, 500, { steps: 5 })
|
||||
|
||||
expect(
|
||||
await page.evaluate(() => ({
|
||||
camera: editor.getCamera(),
|
||||
isPanning: editor.inputs.getIsPanning(),
|
||||
isRightPointing: editor.inputs.getIsRightPointing(),
|
||||
}))
|
||||
).toMatchObject({
|
||||
isPanning: true,
|
||||
isRightPointing: true,
|
||||
})
|
||||
|
||||
await page.mouse.up({ button: 'right' })
|
||||
|
||||
expect(
|
||||
await page.evaluate((initialCamera) => {
|
||||
const camera = editor.getCamera()
|
||||
return {
|
||||
cameraChanged:
|
||||
camera.x !== initialCamera.x ||
|
||||
camera.y !== initialCamera.y ||
|
||||
camera.z !== initialCamera.z,
|
||||
isPanning: editor.inputs.getIsPanning(),
|
||||
isRightPointing: editor.inputs.getIsRightPointing(),
|
||||
}
|
||||
}, initialCamera)
|
||||
).toMatchObject({
|
||||
cameraChanged: true,
|
||||
isPanning: false,
|
||||
isRightPointing: false,
|
||||
})
|
||||
})
|
||||
|
||||
test('left-click closes menu and leaves editor idle', async () => {
|
||||
test.skip(isMobileProject(), 'Mobile emulation does not simulate right-click')
|
||||
// Move to a clearly empty spot so the right-click doesn't select or hover a shape
|
||||
await page.mouse.move(800, 600)
|
||||
await page.mouse.click(800, 600, { button: 'right' })
|
||||
await expect(page.getByTestId('context-menu')).toBeVisible()
|
||||
|
||||
// Left-click elsewhere on empty canvas — should close the menu
|
||||
await page.mouse.click(900, 700)
|
||||
await expect(page.getByTestId('context-menu')).toBeHidden()
|
||||
|
||||
// Tool state clean, pointer released — regardless of selection state
|
||||
expect(
|
||||
await page.evaluate(() => ({
|
||||
path: editor.getPath(),
|
||||
isPointing: editor.inputs.getIsPointing(),
|
||||
isRightPointing: editor.inputs.getIsRightPointing(),
|
||||
}))
|
||||
).toMatchObject({
|
||||
path: 'select.idle',
|
||||
isPointing: false,
|
||||
isRightPointing: false,
|
||||
})
|
||||
})
|
||||
|
||||
test('left-click drag with menu open starts brush selection', async () => {
|
||||
test.skip(isMobileProject(), 'Mobile emulation does not simulate right-click')
|
||||
await page.mouse.move(800, 600)
|
||||
await page.mouse.click(800, 600, { button: 'right' })
|
||||
await expect(page.getByTestId('context-menu')).toBeVisible()
|
||||
|
||||
// Left-click + drag entirely on empty canvas
|
||||
await page.mouse.move(900, 100)
|
||||
await page.mouse.down({ button: 'left' })
|
||||
await page.mouse.move(1100, 300, { steps: 10 })
|
||||
|
||||
// Menu closes, editor enters brushing state
|
||||
await expect(page.getByTestId('context-menu')).toBeHidden()
|
||||
expect(await page.evaluate(() => editor.getPath())).toBe('select.brushing')
|
||||
|
||||
await page.mouse.up({ button: 'left' })
|
||||
|
||||
// After release: brush ended, editor idle, pointer released
|
||||
expect(
|
||||
await page.evaluate(() => ({
|
||||
path: editor.getPath(),
|
||||
isPointing: editor.inputs.getIsPointing(),
|
||||
}))
|
||||
).toMatchObject({
|
||||
path: 'select.idle',
|
||||
isPointing: false,
|
||||
})
|
||||
})
|
||||
|
||||
test('consecutive right-clicks keep menu continuously visible', async () => {
|
||||
test.skip(isMobileProject(), 'Mobile emulation does not simulate right-click')
|
||||
await page.mouse.click(200, 200, { button: 'right' })
|
||||
await expect(page.getByTestId('context-menu')).toBeVisible()
|
||||
|
||||
// Right-click at a new location — observe visibility frequently during the transition
|
||||
// to catch any hidden flash between the old menu closing and the new one opening.
|
||||
await page.mouse.move(600, 400)
|
||||
const menu = page.getByTestId('context-menu')
|
||||
await page.mouse.down({ button: 'right' })
|
||||
await page.mouse.up({ button: 'right' })
|
||||
|
||||
// Poll visibility for ~300ms; it must never be hidden during the transition.
|
||||
const samples = await page.evaluate(async () => {
|
||||
const results: boolean[] = []
|
||||
const el = () => document.querySelector('[data-testid="context-menu"]')
|
||||
for (let i = 0; i < 30; i++) {
|
||||
results.push(!!el())
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
}
|
||||
return results
|
||||
})
|
||||
expect(samples.every((v) => v === true)).toBe(true)
|
||||
await expect(menu).toBeVisible()
|
||||
})
|
||||
|
||||
test('left-click drag works after a right-click drag with menu open', async () => {
|
||||
test.skip(isMobileProject(), 'Mobile emulation does not simulate right-click')
|
||||
// Open a menu
|
||||
await page.mouse.click(200, 200, { button: 'right' })
|
||||
await expect(page.getByTestId('context-menu')).toBeVisible()
|
||||
|
||||
// Right-click + drag to pan; this should release all pointer capture cleanly
|
||||
await page.mouse.move(600, 400)
|
||||
await page.mouse.down({ button: 'right' })
|
||||
await page.mouse.move(700, 500, { steps: 5 })
|
||||
await page.mouse.up({ button: 'right' })
|
||||
await expect(page.getByTestId('context-menu')).toBeHidden()
|
||||
|
||||
// Now a regular left-click drag must still work — if pointer capture
|
||||
// leaked to the capture div or canvas, this brush would not engage.
|
||||
await page.mouse.move(100, 100)
|
||||
await page.mouse.down({ button: 'left' })
|
||||
await page.mouse.move(400, 400, { steps: 10 })
|
||||
expect(await page.evaluate(() => editor.getPath())).toBe('select.brushing')
|
||||
await page.mouse.up({ button: 'left' })
|
||||
expect(await page.evaluate(() => editor.getPath())).toBe('select.idle')
|
||||
})
|
||||
})
|
||||
134
apps/examples/e2e/tests/test-dialogs.spec.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { expect, type Page } from '@playwright/test'
|
||||
import test from '../fixtures/fixtures'
|
||||
|
||||
const content = (page: Page) => page.locator('.tlui-dialog__content')
|
||||
const selectContent = (page: Page) => page.getByTestId('dialog-select.content')
|
||||
|
||||
// Click the dialog backdrop at a point that is outside the centered content.
|
||||
async function clickBackdrop(page: Page) {
|
||||
const box = await content(page).boundingBox()
|
||||
if (!box) throw new Error('Could not find dialog content')
|
||||
// Midpoint of the left-hand backdrop, vertically aligned with the content.
|
||||
await page.mouse.click(Math.max(5, box.x / 2), box.y + box.height / 2)
|
||||
}
|
||||
|
||||
test.describe('dialogs', () => {
|
||||
// These exercise backdrop/drag dismissal, which is desktop pointer behaviour.
|
||||
test.skip(({ isMobile }) => isMobile, 'Dialog dismissal is tested with a desktop pointer')
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('http://localhost:5420/toasts-and-dialogs')
|
||||
await page.waitForSelector('.tl-canvas')
|
||||
})
|
||||
|
||||
test('a backdrop click dismisses a normal dialog', async ({ page }) => {
|
||||
await page.getByTestId('show-dialog').click()
|
||||
await expect(content(page)).toBeVisible()
|
||||
|
||||
await clickBackdrop(page)
|
||||
await expect(content(page)).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('escape dismisses a dialog', async ({ page }) => {
|
||||
await page.getByTestId('show-dialog').click()
|
||||
await expect(content(page)).toBeVisible()
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(content(page)).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('a press that starts inside the content and ends on the backdrop does not dismiss', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.getByTestId('show-dialog').click()
|
||||
await expect(content(page)).toBeVisible()
|
||||
|
||||
const box = await content(page).boundingBox()
|
||||
if (!box) throw new Error('Could not find dialog content')
|
||||
// Press down inside the content (e.g. selecting text)...
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
|
||||
await page.mouse.down()
|
||||
// ...drag out onto the backdrop and release.
|
||||
await page.mouse.move(Math.max(5, box.x / 2), box.y + box.height / 2, { steps: 5 })
|
||||
await page.mouse.up()
|
||||
|
||||
await expect(content(page)).toBeVisible()
|
||||
})
|
||||
|
||||
test('an open select inside a modal is dismissed before the dialog', async ({ page }) => {
|
||||
await page.getByTestId('show-dialog-with-select').click()
|
||||
await expect(content(page)).toBeVisible()
|
||||
|
||||
// Open the select; its listbox portals into the container.
|
||||
await page.getByTestId('dialog-select.trigger').click()
|
||||
await expect(selectContent(page)).toBeVisible()
|
||||
|
||||
// The first outside click closes only the select; the dialog stays open.
|
||||
await clickBackdrop(page)
|
||||
await expect(selectContent(page)).toHaveCount(0)
|
||||
await expect(content(page)).toBeVisible()
|
||||
|
||||
// A second outside click then closes the dialog.
|
||||
await clickBackdrop(page)
|
||||
await expect(content(page)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('stacked dialogs', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('http://localhost:5420/toasts-and-dialogs')
|
||||
await page.waitForSelector('.tl-canvas')
|
||||
})
|
||||
|
||||
// Stacked modal dialogs must stay interactive — including the topmost dialog's controls
|
||||
// on a touchscreen. Making lower stacked dialogs non-modal previously left the nested
|
||||
// dialog unresponsive to taps on mobile, so this runs on mobile as well as desktop.
|
||||
test('a nested dialog stacks over its parent and stays interactive', async ({
|
||||
page,
|
||||
isMobile,
|
||||
}) => {
|
||||
const act = (testId: string) =>
|
||||
isMobile ? page.getByTestId(testId).tap() : page.getByTestId(testId).click()
|
||||
|
||||
await act('show-nested-dialog')
|
||||
await expect(page.getByTestId('dialog-parent')).toBeVisible()
|
||||
|
||||
await act('dialog-parent.open-nested')
|
||||
await expect(page.getByTestId('dialog-nested')).toBeVisible()
|
||||
// Opening the nested dialog must not collapse the parent.
|
||||
await expect(page.getByTestId('dialog-parent')).toBeVisible()
|
||||
|
||||
// The topmost dialog's control must respond (taps included): closing it leaves the
|
||||
// parent open underneath.
|
||||
await act('dialog-nested.confirm')
|
||||
await expect(page.getByTestId('dialog-nested')).toHaveCount(0)
|
||||
await expect(page.getByTestId('dialog-parent')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
// A touch press is checked against the dialog stack at the click that follows it, not at the
|
||||
// press, so this needs a genuine touch-to-click sequence to reproduce. The viewport is wide on
|
||||
// purpose: the editor cancels a touchstart close to the window's edges, which substitutes a
|
||||
// synthesised click and moves the check earlier than the real one, hiding the bug.
|
||||
test.describe('stacked dialogs on a wide touch viewport', () => {
|
||||
test.use({ viewport: { width: 1024, height: 768 }, hasTouch: true, isMobile: true })
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('http://localhost:5420/toasts-and-dialogs')
|
||||
await page.waitForSelector('.tl-canvas')
|
||||
})
|
||||
|
||||
test('confirming the nested dialog by touch leaves the parent open', async ({ page }) => {
|
||||
await page.getByTestId('show-nested-dialog').tap()
|
||||
await expect(page.getByTestId('dialog-parent')).toBeVisible()
|
||||
|
||||
await page.getByTestId('dialog-parent.open-nested').tap()
|
||||
await expect(page.getByTestId('dialog-nested')).toBeVisible()
|
||||
|
||||
// By the time the deferred check runs the nested dialog has unmounted, leaving the parent
|
||||
// topmost — the press that closed the nested dialog must not read as a press outside it.
|
||||
await page.getByTestId('dialog-nested.confirm').tap()
|
||||
await expect(page.getByTestId('dialog-nested')).toHaveCount(0)
|
||||
await expect(page.getByTestId('dialog-parent')).toBeVisible()
|
||||
})
|
||||
})
|
||||
298
apps/examples/e2e/tests/test-focus.spec.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
import test, { expect } from '@playwright/test'
|
||||
import { Editor } from 'tldraw'
|
||||
|
||||
declare const editor: Editor
|
||||
declare const EDITOR_A: Editor
|
||||
declare const EDITOR_B: Editor
|
||||
declare const EDITOR_C: Editor
|
||||
|
||||
// We're just testing the events, not the actual results.
|
||||
|
||||
test.describe('Focus', () => {
|
||||
test('focus events', async ({ page }) => {
|
||||
await page.goto('http://localhost:5420/multiple/full')
|
||||
await page.waitForSelector('.tl-canvas')
|
||||
|
||||
const EditorA = (await page.$(`.A`))!
|
||||
const EditorB = (await page.$(`.B`))!
|
||||
const EditorC = (await page.$(`.C`))!
|
||||
expect(EditorA).toBeTruthy()
|
||||
expect(EditorB).toBeTruthy()
|
||||
expect(EditorC).toBeTruthy()
|
||||
|
||||
async function isOnlyFocused(id: 'A' | 'B' | 'C' | null) {
|
||||
let activeElement: string | null = null
|
||||
const isA = await EditorA.evaluate(
|
||||
(node) => document.activeElement === node || node.contains(document.activeElement)
|
||||
)
|
||||
const isB = await EditorB.evaluate(
|
||||
(node) => document.activeElement === node || node.contains(document.activeElement)
|
||||
)
|
||||
|
||||
const isC = await EditorC.evaluate(
|
||||
(node) => document.activeElement === node || node.contains(document.activeElement)
|
||||
)
|
||||
|
||||
activeElement = isA ? 'A' : isB ? 'B' : isC ? 'C' : null
|
||||
|
||||
expect(
|
||||
activeElement,
|
||||
`Active element should have been ${id}, but was ${activeElement ?? 'null'} instead`
|
||||
).toBe(id)
|
||||
|
||||
await page.evaluate(
|
||||
({ id }) => {
|
||||
if (
|
||||
!(
|
||||
EDITOR_A.getInstanceState().isFocused === (id === 'A') &&
|
||||
EDITOR_B.getInstanceState().isFocused === (id === 'B') &&
|
||||
EDITOR_C.getInstanceState().isFocused === (id === 'C')
|
||||
)
|
||||
) {
|
||||
throw Error('isFocused is not correct')
|
||||
}
|
||||
},
|
||||
{ id }
|
||||
)
|
||||
}
|
||||
|
||||
// Component A has autofocus
|
||||
// Component B does not
|
||||
// Component C does not
|
||||
// Component B and C share persistence id
|
||||
|
||||
await (await page.$('body'))?.click()
|
||||
|
||||
await isOnlyFocused('A')
|
||||
|
||||
await EditorA.click()
|
||||
|
||||
await isOnlyFocused('A')
|
||||
|
||||
await EditorA.click()
|
||||
|
||||
await isOnlyFocused('A')
|
||||
|
||||
await EditorB.click()
|
||||
|
||||
await isOnlyFocused('B')
|
||||
|
||||
// Escape does not break focus
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
await isOnlyFocused('B')
|
||||
})
|
||||
|
||||
test('kbds when not focused', async ({ page }) => {
|
||||
await page.goto('http://localhost:5420/multiple/full')
|
||||
await page.waitForSelector('.tl-canvas')
|
||||
|
||||
// Should not have any shapes on the page
|
||||
expect(await page.evaluate(() => EDITOR_A.getCurrentPageShapes().length)).toBe(0)
|
||||
|
||||
const EditorA = (await page.$(`.A`))!
|
||||
await page.keyboard.press('r')
|
||||
await EditorA.click({ position: { x: 100, y: 100 } })
|
||||
|
||||
// Should not have created a shape
|
||||
expect(await page.evaluate(() => EDITOR_A.getCurrentPageShapes().length)).toBe(1)
|
||||
|
||||
const TextArea = page.getByTestId(`textarea`)
|
||||
await TextArea.focus()
|
||||
await page.keyboard.type('hello world')
|
||||
await page.keyboard.press('Control+A')
|
||||
await page.keyboard.press('Delete')
|
||||
|
||||
// Should not have deleted the page
|
||||
expect(await page.evaluate(() => EDITOR_A.getCurrentPageShapes().length)).toBe(1)
|
||||
})
|
||||
|
||||
test('kbds when focused', async ({ page }) => {
|
||||
await page.goto('http://localhost:5420/multiple/full')
|
||||
await page.waitForSelector('.tl-canvas')
|
||||
|
||||
const EditorA = (await page.$(`.A`))!
|
||||
const EditorB = (await page.$(`.B`))!
|
||||
const EditorC = (await page.$(`.C`))!
|
||||
expect(EditorA).toBeTruthy()
|
||||
expect(EditorB).toBeTruthy()
|
||||
expect(EditorC).toBeTruthy()
|
||||
|
||||
await (await page.$('body'))?.click()
|
||||
|
||||
expect(await EditorA.$('.tlui-button[data-testid="tools.draw"][aria-pressed="true"]')).toBe(
|
||||
null
|
||||
)
|
||||
expect(await EditorB.$('.tlui-button[data-testid="tools.draw"][aria-pressed="true"]')).toBe(
|
||||
null
|
||||
)
|
||||
|
||||
await page.keyboard.press('d')
|
||||
|
||||
expect(await EditorA.$('.tlui-button[data-testid="tools.draw"][aria-pressed="true"]')).not.toBe(
|
||||
null
|
||||
)
|
||||
expect(await EditorB.$('.tlui-button[data-testid="tools.draw"][aria-pressed="true"]')).toBe(
|
||||
null
|
||||
)
|
||||
|
||||
await EditorB.click()
|
||||
await page.waitForTimeout(100) // takes 30ms or so to focus
|
||||
await page.keyboard.press('d')
|
||||
|
||||
expect(await EditorA.$('.tlui-button[data-testid="tools.draw"][aria-pressed="true"]')).not.toBe(
|
||||
null
|
||||
)
|
||||
expect(await EditorB.$('.tlui-button[data-testid="tools.draw"][aria-pressed="true"]')).not.toBe(
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
test('kbds after clicking on ui elements', async ({ page }) => {
|
||||
await page.goto('http://localhost:5420/end-to-end')
|
||||
await page.waitForSelector('.tl-canvas')
|
||||
|
||||
const EditorA = (await page.$(`.tl-container`))!
|
||||
expect(EditorA).toBeTruthy()
|
||||
|
||||
const drawButton = await EditorA.$('.tlui-button[data-testid="tools.draw"]')
|
||||
|
||||
// select button should be selected, not the draw button
|
||||
expect(await EditorA.$('.tlui-button[data-testid="tools.draw"][aria-pressed="true"]')).toBe(
|
||||
null
|
||||
)
|
||||
|
||||
await drawButton?.click()
|
||||
|
||||
// draw button should be selected now
|
||||
expect(await EditorA.$('.tlui-button[data-testid="tools.draw"][aria-pressed="true"]')).not.toBe(
|
||||
null
|
||||
)
|
||||
|
||||
await page.keyboard.press('v')
|
||||
|
||||
// select button should be selected again
|
||||
expect(await EditorA.$('.tlui-button[data-testid="tools.draw"][aria-pressed="true"]')).toBe(
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
test('still focuses text after clicking on style button', async ({ page }) => {
|
||||
await page.goto('http://localhost:5420/end-to-end')
|
||||
await page.waitForSelector('.tl-canvas')
|
||||
|
||||
const EditorA = (await page.$(`.tl-container`))!
|
||||
expect(EditorA).toBeTruthy()
|
||||
|
||||
// Create a new note, text should be focused
|
||||
await page.keyboard.press('n')
|
||||
await (await page.$('body'))?.click()
|
||||
await page.waitForSelector('.tl-shape')
|
||||
|
||||
const blueButton = await page.$('.tlui-button[data-testid="style.color.blue"]')
|
||||
await blueButton?.click()
|
||||
|
||||
// Text should still be focused.
|
||||
expect(
|
||||
await page.evaluate(() => !!document.querySelector('.tl-shape div[contenteditable]:focus'))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('edit->edit, focus stays in the text areas when going from shape-to-shape', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('http://localhost:5420/end-to-end')
|
||||
await page.waitForSelector('.tl-canvas')
|
||||
|
||||
const EditorA = (await page.$(`.tl-container`))!
|
||||
expect(EditorA).toBeTruthy()
|
||||
|
||||
// Create a new note, text should be focused
|
||||
await page.keyboard.press('n')
|
||||
await (await page.$('body'))?.click()
|
||||
await page.waitForSelector('.tl-shape')
|
||||
await page.keyboard.type('test')
|
||||
const firstNoteId = await page.evaluate(() => editor.getEditingShapeId())
|
||||
const firstNote = page.locator(`.tl-shape[data-shape-id="${firstNoteId}"]`)
|
||||
|
||||
// create new note next to it
|
||||
await page.keyboard.press('Tab')
|
||||
await expect(page.locator('.tl-shape')).toHaveCount(2)
|
||||
|
||||
// Tab pans to the new note; let the camera settle so the first note is where
|
||||
// the locator resolves it before clicking.
|
||||
await page.waitForFunction(() => editor.getCameraState() === 'idle')
|
||||
|
||||
await firstNote.click()
|
||||
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// The first note, not the one Tab created, should be the focused one.
|
||||
await expect(firstNote.locator('div[contenteditable]')).toBeFocused()
|
||||
})
|
||||
|
||||
test('exits edit mode when dragging from text label with blurred input', async ({ page }) => {
|
||||
await page.goto('http://localhost:5420/end-to-end')
|
||||
await page.waitForSelector('.tl-canvas')
|
||||
|
||||
// Create a geo shape with text
|
||||
await page.keyboard.press('r')
|
||||
await page.mouse.click(200, 200)
|
||||
await page.mouse.click(400, 400)
|
||||
|
||||
// Double-click to edit the shape
|
||||
await page.mouse.dblclick(300, 300)
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
// Verify we're in edit mode
|
||||
expect(
|
||||
await page.evaluate(() => !!document.querySelector('.tl-shape [contenteditable]:focus'))
|
||||
).toBe(true)
|
||||
|
||||
// Click near the text and drag (this should blur the input and exit edit mode)
|
||||
await page.mouse.move(300, 300)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(350, 350)
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
// Verify we've exited edit mode (no focused contenteditable)
|
||||
expect(
|
||||
await page.evaluate(() => !!document.querySelector('.tl-shape [contenteditable]:focus'))
|
||||
).toBe(false)
|
||||
|
||||
await page.mouse.up()
|
||||
})
|
||||
|
||||
test('selects all text when clicking on blurred text input in editing mode', async ({ page }) => {
|
||||
await page.goto('http://localhost:5420/end-to-end')
|
||||
await page.waitForSelector('.tl-canvas')
|
||||
|
||||
// Create geo shape and enter edit mode
|
||||
await page.keyboard.press('r')
|
||||
await page.mouse.click(200, 200)
|
||||
await page.mouse.click(400, 400)
|
||||
await page.mouse.dblclick(300, 300)
|
||||
|
||||
// Wait for contenteditable to be focused before typing
|
||||
await page.waitForFunction(
|
||||
() => document.activeElement === document.querySelector('.tl-shape [contenteditable]')
|
||||
)
|
||||
|
||||
// Type text
|
||||
await page.keyboard.type('test', { delay: 10 })
|
||||
|
||||
// Verify initial text
|
||||
const contenteditable = page.locator('.tl-shape [contenteditable]')
|
||||
await expect(contenteditable).toHaveText('test')
|
||||
|
||||
// Blur input while staying in editing mode by clicking on the text label (but not the input itself)
|
||||
await page.mouse.click(200, 230)
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
await page.waitForFunction(() => document.getSelection()?.toString() === 'test')
|
||||
|
||||
expect(
|
||||
await page.evaluate(() => !!document.querySelector('.tl-shape div[contenteditable]:focus'))
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
248
apps/examples/e2e/tests/test-image-toolbar.spec.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { Editor, TLAssetId, TLImageShape } from 'tldraw'
|
||||
import test from '../fixtures/fixtures'
|
||||
import { setupOrReset } from '../shared-e2e'
|
||||
|
||||
declare const editor: Editor
|
||||
|
||||
test.describe('Image toolbar behaviour', () => {
|
||||
test.beforeEach(setupOrReset)
|
||||
test.beforeEach(async ({ page, isMobile }) => {
|
||||
// TODO: the mobile e2e test may need special handling
|
||||
if (isMobile) return
|
||||
|
||||
// Create an image shape by inserting an image
|
||||
await page.evaluate(() => {
|
||||
const imageAsBase64 =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='
|
||||
|
||||
const assetId = 'asset:img' as TLAssetId
|
||||
editor.createAssets([
|
||||
{
|
||||
id: assetId,
|
||||
typeName: 'asset',
|
||||
type: 'image',
|
||||
props: {
|
||||
src: imageAsBase64,
|
||||
w: 100,
|
||||
h: 100,
|
||||
name: 'test',
|
||||
isAnimated: false,
|
||||
mimeType: 'image/png',
|
||||
},
|
||||
meta: {},
|
||||
},
|
||||
])
|
||||
editor.createShape({
|
||||
type: 'image',
|
||||
x: 250,
|
||||
y: 250,
|
||||
props: {
|
||||
w: 200,
|
||||
h: 150,
|
||||
crop: {
|
||||
topLeft: { x: 0, y: 0 },
|
||||
bottomRight: { x: 1, y: 1 },
|
||||
},
|
||||
assetId,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Select the image
|
||||
await page.mouse.click(300, 300)
|
||||
|
||||
// Wait for the toolbar to animate in
|
||||
await page.waitForTimeout(150)
|
||||
})
|
||||
|
||||
test('zoom slider changes image crop', async ({ page, isMobile }) => {
|
||||
if (isMobile) return
|
||||
|
||||
const manipulator = page.getByTestId('tool.image-crop')
|
||||
await manipulator.click()
|
||||
|
||||
// Find and use the slider
|
||||
const slider = page.getByTestId('tool.image-zoom')
|
||||
await slider.click()
|
||||
|
||||
// Move to a higher zoom
|
||||
await page.mouse.move(
|
||||
await slider.boundingBox().then((box) => box!.x + 100),
|
||||
await slider.boundingBox().then((box) => box!.y + 20)
|
||||
)
|
||||
await page.mouse.down()
|
||||
await page.mouse.up()
|
||||
|
||||
const crop = await page.evaluate(() => {
|
||||
const imageShape = editor.getSelectedShapes()[0] as TLImageShape
|
||||
return imageShape.props.crop
|
||||
})
|
||||
|
||||
expect(crop!.bottomRight.x).toBeCloseTo(0.6732, 2)
|
||||
expect(crop!.bottomRight.y).toBeCloseTo(0.6732, 2)
|
||||
expect(crop!.topLeft.y).toBeCloseTo(0.3267, 2)
|
||||
expect(crop!.topLeft.y).toBeCloseTo(0.3267, 2)
|
||||
})
|
||||
|
||||
test('aspect ratio dropdown changes image crop', async ({ page, isMobile }) => {
|
||||
if (isMobile) return
|
||||
|
||||
const manipulator = page.getByTestId('tool.image-crop')
|
||||
await manipulator.click()
|
||||
|
||||
// Open aspect ratio dropdown
|
||||
await page.getByTestId('tool.image-aspect-ratio').click()
|
||||
|
||||
// Select the square option
|
||||
await page.getByText('Square').click()
|
||||
|
||||
// Check that the image has been cropped to square
|
||||
const isSquare = await page.evaluate(() => {
|
||||
const imageShape = editor.getSelectedShapes()[0] as TLImageShape
|
||||
const aspectRatio = imageShape.props.w / imageShape.props.h
|
||||
return Math.abs(aspectRatio - 1) < 0.01 // Check if width/height ratio is approximately 1
|
||||
})
|
||||
|
||||
expect(isSquare).toBe(true)
|
||||
|
||||
// Try another aspect ratio - landscape
|
||||
await page.getByText('Landscape').click()
|
||||
|
||||
// Check that the image has been cropped to landscape (4:3)
|
||||
const isLandscape = await page.evaluate(() => {
|
||||
const imageShape = editor.getSelectedShapes()[0] as TLImageShape
|
||||
const aspectRatio = imageShape.props.w / imageShape.props.h
|
||||
return Math.abs(aspectRatio - 4 / 3) < 0.01 // Check if width/height ratio is approximately 4/3
|
||||
})
|
||||
|
||||
expect(isLandscape).toBe(true)
|
||||
})
|
||||
|
||||
test.skip('flip buttons flip the image', async ({ page, isMobile }) => {
|
||||
if (isMobile) return
|
||||
|
||||
const manipulator = page.getByTestId('tool.image-crop')
|
||||
await manipulator.click()
|
||||
|
||||
// Get initial state
|
||||
const initialState = await page.evaluate(() => {
|
||||
const imageShape = editor.getSelectedShapes()[0] as TLImageShape
|
||||
return {
|
||||
w: imageShape.props.w,
|
||||
h: imageShape.props.h,
|
||||
flipX: imageShape.props.flipX ?? false,
|
||||
flipY: imageShape.props.flipY ?? false,
|
||||
}
|
||||
})
|
||||
|
||||
// Click flip horizontal button
|
||||
await page.getByTestId('tool.image-flip-horizontal').click()
|
||||
|
||||
// Check horizontal flip state
|
||||
const afterHorizontalFlip = await page.evaluate(() => {
|
||||
const imageShape = editor.getSelectedShapes()[0] as TLImageShape
|
||||
return {
|
||||
w: imageShape.props.w,
|
||||
h: imageShape.props.h,
|
||||
flipX: imageShape.props.flipX ?? false,
|
||||
flipY: imageShape.props.flipY ?? false,
|
||||
}
|
||||
})
|
||||
|
||||
expect(afterHorizontalFlip.flipX).not.toEqual(initialState.flipX)
|
||||
expect(afterHorizontalFlip.w).toEqual(initialState.w) // Width shouldn't change
|
||||
|
||||
// Click flip vertical button
|
||||
await page.getByTestId('tool.image-flip-vertical').click()
|
||||
|
||||
// Check vertical flip state
|
||||
const afterVerticalFlip = await page.evaluate(() => {
|
||||
const imageShape = editor.getSelectedShapes()[0] as TLImageShape
|
||||
return {
|
||||
w: imageShape.props.w,
|
||||
h: imageShape.props.h,
|
||||
flipX: imageShape.props.flipX ?? false,
|
||||
flipY: imageShape.props.flipY ?? false,
|
||||
}
|
||||
})
|
||||
|
||||
expect(afterVerticalFlip.flipY).not.toEqual(initialState.flipY)
|
||||
expect(afterVerticalFlip.h).toEqual(initialState.h) // Height shouldn't change
|
||||
})
|
||||
|
||||
test('alt text button opens alt text editor', async ({ page, isMobile }) => {
|
||||
if (isMobile) return
|
||||
|
||||
// Click the Alt Text button
|
||||
await page.getByTestId('tool.image-alt-text').click()
|
||||
|
||||
// Enter some alt text
|
||||
await page.getByTestId('media-toolbar.alt-text-input').fill('A test image for e2e testing')
|
||||
|
||||
// Save the alt text
|
||||
await page.getByTestId('tool.media-alt-text-confirm').click()
|
||||
|
||||
// Check that the alt text was saved
|
||||
const altText = await page.evaluate(() => {
|
||||
const imageShape = editor.getSelectedShapes()[0] as TLImageShape
|
||||
return imageShape.props.altText
|
||||
})
|
||||
|
||||
expect(altText).toBe('A test image for e2e testing')
|
||||
})
|
||||
|
||||
test('image replacement works', async ({ page, isMobile }) => {
|
||||
if (isMobile) return
|
||||
|
||||
// Get initial image asset id
|
||||
const initialAssetId = await page.evaluate(() => {
|
||||
const imageShape = editor.getSelectedShapes()[0] as TLImageShape
|
||||
return imageShape.props.assetId
|
||||
})
|
||||
|
||||
// Click the replace image button
|
||||
await page.getByTestId('tool.image-replace').click()
|
||||
|
||||
// This would typically open a file dialog, which is hard to test in Playwright
|
||||
// Instead, we'll directly trigger the image replacement through evaluation
|
||||
await page.evaluate(() => {
|
||||
const imageShape = editor.getSelectedShapes()[0] as TLImageShape
|
||||
const newImageAsBase64 =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAEklEQVR42mNk+M9QzwAEjDAGACCDAv8cI7IoAAAAAElFTkSuQmCC'
|
||||
const newAssetId = 'asset:img-new' as TLAssetId
|
||||
editor.createAssets([
|
||||
{
|
||||
id: newAssetId,
|
||||
typeName: 'asset',
|
||||
type: 'image',
|
||||
props: {
|
||||
src: newImageAsBase64,
|
||||
w: 100,
|
||||
h: 100,
|
||||
name: 'test',
|
||||
isAnimated: false,
|
||||
mimeType: 'image/png',
|
||||
},
|
||||
meta: {},
|
||||
},
|
||||
])
|
||||
|
||||
editor.updateShape({
|
||||
id: imageShape.id,
|
||||
type: imageShape.type,
|
||||
props: {
|
||||
assetId: newAssetId,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Check that asset id changed
|
||||
const newAssetId = await page.evaluate(() => {
|
||||
const imageShape = editor.getSelectedShapes()[0] as TLImageShape
|
||||
return imageShape.props.assetId
|
||||
})
|
||||
|
||||
expect(newAssetId).not.toEqual(initialAssetId)
|
||||
})
|
||||
})
|
||||
891
apps/examples/e2e/tests/test-kbds.spec.ts
Normal file
@@ -0,0 +1,891 @@
|
||||
import { Page, expect } from '@playwright/test'
|
||||
import { Editor } from 'tldraw'
|
||||
import test from '../fixtures/fixtures'
|
||||
import { setupPage, setupPageWithShapes } from '../shared-e2e'
|
||||
|
||||
declare const editor: Editor
|
||||
|
||||
declare const __tldraw_ui_event: { name: string }
|
||||
|
||||
// We're just testing the events, not the actual results.
|
||||
|
||||
let page: Page
|
||||
|
||||
test.describe('Keyboard Shortcuts', () => {
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage()
|
||||
await setupPage(page)
|
||||
})
|
||||
|
||||
test('tools', async ({ isMobile }) => {
|
||||
const geoToolKds = [
|
||||
['r', 'rectangle'],
|
||||
['o', 'ellipse'],
|
||||
]
|
||||
|
||||
for (const [key, geo] of geoToolKds) {
|
||||
await page.keyboard.press('v') // set back to select
|
||||
await page.keyboard.press(key)
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'select-tool',
|
||||
data: { id: `geo-${geo}`, source: 'kbd' },
|
||||
})
|
||||
}
|
||||
|
||||
const simpleToolKbds = [
|
||||
['v', 'select'],
|
||||
['h', 'hand'],
|
||||
]
|
||||
|
||||
for (const [key, tool] of simpleToolKbds) {
|
||||
await page.keyboard.press('v') // set back to select
|
||||
await page.keyboard.press(key)
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'select-tool',
|
||||
data: { id: tool, source: 'kbd' },
|
||||
})
|
||||
}
|
||||
|
||||
const shapeToolKbds = [
|
||||
['d', 'draw'],
|
||||
['x', 'draw'],
|
||||
['a', 'arrow'],
|
||||
['l', 'line'],
|
||||
['f', 'frame'],
|
||||
['n', 'note'],
|
||||
['f', 'frame'],
|
||||
['e', 'eraser'],
|
||||
['k', 'laser'],
|
||||
['t', 'text'],
|
||||
]
|
||||
|
||||
for (const [key, tool] of shapeToolKbds) {
|
||||
await page.keyboard.press('v') // set back to select
|
||||
await page.keyboard.press(key)
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'select-tool',
|
||||
data: { id: tool, source: 'kbd' },
|
||||
})
|
||||
}
|
||||
|
||||
// make sure that the first dropdown item is rectangle
|
||||
await page.keyboard.press('r')
|
||||
|
||||
const positionalToolKbds = [
|
||||
['1', 'select'],
|
||||
['2', 'hand'],
|
||||
['3', 'draw'],
|
||||
['4', 'eraser'],
|
||||
['5', 'arrow'],
|
||||
['6', 'text'],
|
||||
]
|
||||
|
||||
if (isMobile) {
|
||||
// on mobile, the last item (first from the dropdown) is 6
|
||||
positionalToolKbds.push(['7', 'geo-rectangle'])
|
||||
} else {
|
||||
// on desktop, the last item (first from the dropdown) is 9
|
||||
positionalToolKbds.push(['9', 'geo-rectangle'])
|
||||
}
|
||||
for (const [key, tool] of positionalToolKbds) {
|
||||
await page.keyboard.press('v') // set back to select
|
||||
await page.keyboard.press(key)
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'select-tool',
|
||||
data: { id: tool, source: 'toolbar' },
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Keyboard Shortcuts', () => {
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage()
|
||||
await setupPage(page)
|
||||
|
||||
// Make some shapes
|
||||
await page.keyboard.press('r')
|
||||
await page.mouse.click(100, 100)
|
||||
await page.keyboard.press('r')
|
||||
await page.mouse.click(250, 250)
|
||||
await page.keyboard.press('v')
|
||||
})
|
||||
|
||||
test('Zoom in', async () => {
|
||||
await page.keyboard.press('Control+=')
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'zoom-in',
|
||||
data: { source: 'kbd' },
|
||||
})
|
||||
})
|
||||
|
||||
test('Zoom out', async () => {
|
||||
await page.keyboard.press('Control+-')
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'zoom-out',
|
||||
data: { source: 'kbd' },
|
||||
})
|
||||
})
|
||||
|
||||
test('Zoom to fit', async () => {
|
||||
await page.keyboard.press('Shift+1')
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'zoom-to-fit',
|
||||
data: { source: 'kbd' },
|
||||
})
|
||||
})
|
||||
|
||||
test('Zoom to selection', async () => {
|
||||
await page.keyboard.press('Shift+2')
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'zoom-to-selection',
|
||||
data: { source: 'kbd' },
|
||||
})
|
||||
})
|
||||
|
||||
test('Zoom to 100', async () => {
|
||||
await page.keyboard.press('Shift+0')
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'reset-zoom',
|
||||
data: { source: 'kbd' },
|
||||
})
|
||||
})
|
||||
|
||||
/* ---------------------- Files --------------------- */
|
||||
|
||||
// new-project — Cmd+N
|
||||
// open — Cmd+O
|
||||
// save — Cmd+S
|
||||
// save-as — Cmd+Shift+S
|
||||
// upload-media — Cmd+I
|
||||
|
||||
/* -------------------- Clipboard ------------------- */
|
||||
|
||||
// await page.keyboard.press('Control+c')
|
||||
// expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
// name: 'copy',
|
||||
// data: { source: 'kbd' },
|
||||
// })
|
||||
|
||||
// await page.keyboard.press('Control+v')
|
||||
// expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
// name: 'paste',
|
||||
// data: { source: 'kbd' },
|
||||
// })
|
||||
|
||||
// await page.keyboard.press('Control+x')
|
||||
// expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
// name: 'cut',
|
||||
// data: { source: 'kbd' },
|
||||
// })
|
||||
|
||||
test('Toggle grid mode', async () => {
|
||||
await page.keyboard.press("Control+'")
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'toggle-grid-mode',
|
||||
data: { source: 'kbd' },
|
||||
})
|
||||
})
|
||||
|
||||
test('Toggle dark mode', async () => {
|
||||
await page.keyboard.press('Control+/')
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'color-scheme',
|
||||
data: { source: 'kbd' },
|
||||
})
|
||||
})
|
||||
|
||||
test('Toggle tool lock', async () => {
|
||||
await page.keyboard.press('q')
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'toggle-tool-lock',
|
||||
data: { source: 'kbd' },
|
||||
})
|
||||
})
|
||||
|
||||
test('Copy hovered styles', async () => {
|
||||
// The action copies styles from the shape under the pointer, so create a shape in the
|
||||
// middle of the viewport and move the mouse over it before pressing the shortcut.
|
||||
const point = await page.evaluate(() => {
|
||||
const center = editor.getViewportPageBounds().center
|
||||
editor.createShape({
|
||||
type: 'geo',
|
||||
x: center.x - 50,
|
||||
y: center.y - 50,
|
||||
props: { w: 100, h: 100, fill: 'solid' },
|
||||
})
|
||||
const screen = editor.pageToScreen(center)
|
||||
return { x: screen.x, y: screen.y }
|
||||
})
|
||||
await page.mouse.move(point.x, point.y)
|
||||
await page.keyboard.press('Shift+q')
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'copy-hovered-styles',
|
||||
data: { source: 'kbd' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Actions on shapes', () => {
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage()
|
||||
await setupPage(page)
|
||||
})
|
||||
|
||||
/* -------------- Operations on Shapes -------------- */
|
||||
|
||||
test('Operations on shapes', async () => {
|
||||
await setupPageWithShapes(page)
|
||||
|
||||
// needs shapes on the canvas
|
||||
|
||||
// select-all — Cmd+A
|
||||
await page.keyboard.press('Control+a')
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'select-all-shapes',
|
||||
data: { source: 'kbd' },
|
||||
})
|
||||
|
||||
// flip-h — Shift+H
|
||||
await page.keyboard.press('Shift+h')
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'flip-shapes',
|
||||
data: { operation: 'horizontal', source: 'kbd' },
|
||||
})
|
||||
|
||||
// flip-v — Shift+V
|
||||
await page.keyboard.press('Shift+v')
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'flip-shapes',
|
||||
data: { operation: 'vertical', source: 'kbd' },
|
||||
})
|
||||
|
||||
// move-to-front — ]
|
||||
await page.keyboard.press(']')
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'reorder-shapes',
|
||||
data: { operation: 'toFront', source: 'kbd' },
|
||||
})
|
||||
|
||||
// move-forward — Alt+]
|
||||
await page.keyboard.press('Alt+]')
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'reorder-shapes',
|
||||
data: { operation: 'forward', source: 'kbd' },
|
||||
})
|
||||
|
||||
// move-to-back — [
|
||||
await page.keyboard.press('[')
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'reorder-shapes',
|
||||
data: { operation: 'toBack', source: 'kbd' },
|
||||
})
|
||||
|
||||
// move-backward — Alt+[
|
||||
await page.keyboard.press('Alt+[')
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'reorder-shapes',
|
||||
data: { operation: 'backward', source: 'kbd' },
|
||||
})
|
||||
|
||||
// group — Cmd+G
|
||||
await page.keyboard.press('Control+g')
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'group-shapes',
|
||||
data: { source: 'kbd' },
|
||||
})
|
||||
|
||||
// ungroup — Cmd+Shift+G
|
||||
await page.keyboard.press('Control+Shift+g')
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'ungroup-shapes',
|
||||
data: { source: 'kbd' },
|
||||
})
|
||||
|
||||
// duplicate — Cmd+D
|
||||
await page.keyboard.press('Control+d')
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'duplicate-shapes',
|
||||
data: { source: 'kbd' },
|
||||
})
|
||||
|
||||
// align left — Alt+A
|
||||
await page.keyboard.press('Alt+a')
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'align-shapes',
|
||||
data: { operation: 'left', source: 'kbd' },
|
||||
})
|
||||
|
||||
// align right — Alt+D
|
||||
await page.keyboard.press('Alt+d')
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'align-shapes',
|
||||
data: { operation: 'right', source: 'kbd' },
|
||||
})
|
||||
|
||||
// align top — Alt+W
|
||||
await page.keyboard.press('Alt+w')
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'align-shapes',
|
||||
data: { operation: 'top', source: 'kbd' },
|
||||
})
|
||||
|
||||
// align bottom — Alt+W'
|
||||
await page.keyboard.press('Alt+s')
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'align-shapes',
|
||||
data: { operation: 'bottom', source: 'kbd' },
|
||||
})
|
||||
|
||||
// Copy as SVG — this should have a clipboard error
|
||||
|
||||
// await page.keyboard.press('Control+Shift+c')
|
||||
// expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
// name: 'copy-as',
|
||||
// data: { format: 'svg', source: 'kbd' },
|
||||
// })
|
||||
|
||||
// delete — backspace
|
||||
await page.keyboard.press('Control+a') // selected
|
||||
await page.keyboard.press('Backspace')
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'delete-shapes',
|
||||
data: { source: 'kbd' },
|
||||
})
|
||||
|
||||
// delete — ⌫
|
||||
|
||||
// Make some shapes and select them
|
||||
await page.keyboard.press('r')
|
||||
await page.mouse.click(100, 100)
|
||||
await page.keyboard.press('r')
|
||||
await page.mouse.click(250, 250)
|
||||
await page.keyboard.press('v')
|
||||
await page.keyboard.press('Control+a')
|
||||
|
||||
await page.keyboard.press('Delete')
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'delete-shapes',
|
||||
data: { source: 'kbd' },
|
||||
})
|
||||
|
||||
// Next, previous pages — Alt+ArrowLeft, Alt+ArrowRight
|
||||
|
||||
// Try a previous page move. We can't go lower since we're on the first page.
|
||||
// So the most recent event should be the previous delete
|
||||
|
||||
await page.keyboard.press('Alt+ArrowLeft')
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'delete-shapes',
|
||||
data: { source: 'kbd' },
|
||||
})
|
||||
|
||||
// Next page. Since there's only one page and the page is empty, nothing should happen.
|
||||
|
||||
await page.keyboard.press('Alt+ArrowRight')
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'delete-shapes',
|
||||
data: { source: 'kbd' },
|
||||
})
|
||||
|
||||
// make something on the new page and delete it
|
||||
await page.keyboard.press('r')
|
||||
await page.mouse.click(100, 100)
|
||||
|
||||
// If there's something on the page, we can create the next page by moving up
|
||||
await page.keyboard.press('Alt+ArrowRight')
|
||||
|
||||
// We'll also have a change page here... but the most recent will be the new page
|
||||
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'new-page',
|
||||
data: { source: 'kbd' },
|
||||
})
|
||||
|
||||
// We can go back down...
|
||||
await page.keyboard.press('Alt+ArrowLeft')
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'change-page',
|
||||
data: { source: 'kbd', direction: 'prev' },
|
||||
})
|
||||
|
||||
// We can go up again
|
||||
await page.keyboard.press('Alt+ArrowRight')
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'change-page',
|
||||
data: { source: 'kbd', direction: 'next' },
|
||||
})
|
||||
|
||||
// We can back down and up with the up and down keys too...
|
||||
await page.keyboard.press('Alt+ArrowUp')
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'change-page',
|
||||
data: { source: 'kbd', direction: 'prev' },
|
||||
})
|
||||
await page.keyboard.press('Alt+ArrowDown')
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'change-page',
|
||||
data: { source: 'kbd', direction: 'next' },
|
||||
})
|
||||
|
||||
/* ---------------------- Misc ---------------------- */
|
||||
|
||||
// These actions require selected shapes to run
|
||||
|
||||
await page.keyboard.press('r')
|
||||
await page.mouse.click(100, 100)
|
||||
await page.keyboard.press('r')
|
||||
await page.mouse.click(100, 300)
|
||||
await page.keyboard.press('r')
|
||||
await page.mouse.click(300, 300)
|
||||
|
||||
// First verify they don't fire when nothing is selected
|
||||
await page.keyboard.press('Escape') // deselect all
|
||||
await page.keyboard.press('Escape') // deselect all
|
||||
const prevEvent = await page.evaluate(() => __tldraw_ui_event)
|
||||
|
||||
await page.keyboard.press('Shift+l')
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject(prevEvent)
|
||||
|
||||
await page.keyboard.press('Control+Shift+Alt+=')
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject(prevEvent)
|
||||
|
||||
await page.keyboard.press('Control+Shift+Alt+-')
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject(prevEvent)
|
||||
|
||||
// Now select shapes and verify the actions fire
|
||||
await page.keyboard.press('Control+a')
|
||||
|
||||
await page.keyboard.press('Control+Shift+Alt+=')
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'enlarge-shapes',
|
||||
})
|
||||
|
||||
await page.keyboard.press('Control+Shift+Alt+-')
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'shrink-shapes',
|
||||
})
|
||||
|
||||
// toggle lock (must be the last one here)
|
||||
await page.keyboard.press('Shift+l')
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'toggle-lock',
|
||||
})
|
||||
|
||||
// await page.keyboard.press('Control+i')
|
||||
// expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
// name: 'open-menu',
|
||||
// data: { source: 'dialog' },
|
||||
// })
|
||||
|
||||
// await page.keyboard.press('Control+u')
|
||||
// expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
// name: 'open-menu',
|
||||
// data: { source: 'dialog' },
|
||||
// })
|
||||
})
|
||||
|
||||
// TODO: Annoyingly, the alt key is being 'released' in the above test suite.
|
||||
// I'll need to figure that out in a separate PR. In the meantime,
|
||||
// this pulls out the rotate tests into a separate test suite.
|
||||
test('rotate', async () => {
|
||||
await setupPageWithShapes(page)
|
||||
|
||||
await page.keyboard.press('Control+a')
|
||||
|
||||
// rotate — Shift+ArrowLeft, Shift+ArrowRight
|
||||
await page.keyboard.press('Shift+.')
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'rotate-cw',
|
||||
data: { source: 'kbd', fine: false },
|
||||
})
|
||||
await page.keyboard.press('Shift+,')
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'rotate-ccw',
|
||||
data: { source: 'kbd', fine: false },
|
||||
})
|
||||
|
||||
// rotate — Shift+Alt+ArrowLeft, Shift+Alt+ArrowRight
|
||||
await page.keyboard.press('Shift+Alt+.')
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'rotate-cw',
|
||||
data: { source: 'kbd', fine: true },
|
||||
})
|
||||
await page.keyboard.press('Shift+Alt+,')
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'rotate-ccw',
|
||||
data: { source: 'kbd', fine: true },
|
||||
})
|
||||
})
|
||||
})
|
||||
test.describe('Delete bug', () => {
|
||||
test.beforeEach(async ({ browser }) => {
|
||||
page = await browser.newPage()
|
||||
await setupPage(page)
|
||||
})
|
||||
|
||||
test('delete bug without drag', async () => {
|
||||
await page.keyboard.press('r')
|
||||
await page.mouse.click(100, 100)
|
||||
await page.keyboard.press('Backspace')
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'delete-shapes',
|
||||
data: { source: 'kbd' },
|
||||
})
|
||||
})
|
||||
|
||||
test('delete bug with drag', async () => {
|
||||
await page.keyboard.press('r')
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(100, 100)
|
||||
await page.mouse.up()
|
||||
await page.keyboard.press('Backspace')
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'delete-shapes',
|
||||
data: { source: 'kbd' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Shape Navigation', () => {
|
||||
test.beforeEach(async ({ browser }) => {
|
||||
page = await browser.newPage()
|
||||
await setupPage(page)
|
||||
})
|
||||
|
||||
test('Tab navigation between shapes', async ({ isMobile }) => {
|
||||
if (isMobile) return // can't test this on mobile
|
||||
|
||||
// Create multiple shapes
|
||||
await page.keyboard.press('r')
|
||||
await page.mouse.click(100, 100)
|
||||
await page.keyboard.press('r')
|
||||
await page.mouse.click(250, 100)
|
||||
await page.keyboard.press('r')
|
||||
await page.mouse.click(400, 100)
|
||||
await page.keyboard.press('v')
|
||||
|
||||
// Click on the first shape to select it
|
||||
await page.mouse.click(100, 100)
|
||||
|
||||
// Navigate forward with Tab
|
||||
await page.keyboard.press('Tab')
|
||||
expect(await page.evaluate(() => editor.getOnlySelectedShape())).toMatchObject({
|
||||
x: 150,
|
||||
y: 0,
|
||||
})
|
||||
|
||||
// Navigate backward with Shift+Tab
|
||||
await page.keyboard.press('Shift+Tab')
|
||||
expect(await page.evaluate(() => editor.getOnlySelectedShape())).toMatchObject({
|
||||
x: 0,
|
||||
y: 0,
|
||||
})
|
||||
})
|
||||
|
||||
test('Directional navigation between shapes', async ({ isMobile }) => {
|
||||
if (isMobile) return // can't test this on mobile
|
||||
|
||||
// Create shapes in different directions
|
||||
await page.keyboard.press('r')
|
||||
await page.mouse.click(200, 200) // Center shape
|
||||
await page.keyboard.press('r')
|
||||
await page.mouse.click(300, 200) // Right shape
|
||||
await page.keyboard.press('r')
|
||||
await page.mouse.click(100, 200) // Left shape
|
||||
await page.keyboard.press('r')
|
||||
await page.mouse.click(200, 100) // Up shape
|
||||
await page.keyboard.press('r')
|
||||
await page.mouse.click(200, 300) // Down shape
|
||||
await page.keyboard.press('v')
|
||||
|
||||
// Select center shape
|
||||
await page.mouse.click(200, 200)
|
||||
|
||||
// Test navigation to the right
|
||||
await page.keyboard.press('Control+ArrowRight')
|
||||
expect(await page.evaluate(() => editor.getOnlySelectedShape())).toMatchObject({
|
||||
x: 200,
|
||||
y: 100,
|
||||
})
|
||||
|
||||
// Navigate back to center
|
||||
await page.mouse.click(200, 200)
|
||||
|
||||
// Disabled: I think this conflicts with browser nav or something.
|
||||
// // Test navigation to the left
|
||||
// await page.keyboard.press('Control+ArrowLeft')
|
||||
// expect(await page.evaluate(() => editor.getOnlySelectedShape())).toMatchObject({
|
||||
// x: 200,
|
||||
// y: 100
|
||||
// })
|
||||
|
||||
// Navigate back to center
|
||||
await page.mouse.click(200, 200)
|
||||
|
||||
// Test navigation up
|
||||
await page.keyboard.press('Control+ArrowUp')
|
||||
expect(await page.evaluate(() => editor.getOnlySelectedShape())).toMatchObject({
|
||||
x: 100,
|
||||
y: 100,
|
||||
})
|
||||
|
||||
// Navigate back to center
|
||||
await page.mouse.click(200, 200)
|
||||
|
||||
// Test navigation down
|
||||
await page.keyboard.press('Control+ArrowDown')
|
||||
expect(await page.evaluate(() => editor.getOnlySelectedShape())).toMatchObject({
|
||||
x: 100,
|
||||
y: 200,
|
||||
})
|
||||
})
|
||||
|
||||
test('Container navigation (entering and exiting containers)', async ({ isMobile }) => {
|
||||
if (isMobile) return // can't test this on mobile
|
||||
|
||||
// Create a frame
|
||||
await page.keyboard.press('f')
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(300, 300)
|
||||
await page.mouse.up()
|
||||
|
||||
// Create a shape inside the frame
|
||||
await page.keyboard.press('r')
|
||||
await page.mouse.click(200, 200)
|
||||
await page.keyboard.press('v')
|
||||
|
||||
// Navigate up to the parent (frame) with Control+Shift+Up
|
||||
await page.keyboard.press('Control+Shift+ArrowUp')
|
||||
expect(
|
||||
await page.evaluate(() => {
|
||||
const shape = editor.getOnlySelectedShape()
|
||||
return shape && shape.type === 'frame'
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
// Navigate down to the first child with Control+Shift+Down
|
||||
await page.keyboard.press('Control+Shift+ArrowDown')
|
||||
expect(
|
||||
await page.evaluate(() => {
|
||||
const shape = editor.getOnlySelectedShape()
|
||||
return shape && shape.type === 'geo'
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
// Now test with a group
|
||||
// Create multiple shapes to group
|
||||
await page.keyboard.press('v')
|
||||
await page.keyboard.press('r')
|
||||
await page.mouse.click(450, 200)
|
||||
await page.keyboard.press('r')
|
||||
await page.mouse.click(475, 200)
|
||||
|
||||
// Select both shapes
|
||||
await page.keyboard.press('v')
|
||||
await page.mouse.move(310, 50)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(310, 50)
|
||||
await page.mouse.move(700, 500)
|
||||
await page.mouse.up()
|
||||
|
||||
// Group them
|
||||
await page.keyboard.press('Control+g')
|
||||
|
||||
// Navigate up to the group with Control+Shift+Up
|
||||
await page.keyboard.press('Control+Shift+ArrowUp')
|
||||
expect(
|
||||
await page.evaluate(() => {
|
||||
const shape = editor.getOnlySelectedShape()
|
||||
return shape && shape.type === 'group'
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
// Navigate down to a child in the group
|
||||
await page.keyboard.press('Control+Shift+ArrowDown')
|
||||
expect(
|
||||
await page.evaluate(() => {
|
||||
const shape = editor.getOnlySelectedShape()
|
||||
return shape && shape.type === 'geo'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('Tab navigation respects container boundaries', async ({ isMobile }) => {
|
||||
if (isMobile) return // can't test this on mobile
|
||||
|
||||
// Clear the canvas and reset the view
|
||||
await page.keyboard.press('Control+a')
|
||||
await page.keyboard.press('Backspace')
|
||||
await page.keyboard.press('v')
|
||||
|
||||
// Create a frame
|
||||
await page.keyboard.press('f')
|
||||
await page.mouse.move(100, 100)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(300, 300)
|
||||
await page.mouse.up()
|
||||
|
||||
// Create shapes inside the frame
|
||||
await page.keyboard.press('r')
|
||||
await page.mouse.click(150, 150) // Shape inside frame
|
||||
await page.keyboard.press('r')
|
||||
await page.mouse.click(250, 150) // Another shape inside frame
|
||||
|
||||
// Create shapes outside the frame
|
||||
await page.keyboard.press('r')
|
||||
await page.mouse.click(400, 150) // Shape outside frame
|
||||
await page.keyboard.press('r')
|
||||
await page.mouse.click(400, 250) // Another shape outside frame
|
||||
await page.keyboard.press('v')
|
||||
|
||||
// Test 1: Tab from outside shape should only navigate between outside shapes
|
||||
await page.mouse.click(400, 150) // Select first outside shape
|
||||
|
||||
// Tab to next outside shape
|
||||
await page.keyboard.press('Tab')
|
||||
expect(
|
||||
await page.evaluate(() => {
|
||||
const shape = editor.getOnlySelectedShape()!
|
||||
return { x: Math.round(shape.x), y: Math.round(shape.y) }
|
||||
})
|
||||
).toMatchObject({
|
||||
x: 100,
|
||||
y: 100,
|
||||
})
|
||||
|
||||
// Tab again should wrap around to first outside shape
|
||||
await page.keyboard.press('Tab')
|
||||
expect(
|
||||
await page.evaluate(() => {
|
||||
const shape = editor.getOnlySelectedShape()!
|
||||
return { x: Math.round(shape.x), y: Math.round(shape.y) }
|
||||
})
|
||||
).toMatchObject({
|
||||
x: 300,
|
||||
y: 50,
|
||||
})
|
||||
|
||||
// Test 2: Tab from inside shape should only navigate between shapes in the same container
|
||||
await page.mouse.click(150, 150) // Select first inside shape
|
||||
|
||||
// Tab to next shape inside frame
|
||||
await page.keyboard.press('Tab')
|
||||
expect(
|
||||
await page.evaluate(() => {
|
||||
const shape = editor.getOnlySelectedShape()!
|
||||
return { x: Math.round(shape.x), y: Math.round(shape.y) }
|
||||
})
|
||||
).toMatchObject({
|
||||
x: -50,
|
||||
y: -50,
|
||||
})
|
||||
|
||||
// Tab again should wrap around to first shape inside frame
|
||||
await page.keyboard.press('Tab')
|
||||
expect(
|
||||
await page.evaluate(() => {
|
||||
const shape = editor.getOnlySelectedShape()!
|
||||
return { x: Math.round(shape.x), y: Math.round(shape.y) }
|
||||
})
|
||||
).toMatchObject({
|
||||
x: 50,
|
||||
y: -50,
|
||||
})
|
||||
})
|
||||
|
||||
test('Tab navigation with nested containers', async ({ isMobile }) => {
|
||||
if (isMobile) return // can't test this on mobile
|
||||
|
||||
// Clear the canvas and reset the view
|
||||
await page.keyboard.press('Control+a')
|
||||
await page.keyboard.press('Backspace')
|
||||
await page.keyboard.press('v')
|
||||
|
||||
// Create an outer frame
|
||||
await page.keyboard.press('f')
|
||||
await page.mouse.move(100, 100)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(500, 400)
|
||||
await page.mouse.up()
|
||||
|
||||
// Create an inner frame inside the outer frame
|
||||
await page.keyboard.press('f')
|
||||
await page.mouse.move(150, 150)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(300, 300)
|
||||
await page.mouse.up()
|
||||
|
||||
// Create shapes directly inside outer frame
|
||||
await page.keyboard.press('r')
|
||||
await page.mouse.click(400, 200) // Shape in outer frame
|
||||
await page.keyboard.press('r')
|
||||
await page.mouse.click(400, 300) // Another shape in outer frame
|
||||
|
||||
// Create shapes inside inner frame
|
||||
await page.keyboard.press('r')
|
||||
await page.mouse.click(200, 200) // Shape in inner frame
|
||||
await page.keyboard.press('r')
|
||||
await page.mouse.click(250, 200) // Another shape in inner frame
|
||||
await page.keyboard.press('v')
|
||||
|
||||
// Test: Tab from inner frame shape should only navigate within inner frame
|
||||
await page.mouse.click(200, 200) // Select first shape in inner frame
|
||||
|
||||
// Tab to next shape in inner frame
|
||||
await page.keyboard.press('Tab')
|
||||
expect(
|
||||
await page.evaluate(() => {
|
||||
const shape = editor.getOnlySelectedShape()!
|
||||
const parent = editor.getShape(shape.parentId)
|
||||
return {
|
||||
shapeX: Math.round(shape.x),
|
||||
shapeY: Math.round(shape.y),
|
||||
isInInnerFrame:
|
||||
parent && parent.type === 'frame' && parent.parentId !== editor.getCurrentPageId(),
|
||||
}
|
||||
})
|
||||
).toMatchObject({
|
||||
shapeX: 0,
|
||||
shapeY: -50,
|
||||
isInInnerFrame: true,
|
||||
})
|
||||
|
||||
// Tab should never select shapes from outer frame
|
||||
await page.keyboard.press('Tab')
|
||||
expect(
|
||||
await page.evaluate(() => {
|
||||
const shape = editor.getOnlySelectedShape()!
|
||||
const parent = editor.getShape(shape.parentId)
|
||||
return {
|
||||
isInInnerFrame:
|
||||
parent && parent.type === 'frame' && parent.parentId !== editor.getCurrentPageId(),
|
||||
}
|
||||
})
|
||||
).toMatchObject({
|
||||
isInInnerFrame: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('Tab navigation does not happen when alt key is pressed', async ({ isMobile }) => {
|
||||
if (isMobile) return // can't test this on mobile
|
||||
|
||||
// Create multiple shapes
|
||||
await page.keyboard.press('r')
|
||||
await page.mouse.click(100, 100)
|
||||
await page.keyboard.press('r')
|
||||
await page.mouse.click(250, 100)
|
||||
await page.keyboard.press('r')
|
||||
await page.mouse.click(400, 100)
|
||||
await page.keyboard.press('v')
|
||||
|
||||
// Click on the first shape to select it
|
||||
await page.mouse.click(100, 100)
|
||||
|
||||
await page.keyboard.press('Alt+Tab')
|
||||
expect(await page.evaluate(() => editor.getOnlySelectedShape())).toMatchObject({
|
||||
x: 0,
|
||||
y: 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
182
apps/examples/e2e/tests/test-licensing.spec.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import test from '../fixtures/fixtures'
|
||||
|
||||
// Helper function to set up license testing
|
||||
async function setupLicenseTest(page: any, licenseKey: string) {
|
||||
await page.addInitScript(`
|
||||
window.__TLDRAW_LICENSE_KEY__ = "${licenseKey}";
|
||||
`)
|
||||
await page.goto('http://localhost:5420/end-to-end')
|
||||
await page.waitForTimeout(6000) // Wait for license processing
|
||||
}
|
||||
|
||||
test.describe('Internal license', () => {
|
||||
test('does not render editor for expired internal license', async ({ page }) => {
|
||||
const expiredInternalLicenseKey =
|
||||
'tldraw-/WyJ0ZXN0LWludGVybmFsLWV4cGlyZWQtNm1vbnRocyIsWyJsb2NhbGhvc3QiXSw1LCIyMDI1LTAzLTAyVDA5OjM5OjMxLjYzMVoiXQ==.efmj2rGm9PqHqW4HqYE420Nomdubcm693r2gr+WWaIRQoA+I5BNJ2Z2kan0SdbXKcWj4oeyNruSeo7ixtRmfbg=='
|
||||
|
||||
const consoleMessages: string[] = []
|
||||
page.on('console', (msg) => {
|
||||
consoleMessages.push(msg.text())
|
||||
})
|
||||
|
||||
await setupLicenseTest(page, expiredInternalLicenseKey)
|
||||
|
||||
await expect(page.getByTestId('tl-license-expired')).toBeAttached()
|
||||
|
||||
// The actual editor canvas should not be rendered
|
||||
await expect(page.locator('.tl-canvas')).not.toBeVisible()
|
||||
|
||||
// Check that console contains license expiry message
|
||||
const hasExpiredMessage = consoleMessages.some((msg) =>
|
||||
msg.includes('Your tldraw license has been expired for more than 30 days!')
|
||||
)
|
||||
expect(hasExpiredMessage).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Watermarked license', () => {
|
||||
test('shows watermark with default license key', async ({ page }) => {
|
||||
// Don't set any license key - this should use our default license that shows the watermark
|
||||
await page.goto('http://localhost:5420/end-to-end')
|
||||
await page.waitForSelector('.tl-canvas')
|
||||
|
||||
// The editor should render normally
|
||||
await expect(page.locator('.tl-canvas')).toBeVisible()
|
||||
|
||||
// The watermark should be visible (default license has WITH_WATERMARK flag)
|
||||
await expect(page.getByTestId('tl-watermark-licensed')).toBeVisible()
|
||||
|
||||
// The unlicensed watermark should NOT be visible
|
||||
await expect(page.getByTestId('tl-watermark-unlicensed')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Unlicensed', () => {
|
||||
test('shows unlicensed watermark when no license key provided', async ({ page }) => {
|
||||
const consoleMessages: string[] = []
|
||||
page.on('console', (msg) => {
|
||||
consoleMessages.push(msg.text())
|
||||
})
|
||||
|
||||
// Explicitly set no license key by setting it to null
|
||||
await page.addInitScript(`
|
||||
window.__TLDRAW_LICENSE_KEY__ = null;
|
||||
`)
|
||||
await page.goto('http://localhost:5420/end-to-end')
|
||||
await page.waitForSelector('.tl-canvas')
|
||||
|
||||
// In development mode (localhost), the editor should render normally with watermark
|
||||
await expect(page.locator('.tl-canvas')).toBeVisible()
|
||||
|
||||
// The unlicensed watermark should be visible since we have no license
|
||||
await expect(page.getByTestId('tl-watermark-unlicensed')).toBeVisible()
|
||||
|
||||
// The watermark should NOT be visible
|
||||
await expect(page.getByTestId('tl-watermark-licensed')).not.toBeVisible()
|
||||
|
||||
// License gate should not be visible (editor is not blocked)
|
||||
await expect(page.getByTestId('tl-license-expired')).not.toBeVisible()
|
||||
|
||||
// Debug: Check what license key is being used (should be null)
|
||||
const licenseKeyUsed = await page.evaluate(() => {
|
||||
return (window as any).__TLDRAW_LICENSE_KEY__
|
||||
})
|
||||
expect(licenseKeyUsed).toBe(null)
|
||||
})
|
||||
|
||||
test('shows unlicensed watermark when license key is undefined', async ({ page }) => {
|
||||
// Explicitly set license key to undefined
|
||||
await page.addInitScript(`
|
||||
window.__TLDRAW_LICENSE_KEY__ = undefined;
|
||||
`)
|
||||
await page.goto('http://localhost:5420/end-to-end')
|
||||
await page.waitForSelector('.tl-canvas')
|
||||
|
||||
// In development mode (localhost), the editor should render normally with watermark
|
||||
await expect(page.locator('.tl-canvas')).toBeVisible()
|
||||
|
||||
// The unlicensed watermark should be visible since we have no license
|
||||
await expect(page.getByTestId('tl-watermark-unlicensed')).toBeVisible()
|
||||
|
||||
// The watermark should NOT be visible
|
||||
await expect(page.getByTestId('tl-watermark-licensed')).not.toBeVisible()
|
||||
|
||||
// License gate should not be visible (editor is not blocked)
|
||||
await expect(page.getByTestId('tl-license-expired')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('shows unlicensed watermark when license key is empty string', async ({ page }) => {
|
||||
// Set license key to empty string
|
||||
await page.addInitScript(`
|
||||
window.__TLDRAW_LICENSE_KEY__ = "";
|
||||
`)
|
||||
await page.goto('http://localhost:5420/end-to-end')
|
||||
await page.waitForSelector('.tl-canvas')
|
||||
|
||||
// In development mode (localhost), the editor should render normally with watermark
|
||||
await expect(page.locator('.tl-canvas')).toBeVisible()
|
||||
|
||||
// The unlicensed watermark should be visible since we have no valid license
|
||||
await expect(page.getByTestId('tl-watermark-unlicensed')).toBeVisible()
|
||||
|
||||
// The watermark should NOT be visible
|
||||
await expect(page.getByTestId('tl-watermark-licensed')).not.toBeVisible()
|
||||
|
||||
// License gate should not be visible (editor is not blocked)
|
||||
await expect(page.getByTestId('tl-license-expired')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Expired evaluation license', () => {
|
||||
test('does not render editor for expired evaluation license', async ({ page }) => {
|
||||
const expiredEvaluationLicenseKey =
|
||||
'tldraw-/WyJ0ZXN0LWV2YWwtZXhwaXJlZC03ZGF5cyIsWyJsb2NhbGhvc3QiXSwxNiwiMjAyNS0wOC0yMlQwODoyMDoyMC43MjJaIl0=.XIuM3PlrSjs3WMX2tWzgZil/x/TNliCR/NgHtUHcr6nzk2n6S+2ijsd6w+MPP9uEyUdRdg/FQLfcYJ5pqA/G0w=='
|
||||
|
||||
const consoleMessages: string[] = []
|
||||
page.on('console', (msg) => {
|
||||
consoleMessages.push(msg.text())
|
||||
})
|
||||
|
||||
await setupLicenseTest(page, expiredEvaluationLicenseKey)
|
||||
|
||||
await expect(page.getByTestId('tl-license-expired')).toBeAttached()
|
||||
|
||||
await expect(page.locator('.tl-canvas')).not.toBeVisible()
|
||||
|
||||
const hasExpiredMessage = consoleMessages.some((msg) =>
|
||||
msg.includes('Your tldraw evaluation license has expired!')
|
||||
)
|
||||
expect(hasExpiredMessage).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Expired annual/perpetual license', () => {
|
||||
test('does not render editor for expired annual license (beyond grace period)', async ({
|
||||
page,
|
||||
}) => {
|
||||
const expiredAnnualLicenseKey =
|
||||
'tldraw-/WyJ0ZXN0LWV4cGlyZWQtNzAiLFsibG9jYWxob3N0Il0sMSwiMjAyNS0wNi0yMFQwODoxODoyMS4zODRaIl0=.99Ce05q5rnBGhpTzptrTtvmYGjD4EEmWRbcrYqcVFdR1cRbcCzmHNSoGIz1wFWjpZSNQrMZ2ezj5yPALFRjVEw=='
|
||||
|
||||
const consoleMessages: string[] = []
|
||||
page.on('console', (msg) => {
|
||||
consoleMessages.push(msg.text())
|
||||
})
|
||||
|
||||
await setupLicenseTest(page, expiredAnnualLicenseKey)
|
||||
|
||||
await expect(page.getByTestId('tl-license-expired')).toBeAttached()
|
||||
|
||||
await expect(page.locator('.tl-canvas')).not.toBeVisible()
|
||||
|
||||
const hasExpiredMessage = consoleMessages.some((msg) =>
|
||||
msg.includes('Your tldraw license has been expired for more than 30 days!')
|
||||
)
|
||||
expect(hasExpiredMessage).toBe(true)
|
||||
|
||||
const hasAnnualMessage = consoleMessages.some((msg) =>
|
||||
msg.includes('Please reach out to sales@tldraw.com to renew your license.')
|
||||
)
|
||||
expect(hasAnnualMessage).toBe(true)
|
||||
})
|
||||
})
|
||||
44
apps/examples/e2e/tests/test-main-menu.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import test from '../fixtures/fixtures'
|
||||
import { setupOrReset } from '../shared-e2e'
|
||||
|
||||
test.describe('help menu', () => {
|
||||
test.beforeEach(setupOrReset)
|
||||
|
||||
test('you can open and close the menu', async ({ mainMenu, page }) => {
|
||||
const { mainMenuButton, buttons } = mainMenu
|
||||
const { editSubmenu, viewSubmenu, preferencesSubmenu } = mainMenu.subMenus
|
||||
const submenus = [
|
||||
{ name: 'preferences', submenu: preferencesSubmenu, button: buttons.preferences },
|
||||
{ name: 'view', submenu: viewSubmenu, button: buttons.view },
|
||||
{ name: 'edit', submenu: editSubmenu, button: buttons.edit },
|
||||
]
|
||||
|
||||
await test.step('open main menu', async () => {
|
||||
await expect(buttons.edit).toBeHidden()
|
||||
await mainMenuButton.click()
|
||||
await expect(buttons.edit).toBeVisible()
|
||||
})
|
||||
for (const submenu of submenus) {
|
||||
await test.step(`hovering opens ${submenu.name} submenu`, async () => {
|
||||
await expect(submenu.submenu[0]).toBeHidden()
|
||||
await submenu.button.hover()
|
||||
await expect(submenu.submenu[0]).toBeVisible()
|
||||
})
|
||||
}
|
||||
await test.step('close main menu', async () => {
|
||||
// close the menu by clicking the main menu button again
|
||||
await mainMenuButton.click()
|
||||
await expect(buttons.edit).toBeHidden()
|
||||
// open the menu again
|
||||
await mainMenuButton.click()
|
||||
// click somewhere on the canvas to close the menu
|
||||
await page.mouse.click(250, 150)
|
||||
await expect(buttons.edit).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
// ...
|
||||
// More tests here
|
||||
// ...
|
||||
})
|
||||
33
apps/examples/e2e/tests/test-navigation-panel.spec.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import test from '../fixtures/fixtures'
|
||||
import { setupOrReset } from '../shared-e2e'
|
||||
|
||||
test.describe('navigationPanel', () => {
|
||||
test.beforeEach(setupOrReset)
|
||||
|
||||
test('you can open and close the zoom menu', async ({ navigationPanel, isMobile }) => {
|
||||
// no navigationPanel on mobile
|
||||
test.skip(isMobile)
|
||||
const { zoomMenuButton } = navigationPanel
|
||||
const { zoomIn } = navigationPanel.zoomMenuItems
|
||||
await expect(zoomIn).toBeHidden()
|
||||
await zoomMenuButton.click()
|
||||
await expect(zoomIn).toBeVisible()
|
||||
await zoomMenuButton.click()
|
||||
await expect(zoomIn).toBeHidden()
|
||||
})
|
||||
test('you can toggle the minimap', async ({ navigationPanel, isMobile }) => {
|
||||
// no navigationPanel on mobile
|
||||
test.skip(isMobile)
|
||||
const { minimap, toggleButton } = navigationPanel
|
||||
await expect(minimap).toBeHidden()
|
||||
await toggleButton.click()
|
||||
await expect(minimap).toBeVisible()
|
||||
await toggleButton.click()
|
||||
await expect(minimap).toBeHidden()
|
||||
})
|
||||
|
||||
// ...
|
||||
// More tests here
|
||||
// ...
|
||||
})
|
||||
261
apps/examples/e2e/tests/test-overlay-snapshots.spec.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { Page, expect } from '@playwright/test'
|
||||
import { Editor, IndexKey } from 'tldraw'
|
||||
import test from '../fixtures/fixtures'
|
||||
import { hardResetEditor, setup } from '../shared-e2e'
|
||||
|
||||
declare const editor: Editor
|
||||
|
||||
// Screenshot-based regression tests for canvas overlay appearance.
|
||||
//
|
||||
// Overlays render to a single <canvas class="tl-canvas-overlays"> element, so
|
||||
// we screenshot the `.tl-canvas` region and let the image compare cover every
|
||||
// overlay kind (selection foreground, shape handles, brush, snap indicators,
|
||||
// scribble, etc.) at once.
|
||||
//
|
||||
// Tests use direct editor state injection (brush/zoomBrush via
|
||||
// updateInstanceState, scribbles via ScribbleManager, snap indicators via
|
||||
// SnapManager.setIndicators) rather than simulating drags. This is what the
|
||||
// unit tests for each OverlayUtil already do, and it keeps the states frame-
|
||||
// exact rather than timing-dependent.
|
||||
|
||||
async function prepare(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
editor.user.updateUserPreferences({ colorScheme: 'light', animationSpeed: 0 })
|
||||
editor.setCamera({ x: 0, y: 0, z: 1 }, { immediate: true })
|
||||
editor.updateInstanceState({ isGridMode: false })
|
||||
})
|
||||
// Move mouse out of any shape to avoid hover indicators.
|
||||
await page.mouse.move(0, 0)
|
||||
}
|
||||
|
||||
async function snapshotCanvas(page: Page) {
|
||||
// Wait for two rAFs so reactive overlay canvases have committed their paint.
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
|
||||
)
|
||||
)
|
||||
// Allow a small tolerance to absorb sub-pixel anti-aliasing variance on
|
||||
// rotated or curved overlay strokes. A real overlay regression (missing
|
||||
// handle, wrong color, moved stroke) changes far more than 0.5% of pixels.
|
||||
// Screenshot capture also waits for the canvas element to become stable,
|
||||
// which can exceed the global 2s expect timeout on cold preview runs.
|
||||
await expect(page.locator('.tl-canvas')).toHaveScreenshot({
|
||||
maxDiffPixelRatio: 0.005,
|
||||
timeout: 10_000,
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Overlay snapshots', () => {
|
||||
test.skip(
|
||||
({ isMobile }) => isMobile,
|
||||
'Overlay snapshots are desktop-only; mobile overlays render differently (coarse-pointer rotate handle, different resize handle visibility).'
|
||||
)
|
||||
|
||||
test.beforeEach(async ({ page, context }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('end-to-end')) {
|
||||
await setup({ page, context } as any)
|
||||
} else {
|
||||
await hardResetEditor(page)
|
||||
}
|
||||
await prepare(page)
|
||||
})
|
||||
|
||||
test('selection foreground: single shape', async ({ page }) => {
|
||||
await page.evaluate(() => {
|
||||
editor.createShape({
|
||||
type: 'geo',
|
||||
x: 220,
|
||||
y: 160,
|
||||
props: { w: 240, h: 160, geo: 'rectangle', dash: 'solid', fill: 'none' },
|
||||
})
|
||||
editor.selectAll()
|
||||
})
|
||||
await snapshotCanvas(page)
|
||||
})
|
||||
|
||||
test('selection foreground: multiple shapes', async ({ page }) => {
|
||||
await page.evaluate(() => {
|
||||
editor.createShapes([
|
||||
{
|
||||
type: 'geo',
|
||||
x: 180,
|
||||
y: 140,
|
||||
props: { w: 120, h: 100, geo: 'rectangle', dash: 'solid', fill: 'none' },
|
||||
},
|
||||
{
|
||||
type: 'geo',
|
||||
x: 360,
|
||||
y: 200,
|
||||
props: { w: 140, h: 120, geo: 'ellipse', dash: 'solid', fill: 'none' },
|
||||
},
|
||||
])
|
||||
editor.selectAll()
|
||||
})
|
||||
await snapshotCanvas(page)
|
||||
})
|
||||
|
||||
test('selection foreground: rotated shape', async ({ page }) => {
|
||||
await page.evaluate(() => {
|
||||
editor.createShape({
|
||||
type: 'geo',
|
||||
x: 260,
|
||||
y: 180,
|
||||
rotation: Math.PI / 6,
|
||||
props: { w: 220, h: 140, geo: 'rectangle', dash: 'solid', fill: 'none' },
|
||||
})
|
||||
editor.selectAll()
|
||||
})
|
||||
await snapshotCanvas(page)
|
||||
})
|
||||
|
||||
test('shape handles: arrow endpoints', async ({ page }) => {
|
||||
await page.evaluate(() => {
|
||||
editor.createShape({
|
||||
type: 'arrow',
|
||||
x: 220,
|
||||
y: 220,
|
||||
props: {
|
||||
start: { x: 0, y: 0 },
|
||||
end: { x: 280, y: 0 },
|
||||
dash: 'solid',
|
||||
},
|
||||
})
|
||||
editor.selectAll()
|
||||
})
|
||||
await snapshotCanvas(page)
|
||||
})
|
||||
|
||||
test('shape handles: line with vertices', async ({ page }) => {
|
||||
await page.evaluate(() => {
|
||||
editor.createShape({
|
||||
type: 'line',
|
||||
x: 200,
|
||||
y: 260,
|
||||
props: {
|
||||
dash: 'solid',
|
||||
points: {
|
||||
a1: { id: 'a1', index: 'a1' as IndexKey, x: 0, y: 0 },
|
||||
a2: { id: 'a2', index: 'a2' as IndexKey, x: 180, y: -60 },
|
||||
a3: { id: 'a3', index: 'a3' as IndexKey, x: 360, y: 0 },
|
||||
},
|
||||
},
|
||||
})
|
||||
editor.selectAll()
|
||||
})
|
||||
await snapshotCanvas(page)
|
||||
})
|
||||
|
||||
test('shape handles: line endpoints and hovered center handle', async ({ page }) => {
|
||||
// Capture the two states a user sees on a selected line: the idle state
|
||||
// where only the vertex handles at the start and end are visible, and
|
||||
// the hover state where the midpoint create handle becomes visible
|
||||
// with its hover halo.
|
||||
await page.evaluate(() => {
|
||||
editor.createShape({
|
||||
type: 'line',
|
||||
x: 200,
|
||||
y: 200,
|
||||
props: {
|
||||
dash: 'solid',
|
||||
points: {
|
||||
a1: { id: 'a1', index: 'a1' as IndexKey, x: 0, y: 0 },
|
||||
a2: { id: 'a2', index: 'a2' as IndexKey, x: 280, y: 280 },
|
||||
},
|
||||
},
|
||||
})
|
||||
editor.selectAll()
|
||||
})
|
||||
|
||||
await snapshotCanvas(page)
|
||||
|
||||
// Force-hover the midpoint 'create' handle so the screenshot is frame-
|
||||
// exact regardless of the browser's pointer coordinate rounding.
|
||||
await page.evaluate(() => {
|
||||
const shape = editor.getOnlySelectedShape()!
|
||||
const handles = editor.getShapeHandles(shape)!
|
||||
const midHandle = handles.find((h) => h.type === 'create')!
|
||||
editor.overlays.setHoveredOverlay(`handle:${shape.id}:${midHandle.id}`)
|
||||
})
|
||||
await snapshotCanvas(page)
|
||||
})
|
||||
|
||||
test('brush overlay', async ({ page }) => {
|
||||
await page.evaluate(() => {
|
||||
editor.updateInstanceState({ brush: { x: 180, y: 140, w: 320, h: 200 } })
|
||||
})
|
||||
await snapshotCanvas(page)
|
||||
})
|
||||
|
||||
test('zoom brush overlay', async ({ page }) => {
|
||||
await page.evaluate(() => {
|
||||
editor.updateInstanceState({ zoomBrush: { x: 180, y: 140, w: 320, h: 200 } })
|
||||
})
|
||||
await snapshotCanvas(page)
|
||||
})
|
||||
|
||||
test('snap indicator overlay: point snaps', async ({ page }) => {
|
||||
await page.evaluate(() => {
|
||||
editor.createShapes([
|
||||
{
|
||||
type: 'geo',
|
||||
x: 180,
|
||||
y: 160,
|
||||
props: { w: 100, h: 100, geo: 'rectangle', dash: 'solid', fill: 'none' },
|
||||
},
|
||||
{
|
||||
type: 'geo',
|
||||
x: 380,
|
||||
y: 160,
|
||||
props: { w: 100, h: 100, geo: 'rectangle', dash: 'solid', fill: 'none' },
|
||||
},
|
||||
])
|
||||
editor.snaps.setIndicators([
|
||||
{
|
||||
id: 'p-top',
|
||||
type: 'points',
|
||||
points: [
|
||||
{ x: 230, y: 160 },
|
||||
{ x: 430, y: 160 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'p-bottom',
|
||||
type: 'points',
|
||||
points: [
|
||||
{ x: 230, y: 260 },
|
||||
{ x: 430, y: 260 },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
await snapshotCanvas(page)
|
||||
})
|
||||
|
||||
test('scribble overlay', async ({ page }) => {
|
||||
await page.evaluate(async () => {
|
||||
const item = editor.scribbles.addScribble(
|
||||
{ color: 'accent', size: 20, opacity: 0.8, taper: true },
|
||||
'snapshot-scribble'
|
||||
)
|
||||
// Build a smooth arc of points. Advance the tick between each
|
||||
// addPoint so ScribbleManager moves `next` into `scribble.points`.
|
||||
const pts: Array<[number, number]> = []
|
||||
for (let i = 0; i <= 20; i++) {
|
||||
const t = i / 20
|
||||
const x = 200 + t * 320
|
||||
const y = 240 + Math.sin(t * Math.PI) * -60
|
||||
pts.push([x, y])
|
||||
}
|
||||
for (const [x, y] of pts) {
|
||||
editor.scribbles.addPoint(item.id, x, y, 0.5)
|
||||
editor.scribbles.tick(16)
|
||||
}
|
||||
editor.scribbles.complete(item.id)
|
||||
editor.scribbles.tick(16)
|
||||
})
|
||||
await snapshotCanvas(page)
|
||||
})
|
||||
})
|
||||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 36 KiB |