snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled

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

View File

@@ -0,0 +1,68 @@
import fs from 'fs'
import { Page } from '@playwright/test'
import { DB } from '@tldraw/dotcom-shared'
import { Kysely, PostgresDialect, sql } from 'kysely'
import pg from 'pg'
import { OTHER_USERS, USERS } from '../consts'
import { getStorageStateFileName } from './helpers'
const db = new Kysely<DB>({
dialect: new PostgresDialect({
pool: new pg.Pool({
connectionString: 'postgresql://user:password@127.0.0.1:6432/postgres',
application_name: 'migrate',
idleTimeoutMillis: 10_000,
max: 10,
}),
}),
log: ['error'],
})
export class Database {
constructor(
readonly page: Page | null,
private parallelIndex: number
) {}
async reset() {
await this.cleanUpUser(true)
await this.cleanUpUser(false)
}
getEmail(isOther: boolean = false) {
return getTestUserEmail(this.parallelIndex, isOther ? 'suppy' : 'huppy')
}
async getUserId(isOther: boolean = false) {
return await this.getUserIdByEmail(this.getEmail(isOther))
}
async getUserIdByEmail(email: string) {
const dbUser = await sql<{
id: string
}>`SELECT id FROM public.user WHERE email = ${email ?? ''}`.execute(db)
if (!dbUser.rows[0]) return
return dbUser.rows[0].id
}
private async cleanUpUser(isOther: boolean) {
const fileName = getStorageStateFileName(this.parallelIndex, isOther ? 'suppy' : 'huppy')
if (!fs.existsSync(fileName)) return
const id = await this.getUserId(isOther)
if (!id) return
try {
// eslint-disable-next-line no-restricted-globals
await fetch(`http://localhost:3000/api/app/__test__/user/${id}/prepare-for-test`, {
method: 'POST',
})
} catch (e) {
console.error('Error', e)
}
}
}
export type TestUser = 'huppy' | 'suppy'
export function getTestUserEmail(index: number, user: TestUser) {
return user === 'suppy' ? OTHER_USERS[index] : USERS[index]
}

View File

@@ -0,0 +1,34 @@
import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
export class DeleteFileDialog {
public readonly dialog: Locator
public readonly confirmButton: Locator
public readonly cancelButton: Locator
constructor(public readonly page: Page) {
this.dialog = this.page.getByRole('dialog', { name: 'Delete file' })
this.confirmButton = this.dialog.getByRole('button', { name: 'Delete' })
this.cancelButton = this.dialog.getByRole('button', { name: 'Cancel' })
}
async isVisible() {
return this.dialog.isVisible()
}
async expectIsVisible() {
await expect(this.dialog).toBeVisible()
}
async expectIsNotVisible() {
await expect(this.dialog).not.toBeVisible()
}
async confirmDeletion() {
await this.confirmButton.click()
}
async cancelDeletion() {
await this.cancelButton.click()
}
}

View File

@@ -0,0 +1,95 @@
import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import { sleep } from './helpers'
import { Sidebar } from './Sidebar'
import { step } from './tla-test'
export class Editor {
public readonly sidebarToggle: Locator
private readonly fileName: Locator
private readonly shapes: Locator
private readonly pageMenu: Locator
constructor(
public readonly page: Page,
private readonly sidebar: Sidebar
) {
this.sidebarToggle = this.page.getByTestId('tla-sidebar-toggle')
this.fileName = this.page.getByTestId('tla-file-name')
this.shapes = this.page.locator('.tl-shape')
this.pageMenu = this.page.getByTestId('tla-main-menu')
}
async toggleSidebar() {
await this.sidebarToggle.click()
await sleep(500)
}
@step
async ensureSidebarOpen() {
const visible = await this.sidebar.isVisible()
if (!visible) {
await this.toggleSidebar()
}
await this.sidebar.expectIsVisible()
}
@step
async ensureSidebarClosed() {
const visible = await this.sidebar.isVisible()
if (visible) {
await this.toggleSidebar()
}
await this.sidebar.expectIsNotVisible()
}
async isLoaded() {
await expect(this.sidebarToggle).toBeVisible()
}
async getShapeCount() {
return await this.shapes.count()
}
@step
async expectShapesCount(expected: number, timeout?: number) {
await expect(this.shapes).toHaveCount(expected, { timeout })
}
async getCurrentFileName() {
const text = await this.fileName.innerText()
// The UI replaces spaces with \u00a0 to prevent line-breaking; normalise back.
return text.replace(/\u00a0/g, ' ')
}
@step
async rename(newName: string) {
await this.fileName.click()
await this.page.getByTestId('tla-file-name-input').fill(newName)
await this.page.keyboard.press('Enter')
}
@step
async openPageMenu() {
await this.pageMenu.click()
}
@step
async createNewPage() {
await this.page.getByTestId('page-menu.button').click()
await expect(this.page.getByTestId('page-menu.create')).toBeVisible()
await expect(this.page.getByTestId('page-menu.item').first()).toBeVisible()
const count = await this.page.getByTestId('page-menu.item').count()
await this.page.getByTestId('page-menu.create').click()
await expect(this.page.getByTestId('page-menu.item')).toHaveCount(count + 1)
await this.page.keyboard.press('Enter')
await this.page.keyboard.press('Escape')
}
@step
async createTextShape(text: string) {
await this.page.getByTestId('tools.select').click()
await this.page.locator('.tl-background').click({ clickCount: 2 })
await this.page.locator('div[contenteditable="true"]').fill(text)
}
}

View File

@@ -0,0 +1,30 @@
import { Locator, Page } from '@playwright/test'
import { expect } from '../fixtures/tla-test'
export class ErrorPage {
public readonly notFound: Locator
public readonly pageNotFound: Locator
public readonly privateFile: Locator
constructor(public readonly page: Page) {
this.notFound = this.page.getByRole('heading', { name: 'Not found' })
this.pageNotFound = this.page.getByRole('heading', { name: 'Page not found' })
this.privateFile = this.page.getByRole('heading', { name: 'Private file' })
}
async expectNotFoundVisible() {
await expect(this.notFound).toBeVisible()
}
async expectNotFoundNotVisible() {
await expect(this.notFound).not.toBeVisible()
}
async expectPageNotFoundVisible() {
await expect(this.pageNotFound).toBeVisible()
}
async expectPrivateFileVisible() {
await expect(this.privateFile).toBeVisible()
}
}

View File

@@ -0,0 +1,103 @@
import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import { Editor } from './Editor'
import { step } from './tla-test'
const rootUrl = 'http://localhost:3000/'
export class HomePage {
public readonly signInButton: Locator
public readonly tldrawEditor: Locator
public readonly tldrawCanvas: Locator
constructor(
private readonly page: Page,
private readonly editor: Editor
) {
this.signInButton = this.page.getByTestId('tla-sign-in-button')
this.tldrawEditor = this.page.getByTestId('tla-editor')
this.tldrawCanvas = this.page.getByTestId('canvas')
}
@step
async loginAs(email: string) {
if (this.page.url() !== rootUrl) {
await this.goto()
}
await this.page.waitForLoadState('domcontentloaded')
const isSideBarToggleVisible =
(await this.editor.sidebarToggle.isVisible().catch(() => false)) ?? false
if (isSideBarToggleVisible) return
await expect(this.signInButton).toBeVisible()
await this.signInButton.click({ force: true })
await this.page.getByTestId('tla-identifier-input').fill(email)
await this.page.getByTestId('tla-continue-with-email-button').click()
// will go to
await this.page.getByTestId('tla-verification-code-input').fill('424242')
// Wait till we're on a file page, e.g. /f/:someId
await this.page.waitForURL(new RegExp(`${rootUrl}f/.*`))
await expect(async () => {
await expect(this.page.getByTestId('tla-sidebar-toggle')).toBeVisible()
}).toPass()
}
async loginWithEmailAndPassword(email: string, password: string) {
const isSideBarToggleVisible = await this.editor.sidebarToggle.isVisible()
// We are already signed in
if (isSideBarToggleVisible) return
await expect(this.signInButton).toBeVisible()
await this.signInButton.click()
await this.page.getByLabel('Email address').fill(email)
// Note: This method is for staging/production Clerk flows that use passwords
// The password field appears after clicking continue on email
await this.page.getByTestId('tla-continue-with-email-button').click()
await this.page.waitForTimeout(500)
await this.page.getByRole('textbox', { name: 'Password' }).fill(password)
// After entering password, look for any Continue button (Clerk's default buttons don't have test IDs)
await this.page.getByRole('button', { name: 'Continue', exact: true }).click()
await this.page.waitForTimeout(1000)
await this.handleTermsIfNeeded()
await expect(async () => {
await expect(this.page.getByTestId('tla-sidebar-toggle')).toBeVisible()
}).toPass()
}
private async handleTermsIfNeeded() {
const acceptAndContinueButton = this.page.getByTestId('tla-accept-and-continue-button')
// Wait a bit for the dialog to potentially appear
await this.page.waitForTimeout(500)
if ((await acceptAndContinueButton.count()) === 0) return
await expect(acceptAndContinueButton).toBeVisible()
await expect(acceptAndContinueButton).toBeEnabled()
await acceptAndContinueButton.click()
// Wait for the button click to complete
await this.page.waitForTimeout(500)
}
async expectSignInButtonVisible() {
await expect(async () => {
await expect(this.signInButton).toBeVisible()
}).toPass()
}
async expectSignInButtonNotVisible() {
await expect(async () => {
await expect(this.signInButton).not.toBeVisible()
}).toPass()
}
async goto(url = rootUrl) {
await this.page.goto(url, { waitUntil: 'load' })
}
async isLoaded() {
// Swat away the terms dialog if it appears.
await this.handleTermsIfNeeded()
await expect(async () => {
await expect(this.tldrawEditor).toBeVisible({ timeout: 10000 })
await expect(this.tldrawCanvas).toBeVisible({ timeout: 10000 })
}).toPass()
}
}

