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 { 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 { const fileLink = this.getFileByName(name) await this.openFileMenu(fileLink) await this.copyFileLinkFromFileMenu() return await this.readClipboardUrl(/^\/f\//) } async getAfterElementStyle(element: Locator, property: string): Promise { return element.evaluate((el, property) => { return window.getComputedStyle(el, '::after').getPropertyValue(property) }, property) } async isHinted(fileLink: Locator): Promise { 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 { // 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 { 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()) } }