Files
upstream-tldraw/apps/dotcom/client/e2e/tests/ui.scenario.spec.ts
upstream-mirror a774ac673a
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
snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
2026-09-23 16:12:52 +05:00

380 lines
16 KiB
TypeScript

import { expect, selectTlaMenuOption, test } from '../fixtures/scenario-test'
const ROOT_URL = 'http://localhost:3000'
test.describe.configure({ mode: 'serial' })
test.describe('UI scenarios', () => {
test('file creation and inline rename submit/cancel stay in sync', async ({
owner,
scenario,
}) => {
const originalName = scenario.name('created file')
const cancelledName = scenario.name('cancelled file')
const submittedName = scenario.name('submitted file')
await scenario.createPersonalFile(owner, originalName)
const fileUrl = owner.page.url()
expect(new URL(fileUrl).pathname).toMatch(/^\/f\//)
await owner.sidebar.expectFileVisible(originalName)
await owner.sidebar.expectFileActive(originalName)
expect(await owner.editor.getCurrentFileName()).toBe(originalName)
await owner.goto(fileUrl)
await owner.editor.ensureSidebarOpen()
await owner.sidebar.expectFileActive(originalName)
expect(await owner.editor.getCurrentFileName()).toBe(originalName)
await owner.sidebar.getFileByName(originalName).dblclick()
const cancelInput = owner.page.getByTestId('tla-sidebar-rename-input')
await expect(cancelInput).toBeFocused()
await cancelInput.fill(cancelledName)
await owner.page.keyboard.press('Escape')
await expect(cancelInput).not.toBeVisible()
await owner.sidebar.expectFileVisible(originalName)
await owner.sidebar.expectFileNotVisible(cancelledName)
expect(await owner.editor.getCurrentFileName()).toBe(originalName)
await owner.sidebar.getFileByName(originalName).dblclick()
const submitInput = owner.page.getByTestId('tla-sidebar-rename-input')
await expect(submitInput).toBeFocused()
await submitInput.fill(submittedName)
await owner.page.keyboard.press('Enter')
await expect(submitInput).not.toBeVisible()
await owner.sidebar.expectFileVisible(submittedName)
await owner.sidebar.expectFileNotVisible(originalName)
await owner.sidebar.expectFileActive(submittedName)
expect(await owner.editor.getCurrentFileName()).toBe(submittedName)
})
test('file menu commands update the file list and active file', async ({ owner, scenario }) => {
const originalName = scenario.name('menu original')
const renamedName = scenario.name('menu renamed')
const duplicateName = scenario.name('menu duplicate')
await scenario.createPersonalFile(owner, originalName)
await scenario.createRectangle(owner)
// Duplicating below copies the room's server-side persisted content (see
// TLFileDurableObject.handleFileCreateFromSource), which only reflects shapes that have
// round-tripped over the room's own sync websocket (independent of Zero/app mutations).
// expectShapesCount above only proves the shape rendered locally; give the push time to
// reach the server before duplicating, or the copy can race the still-in-flight change and
// silently omit the shape.
// TODO: replace with a condition-based wait — needs a __test__ route exposing the room DO's
// persisted doc state (no such signal exists today), a fixed timeout can still lose the race.
await owner.page.waitForTimeout(1000)
await owner.sidebar.renameFileByName(originalName, renamedName)
await owner.sidebar.expectFileVisible(renamedName)
await owner.sidebar.expectFileNotVisible(originalName)
await owner.sidebar.expectFileActive(renamedName)
expect(await owner.editor.getCurrentFileName()).toBe(renamedName)
const copiedLink = await owner.sidebar.copyFileLinkByName(renamedName)
expect(new URL(copiedLink).origin).toBe(ROOT_URL)
expect(new URL(copiedLink).pathname).toMatch(/^\/f\//)
await owner.sidebar.duplicateFileByName(renamedName, duplicateName)
await owner.sidebar.expectFileVisible(renamedName)
await owner.sidebar.expectFileVisible(duplicateName)
await owner.sidebar.expectFileActive(duplicateName)
expect(await owner.editor.getCurrentFileName()).toBe(duplicateName)
// Duplicating copies the room server-side, so the new file's shapes only render once the
// editor navigates and loads the copied content. Allow more than the default 5s for that.
await owner.editor.expectShapesCount(1, 15000)
await owner.sidebar.deleteFileByName(duplicateName)
await owner.deleteFileDialog.expectIsVisible()
await owner.deleteFileDialog.confirmDeletion()
await owner.deleteFileDialog.expectIsNotVisible()
await owner.sidebar.expectFileNotVisible(duplicateName)
await owner.sidebar.expectFileVisible(renamedName)
await expect(owner.page.getByTestId('tla-editor')).toBeVisible()
})
test('pinned section only appears while files are pinned', async ({ owner, scenario }) => {
const fileName = scenario.name('pinned file')
await scenario.createPersonalFile(owner, fileName)
await expect(owner.page.getByTestId('tla-file-link-pinned-0')).not.toBeVisible()
await owner.sidebar.pinFile(fileName)
await owner.sidebar.expectFilePinned(fileName)
await expect(owner.page.getByTestId('tla-file-link-pinned-0')).toContainText(fileName)
await owner.sidebar.expectFileActive(fileName)
await owner.sidebar.unpinFile(fileName)
await owner.sidebar.expectFileNotPinned(fileName)
await expect(owner.page.getByTestId('tla-file-link-pinned-0')).not.toBeVisible()
await owner.sidebar.expectFileActive(fileName)
})
test('toggling the sidebar preserves the active editor and file selection', async ({
owner,
scenario,
}) => {
const fileName = scenario.name('sidebar toggle file')
await scenario.createPersonalFile(owner, fileName)
await owner.editor.ensureSidebarClosed()
await expect(owner.page.getByTestId('canvas')).toBeVisible()
expect(await owner.editor.getCurrentFileName()).toBe(fileName)
await owner.editor.ensureSidebarOpen()
await owner.sidebar.expectFileActive(fileName)
expect(await owner.editor.getCurrentFileName()).toBe(fileName)
})
test('workspace creation, moving, and deletion update navigation', async ({
owner,
scenario,
}) => {
const workspaceName = scenario.name('workspace nav')
const fileName = scenario.name('workspace movable file')
await scenario.goToAndOpenSidebar(owner)
await scenario.createPersonalFile(owner, fileName)
await owner.sidebar.createWorkspace(workspaceName)
await owner.sidebar.expectActiveWorkspace(workspaceName)
await owner.sidebar.expectWorkspaceVisible(workspaceName)
await owner.sidebar.switchToHomeWorkspace()
await owner.sidebar.moveFileToWorkspace(fileName, workspaceName)
await owner.sidebar.expectActiveWorkspace(workspaceName)
await owner.sidebar.expectFileVisible(fileName)
await owner.sidebar.switchToHomeWorkspace()
await owner.sidebar.expectFileNotVisible(fileName)
await owner.sidebar.switchToWorkspace(workspaceName)
await owner.sidebar.deleteWorkspace(workspaceName)
await owner.sidebar.expectWorkspaceNotVisible(workspaceName)
await owner.sidebar.expectFileNotVisible(fileName)
await owner.sidebar.expectActiveHomeWorkspace()
})
test('share and publish controls expose current access and published URLs', async ({
owner,
visitor,
scenario,
}) => {
await scenario.createPersonalFile(owner, scenario.name('publish controls file'))
await owner.shareMenu.open()
await expect(owner.shareMenu.inviteTabButton).toBeVisible()
await expect(owner.shareMenu.exportTabButton).toBeVisible()
await expect(owner.shareMenu.publishTabButton).toBeVisible()
await expect(owner.shareMenu.inviteTabPage).toBeVisible()
await expect(owner.page.getByTestId('shared-link-shared-switch')).toBeVisible()
await owner.page.keyboard.press('Escape')
const publishedUrl = await scenario.publishFile(owner)
expect(new URL(publishedUrl).pathname).toMatch(/^\/p\//)
await visitor.page.goto(publishedUrl, { waitUntil: 'load' })
await expect(visitor.page.getByTestId('tla-editor')).toBeVisible()
await visitor.editor.expectShapesCount(0)
await owner.shareMenu.open()
await owner.shareMenu.unpublishFile()
await owner.page.keyboard.press('Escape')
// The unpublish applies optimistically; the visitor only sees it once the server has
// committed, so reload until the published page stops resolving.
await expect(async () => {
await visitor.page.goto(publishedUrl, { waitUntil: 'load' })
await expect(visitor.page.getByTestId('tla-error')).toBeVisible({ timeout: 3000 })
}).toPass({ timeout: 15000 })
})
test('missing files show not-found UI to signed-in and signed-out users', async ({
owner,
visitor,
}) => {
const missingFileUrl = `${ROOT_URL}/f/${Math.random().toString(36).substring(2, 15)}`
await owner.page.goto(missingFileUrl, { waitUntil: 'load' })
await expect(async () => {
await owner.errorPage.expectNotFoundVisible()
}).toPass()
await expect(owner.page.getByTestId('tla-error')).toBeVisible()
await visitor.page.goto(missingFileUrl, { waitUntil: 'load' })
await expect(async () => {
await visitor.errorPage.expectNotFoundVisible()
}).toPass()
await expect(visitor.page.getByTestId('tla-error')).toBeVisible()
})
test('anonymous export downloads an image file', async ({ visitor }) => {
await visitor.homePage.expectSignInButtonVisible()
await visitor.page.evaluate(() => {
const editor = (window as any).editor
editor.createShapes([
{
type: 'geo',
x: 100,
y: 100,
props: { geo: 'rectangle', w: 100, h: 100 },
},
])
})
await visitor.shareMenu.open()
await visitor.shareMenu.ensureTabSelected('export')
await expect(visitor.shareMenu.exportTabPage).toBeVisible()
const downloadPromise = visitor.page.waitForEvent('download')
await visitor.shareMenu.exportImageButton.click()
const download = await downloadPromise
expect(download.suggestedFilename()).toMatch(/\.(png|svg)$/)
})
test('workspace settings reflect role-specific controls', async ({ owner, member, scenario }) => {
const { workspaceName, fileName, memberUserId } = await scenario.createWorkspaceWithMember({
owner,
member,
workspaceName: scenario.name('settings controls workspace'),
fileName: scenario.name('settings controls file'),
})
await owner.sidebar.openWorkspaceSettings(workspaceName)
const ownerDialog = owner.page.getByRole('dialog', { name: 'Manage workspace' })
// Owners see the full dialog surface and member roster.
await expect(ownerDialog).toBeVisible()
await expect(ownerDialog.getByPlaceholder('Workspace name')).toBeVisible()
await expect(ownerDialog.getByText('Invite teammates')).toBeVisible()
await expect(ownerDialog.getByRole('button', { name: 'Copy invite link' })).toBeVisible()
await expect(ownerDialog.getByRole('tab', { name: 'Members' })).toBeVisible()
await expect(ownerDialog.getByText(/\(you\)/)).toBeVisible()
const memberRoleSelect = ownerDialog.locator(`[id="workspace-member-role-${memberUserId}"]`)
await expect(memberRoleSelect).toHaveText('Member')
// Interacting with the portalled role select should not count as a background click.
await selectTlaMenuOption(owner.page, memberRoleSelect, 'Member')
await expect(ownerDialog).toBeVisible()
// The dialog exposes the invite link only through the Copy button (no visible URL
// field), so read it from the clipboard. Regenerating from the Settings tab
// replaces the link, so a later copy returns a different URL.
await ownerDialog.getByRole('button', { name: 'Copy invite link' }).click()
const firstInviteUrl = await owner.page.evaluate(() => navigator.clipboard.readText())
expect(new URL(firstInviteUrl).pathname).toMatch(/^\/invite\//)
await ownerDialog.getByRole('tab', { name: 'Settings' }).click()
await ownerDialog.getByRole('button', { name: 'Regenerate invite link' }).click()
await owner.page.getByRole('button', { name: 'Regenerate', exact: true }).click()
// Copy again (after the 1s copy-button guard) and poll until the new link lands.
await expect
.poll(
async () => {
await owner.page.waitForTimeout(1100)
await ownerDialog.getByRole('button', { name: 'Copy invite link' }).click()
return owner.page.evaluate(() => navigator.clipboard.readText())
},
{ timeout: 15000 }
)
.not.toBe(firstInviteUrl)
const regeneratedInviteUrl = await owner.page.evaluate(() => navigator.clipboard.readText())
expect(new URL(regeneratedInviteUrl).pathname).toMatch(/^\/invite\//)
await owner.page.keyboard.press('Escape')
// Non-owners can inspect settings but cannot access owner-only controls.
await member.sidebar.openWorkspaceSettings(workspaceName)
const memberDialog = member.page.getByRole('dialog', { name: 'Manage workspace' })
await expect(memberDialog.getByPlaceholder('Workspace name')).toBeDisabled()
await expect(
memberDialog.locator(`[id="workspace-member-role-${memberUserId}"]`)
).not.toBeVisible()
// Leave/Delete live on the Settings tab; members get Leave but not Delete.
await memberDialog.getByRole('tab', { name: 'Settings' }).click()
await expect(memberDialog.getByRole('button', { name: /Delete workspace/ })).not.toBeVisible()
await expect(memberDialog.getByRole('button', { name: /Leave workspace/ })).toBeVisible()
// Leaving requires confirmation (the confirm button is just "Leave") and removes access.
await memberDialog.getByRole('button', { name: /Leave workspace/ }).click()
await member.page.getByRole('button', { name: 'Leave', exact: true }).click()
await member.sidebar.expectWorkspaceNotVisible(workspaceName)
await member.sidebar.expectFileNotVisible(fileName)
})
test('home workspace settings show a private rename dialog, not an empty one', async ({
owner,
scenario,
}) => {
// Regression: the home workspace ("My workspace") used to render an empty dialog — the
// component returned null for it while the sidebar still opened the dialog, so the page
// just dimmed with no content. It's private: it can be renamed, but not shared or managed.
await scenario.goToAndOpenSidebar(owner)
await owner.sidebar.switchToHomeWorkspace()
await owner.page.getByTestId('tla-sidebar-workspace-settings').click()
const dialog = owner.page.getByRole('dialog', { name: 'Manage workspace' })
await expect(dialog).toBeVisible()
// Renameable: the name field is present and editable.
await expect(dialog.getByPlaceholder('Workspace name')).toBeEnabled()
// Private: a disabled invite control with an explanatory note (no shareable link).
await expect(
dialog.getByText(
'This is your private workspace. Create a new workspace to invite teammates.'
)
).toBeVisible()
await expect(dialog.getByRole('button', { name: 'Copy invite link' })).toBeDisabled()
// Nothing to manage, so the dialog has no Members/Settings tabs at all.
await expect(dialog.getByRole('tab')).toHaveCount(0)
await owner.page.keyboard.press('Escape')
})
test('the home workspace can be renamed from its settings dialog', async ({
owner,
scenario,
}) => {
// Regression: the home workspace is renameable (the reason its settings dialog exists at
// all), so the rename must apply. Restore the name afterwards so later serial tests still
// see the original.
await scenario.goToAndOpenSidebar(owner)
await owner.sidebar.switchToHomeWorkspace()
const originalName = await owner.page.getByTestId('tla-active-workspace-name').innerText()
const newName = scenario.name('home renamed')
await owner.page.getByTestId('tla-sidebar-workspace-settings').click()
const dialog = owner.page.getByRole('dialog', { name: 'Manage workspace' })
await dialog.getByPlaceholder('Workspace name').fill(newName)
await owner.page.getByRole('button', { name: 'Close' }).click()
await owner.sidebar.expectActiveWorkspace(newName)
// Restore the original home workspace name.
await owner.page.getByTestId('tla-sidebar-workspace-settings').click()
await dialog.getByPlaceholder('Workspace name').fill(originalName)
await owner.page.getByRole('button', { name: 'Close' }).click()
await owner.sidebar.expectActiveWorkspace(originalName)
})
test('workspace settings rename updates labels and persists', async ({ owner, scenario }) => {
const workspaceName = scenario.name('settings rename old')
const newWorkspaceName = scenario.name('settings rename new')
await scenario.goToAndOpenSidebar(owner)
await owner.sidebar.createWorkspace(workspaceName)
await owner.sidebar.renameWorkspace(workspaceName, newWorkspaceName)
await owner.sidebar.expectActiveWorkspace(newWorkspaceName)
await owner.sidebar.expectWorkspaceVisible(newWorkspaceName)
await owner.sidebar.expectWorkspaceNotVisible(workspaceName)
await owner.page.reload()
await owner.waitForAppReady()
await owner.editor.ensureSidebarOpen()
await owner.sidebar.expectWorkspaceVisible(newWorkspaceName)
await owner.sidebar.expectWorkspaceNotVisible(workspaceName)
})
})