View File

@@ -0,0 +1,51 @@
import type { Page } from '@playwright/test'
import { step } from './tla-test'
export const IMPORT_URL = 'https://e2e-mock.tldraw.xyz/test-import.tldr'
// Sequences match the current record versions so no migrations run on import,
// which would otherwise overwrite fields (e.g. AddName sets name='').
const MINIMAL_TLDR_FILE = {
tldrawFileFormatVersion: 1,
schema: {
schemaVersion: 2,
sequences: { 'com.tldraw.document': 2, 'com.tldraw.page': 1 } as Record<string, number>,
},
records: [
{
typeName: 'document',
id: 'document:document',
name: 'e2e import test',
gridSize: 10,
meta: {},
},
{
typeName: 'page',
id: 'page:page',
index: 'a1',
meta: {},
name: 'Page 1',
},
],
}
export class ImportHelper {
constructor(private readonly page: Page) {}
@step
async mockUrl(url = IMPORT_URL) {
await this.page.route(url, (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
headers: { 'Access-Control-Allow-Origin': '*' },
body: JSON.stringify(MINIMAL_TLDR_FILE),
})
)
}
@step
async navigate(url = IMPORT_URL) {
await this.page.goto(`http://localhost:3000/import?url=${encodeURIComponent(url)}`)
}
}

View File

@@ -0,0 +1,169 @@
import type { Locator, Page } from '@playwright/test'
import { expect, step } from './tla-test'
type ShareMenuTab = 'invite' | 'export' | 'publish'
export class ShareMenu {
public readonly shareButton: Locator
public readonly exportTabButton: Locator
public readonly inviteTabButton: Locator
public readonly publishTabButton: Locator
public readonly anonShareTabButton: Locator
public readonly exportTabPage: Locator
public readonly inviteTabPage: Locator
public readonly publishTabPage: Locator
public readonly anonShareTabPage: Locator
public readonly publishChangesButton: Locator
public readonly copyLinkButton: Locator
public readonly exportImageButton: Locator
public readonly tabs: { anonShare: Locator; invite: Locator; export: Locator; publish: Locator }
constructor(public readonly page: Page) {
this.shareButton = this.page.getByTestId('tla-share-button')
this.exportTabButton = this.page.getByTestId('tla-share-tab-button-export')
this.inviteTabButton = this.page.getByTestId('tla-share-tab-button-share')
this.publishTabButton = this.page.getByTestId('tla-share-tab-button-publish')
this.anonShareTabButton = this.page.getByTestId('tla-share-tab-button-anon-share')
this.exportTabPage = this.page.getByTestId('tla-share-tab-page-export')
this.inviteTabPage = this.page.getByTestId('tla-share-tab-page-share')
this.publishTabPage = this.page.getByTestId('tla-share-tab-page-publish')
this.anonShareTabPage = this.page.getByTestId('tla-share-tab-page-anon-share')
this.publishChangesButton = this.page.getByRole('button', {
name: 'Publish changes',
exact: true,
})
this.copyLinkButton = this.page.getByRole('button', { name: 'Copy link' })
this.exportImageButton = this.page.getByRole('button', { name: 'Export image' })
this.tabs = {
anonShare: this.anonShareTabButton,
invite: this.inviteTabButton,
export: this.exportTabButton,
publish: this.publishTabButton,
}
this.shareFile = this.shareFile.bind(this)
this.unshareFile = this.unshareFile.bind(this)
this.publishFile = this.publishFile.bind(this)
this.unpublishFile = this.unpublishFile.bind(this)
}
async open() {
await this.shareButton.click()
}
async isInviteButtonVisible() {
return await this.inviteTabButton.isVisible()
}
async share(tab: ShareMenuTab) {
await this.ensureTabSelected(tab)
if (!(await this.isToggleChecked())) {
await this.page.getByRole('switch').check()
}
}
async unshare(tab: ShareMenuTab) {
await this.ensureTabSelected(tab)
if (await this.isToggleChecked()) {
await this.page.getByRole('switch').uncheck()
}
}
@step
async shareFile() {
await this.share('invite')
}
@step
async unshareFile() {
await this.unshare('invite')
}
@step
async publishFile() {
await this.share('publish')
}
@step
async unpublishFile() {
await this.unshare('publish')
}
async isToggleChecked() {
return await this.page.getByRole('switch').isChecked()
}
@step
async copyLink() {
const sentinel = `__copy_link_pending_${Date.now()}__`
await this.page.evaluate(async (value) => {
await navigator.clipboard.writeText(value)
}, sentinel)
await this.copyLinkButton.click()
let url = ''
await expect(async () => {
url = await this.page.evaluate(async () => await navigator.clipboard.readText())
expect(url).not.toBe(sentinel)
expect(url).toBeTruthy()
const parsed = new URL(url)
expect(parsed.origin).toBe('http://localhost:3000')
// Share menu links should point to file/publish routes, not group invite routes.
expect(parsed.pathname.startsWith('/f/') || parsed.pathname.startsWith('/p/')).toBe(true)
}).toPass({ timeout: 5000 })
return url
}
@step
async openShareMenuAndCopyInviteLink() {
await this.open()
await this.inviteTabButton.click()
await this.ensureTabSelected('invite')
return await this.copyLink()
}
@step
async openShareMenuAndCopyPublishedLink() {
await this.open()
await this.publishTabButton.click()
await this.ensureTabSelected('publish')
return await this.copyLink()
}
@step
async exportFile() {
await this.ensureTabSelected('export')
await this.exportImageButton.click()
}
@step
async isTabSelected(tab: ShareMenuTab) {
const attr = await this.tabs[tab].getAttribute('data-active')
return attr === 'true'
}
@step
async ensureTabSelected(tab: ShareMenuTab) {
if (await this.isTabSelected(tab)) return
const locator = this.tabs[tab]
await locator.click()
}
@step
async publishChanges() {
await this.ensureTabSelected('publish')
await this.publishChangesButton.click()
}
async getShareType() {
return (await this.page.waitForSelector('[data-testid="shared-link-type-select"]'))
.textContent()
.then((text) => text?.trim())
}
}

View File

