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
650 lines
22 KiB
TypeScript
650 lines
22 KiB
TypeScript
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())
|
|
}
|
|
}
|