Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/dotcom/client/e2e/fixtures/Editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ export class Editor {
}

@step
async expectShapesCount(expected: number) {
await expect(this.shapes).toHaveCount(expected)
async expectShapesCount(expected: number, timeout?: number) {
await expect(this.shapes).toHaveCount(expected, { timeout })
}

async getCurrentFileName() {
Expand Down
74 changes: 32 additions & 42 deletions apps/dotcom/client/e2e/fixtures/Sidebar.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
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
Expand Down Expand Up @@ -311,6 +320,7 @@ export class Sidebar {

@step
async createWorkspace(name: string) {
name = clampWorkspaceName(name)
// The standalone button is only shown while the user has no workspaces;
// after that, creating happens from the workspace switcher dropdown.
if (await this.createWorkspaceButton.isVisible()) {
Expand Down Expand Up @@ -339,7 +349,9 @@ export class Sidebar {
}

getWorkspaceLink(name: string) {
return this.page.locator('[data-element="workspace-link"]').filter({ hasText: name })
return this.page
.locator('[data-element="workspace-link"]')
.filter({ hasText: clampWorkspaceName(name) })
}

async getHomeWorkspaceName() {
Expand All @@ -354,6 +366,7 @@ export class Sidebar {

@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
Expand All @@ -368,6 +381,7 @@ export class Sidebar {

@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)
Expand All @@ -387,6 +401,7 @@ export class Sidebar {
* 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(
() =>
Expand All @@ -401,6 +416,7 @@ export class Sidebar {

@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, {
Expand All @@ -415,6 +431,7 @@ export class Sidebar {

@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 () => {
Expand All @@ -436,6 +453,7 @@ export class Sidebar {

@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()
Expand All @@ -447,6 +465,7 @@ export class Sidebar {

@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)
Expand All @@ -463,26 +482,26 @@ export class Sidebar {
async deleteWorkspace(name: string) {
await this.openWorkspaceSettings(name)

// Click the Delete workspace button (exact text as user sees it)
await this.page.getByRole('button', { name: 'Delete workspace…' }).click()
// 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 deletion in the confirmation dialog
await this.page.getByRole('button', { name: 'Delete workspace' }).click()
// Confirm in the confirmation dialog, whose button is just "Delete".
await this.page.getByRole('button', { name: 'Delete', exact: true }).click()
await this.mutationResolution()
}

@step
async copyWorkspaceInviteLink(name: string): Promise<string> {
// The invite link now lives in the workspace settings dialog rather than a
// dedicated sidebar action, so open settings and read it from there.
// 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: 'Workspace settings' })
const inviteInput = dialog.locator('input[readonly]').first()
// The invite secret can load asynchronously, so poll until the input holds a valid URL.
const dialog = this.page.getByRole('dialog', { name: 'Manage workspace' })
let inviteUrl = ''
await expect(async () => {
inviteUrl = await inviteInput.inputValue()
expect(new URL(inviteUrl).pathname).toMatch(/^\/invite\//)
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
Expand Down Expand Up @@ -519,36 +538,6 @@ export class Sidebar {
await button.click()
}

@step
async dragFileToPinnedSection(fileName: string) {
const fileElement = this.getFileByName(fileName)
const fileBox = await fileElement.boundingBox()
const topFile = this.sidebar.locator('[data-drop-target-id^="file:"]').first()
const topBox = await topFile.boundingBox()

if (!fileBox || !topBox) 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 just above the top of the file list, inside the pin zone
await this.page.mouse.move(topBox.x + topBox.width / 2, topBox.y - 5, { steps: 10 })

// Small delay before release
await this.page.waitForTimeout(50)

// Release
await this.page.mouse.up()

await this.mutationResolution()
}

@step
async dragFileToUnpinnedSection(fileName: string) {
const fileElement = this.getFileByName(fileName)
Expand Down Expand Up @@ -664,6 +653,7 @@ export class Sidebar {

@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).
Expand Down
49 changes: 38 additions & 11 deletions apps/dotcom/client/e2e/fixtures/scenario-test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
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, Page, TestInfo } 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'
Expand All @@ -22,6 +24,17 @@ 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
Expand Down Expand Up @@ -359,8 +372,7 @@ class DotcomScenario {
const select = actor.page.getByTestId('shared-link-type-select')
const expectedLabel = linkType === 'edit' ? 'Editor' : 'Viewer'
if ((await select.innerText()) !== expectedLabel) {
await select.click()
await actor.page.getByRole('option', { name: expectedLabel }).click()
await selectTlaMenuOption(actor.page, select, expectedLabel)
}
await expect(select).toHaveText(expectedLabel)
await actor.waitForMutationResolution()
Expand Down Expand Up @@ -488,7 +500,12 @@ class DotcomScenario {
workspaceName?: string
fileName?: string
}) {
const workspaceName = opts.workspaceName ?? this.name('workspace')
// 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.ensureGroupsReady(opts.owner)
Expand Down Expand Up @@ -537,9 +554,13 @@ class DotcomScenario {
memberUserId: string
}) {
await opts.owner.sidebar.openWorkspaceSettings(opts.workspaceName)
await opts.owner.page.locator(`[id="workspace-member-role-${opts.memberUserId}"]`).click()
await opts.owner.page.getByRole('option', { name: 'Remove' }).click()
await opts.owner.page.getByRole('button', { name: 'Remove member' }).click()
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.waitForMutationResolution()
await opts.owner.page.keyboard.press('Escape')
}
Expand All @@ -555,8 +576,7 @@ class DotcomScenario {
`[id="workspace-member-role-${opts.memberUserId}"]`
)
const roleLabel = opts.role === 'owner' ? 'Owner' : 'Member'
await memberRoleSelect.click()
await opts.owner.page.getByRole('option', { name: roleLabel }).click()
await selectTlaMenuOption(opts.owner.page, memberRoleSelect, roleLabel)
await expect(memberRoleSelect).toHaveText(roleLabel)
await opts.owner.waitForMutationResolution()
await opts.owner.page.keyboard.press('Escape')
Expand Down Expand Up @@ -668,13 +688,20 @@ async function ensureStorageState(
}

function getScenarioId(testInfo: TestInfo) {
const slug = testInfo.titlePath
const fullSlug = testInfo.titlePath
.slice(1)
.join(' ')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 64)

// 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}`
Expand Down
3 changes: 3 additions & 0 deletions apps/dotcom/client/e2e/tests/sharing-live.scenario.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,9 @@ test.describe('live sharing scenarios', () => {
await member.sidebar.expectFileVisible(fileName)

await member.sidebar.openWorkspaceSettings(workspaceName)
// Delete lives on the Settings tab and only for owners; promoting the member should
// surface it there reactively, without a reload.
await member.page.getByRole('tab', { name: 'Settings' }).click()
const deleteWorkspaceButton = member.page.getByRole('button', { name: /Delete workspace/ })
await expect(deleteWorkspaceButton).not.toBeVisible()

Expand Down
14 changes: 5 additions & 9 deletions apps/dotcom/client/e2e/tests/smoke/workspaces.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,25 +257,21 @@ test.describe('workspaces', () => {
expect(fileOrder[0]).toBe(file1)
})

test('drag to unpin a file in a workspace', async ({ sidebar }) => {
test('dragging a pinned file does not unpin it', async ({ sidebar }) => {
const workspaceName = getRandomName()
const file1 = getRandomName()

await sidebar.createWorkspace(workspaceName)
await sidebar.createNewDocument(file1)

// Dragging an unpinned file to the pinned section no longer pins it.
await sidebar.expectFileNotPinned(file1)
await sidebar.dragFileToPinnedSection(file1)
await sidebar.expectFileNotPinned(file1)

// Pinning happens through the file menu, but a pinned file can still
// be unpinned by dragging it out of the pinned section.
// Pinning happens through the file menu.
await sidebar.pinFile(file1)
await sidebar.expectFilePinned(file1)

// Dragging a file is now just native link dragging, so dragging a
// pinned file out of the pinned section no longer unpins it.
await sidebar.dragFileToUnpinnedSection(file1)
await sidebar.expectFileNotPinned(file1)
await sidebar.expectFilePinned(file1)
})

test('deleting the active file in a workspace stays in the workspace', async ({
Expand Down
Loading
Loading