@@ -0,0 +1,649 @@
import type { Locator, Page } from '@playwright/test'
import { MAX_WORKSPACE_NAME_LENGTH } from '@tldraw/dotcom-shared'
import { expect, step } from './tla-test'
// The createWorkspace/updateWorkspace mutators clamp names to MAX_WORKSPACE_NAME_LENGTH, so the
// stored (and therefore displayed and synced) name diverges from a longer requested name. Scenario
// ids alone run close to that limit, so workspace helpers must match against the clamped form or
// they search for a name the app never renders.
function clampWorkspaceName(name: string) {
return name.trim().slice(0, MAX_WORKSPACE_NAME_LENGTH)
}
// Cross-client Zero sync (accepting an invite, being removed, a workspace being deleted) delivers
// workspace-membership rows asynchronously and with variable latency on a busy CI machine. This is
// the budget for the data-layer wait that actually gates on that sync; it is intentionally generous
// while still bounded, so a genuine sync regression fails with a clear signal rather than hanging.
const WORKSPACE_MEMBERSHIP_SYNC_TIMEOUT = 30_000
export class Sidebar {
public readonly fileLink = '[data-element="file-link"]'
public readonly sidebarLayout: Locator
public readonly sidebar: Locator
public readonly sidebarLogo: Locator
public readonly createFileButton: Locator
public readonly userSettingsMenu: Locator
public readonly helpMenu: Locator
public readonly themeButton: Locator
public readonly darkModeButton: Locator
public readonly signOutButton: Locator
constructor(public readonly page: Page) {
this.sidebarLayout = this.page.getByTestId('tla-sidebar-layout')
this.sidebar = this.page.getByTestId('tla-sidebar')
this.sidebarLogo = this.page.getByTestId('tla-sidebar-logo-icon')
this.createFileButton = this.page.getByTestId('tla-create-file')
this.userSettingsMenu = this.page.getByTestId('tla-sidebar-user-settings-trigger')
this.helpMenu = this.userSettingsMenu
this.themeButton = this.page.getByTestId('dialog-sub.theme-button')
this.darkModeButton = this.page.getByRole('menuitemcheckbox', { name: 'Dark', exact: true })
this.signOutButton = this.page.getByTestId('dialog.sign-out')
}
async isVisible() {
return this.sidebarLogo.isVisible()
}
async expectIsVisible() {
await expect(this.sidebarLogo).toBeInViewport()
}
async expectIsNotVisible() {
await expect(this.sidebarLogo).not.toBeInViewport()
}
async createNewDocument(name?: string) {
const numDocuments = await this.getNumberOfFiles()
const previousUrl = this.page.url()
await this.createFileButton.click()
const input = this.page.getByTestId('tla-sidebar-rename-input')
await expect(input).toBeVisible()
await expect(input).toBeFocused()
if (name) {
await input.fill(name)
}
await Promise.all([
this.page.waitForURL(
(url) => url.toString() !== previousUrl && url.pathname.startsWith('/f/'),
{ timeout: 10000 }
),
this.page.keyboard.press('Enter'),
])
await expect.poll(() => this.getNumberOfFiles()).toBe(numDocuments + 1)
// the create button has a 1000ms throttle - wait so the next creation isn't swallowed
await this.page.waitForTimeout(1100)
}
async getNumberOfFiles() {
return (await this.page.$$(this.fileLink)).length
}
async openUserSettingsMenu() {
await this.userSettingsMenu.hover()
await this.userSettingsMenu.click()
// Wait for the dropdown content to mount before child-item clicks race the open animation.
await expect(this.page.getByRole('menu')).toBeVisible()
}
@step
async setDarkMode() {
await this.openUserSettingsMenu()
await this.themeButton.hover()
await this.darkModeButton.click()
}
@step
async openLanguageMenu(languageButtonText: string) {
// need the editor to be mounted for the menu to be available
await expect(this.page.getByTestId('canvas')).toBeVisible()
await this.openUserSettingsMenu()
await this.page.getByText(languageButtonText).hover()
}
@step
async expectLanguageToBe(languageButtonText: string, language: string) {
await this.openLanguageMenu(languageButtonText)
await expect(async () => {
const checkedAttribute = await this.page
.getByRole('menuitemcheckbox', { name: language })
.getAttribute('data-state')
expect(checkedAttribute).toBe('checked')
}).toPass()
}
@step
async setLanguage(languageButtonText: string, language: string) {
await this.openLanguageMenu(languageButtonText)
await this.page.getByRole('menuitemcheckbox', { name: language }).click()
await this.page.keyboard.press('Escape')
}
@step
async signOut() {
await this.openUserSettingsMenu()
await this.signOutButton.click()
}
async expectToContainText(text: string) {
await expect(this.sidebarLayout).toContainText(text)
}
async expectNotToContainText(text: string) {
await expect(this.sidebarLayout).not.toContainText(text)
}
private getTestId(section: string, index: number, suffix?: string) {
return `tla-file-link-${section}-${index}${suffix ? `-${suffix}` : ''}`
}
getFirstFileLink() {
return this.getFileLink('today', 0)
}
getFileLink(section: string, index: number) {
return this.page.getByTestId(this.getTestId(section, index))
}
async getFirstFileName() {
return await this.getFileName(0)
}
async getFileName(index: number) {
return await this.page
.getByTestId(this.getTestId('today', index, 'name'))
.innerText({ timeout: 5000 })
}
@step
private async openFileMenu(fileLink: Locator) {
await fileLink?.hover()
const button = fileLink.getByRole('button')
await button?.click()
}
@step
async deleteFile(index: number) {
await this.openFileMenu(this.getFileLink('today', index))
await this.deleteFromFileMenu()
}
@step
private async deleteFromFileMenu() {
await this.page.getByRole('menuitem', { name: 'Delete' }).click()
}
@step
async renameFile(index: number, newName: string) {
const fileLink = this.getFileLink('today', index)
await this.openFileMenu(fileLink)
await this.renameFromFileMenu(newName)
}
@step
async renameFileByName(fileName: string, newName: string) {
await this.openFileMenuByName(fileName)
await this.renameFromFileMenu(newName)
}
@step
async renameFromFileMenu(name: string) {
await this.page.getByRole('menuitem', { name: 'Rename' }).click()
const input = this.page.getByTestId('tla-sidebar-rename-input')
await input.fill(name)
await this.page.keyboard.press('Enter')
}
@step
private async duplicateFromFileMenu(name?: string) {
await this.page.getByRole('menuitem', { name: 'Duplicate' }).click()
const input = this.page.getByTestId('tla-sidebar-rename-input')
await expect(input).toBeVisible()
await expect(input).toBeFocused()
if (name) {
await input.fill(name)
}
await this.page.keyboard.press('Enter')
}
@step
async pinFromFileMenu(index: number) {
const fileLink = this.getFileLink('today', index)
await this.openFileMenu(fileLink)
await this.page.getByRole('menuitem', { name: 'Pin' }).click()
}
@step
async unpinFromFileMenu(index: number) {
const fileLink = this.getFileLink('pinned', index)
await this.openFileMenu(fileLink)
await this.page.getByRole('menuitem', { name: 'Unpin' }).click()
}
@step
async duplicateFile(index: number, name?: string) {
const fileLink = this.getFileLink('today', index)
await this.openFileMenu(fileLink)
await this.duplicateFromFileMenu(name)
}
@step
async copyFileLinkFromFileMenu() {
await this.page.getByRole('menuitem', { name: 'Copy link' }).click()
}
// The app writes to the clipboard asynchronously after the copy action, so reading it once can
// return a stale or empty value. Poll until the clipboard holds a valid URL (optionally matching
// an expected path) to avoid races.
private async readClipboardUrl(pathPattern?: RegExp): Promise<string> {
let value = ''
await expect(async () => {
value = await this.page.evaluate(() => navigator.clipboard.readText())
const url = new URL(value)
if (pathPattern) expect(url.pathname).toMatch(pathPattern)
}).toPass({ timeout: 10000 })
return value
}
@step
async copyFileLink(index: number) {
const fileLink = this.getFileLink('today', index)
await this.openFileMenu(fileLink)
await this.copyFileLinkFromFileMenu()
return await this.readClipboardUrl(/^\/f\//)
}
@step
async copyFileLinkByName(name: string): Promise<string> {
const fileLink = this.getFileByName(name)
await this.openFileMenu(fileLink)
await this.copyFileLinkFromFileMenu()
return await this.readClipboardUrl(/^\/f\//)
}
async getAfterElementStyle(element: Locator, property: string): Promise<string> {
return element.evaluate((el, property) => {
return window.getComputedStyle(el, '::after').getPropertyValue(property)
}, property)
}
async isHinted(fileLink: Locator): Promise<boolean> {
const backgroundColor = await this.getAfterElementStyle(fileLink, 'background-color')
return backgroundColor === 'rgba(9, 11, 12, 0.043)'
}
@step
async closeAccountMenu() {
await this.page.keyboard.press('Escape')
}
// Workspace-related methods
// A single attempt at opening the switcher: click only when it isn't already open, then confirm
// the menu content mounted (Home is always present). Callers that also act on a menu item should
// run this *inside* their own retry loop alongside that action — re-renders and navigation that
// fire while the app settles after a cross-client change can dismiss the menu between opening it
// and acting, so the open and the action have to be retried together, not in sequence.
private async ensureWorkspaceSwitcherOpen() {
const home = this.page.getByTestId('tla-workspace-switcher-home')
if (!(await home.isVisible())) {
await this.page.getByTestId('tla-workspace-switcher').click()
}
await expect(home).toBeVisible({ timeout: 2000 })
}
@step
async openWorkspaceSwitcher() {
await expect(() => this.ensureWorkspaceSwitcherOpen()).toPass({ timeout: 15000 })
}
@step
async createWorkspace(name: string) {
name = clampWorkspaceName(name)
// Creating a workspace happens from the workspace switcher dropdown.
await this.openWorkspaceSwitcher()
await this.page.getByTestId('tla-create-workspace-menu-item').click()
const input = this.page.getByPlaceholder('Workspace name')
await expect(input).toBeVisible()
await input.fill(name)
await this.page.getByRole('button', { name: 'Create workspace' }).click()
// Creating a workspace switches to it and opens its seeded welcome file. That file
// arrives named, so (unlike a blank file) there is no inline rename to dismiss.
await expect(async () => {
const activeName = await this.page.getByTestId('tla-active-workspace-name').innerText()
if (activeName !== name) {
await this.switchToWorkspace(name)
}
await this.expectActiveWorkspace(name)
}).toPass({ timeout: 20000 })
}
getWorkspaceLink(name: string) {
return this.page
.locator('[data-element="workspace-link"]')
.filter({ hasText: clampWorkspaceName(name) })
}
async getHomeWorkspaceName() {
const name = await this.page.evaluate(() => {
const app = (window as any).app
const homeWorkspaceId = app?.getHomeWorkspaceId?.()
return app?.getWorkspaceMembership?.(homeWorkspaceId)?.group?.name
})
if (!name) throw new Error('Could not resolve home workspace name')
return name
}
@step
async expectWorkspaceVisible(name: string) {
name = clampWorkspaceName(name)
// Gate on cross-client sync at the data layer first — immune to dropdown churn — then assert
// the switcher renders the workspace. Re-open and re-check together: the membership can be
// fully synced (the member may even be active in the workspace) yet the assertion still fails
// because a settling re-render dismissed the menu, and a closed switcher has no link items.
await this.waitForWorkspaceMembershipSync(name, true)
await expect(async () => {
await this.ensureWorkspaceSwitcherOpen()
await expect(this.getWorkspaceLink(name)).toBeVisible({ timeout: 2000 })
}).toPass({ timeout: 20000 })
await this.page.keyboard.press('Escape')
}
@step
async expectWorkspaceNotVisible(name: string) {
name = clampWorkspaceName(name)
// Workspace removal (member removed, workspace deleted) reaches an active member via
// cross-client sync too. Wait for the membership to leave the data layer first, then confirm
// the switcher no longer lists it. Keep the menu open while checking (Home is always present)
// so the absence can't pass vacuously against a dropdown that closed under us mid-settle.
await this.waitForWorkspaceMembershipSync(name, false)
await expect(async () => {
await this.ensureWorkspaceSwitcherOpen()
await expect(this.getWorkspaceLink(name)).toHaveCount(0)
}).toPass({ timeout: 20000 })
await this.page.keyboard.press('Escape')
}
/**
* Poll the app's data layer until a workspace membership with the given name is present (or
* absent). The membership backs the workspace switcher reactively via `getWorkspaceMemberships`,
* so once it settles the UI follows immediately — this lets callers gate on cross-client sync
* without depending on the dropdown being open at the moment the row arrives.
*/
private async waitForWorkspaceMembershipSync(name: string, present: boolean) {
name = clampWorkspaceName(name)
await expect
.poll(
() =>
this.page.evaluate((workspaceName) => {
const memberships = (window as any).app?.getWorkspaceMemberships?.() ?? []
return memberships.some((m: any) => m?.group?.name === workspaceName)
}, name),
{ timeout: WORKSPACE_MEMBERSHIP_SYNC_TIMEOUT }
)
.toBe(present)
}
@step
async expectActiveWorkspace(name: string) {
name = clampWorkspaceName(name)
// Switching workspaces navigates to (or creates) a file in the target before the active name
// updates, so allow the suite's cross-client budget rather than the default 5s.
await expect(this.page.getByTestId('tla-active-workspace-name')).toHaveText(name, {
timeout: 10000,
})
}
@step
async expectActiveHomeWorkspace() {
await this.expectActiveWorkspace(await this.getHomeWorkspaceName())
}
@step
async switchToWorkspace(name: string) {
name = clampWorkspaceName(name)
// Re-open and click together: a settling re-render can dismiss the menu between opening it and
// clicking the workspace, leaving the click waiting on an item that no longer exists.
await expect(async () => {
await this.ensureWorkspaceSwitcherOpen()
await this.getWorkspaceLink(name).click({ timeout: 2000 })
}).toPass({ timeout: 20000 })
await this.expectActiveWorkspace(name)
}
@step
async switchToHomeWorkspace() {
// The home workspace is renameable, so target its stable switcher item rather than a label.
await expect(async () => {
await this.ensureWorkspaceSwitcherOpen()
await this.page.getByTestId('tla-workspace-switcher-home').click({ timeout: 2000 })
}).toPass({ timeout: 20000 })
await this.expectActiveHomeWorkspace()
}
@step
async openWorkspaceSettings(name: string) {
name = clampWorkspaceName(name)
// The settings action lives in the active workspace's action rows, so
// switch to the workspace first if needed.
const activeName = await this.page.getByTestId('tla-active-workspace-name').innerText()
if (activeName !== name) {
await this.switchToWorkspace(name)
}
await this.page.getByTestId('tla-sidebar-workspace-settings').click()
}
@step
async renameWorkspace(oldName: string, newName: string) {
newName = clampWorkspaceName(newName)
await this.openWorkspaceSettings(oldName)
// Find the name input and change it (use placeholder as user sees it)
const input = this.page.getByPlaceholder('Workspace name')
await expect(input).toBeVisible()
await input.fill(newName)
// Close the dialog
await this.page.getByRole('button', { name: 'Close' }).click()
}
@step
async deleteWorkspace(name: string) {
await this.openWorkspaceSettings(name)
// Delete lives on the Settings tab of the Manage workspace dialog.
await this.page.getByRole('tab', { name: 'Settings' }).click()
await this.page.getByRole('button', { name: 'Delete workspace', exact: true }).click()
// Confirm in the confirmation dialog, whose button is just "Delete".
await this.page.getByRole('button', { name: 'Delete', exact: true }).click()
}
@step
async copyWorkspaceInviteLink(name: string): Promise<string> {
// The invite link lives in the Manage workspace dialog and is only exposed via
// the Copy button (no visible URL field), so copy it and read it back from the
// clipboard. The invite secret can load asynchronously, so poll until valid.
await this.openWorkspaceSettings(name)
const dialog = this.page.getByRole('dialog', { name: 'Manage workspace' })
let inviteUrl = ''
await expect(async () => {
await dialog.getByRole('button', { name: 'Copy invite link' }).click()
inviteUrl = await this.readClipboardUrl(/^\/invite\//)
}).toPass({ timeout: 10000 })
await this.page.getByRole('button', { name: 'Close' }).click()
return inviteUrl
}
// File visibility methods
getFileByName(fileName: string) {
return this.page.locator('[data-element="file-link"]').filter({ hasText: fileName })
}
@step
async expectFileVisible(fileName: string) {
// A file can appear in a member's list via cross-client sync (workspace files, shared guest
// files), so allow the same propagation budget the rest of the suite uses rather than the
// default 5s assertion timeout.
await expect(this.getFileByName(fileName)).toBeVisible({
timeout: WORKSPACE_MEMBERSHIP_SYNC_TIMEOUT,
})
}
@step
async expectFileNotVisible(fileName: string) {
// File removal (deletion, losing workspace access) also propagates via cross-client sync.
await expect(this.getFileByName(fileName)).not.toBeVisible({
timeout: WORKSPACE_MEMBERSHIP_SYNC_TIMEOUT,
})
}
@step
private async openFileMenuByName(fileName: string) {
const fileLink = this.getFileByName(fileName)
await fileLink.hover()
const button = fileLink.getByRole('button')
await button.click()
}
@step
async dragFileToUnpinnedSection(fileName: string) {
const fileElement = this.getFileByName(fileName)
const fileBox = await fileElement.boundingBox()
const lastFile = this.sidebar.locator('[data-drop-target-id^="file:"]').last()
const lastBox = await lastFile.boundingBox()
if (!fileBox || !lastBox) throw new Error('Could not get bounding boxes')
// Move to file
await this.page.mouse.move(fileBox.x + fileBox.width / 2, fileBox.y + fileBox.height / 2)
// Press and hold
await this.page.mouse.down()
// Small delay to let browser detect drag intent
await this.page.waitForTimeout(100)
// Drop on the unpinned part of the list, below the pin zone
await this.page.mouse.move(lastBox.x + lastBox.width / 2, lastBox.y + lastBox.height / 2, {
steps: 10,
})
// Small delay before release
await this.page.waitForTimeout(50)
// Release
await this.page.mouse.up()
}
@step
async pinFile(fileName: string) {
await this.openFileMenuByName(fileName)
await this.page.getByRole('menuitem', { name: 'Pin' }).click()
}
@step
async unpinFile(fileName: string) {
await this.openFileMenuByName(fileName)
await this.page.getByRole('menuitem', { name: 'Unpin' }).click()
}
@step
async expectFilePinned(fileName: string) {
const fileLink = this.getFileByName(fileName)
await expect(fileLink).toHaveAttribute('data-is-pinned', 'true')
}
@step
async expectFileNotPinned(fileName: string) {
const fileLink = this.getFileByName(fileName)
await expect(fileLink).toHaveAttribute('data-is-pinned', 'false')
}
@step
async expectFileActive(fileName: string) {
const fileLink = this.getFileByName(fileName)
await expect(fileLink).toHaveAttribute('data-active', 'true')
}
@step
async expectFileNotActive(fileName: string) {
const fileLink = this.getFileByName(fileName)
await expect(fileLink).toHaveAttribute('data-active', 'false')
}
/** The names of the files currently shown in the file list (the active workspace's files). */
async getVisibleFiles(): Promise<string[]> {
const fileLinks = this.sidebar.locator('[data-element="file-link"]')
const count = await fileLinks.count()
const fileNames: string[] = []
for (let i = 0; i < count; i++) {
const fileName = await fileLinks.nth(i).locator('[data-testid*="-name"]').innerText()
fileNames.push(fileName)
}
return fileNames
}
@step
async deleteFileByName(fileName: string) {
await this.openFileMenuByName(fileName)
await this.deleteFromFileMenu()
}
@step
async duplicateFileByName(fileName: string, newName?: string) {
await this.openFileMenuByName(fileName)
await this.duplicateFromFileMenu(newName)
}
/**
* One attempt at opening the file menu and its move-to submenu. Every wait in here is
* short and bounded so a failed attempt throws quickly and the caller's retry loop gets to
* re-open the file menu — the open click itself is what flakes (the sidebar can re-render
* mid-click and swallow it), so any recovery must include re-clicking, not just re-waiting.
*/
private async tryOpenMoveToMenu(fileName: string) {
// Close whatever menu/submenu a previous attempt left behind.
await this.page.keyboard.press('Escape')
await this.page.keyboard.press('Escape')
await this.openFileMenuByName(fileName)
const moveToButton = this.page.getByTestId('dialog-sub.move-to-workspace-button')
await expect(moveToButton).toBeVisible({ timeout: 2000 })
await moveToButton.hover({ force: true })
await expect(this.page.getByTestId('dialog-sub.move-to-workspace-content')).toBeVisible({
timeout: 1000,
})
}
@step
async openMoveToMenu(fileName: string) {
await expect(() => this.tryOpenMoveToMenu(fileName)).toPass({ timeout: 20000 })
}
@step
async closeMoveToMenu() {
await this.page.keyboard.press('Escape')
await this.page.keyboard.press('Escape')
}
@step
async moveFileToWorkspace(fileName: string, targetWorkspaceName: string) {
targetWorkspaceName = clampWorkspaceName(targetWorkspaceName)
// The move-to menu is a checklist: each destination is a checkbox item, and the
// destination we're moving to is never the current (checked) one, so its accessible
// name is just the workspace name (an unchecked item adds no "checked" prefix).
await expect(async () => {
await this.tryOpenMoveToMenu(fileName)
await this.page
.getByRole('menuitemcheckbox', { name: targetWorkspaceName, exact: true })
.click({ timeout: 2000 })
}).toPass({ timeout: 20000 })
}
@step
async moveFileToHome(fileName: string) {
await this.moveFileToWorkspace(fileName, await this.getHomeWorkspaceName())
}
}

View File

@@ -0,0 +1,66 @@
import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import { step } from './tla-test'
export class SignInDialog {
public readonly googleButton: Locator
public readonly emailInput: Locator
public readonly continueWithEmailButton: Locator
public readonly codeInput: Locator
public readonly resendButton: Locator
public readonly acceptAndContinueButton: Locator
constructor(private readonly page: Page) {
this.googleButton = this.page.getByTestId('tla-google-sign-in-button')
this.emailInput = this.page.getByLabel('Email address')
this.continueWithEmailButton = this.page.getByTestId('tla-continue-with-email-button')
this.codeInput = this.page.locator('#tla-verification-code')
this.resendButton = this.page.getByTestId('tla-resend-code-button')
this.acceptAndContinueButton = this.page.getByTestId('tla-accept-and-continue-button')
}
@step
async expectInitialElements() {
await expect(this.googleButton).toBeVisible()
await expect(this.emailInput).toBeVisible()
await expect(this.continueWithEmailButton).toBeVisible()
}
@step
async continueWithEmail(email: string) {
await this.emailInput.fill(email)
await expect(this.continueWithEmailButton).toBeEnabled()
await this.continueWithEmailButton.click()
}
@step
async expectCodeStageVisible() {
await expect(this.codeInput).toBeVisible()
}
@step
async fillCode(code: string) {
await this.codeInput.fill(code)
}
@step
async clickResend() {
await expect(this.resendButton).toBeVisible()
await this.resendButton.click()
}
@step
async expectTermsStageVisible() {
await expect(this.acceptAndContinueButton).toBeVisible()
}
@step
async expectAcceptAndContinueDisabled() {
await expect(this.acceptAndContinueButton).toBeDisabled()
}
@step
async acceptAndContinue() {
await this.acceptAndContinueButton.click()
}
}

View File

@@ -0,0 +1,29 @@
import type { Locator, Page } from '@playwright/test'
import { expect, step } from './tla-test'
export class WorkspaceInviteDialog {
public readonly dialog: Locator
public readonly acceptButton: Locator
constructor(public readonly page: Page) {
this.dialog = this.page.getByText('You have been invited to join')
this.acceptButton = this.page.getByRole('button', { name: 'Accept invitation' })
}
async isVisible() {
return this.dialog.isVisible()
}
async expectIsVisible() {
await expect(this.dialog).toBeVisible()
}
async expectIsNotVisible() {
await expect(this.dialog).not.toBeVisible()
}
@step
async acceptInvitation() {
await this.acceptButton.click()
}
}

View File

@@ -0,0 +1,121 @@
import fs from 'fs'
import path from 'path'
import { Browser, BrowserContext, Page, test } from '@playwright/test'
import { Editor } from './Editor'
import { ErrorPage } from './ErrorPages'
import { HomePage } from './HomePage'
import { ShareMenu } from './ShareMenu'
import { Sidebar } from './Sidebar'
import { WorkspaceInviteDialog } from './WorkspaceInviteDialog'
export type UserName = 'huppy' | 'suppy'
type UserProps = { user: UserName; index: number } | undefined
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
export async function openNewTab(
browser: Browser,
opts: { url?: string; userProps: UserProps; allowClipboard?: boolean }
) {
return await test.step('open new incognito page', async () => {
const { url, userProps, allowClipboard } = opts
let newContext: BrowserContext
if (userProps === undefined) {
newContext = await browser.newContext({ storageState: undefined })
} else {
const storageStateFileName = getStorageStateFileName(userProps.index, userProps.user)
const storageState = JSON.parse(fs.readFileSync(storageStateFileName, 'utf-8'))
newContext = await browser.newContext({ storageState })
}
if (allowClipboard) {
await newContext.grantPermissions(['clipboard-read', 'clipboard-write'])
}
const newPage = await newContext.newPage()
const {
newSidebar,
newEditor,
newHomePage,
newShareMenu,
newWorkspaceInviteDialog,
errorPage,
} = createFixtures(newPage)
if (url) {
await newPage.goto(url)
} else {
await newHomePage.goto()
}
await newHomePage.isLoaded()
return {
newPage,
newContext,
newSidebar,
newHomePage,
newEditor,
newShareMenu,
newWorkspaceInviteDialog,
errorPage,
}
})
}
export function getStorageStateFileName(index: number, user: UserName) {
return path.join(__dirname, `../.auth/${user}${index + 1}.json`)
}
export function areUrlsEqual(
url1: string,
url2: string,
opts: { ignoreSearch?: boolean } = { ignoreSearch: true }
) {
if (opts.ignoreSearch) {
const url1Obj = new URL(url1)
const url2Obj = new URL(url2)
return url1Obj.origin === url2Obj.origin && url1Obj.pathname === url2Obj.pathname
}
return url1 === url2
}
export function createFixtures(page: Page) {
const newSidebar = new Sidebar(page)
const newEditor = new Editor(page, newSidebar)
const newHomePage = new HomePage(page, newEditor)
const newShareMenu = new ShareMenu(page)
const newWorkspaceInviteDialog = new WorkspaceInviteDialog(page)
const errorPage = new ErrorPage(page)
return { newSidebar, newEditor, newHomePage, newShareMenu, newWorkspaceInviteDialog, errorPage }
}
export function getRandomName() {
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
let result = ''
for (let i = 0; i < 10; i++) {
const randomIndex = Math.floor(Math.random() * characters.length)
result += characters[randomIndex]
}
return result
}
const PROPAGATE_CHANGES_TIMEOUT = 100
/**
* Ensures that the provided expectations are met both before and after a page reload.
*
* @param fn - An asynchronous function containing the expectations to be tested.
* @param page - The Playwright Page object representing the browser page.
*
* The function performs the following steps:
* 1. Executes the provided expectations and ensures they pass.
* 2. Waits for a specified timeout to allow optimistic changes to propagate to the server.
* 3. Reloads the page.
* 4. Executes the provided expectations again and ensures they pass.
*/
export async function expectBeforeAndAfterReload(fn: () => Promise<void>, page: Page) {
await fn()
await sleep(PROPAGATE_CHANGES_TIMEOUT)
await page.reload()
const { newHomePage } = createFixtures(page)
await newHomePage.isLoaded()
await fn()
}

View File

@@ -0,0 +1,695 @@
import { createHash } from 'crypto'
import fs from 'fs'
import { setupClerkTestingToken } from '@clerk/testing/playwright'
import { expect, test as base } from '@playwright/test'
import type { Browser, BrowserContext, Download, Locator, Page, TestInfo } from '@playwright/test'
import { MAX_WORKSPACE_NAME_LENGTH } from '@tldraw/dotcom-shared'
import { NUMBER_OF_USERS } from '../consts'
import { Database, getTestUserEmail } from './Database'
import { DeleteFileDialog } from './DeleteFileDialog'
import { Editor } from './Editor'
import { ErrorPage } from './ErrorPages'
import { getStorageStateFileName } from './helpers'
import { HomePage } from './HomePage'
import { ImportHelper } from './ImportHelper'
import { ShareMenu } from './ShareMenu'
import { Sidebar } from './Sidebar'
import { SignInDialog } from './SignInDialog'
import { WorkspaceInviteDialog } from './WorkspaceInviteDialog'
type SignedInActorName = 'owner' | 'member'
type DotcomActorName = SignedInActorName | 'visitor'
type SharedLinkType = 'edit' | 'view' | 'no-access'
type WorkspaceMemberRole = 'owner' | 'member'
const SCENARIO_USER_POOL_START = 4
const ROOT_URL = 'http://localhost:3000'
const MENU_INTERACTION_TIMEOUT = 5_000
export async function selectTlaMenuOption(page: Page, select: Locator, optionLabel: string) {
await select.click({ timeout: MENU_INTERACTION_TIMEOUT })
const openListbox = page.locator('[role="listbox"][data-state="open"]')
await expect(openListbox).toBeVisible({ timeout: MENU_INTERACTION_TIMEOUT })
await openListbox
.getByRole('option', { name: optionLabel, exact: true })
.click({ timeout: MENU_INTERACTION_TIMEOUT })
await expect(openListbox).not.toBeVisible({ timeout: MENU_INTERACTION_TIMEOUT })
}
interface SignedInActorAccount {
email: string
storageState: string
}
interface ScenarioWorkerFixtures {
actorAccounts: Record<SignedInActorName, SignedInActorAccount>
}
interface ScenarioFixtures {
actors: DotcomActors
owner: DotcomActor
member: DotcomActor
visitor: DotcomActor
scenario: DotcomScenario
setupAndCleanup: void
}
interface LegacyRouteFixture {
slug: string
readonlySlug: string
legacyReadonlySlug: string
snapshotSlug: string
historyTimestamp: string
urls: {
room: string
readonly: string
legacyReadonly: string
snapshot: string
history: string
historySnapshot: string
}
}
export class DotcomActor {
readonly sidebar: Sidebar
readonly editor: Editor
readonly homePage: HomePage
readonly shareMenu: ShareMenu
readonly deleteFileDialog: DeleteFileDialog
readonly errorPage: ErrorPage
readonly importHelper: ImportHelper
readonly signInDialog: SignInDialog
readonly workspaceInviteDialog: WorkspaceInviteDialog
constructor(
readonly name: DotcomActorName,
readonly page: Page,
readonly context: BrowserContext,
readonly email?: string
) {
this.sidebar = new Sidebar(page)
this.editor = new Editor(page, this.sidebar)
this.homePage = new HomePage(page, this.editor)
this.shareMenu = new ShareMenu(page)
this.deleteFileDialog = new DeleteFileDialog(page)
this.errorPage = new ErrorPage(page)
this.importHelper = new ImportHelper(page)
this.signInDialog = new SignInDialog(page)
this.workspaceInviteDialog = new WorkspaceInviteDialog(page)
}
async goto(url = 'http://localhost:3000/') {
await this.homePage.goto(url)
await this.waitForAppReady()
}
async waitForAppReady() {
await this.homePage.isLoaded()
await this.waitForEditorReady()
await this.waitForAuthLoaded()
await this.waitForAppStoreHydrated()
await this.waitForFileRoomConnected()
await this.waitForVisitorAccessMetadata()
}
async waitForAuthLoaded() {
await this.page.waitForFunction(
([isSignedIn]) => {
const app = (window as any).app
const editor = (window as any).editor
const path = window.location.pathname
const isAppRoute = path === '/' || path.startsWith('/f/')
const isLegacyReadonlyRoute = path.startsWith('/ro/') || path.startsWith('/v/')
const hasMountedCanvas = !!document.querySelector('[data-testid="canvas"]')
if (isSignedIn && isAppRoute) return !!app?.getUser?.()
if (isSignedIn && isLegacyReadonlyRoute)
return !!app?.getUser?.() || !!editor || hasMountedCanvas
if (isSignedIn) return !!app?.getUser?.() || !!editor
return (
!!document.querySelector('[data-testid="tla-sign-in-button"]') ||
!!document.querySelector('[data-testid="tla-sidebar-toggle"]')
)
},
[!!this.email]
)
}
async waitForAppStoreHydrated() {
if (!this.email) return
await this.page.waitForFunction(() => {
const app = (window as any).app
const path = window.location.pathname
const isAppRoute = path === '/' || path.startsWith('/f/')
if (!isAppRoute) return true
return !!app?.getUser?.() && Array.isArray(app?.getUserFileStates?.())
})
}
async waitForEditorReady() {
await this.page.waitForFunction(() => {
const editor = (window as any).editor
if (editor?.getCurrentPageId?.()) return true
const path = window.location.pathname
const hasMountedCanvas = !!document.querySelector('[data-testid="canvas"]')
return hasMountedCanvas && (path.startsWith('/ro/') || path.startsWith('/v/'))
})
}
async waitForFileRoomConnected() {
await this.page.waitForFunction(() => {
const editor = (window as any).editor
if (!editor?.store) {
const path = window.location.pathname
return (
!!document.querySelector('[data-testid="canvas"]') &&
(path.startsWith('/ro/') || path.startsWith('/v/'))
)
}
const status = editor.store.status
return status === undefined || status === 'synced-remote'
})
}
async waitForVisitorAccessMetadata() {
await this.page.waitForFunction(() => {
const editor = (window as any).editor
if (!editor?.getIsReadonly) {
const path = window.location.pathname
return (
!!document.querySelector('[data-testid="canvas"]') &&
(path.startsWith('/ro/') || path.startsWith('/v/'))
)
}
// Calling getIsReadonly proves the mounted editor has received the
// current access mode for signed-out visitors and signed-in guests.
return typeof editor.getIsReadonly() === 'boolean'
})
}
async waitForSessionClosed(timeout = 20000) {
await expect
.poll(
async () => {
return await this.page.evaluate(() => {
const editor = (window as any).editor
return editor?.getCollaborators?.().length ?? 0
})
},
{ timeout }
)
.toBe(0)
}
async getCollaboratorCount() {
return await this.page.evaluate(() => (window as any).editor?.getCollaborators?.().length ?? 0)
}
async expectCollaboratorCount(count: number, timeout = 10000) {
await expect.poll(() => this.getCollaboratorCount(), { timeout }).toBe(count)
}
async getIsReadonly() {
return await this.page.evaluate(() => {
const editorReadonly = (window as any).editor?.getIsReadonly?.()
if (typeof editorReadonly === 'boolean') return editorReadonly
const path = window.location.pathname
return (
!!document.querySelector('[data-testid="canvas"]') &&
(path.startsWith('/ro/') || path.startsWith('/v/'))
)
})
}
async expectReadonly(readonly: boolean, timeout = 10000) {
await expect.poll(() => this.getIsReadonly(), { timeout }).toBe(readonly)
}
async close() {
await this.context.close()
}
}
class DotcomActors {
private readonly openActors: DotcomActor[] = []
constructor(
private readonly browser: Browser,
private readonly actorAccounts: Record<SignedInActorName, SignedInActorAccount>
) {}
async open(
name: DotcomActorName,
opts: { url?: string; allowClipboard?: boolean; goto?: boolean } = {}
) {
const account = name === 'visitor' ? undefined : this.actorAccounts[name]
const context = await this.browser.newContext({
storageState: account?.storageState,
})
if (opts.allowClipboard ?? true) {
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
}
const page = await context.newPage()
await setupClerkTestingToken({ page })
const actor = new DotcomActor(name, page, context, account?.email)
this.openActors.push(actor)
if (opts.goto ?? true) {
await actor.goto(opts.url)
}
return actor
}
async closeAll() {
await Promise.allSettled(this.openActors.splice(0).map((actor) => actor.close()))
}
}
class DotcomScenario {
readonly id: string
private readonly database: Database
constructor(private readonly testInfo: TestInfo) {
this.id = getScenarioId(testInfo)
// Scenario actors live in the scenario user pool, so the database must use the same index
// for its index-based helpers to target the right Clerk accounts.
this.database = new Database(null, getScenarioUserIndex(testInfo.parallelIndex))
}
name(label: string) {
return `${this.id} ${label}`
}
async createPersonalFile(actor: DotcomActor, fileName = this.name('file')) {
await actor.editor.ensureSidebarOpen()
await this.switchToHomeIfAvailable(actor)
await actor.sidebar.createNewDocument(fileName)
await actor.sidebar.expectFileActive(fileName)
return { fileName, url: actor.page.url() }
}
async createSharedFile(
actor: DotcomActor,
linkType: Exclude<SharedLinkType, 'no-access'> = 'edit',
fileName = this.name(`${linkType} shared file`)
) {
const file = await this.createPersonalFile(actor, fileName)
const sharedUrl = await this.shareFile(actor, linkType)
return { ...file, sharedUrl, linkType }
}
async createGuestFile(opts: {
owner: DotcomActor
guest: DotcomActor
linkType: Exclude<SharedLinkType, 'no-access'>
fileName?: string
}) {
const file = await this.createSharedFile(opts.owner, opts.linkType, opts.fileName)
await opts.guest.goto(file.sharedUrl)
return file
}
async createGuestEditFile(owner: DotcomActor, guest: DotcomActor, fileName?: string) {
return await this.createGuestFile(
fileName === undefined
? { owner, guest, linkType: 'edit' }
: { owner, guest, fileName, linkType: 'edit' }
)
}
async createGuestViewFile(owner: DotcomActor, guest: DotcomActor, fileName?: string) {
return await this.createGuestFile(
fileName === undefined
? { owner, guest, linkType: 'view' }
: { owner, guest, fileName, linkType: 'view' }
)
}
async createRectangle(actor: DotcomActor) {
await actor.page.getByTestId('tools.rectangle').click()
await actor.page.locator('.tl-background').click()
await actor.editor.expectShapesCount(1)
}
async shareFile(actor: DotcomActor, linkType: Exclude<SharedLinkType, 'no-access'> = 'edit') {
await actor.shareMenu.open()
await this.setSharedLinkType(actor, linkType)
const url = await actor.shareMenu.copyLink()
await actor.page.keyboard.press('Escape')
return url
}
async setSharedLinkType(actor: DotcomActor, linkType: SharedLinkType) {
if (!(await actor.shareMenu.inviteTabButton.isVisible().catch(() => false))) {
await actor.shareMenu.open()
}
await actor.shareMenu.ensureTabSelected('invite')
if (linkType === 'no-access') {
await actor.page.getByRole('switch').uncheck()
return
}
await actor.page.getByRole('switch').check()
const select = actor.page.getByTestId('shared-link-type-select')
const expectedLabel = linkType === 'edit' ? 'Editor' : 'Viewer'
if ((await select.innerText()) !== expectedLabel) {
await selectTlaMenuOption(actor.page, select, expectedLabel)
}
await expect(select).toHaveText(expectedLabel)
}
async publishFile(actor: DotcomActor) {
await actor.shareMenu.open()
await actor.shareMenu.publishFile()
const url = await actor.shareMenu.copyLink()
await actor.page.keyboard.press('Escape')
return url
}
async createPublishedFile(actor: DotcomActor, fileName = this.name('published file')) {
const file = await this.createPersonalFile(actor, fileName)
const publishedUrl = await this.publishFile(actor)
return { ...file, publishedUrl }
}
async importFileFromUrl(actor: DotcomActor, url?: string) {
await actor.importHelper.mockUrl(url)
await actor.importHelper.navigate(url)
await actor.page.waitForURL(/\/f\//)
await actor.waitForAppReady()
return { fileName: await actor.editor.getCurrentFileName(), url: actor.page.url() }
}
async downloadFileFromSidebar(actor: DotcomActor, fileName: string): Promise<Download> {
await actor.editor.ensureSidebarOpen()
const fileLink = actor.sidebar.getFileByName(fileName)
await fileLink.hover()
await fileLink.getByRole('button').click()
const downloadPromise = actor.page.waitForEvent('download')
await actor.page.getByRole('menuitem', { name: 'Download' }).click()
return await downloadPromise
}
async publishChanges(actor: DotcomActor) {
if (!(await actor.shareMenu.publishTabButton.isVisible().catch(() => false))) {
await actor.shareMenu.open()
}
await actor.shareMenu.publishChanges()
await actor.page.keyboard.press('Escape')
}
async createLegacyRouteFixture(actor: DotcomActor): Promise<LegacyRouteFixture> {
const slug = this.id.slice(0, 96)
let fixture: Omit<LegacyRouteFixture, 'urls'> | null = null
let lastError = ''
for (let attempt = 0; attempt < 30; attempt++) {
const response = await actor.page.request.post(`${ROOT_URL}/api/app/__test__/legacy-room`, {
data: {
slug,
readonlySlug: `${slug}-ro`,
legacyReadonlySlug: `${slug}-v`,
snapshotSlug: `v2_c_${slug}`,
historyTimestamp: '2020-01-02T03:04:05.000Z',
},
})
if (response.ok()) {
fixture = (await response.json()) as Omit<LegacyRouteFixture, 'urls'>
break
}
lastError = `${response.status()} ${await response.text()}`
await actor.page.waitForTimeout(500)
}
if (!fixture) throw new Error(`Legacy route fixture was not created: ${lastError}`)
return {
...fixture,
urls: {
room: `${ROOT_URL}/r/${fixture.slug}`,
readonly: `${ROOT_URL}/ro/${fixture.readonlySlug}`,
legacyReadonly: `${ROOT_URL}/v/${fixture.legacyReadonlySlug}`,
snapshot: `${ROOT_URL}/s/${fixture.snapshotSlug}`,
history: `${ROOT_URL}/r/${fixture.slug}/history`,
historySnapshot: `${ROOT_URL}/r/${fixture.slug}/history/${fixture.historyTimestamp}`,
},
}
}
async createWorkspaceWithMember(opts: {
owner: DotcomActor
member: DotcomActor
workspaceName?: string
fileName?: string
}) {
const invite = await this.createPendingWorkspaceInvite(opts)
await opts.member.goto(invite.inviteUrl)
await opts.member.editor.ensureSidebarOpen()
await opts.member.workspaceInviteDialog.acceptInvitation()
await opts.member.sidebar.expectWorkspaceVisible(invite.workspaceName)
await opts.member.sidebar.switchToWorkspace(invite.workspaceName)
await opts.member.sidebar.expectFileVisible(invite.fileName)
return invite
}
async createWorkspaceWithRemovedMember(opts: {
owner: DotcomActor
member: DotcomActor
workspaceName?: string
fileName?: string
}) {
const workspace = await this.createWorkspaceWithMember(opts)
await this.removeWorkspaceMember({
owner: opts.owner,
workspaceName: workspace.workspaceName,
memberUserId: workspace.memberUserId,
})
return workspace
}
async createPendingWorkspaceInvite(opts: {
owner: DotcomActor
member: DotcomActor
workspaceName?: string
fileName?: string
}) {
// The createWorkspace mutator clamps names to MAX_WORKSPACE_NAME_LENGTH, so mirror that here:
// otherwise a long scenario id makes the stored (and displayed) name diverge from what the
// sidebar helpers search for, and the workspace link never matches.
const workspaceName = (opts.workspaceName ?? this.name('workspace'))
.trim()
.slice(0, MAX_WORKSPACE_NAME_LENGTH)
const fileName = opts.fileName ?? this.name('workspace file')
await this.goToAndOpenSidebar(opts.owner)
await this.goToAndOpenSidebar(opts.member)
if (!opts.member.email) throw new Error('Workspace member actor is not signed in')
const memberUserId = await this.database.getUserIdByEmail(opts.member.email)
if (!memberUserId) throw new Error(`Member user not found: ${opts.member.email}`)
await opts.owner.editor.ensureSidebarOpen()
await this.switchToHomeIfAvailable(opts.owner)
const { fileId } = await opts.owner.page.evaluate(
async ({ workspaceName, fileName }) => {
const app = (window as any).app
const uniqueId = () => {
const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz-'
const bytes = crypto.getRandomValues(new Uint8Array(21))
return Array.from(bytes, (byte) => alphabet[byte & 63]).join('')
}
const workspaceId = uniqueId()
const fileId = uniqueId()
// Await server acknowledgement (not just optimistic apply) so the file exists
// server-side before we navigate to it. These promises never reject — a rejected
// mutation resolves with {type: 'error'} — so check the result or a failure
// surfaces later as an unrelated timeout.
const workspaceRes = await app.z.mutate.createWorkspace({
id: workspaceId,
name: workspaceName,
}).server
if (workspaceRes.type !== 'success') {
throw new Error(`createWorkspace rejected: ${JSON.stringify(workspaceRes)}`)
}
const fileRes = await app.z.mutate.createFile({
fileId,
workspaceId,
name: fileName,
createSource: null,
time: Date.now(),
}).server
if (fileRes.type !== 'success') {
throw new Error(`createFile rejected: ${JSON.stringify(fileRes)}`)
}
return { fileId }
},
{ workspaceName, fileName }
)
await opts.owner.goto(`${ROOT_URL}/f/${fileId}`)
await opts.owner.editor.ensureSidebarOpen()
const inviteUrl = await opts.owner.sidebar.copyWorkspaceInviteLink(workspaceName)
return { workspaceName, fileName, inviteUrl, memberUserId }
}
async removeWorkspaceMember(opts: {
owner: DotcomActor
workspaceName: string
memberUserId: string
}) {
await opts.owner.sidebar.openWorkspaceSettings(opts.workspaceName)
await selectTlaMenuOption(
opts.owner.page,
opts.owner.page.locator(`[id="workspace-member-role-${opts.memberUserId}"]`),
'Remove'
)
// The remove confirmation dialog's button is just "Remove".
await opts.owner.page.getByRole('button', { name: 'Remove', exact: true }).click()
await opts.owner.page.keyboard.press('Escape')
}
async setWorkspaceMemberRole(opts: {
owner: DotcomActor
workspaceName: string
memberUserId: string
role: WorkspaceMemberRole
}) {
await opts.owner.sidebar.openWorkspaceSettings(opts.workspaceName)
const memberRoleSelect = opts.owner.page.locator(
`[id="workspace-member-role-${opts.memberUserId}"]`
)
const roleLabel = opts.role === 'owner' ? 'Owner' : 'Member'
await selectTlaMenuOption(opts.owner.page, memberRoleSelect, roleLabel)
await expect(memberRoleSelect).toHaveText(roleLabel)
await opts.owner.page.keyboard.press('Escape')
}
async goToAndOpenSidebar(actor: DotcomActor) {
if (!actor.email) throw new Error(`Actor ${actor.name} is not signed in`)
await actor.goto()
await actor.editor.ensureSidebarOpen()
}
private async switchToHomeIfAvailable(actor: DotcomActor) {
if (
await actor.page
.getByTestId('tla-workspace-switcher')
.isVisible()
.catch(() => false)
) {
await actor.sidebar.switchToHomeWorkspace()
}
}
}
export const test = base.extend<ScenarioFixtures, ScenarioWorkerFixtures>({
actorAccounts: [
async ({ browser }, testUse, workerInfo) => {
const userIndex = getScenarioUserIndex(workerInfo.parallelIndex)
const owner = await ensureStorageState(browser, userIndex, 'huppy')
const member = await ensureStorageState(browser, userIndex, 'suppy')
await testUse({ owner, member })
},
{ scope: 'worker' },
],
setupAndCleanup: [
async ({ actorAccounts: _actorAccounts }, testUse, testInfo) => {
const database = new Database(null, getScenarioUserIndex(testInfo.parallelIndex))
await database.reset()
await testUse()
},
{ auto: true },
],
actors: async ({ browser, actorAccounts }, testUse) => {
const actors = new DotcomActors(browser, actorAccounts)
await testUse(actors)
await actors.closeAll()
},
owner: async ({ actors }, testUse) => {
await testUse(await actors.open('owner'))
},
member: async ({ actors }, testUse) => {
await testUse(await actors.open('member'))
},
visitor: async ({ actors }, testUse) => {
await testUse(await actors.open('visitor'))
},
scenario: async ({ browser: _browser }, testUse, testInfo) => {
await testUse(new DotcomScenario(testInfo))
},
})
async function ensureStorageState(
browser: Browser,
parallelIndex: number,
user: 'huppy' | 'suppy'
): Promise<SignedInActorAccount> {
const storageState = getStorageStateFileName(parallelIndex, user)
const email = getTestUserEmail(parallelIndex, user)
if (fs.existsSync(storageState)) {
return { email, storageState }
}
await base.step(`Sign in ${user} actor`, async () => {
const page = await browser.newPage({ storageState: undefined })
await setupClerkTestingToken({ page })
const sidebar = new Sidebar(page)
const editor = new Editor(page, sidebar)
const homePage = new HomePage(page, editor)
await homePage.loginAs(email)
await expect(page.getByTestId('tla-sidebar-layout')).toBeVisible()
await page.context().storageState({ path: storageState })
await page.close()
})
return { email, storageState }
}
function getScenarioId(testInfo: TestInfo) {
const fullSlug = testInfo.titlePath
.slice(1)
.join(' ')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
// Keep the id compact: workspace names are capped at MAX_WORKSPACE_NAME_LENGTH, and a name is
// `${id} ${label}`, so a long id leaves no room for a distinguishing label — two labels that
// differ only past the cap would collide once the mutator clamps the stored name. A short
// readable prefix keeps debugging tractable; a hash of the full title preserves the cross-test
// uniqueness that a truncated prefix alone would lose.
const titleHash = createHash('sha1').update(fullSlug).digest('hex').slice(0, 6)
const slug = `${fullSlug.slice(0, 24).replace(/-+$/g, '')}-${titleHash}`
const runId = process.env.GITHUB_RUN_ID ?? Date.now().toString(36)
return `e2e-${runId}-w${testInfo.parallelIndex}-x${testInfo.repeatEachIndex}-r${testInfo.retry}-${slug}`
}
function getScenarioUserIndex(parallelIndex: number) {
const userIndex = SCENARIO_USER_POOL_START + parallelIndex
if (userIndex >= NUMBER_OF_USERS) {
throw new Error(
`Not enough Clerk test users for scenario worker ${parallelIndex}. Add more test users or lower the Playwright worker count.`
)
}
return userIndex
}
export { expect } from '@playwright/test'

View File

@@ -0,0 +1,39 @@
import { setupClerkTestingToken } from '@clerk/testing/playwright'
import { test as base } from '@playwright/test'
import { Editor } from './Editor'
import { HomePage } from './HomePage'
import { Sidebar } from './Sidebar'
interface StagingFixtures {
editor: Editor
sidebar: Sidebar
homePage: HomePage
setupAndCleanup: void
}
const STAGING_URL = 'https://staging.tldraw.com/'
export const test = base.extend<StagingFixtures>({
sidebar: async ({ page }, testUse) => {
await testUse(new Sidebar(page))
},
editor: async ({ page, sidebar }, testUse) => {
await testUse(new Editor(page, sidebar))
},
homePage: async ({ page, editor }, testUse) => {
await testUse(new HomePage(page, editor))
},
setupAndCleanup: [
async ({ page, homePage }, testUse) => {
await setupClerkTestingToken({ page })
await homePage.goto(STAGING_URL)
await homePage.isLoaded()
await testUse()
},
{ auto: true },
],
})
export { expect } from '@playwright/test'

View File

@@ -0,0 +1,203 @@
import fs from 'fs'
import { setupClerkTestingToken } from '@clerk/testing/playwright'
import { test as base, expect } from '@playwright/test'
import { OTHER_USERS, USERS } from '../consts'
import { Database } from './Database'
import { DeleteFileDialog } from './DeleteFileDialog'
import { Editor } from './Editor'
import { getStorageStateFileName } from './helpers'
import { HomePage } from './HomePage'
import { ImportHelper } from './ImportHelper'
import { ShareMenu } from './ShareMenu'
import { Sidebar } from './Sidebar'
import { SignInDialog } from './SignInDialog'
import { WorkspaceInviteDialog } from './WorkspaceInviteDialog'
interface TlaFixtures {
homePage: HomePage
editor: Editor
sidebar: Sidebar
shareMenu: ShareMenu
database: Database
deleteFileDialog: DeleteFileDialog
workspaceInviteDialog: WorkspaceInviteDialog
signInDialog: SignInDialog
importHelper: ImportHelper
setupAndCleanup: void
retry(fn: () => Promise<void>): Promise<void>
}
interface TlaWorkerFixtures {
workerStorageState: string
}
export const test = base.extend<TlaFixtures, TlaWorkerFixtures>({
sidebar: async ({ page }, testUse) => {
await testUse(new Sidebar(page))
},
editor: async ({ page, sidebar }, testUse) => {
await testUse(new Editor(page, sidebar))
},
homePage: async ({ page, editor }, testUse) => {
await testUse(new HomePage(page, editor))
},
database: async ({ page }, testUse) => {
await testUse(new Database(page, test.info().parallelIndex))
},
shareMenu: async ({ page }, testUse) => {
await testUse(new ShareMenu(page))
},
deleteFileDialog: async ({ page }, testUse) => {
await testUse(new DeleteFileDialog(page))
},
workspaceInviteDialog: async ({ page }, testUse) => {
await testUse(new WorkspaceInviteDialog(page))
},
signInDialog: async ({ page }, testUse) => {
await testUse(new SignInDialog(page))
},
importHelper: async ({ page }, testUse) => {
await testUse(new ImportHelper(page))
},
// This is an auto fixture which makes sure that we are on the home page when the test starts
// and that we clean up when the tests completes
setupAndCleanup: [
async ({ page, homePage, database }, testUse) => {
// All the code before testUse is run before each test
// Make sure we don't get marked as a bot
// https://clerk.com/docs/testing/playwright/overview
await setupClerkTestingToken({ page })
await database.reset()
await homePage.goto()
await homePage.isLoaded()
await testUse()
},
{ auto: true },
],
storageState: ({ workerStorageState }, testUse) => testUse(workerStorageState),
workerStorageState: [
async ({ browser }, testUse) => {
const id = test.info().parallelIndex
const fileName = getStorageStateFileName(id, 'huppy')
if (fs.existsSync(fileName)) {
// Reuse existing authentication state if any.
await testUse(fileName)
return
}
for (const user of ['huppy' as const, 'suppy' as const]) {
const fileName = getStorageStateFileName(id, user)
// Important: make sure we authenticate in a clean environment by unsetting storage state.
const page = await browser.newPage({ storageState: undefined })
let email: string
if (user === 'huppy') {
email = USERS[id]
} else {
email = OTHER_USERS[id]
}
const sidebar = new Sidebar(page)
const editor = new Editor(page, sidebar)
const homePage = new HomePage(page, editor)
await homePage.loginAs(email)
await expect(page.getByTestId('tla-sidebar-layout')).toBeVisible()
await page.context().storageState({ path: fileName })
await page.close()
}
await testUse(fileName)
},
{ scope: 'worker' },
],
})
export { expect } from '@playwright/test'
/** Stage 3 / TS 5.2+ method decorator context (subset; avoids coupling to a specific TS lib version). */
interface StepMethodDecoratorContext {
kind: 'method'
name: string | symbol
}
function isStage3MethodDecoratorContext(value: unknown): value is StepMethodDecoratorContext {
return (
typeof value === 'object' &&
value !== null &&
'kind' in value &&
(value as StepMethodDecoratorContext).kind === 'method'
)
}
/** Legacy method decorator (`experimentalDecorators`). */
export function step<T extends (...args: any[]) => any>(
target: object,
propertyKey: string | symbol,
descriptor: TypedPropertyDescriptor<T>
): TypedPropertyDescriptor<T>
/** Stage 3 method decorator (e.g. Node `--no-strip-types`). */
export function step<T extends (...args: any[]) => any>(
originalMethod: T,
context: StepMethodDecoratorContext
): T
/**
* Wraps page-object methods in `test.step` for clearer Playwright reports.
* Supports both legacy (`experimentalDecorators`) and Stage 3 method decorators
* (e.g. when tests run under Node's `--no-strip-types` + modern emit).
*/
export function step(
target: object | ((...args: any[]) => any),
propertyKeyOrContext: string | symbol | StepMethodDecoratorContext,
descriptor?: TypedPropertyDescriptor<(...args: any[]) => any>
): any {
if (
descriptor === undefined &&
isStage3MethodDecoratorContext(propertyKeyOrContext) &&
typeof target === 'function'
) {
const original = target as (...args: any[]) => any
const ctx = propertyKeyOrContext
const key = typeof ctx.name === 'string' ? ctx.name : String(ctx.name)
return async function (this: object, ...args: any[]) {
return await test.step(`${(this as { constructor: { name: string } }).constructor.name}.${key}`, async () => {
return original.apply(this, args)
})
}
}
if (!descriptor?.value) {
throw new Error(
'step: expected a legacy method decorator with descriptor.value, or a Stage 3 method decorator'
)
}
const propertyKey = propertyKeyOrContext as string
const original = descriptor.value
descriptor.value = async function (this: object, ...args: any[]) {
return await test.step(`${(this as { constructor: { name: string } }).constructor.name}.${String(propertyKey)}`, async () => {
return original.apply(this, args)
})
}
return descriptor
}
export function repeatTest(
name: string,
fn: (...args: any) => Promise<void>,
{ times = 5, only = false } = {}
) {
const getName = (i: number) => `${name} (${i + 1} of ${times})`
for (let i = 0; i < times; i++) {
if (only) {
// eslint-disable-next-line tldraw/no-focused-tests
test.only(getName(i), fn)
} else {
test(getName(i), fn)
}
}
return
}
repeatTest.only = (name: string, fn: (...args: any) => Promise<void>, { times = 5 } = {}) =>
repeatTest(name, fn, { times, only: true })