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,35 @@
import { expect, test } from '../fixtures/scenario-test'
// Signed-out dialog scenarios.
test.describe.configure({ mode: 'parallel' })
test.describe('auth dialog scenarios', () => {
test('visitor can open and close the sign-in dialog without signing in', async ({ visitor }) => {
await visitor.homePage.expectSignInButtonVisible()
await visitor.homePage.signInButton.click()
await visitor.signInDialog.expectInitialElements()
await visitor.signInDialog.emailInput.fill('partial@example.com')
await visitor.page.keyboard.press('Escape')
await expect(visitor.signInDialog.emailInput).not.toBeVisible()
await visitor.homePage.expectSignInButtonVisible()
})
test('visitor cannot submit empty or malformed email from the sign-in dialog', async ({
visitor,
}) => {
await visitor.homePage.expectSignInButtonVisible()
await visitor.homePage.signInButton.click()
await visitor.signInDialog.expectInitialElements()
await visitor.signInDialog.continueWithEmailButton.click()
await expect(visitor.signInDialog.emailInput).toBeVisible()
await expect(visitor.signInDialog.codeInput).not.toBeVisible()
await visitor.signInDialog.emailInput.fill('not-an-email')
await visitor.signInDialog.continueWithEmailButton.click()
await expect(visitor.signInDialog.emailInput).toBeVisible()
await expect(visitor.signInDialog.codeInput).not.toBeVisible()
})
})

View File

@@ -0,0 +1,25 @@
import { expect, test } from '../fixtures/scenario-test'
test.describe.configure({ mode: 'parallel' })
test.describe('client version scenarios', () => {
test('owner recovers when Zero reports the client is too old', async ({ owner }) => {
await owner.editor.isLoaded()
const reloadButton = owner.page.locator('button:has-text("Reload")')
// Zero (not our own backend) owns "client too old" detection: it fires `onUpdateNeeded` when
// the client's schema/protocol falls behind what zero-cache supports. There's no supported way
// to force that mismatch from a test, so this triggers the same `onClientTooOld` callback the
// Zero client would call, and verifies the UI + reload-recovery flow that callback protects.
await owner.page.evaluate(() => {
;(window as any).app.__test__triggerClientTooOld()
})
await expect(owner.page.getByText('Please reload the page')).toBeVisible()
await expect(reloadButton).toBeVisible()
await reloadButton.click()
await owner.editor.isLoaded()
await owner.editor.ensureSidebarOpen()
await owner.sidebar.createFileButton.click()
})
})

View File

@@ -0,0 +1,44 @@
import fs from 'fs'
import { expect, test } from '../fixtures/scenario-test'
test.describe.configure({ mode: 'parallel' })
function validateTldrJson(json: unknown) {
expect(json).toMatchObject({
tldrawFileFormatVersion: 1,
schema: expect.objectContaining({ schemaVersion: expect.any(Number) }),
records: expect.arrayContaining([expect.objectContaining({ typeName: 'document' })]),
})
}
test.describe('import and download scenarios', () => {
test('owner imports a tldr file from a URL into a scenario file', async ({ owner, scenario }) => {
await owner.editor.ensureSidebarOpen()
const fileCountBefore = await owner.sidebar.getNumberOfFiles()
const importedFile = await scenario.importFileFromUrl(owner)
await expect(async () => {
expect(await owner.sidebar.getNumberOfFiles()).toBe(fileCountBefore + 1)
}).toPass()
expect(importedFile.fileName).toBe('e2e import test')
expect(importedFile.url).toMatch(/\/f\//)
})
test('owner downloads a namespaced scenario file from the sidebar', async ({
owner,
scenario,
}) => {
const fileName = scenario.name('download file')
await scenario.createPersonalFile(owner, fileName)
const download = await scenario.downloadFileFromSidebar(owner, fileName)
const filePath = await download.path()
expect(filePath).toBeTruthy()
const json = JSON.parse(fs.readFileSync(filePath!, 'utf-8'))
validateTldrJson(json)
expect(download.suggestedFilename()).toContain(fileName)
expect(download.suggestedFilename()).toMatch(/\.tldr$/)
})
})

View File

@@ -0,0 +1,60 @@
import { expect, test } from '../fixtures/scenario-test'
// Signed-out workspace invite sign-in dialog UX. The signed-in accept path
// lives in sharing-live.scenario.spec.ts; the sign-in-through-the-dialog flow
// lives in the legacy smoke suite, which can still drive a live Clerk sign-in.
test.describe.configure({ mode: 'parallel' })
test.describe('invite sign-in scenarios', () => {
test('signed-out invite link shows the sign-in dialog over the anonymous editor', async ({
owner,
member,
visitor,
scenario,
}) => {
const { workspaceName, inviteUrl } = await scenario.createPendingWorkspaceInvite({
owner,
member,
workspaceName: scenario.name('invite sign-in workspace'),
fileName: scenario.name('invite sign-in file'),
})
// The invite route resolves to the root and keeps the ?invite marker, so
// the anonymous editor renders behind the sign-in dialog (which names the
// invited workspace) and the URL reflects the active invite flow.
await visitor.page.goto(inviteUrl)
await visitor.page.waitForURL('http://localhost:3000/?invite=true')
await expect(visitor.signInDialog.googleButton).toBeVisible()
await expect(visitor.page.locator('strong', { hasText: workspaceName })).toBeVisible()
await expect(visitor.homePage.tldrawCanvas).toBeVisible()
// Refreshing while the dialog is up keeps it: the marker is still in the URL.
await visitor.page.reload()
await visitor.waitForAppReady()
await expect(visitor.signInDialog.googleButton).toBeVisible()
// Dismissing clears the marker, leaving the visitor on the usable anonymous
// editor at the bare root.
await visitor.page.keyboard.press('Escape')
await visitor.page.waitForURL('http://localhost:3000/')
await expect(visitor.signInDialog.googleButton).not.toBeVisible()
await expect(visitor.homePage.signInButton).toBeVisible()
// With the marker cleared, a plain reload must not bring the dialog back.
// The invite token deliberately lingers in session storage (so a later
// sign-in still completes the join), but the marker, not the token, gates
// the dialog. Waiting for app readiness lets the handler effect run, so the
// absence is meaningful.
await visitor.page.reload()
await visitor.waitForAppReady()
await expect(visitor.signInDialog.googleButton).not.toBeVisible()
// Dismissing pushes rather than replaces, so the invite stays a real
// back-navigable history state: going back re-activates the marker and the
// dialog returns.
await visitor.page.goBack()
await visitor.page.waitForURL('http://localhost:3000/?invite=true')
await expect(visitor.signInDialog.googleButton).toBeVisible()
await expect(visitor.page.locator('strong', { hasText: workspaceName })).toBeVisible()
})
})

View File

@@ -0,0 +1,86 @@
import { expect, test } from '../fixtures/scenario-test'
test.describe.configure({ mode: 'parallel' })
test.describe('legacy routes', () => {
test('signed-out visitors can view legacy room routes', async ({ actors, scenario }) => {
const owner = await actors.open('owner', { goto: false })
const fixture = await scenario.createLegacyRouteFixture(owner)
const visitor = await actors.open('visitor', { goto: false })
for (const url of [fixture.urls.room, fixture.urls.readonly, fixture.urls.legacyReadonly]) {
await visitor.goto(url)
await expect(visitor.page.getByTestId('tla-sign-in-button')).toBeVisible()
await expect(visitor.page.getByTestId('tla-sidebar-layout')).toHaveCount(0)
}
})
test('signed-in actors can open legacy rooms with copy affordance', async ({
actors,
scenario,
}) => {
const owner = await actors.open('owner', { goto: false })
const fixture = await scenario.createLegacyRouteFixture(owner)
await owner.goto(fixture.urls.room)
await expect(owner.page.getByText('This file is now read-only')).toBeVisible()
await owner.page.getByTestId('dialog.close').click()
await expect(owner.page.getByTestId('tla-import-button')).toBeVisible()
await expect(owner.page.getByTestId('tla-sidebar-layout')).toBeVisible()
})
test('signed-in actors can open legacy readonly rooms with copy affordance', async ({
actors,
scenario,
}) => {
const owner = await actors.open('owner', { goto: false })
const fixture = await scenario.createLegacyRouteFixture(owner)
for (const url of [fixture.urls.readonly, fixture.urls.legacyReadonly]) {
await owner.goto(url)
await owner.expectReadonly(true)
await expect(owner.page.getByTestId('tla-sidebar-layout')).toBeVisible()
await expect(owner.page.getByTestId('tla-import-button')).toBeVisible()
}
})
test('legacy snapshots load for signed-out and signed-in actors', async ({
actors,
scenario,
}) => {
const owner = await actors.open('owner', { goto: false })
const fixture = await scenario.createLegacyRouteFixture(owner)
const visitor = await actors.open('visitor', { goto: false })
await visitor.goto(fixture.urls.snapshot)
await expect(visitor.page.getByTestId('tla-sign-in-button')).toBeVisible()
await expect(visitor.page.getByTestId('tla-editor')).toBeVisible()
await owner.goto(fixture.urls.snapshot)
await expect(owner.page.getByTestId('tla-import-button')).toBeVisible()
await expect(owner.page.getByTestId('tla-sidebar-layout')).toBeVisible()
})
test('legacy history routes are staff-only', async ({ actors, scenario }) => {
const owner = await actors.open('owner', { goto: false })
const fixture = await scenario.createLegacyRouteFixture(owner)
// History is restricted to tldraw staff. The signed-in test users use @tldraw.com
// emails, so the owner is staff and can load history and restore versions.
await owner.page.goto(fixture.urls.history, { waitUntil: 'load' })
await expect(owner.page.getByRole('heading', { name: 'Board history' })).toBeVisible()
await expect
.poll(async () => await owner.page.locator('.board-history').getByRole('link').count())
.toBeGreaterThan(0)
await owner.page.goto(fixture.urls.historySnapshot, { waitUntil: 'load' })
await owner.homePage.isLoaded()
await expect(owner.page.getByRole('button', { name: 'Restore version' })).toBeVisible()
// Non-staff visitors (here, signed out) are redirected away from history.
const visitor = await actors.open('visitor', { goto: false })
await visitor.page.goto(fixture.urls.history, { waitUntil: 'load' })
await expect(visitor.page).not.toHaveURL(/history/)
await expect(visitor.page.getByRole('heading', { name: 'Board history' })).toHaveCount(0)
})
})

View File

@@ -0,0 +1,285 @@
import { expect, test } from '../fixtures/scenario-test'
// Live share and workspace membership scenarios.
//
// These scenarios drive two browser contexts and assert on state that propagates between them via
// Zero sync. The membership/file waits gate on the data layer first (see Sidebar), but the heavy
// per-test setup plus genuinely variable cross-client sync latency on CI needs more than the default
// 30s per-test budget, so raise it for the suite.
test.describe.configure({ mode: 'parallel', timeout: 60_000 })
test.describe('live sharing scenarios', () => {
test('owner and visitor see live edits through an edit link', async ({
owner,
visitor,
scenario,
}) => {
await scenario.createGuestEditFile(owner, visitor, scenario.name('live edit file'))
await visitor.editor.expectShapesCount(0)
await owner.page.getByTestId('tools.rectangle').click()
await owner.page.locator('.tl-background').click()
await owner.editor.expectShapesCount(1)
await visitor.editor.expectShapesCount(1)
})
test('owner sees collaborator presence join and leave live', async ({
owner,
visitor,
scenario,
}) => {
await scenario.createGuestEditFile(owner, visitor, scenario.name('presence file'))
await owner.expectCollaboratorCount(1)
await visitor.expectCollaboratorCount(1)
await visitor.close()
await owner.waitForSessionClosed()
})
test('visitor permissions update while the shared file is open', async ({
owner,
visitor,
scenario,
}) => {
await scenario.createGuestEditFile(owner, visitor, scenario.name('permission file'))
await expect(visitor.page.getByTestId('tools.draw')).toBeVisible()
await scenario.setSharedLinkType(owner, 'view')
await expect(visitor.page.getByTestId('tools.draw')).not.toBeVisible({ timeout: 10000 })
await scenario.setSharedLinkType(owner, 'edit')
await expect(visitor.page.getByTestId('tools.draw')).toBeVisible({ timeout: 10000 })
})
test('view-only visitors see live owner changes without edit access', async ({
owner,
visitor,
scenario,
}) => {
await scenario.createGuestViewFile(owner, visitor, scenario.name('view-only guest file'))
await visitor.expectReadonly(true)
await expect(visitor.page.getByTestId('tools.draw')).not.toBeVisible()
await visitor.editor.expectShapesCount(0)
await scenario.createRectangle(owner)
await owner.editor.expectShapesCount(1)
await visitor.editor.expectShapesCount(1)
})
test('signed-in non-member sees shared files as a live guest file', async ({
owner,
member,
scenario,
}) => {
const fileName = scenario.name('signed-in guest file')
await scenario.createGuestEditFile(owner, member, fileName)
await member.editor.ensureSidebarOpen()
await member.sidebar.expectFileVisible(fileName)
await expect(member.page.getByTestId(`guest-badge-${fileName}`)).toBeVisible()
await expect(member.page.getByTestId('tools.draw')).toBeVisible()
await scenario.setSharedLinkType(owner, 'view')
await expect(member.page.getByTestId('tools.draw')).not.toBeVisible({ timeout: 10000 })
})
test('opening a guest file does not trap the member outside their home after they join its workspace', async ({
owner,
member,
scenario,
}) => {
// owner makes a workspace with a (shared) file owned by that workspace, plus an invite.
const { workspaceName, fileName, inviteUrl } = await scenario.createPendingWorkspaceInvite({
owner,
member,
})
const fileUrl = owner.page.url() // owner is left viewing the workspace file, which is shared
// member opens that file via its link while NOT a member of the workspace: it gets mirrored
// into their home as a guest file (visible in their sidebar).
await member.goto(fileUrl)
await member.editor.ensureSidebarOpen()
await member.sidebar.expectFileVisible(fileName)
// member then accepts the invite and joins the workspace.
await member.goto(inviteUrl)
await member.editor.ensureSidebarOpen()
await member.workspaceInviteDialog.acceptInvitation()
await member.sidebar.expectWorkspaceVisible(workspaceName)
// Now switching to the home workspace must stay there. The file is owned by a workspace the
// member belongs to, so it must not be listed in home: before the fix it was, and opening it
// re-activated the workspace, so the member could never get back to their home.
await member.sidebar.switchToHomeWorkspace()
await member.sidebar.expectActiveHomeWorkspace()
await member.sidebar.expectFileNotVisible(fileName)
})
test('unshare removes visitor access while the file is open', async ({
owner,
visitor,
scenario,
}) => {
const { sharedUrl } = await scenario.createSharedFile(
owner,
'edit',
scenario.name('unshare file')
)
await visitor.goto(sharedUrl)
await expect(visitor.shareMenu.shareButton).toBeVisible()
await expect(visitor.page.getByTestId('tla-error')).not.toBeVisible()
await scenario.setSharedLinkType(owner, 'no-access')
await expect(visitor.shareMenu.shareButton).not.toBeVisible({ timeout: 10000 })
await expect(visitor.page.getByTestId('tla-error')).toBeVisible({ timeout: 10000 })
})
test('published snapshots update only after publishing changes', async ({
owner,
visitor,
scenario,
}) => {
const { publishedUrl } = await scenario.createPublishedFile(
owner,
scenario.name('published snapshot file')
)
await visitor.goto(publishedUrl)
await visitor.editor.expectShapesCount(0)
await scenario.createRectangle(owner)
await owner.editor.expectShapesCount(1)
await visitor.page.reload()
await visitor.waitForAppReady()
await visitor.editor.expectShapesCount(0)
await scenario.publishChanges(owner)
await visitor.page.reload()
await visitor.waitForAppReady()
await visitor.editor.expectShapesCount(1)
})
test('workspace file deletion removes the file from active members without reload', async ({
owner,
member,
scenario,
}) => {
const { workspaceName, fileName } = await scenario.createWorkspaceWithMember({
owner,
member,
workspaceName: scenario.name('delete file workspace'),
fileName: scenario.name('live deleted workspace file'),
})
await owner.sidebar.switchToWorkspace(workspaceName)
await owner.sidebar.deleteFileByName(fileName)
await owner.deleteFileDialog.expectIsVisible()
await owner.deleteFileDialog.confirmDeletion()
await owner.deleteFileDialog.expectIsNotVisible()
await owner.sidebar.expectFileNotVisible(fileName)
await member.sidebar.expectFileNotVisible(fileName)
})
test('workspace invite acceptance appears in owner settings without reload', async ({
owner,
member,
scenario,
}) => {
const { workspaceName, fileName, inviteUrl, memberUserId } =
await scenario.createPendingWorkspaceInvite({
owner,
member,
workspaceName: scenario.name('pending invite workspace'),
fileName: scenario.name('pending invite file'),
})
const memberRoleSelect = owner.page.locator(`[id="workspace-member-role-${memberUserId}"]`)
await owner.sidebar.openWorkspaceSettings(workspaceName)
await expect(memberRoleSelect).not.toBeVisible()
await member.goto(inviteUrl)
await member.editor.ensureSidebarOpen()
await member.workspaceInviteDialog.expectIsVisible()
await member.workspaceInviteDialog.acceptInvitation()
await member.sidebar.expectWorkspaceVisible(workspaceName)
await member.sidebar.expectFileVisible(fileName)
await expect(memberRoleSelect).toBeVisible({ timeout: 10000 })
await owner.page.keyboard.press('Escape')
})
test('member removal revokes the active member window without reload', async ({
owner,
member,
scenario,
}) => {
const { workspaceName, fileName } = await scenario.createWorkspaceWithRemovedMember({
owner,
member,
workspaceName: scenario.name('member removal workspace'),
fileName: scenario.name('member removal file'),
})
await member.sidebar.expectWorkspaceNotVisible(workspaceName)
await member.sidebar.expectFileNotVisible(fileName)
})
test('workspace role changes update active members without reload', async ({
owner,
member,
scenario,
}) => {
const { workspaceName, fileName, memberUserId } = await scenario.createWorkspaceWithMember({
owner,
member,
workspaceName: scenario.name('role change workspace'),
fileName: scenario.name('role change file'),
})
await member.sidebar.expectWorkspaceVisible(workspaceName)
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()
await scenario.setWorkspaceMemberRole({
owner,
workspaceName,
memberUserId,
role: 'owner',
})
await expect(deleteWorkspaceButton).toBeVisible({ timeout: 10000 })
await member.page.keyboard.press('Escape')
})
test('workspace deletion removes the workspace from active members without reload', async ({
owner,
member,
scenario,
}) => {
const { workspaceName, fileName } = await scenario.createWorkspaceWithMember({
owner,
member,
workspaceName: scenario.name('live workspace'),
fileName: scenario.name('shared workspace file'),
})
await member.sidebar.expectWorkspaceVisible(workspaceName)
await member.sidebar.expectFileVisible(fileName)
await owner.sidebar.deleteWorkspace(workspaceName)
await owner.sidebar.expectWorkspaceNotVisible(workspaceName)
await member.sidebar.expectWorkspaceNotVisible(workspaceName)
await member.sidebar.expectFileNotVisible(fileName)
})
})

View File

@@ -0,0 +1,110 @@
import { expect, test } from '../../fixtures/tla-test'
const COOKIE_CONSENT_KEY = 'tldraw_cookie_consent'
// Exercise the banner as a signed-out visitor so consent relies on localStorage.
test.use({ storageState: { cookies: [], origins: [] } })
test.describe('cookie consent banner', () => {
test.beforeEach(async ({ page }) => {
// Mock the consent check to always require consent
await page.route('https://consent.tldraw.xyz', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ requires_consent: true, country_code: 'TEST' }),
})
})
await page.evaluate((key) => {
// eslint-disable-next-line tldraw/no-direct-storage
window.localStorage.removeItem(key)
}, COOKIE_CONSENT_KEY)
})
test('shows banner and animations work without prefers-reduced-motion', async ({
page,
homePage,
}) => {
await homePage.goto()
await homePage.isLoaded()
const banner = page.getByTestId('tla-cookie-consent')
await expect(banner).toBeVisible()
// Verify animation duration is set (not instant)
const animationDuration = await banner.evaluate((el) => {
const styles = window.getComputedStyle(el)
return styles.animationDuration
})
expect(animationDuration).not.toBe('0s')
await expect(banner.getByRole('button', { name: 'Opt out' })).toBeVisible()
await expect(banner.getByRole('button', { name: 'Settings' })).toBeVisible()
await expect(banner.getByRole('button', { name: 'Accept all' })).toBeVisible()
})
test.describe('with prefers-reduced-motion', () => {
test.beforeEach(async ({ page, homePage }) => {
await page.emulateMedia({ reducedMotion: 'reduce' })
await homePage.goto()
await homePage.isLoaded()
})
test('animations are instant', async ({ page }) => {
const banner = page.getByTestId('tla-cookie-consent')
await expect(banner).toBeVisible()
// Check that animation duration is 0
const animationDuration = await banner.evaluate((el) => {
const styles = window.getComputedStyle(el)
return styles.animationDuration
})
expect(animationDuration).toBe('0s')
})
test('accepting cookies hides banner and persists choice', async ({ page, homePage }) => {
const banner = page.getByTestId('tla-cookie-consent')
await expect(banner).toBeVisible()
await banner.getByRole('button', { name: 'Accept all' }).click()
await expect(banner).not.toBeVisible()
await expect(async () => {
const storedConsent = await page.evaluate(
// eslint-disable-next-line tldraw/no-direct-storage
(key) => window.localStorage.getItem(key),
COOKIE_CONSENT_KEY
)
expect(storedConsent).toBe(JSON.stringify({ analytics: true }))
}).toPass()
await page.reload()
await homePage.isLoaded()
await expect(page.getByTestId('tla-cookie-consent')).not.toBeVisible()
})
test('opting out hides banner and persists choice', async ({ page, homePage }) => {
const banner = page.getByTestId('tla-cookie-consent')
await expect(banner).toBeVisible()
await banner.getByRole('button', { name: 'Opt out' }).click()
await expect(banner).not.toBeVisible()
await expect(async () => {
const storedConsent = await page.evaluate(
// eslint-disable-next-line tldraw/no-direct-storage
(key) => window.localStorage.getItem(key),
COOKIE_CONSENT_KEY
)
expect(storedConsent).toBe(JSON.stringify({ analytics: false }))
}).toPass()
await page.reload()
await homePage.isLoaded()
await expect(page.getByTestId('tla-cookie-consent')).not.toBeVisible()
})
})
})

View File

@@ -0,0 +1,35 @@
import { Editor } from '../../fixtures/Editor'
import { sleep } from '../../fixtures/helpers'
import { HomePage } from '../../fixtures/HomePage'
import { Sidebar } from '../../fixtures/Sidebar'
import { expect, test } from '../../fixtures/tla-test'
test('correctly clears db ', async ({ page, browser, editor, sidebar, database }) => {
const documentsToAdd = 3
await test.step(`create ${documentsToAdd} files file`, async () => {
await editor.ensureSidebarOpen()
const currentCount = await sidebar.getNumberOfFiles()
for (let i = 0; i < documentsToAdd; i++) {
await sleep(1100)
await sidebar.createNewDocument()
}
const newCount = await sidebar.getNumberOfFiles()
expect(newCount).toBe(currentCount + documentsToAdd)
})
await test.step('close the page and reset db', async () => {
await page.close()
await database.reset()
})
await test.step('check that files reset', async () => {
const newPage = await browser.newPage()
const newSidebar = new Sidebar(newPage)
const newEditor = new Editor(newPage, newSidebar)
const newHomePage = new HomePage(newPage, newEditor)
await newHomePage.goto()
await newHomePage.isLoaded()
const afterReset = await newSidebar.getNumberOfFiles()
expect(afterReset).toBe(1)
})
})

View File

@@ -0,0 +1,162 @@
import { expectBeforeAndAfterReload, getRandomName, sleep } from '../../fixtures/helpers'
import { expect, test } from '../../fixtures/tla-test'
test.beforeEach(async ({ editor }) => {
await editor.isLoaded()
})
test('can instantiate', async ({ page }) => {
const dialogCloseButton = page.getByTestId('dialog.close')
// expect this button not to be present; ie that the dialog is not open
await expect(dialogCloseButton).not.toBeVisible()
})
test.describe('preferences', () => {
test('can toggle dark mode', async ({ page, editor, sidebar }) => {
await test.step('is light mode by default', async () => {
await expect(page.locator('div.tla-theme__light.tl-theme__light')).toBeVisible()
await expect(page.locator('div.tla-theme__dark.tl-theme__dark')).not.toBeVisible()
await expect(page.locator('div.tl-background')).toHaveCSS(
'background-color',
'rgb(249, 250, 251)'
)
await expect(page.locator('div.tla-theme-container')).toHaveCSS(
'background-color',
'rgb(252, 252, 252)'
)
})
await test.step('can toggle dark mode', async () => {
await editor.ensureSidebarOpen()
await sidebar.setDarkMode()
})
await test.step('is dark mode', async () => {
await expectBeforeAndAfterReload(async () => {
await expect(page.locator('div.tla-theme__light.tl-theme__light')).not.toBeVisible()
await expect(page.locator('div.tla-theme__dark.tl-theme__dark')).toBeVisible()
await expect(page.locator('div.tl-background')).toHaveCSS(
'background-color',
'rgb(16, 16, 17)'
)
await expect(page.locator('div.tla-theme-container')).toHaveCSS(
'background-color',
'rgb(13, 13, 13)'
)
}, page)
})
})
test('can change language', async ({ sidebar, page, editor }) => {
await editor.ensureSidebarOpen()
await test.step('English by default', async () => {
await sidebar.expectLanguageToBe('Language', 'English')
})
await sidebar.closeAccountMenu()
await test.step('change language', async () => {
await sidebar.setLanguage('Language', 'Français')
})
await test.step('changed to French', async () => {
await sidebar.expectLanguageToBe('Langue', 'Français')
await editor.openPageMenu()
// Make sure the file / page menu is using the correct language
await expect(page.getByText('Charger un média')).toBeVisible()
})
})
})
test.describe('sidebar actions', () => {
test('can pin a file from the file menu', async ({ page, editor, sidebar }) => {
// Create two files so that there are three
await editor.ensureSidebarOpen()
await sidebar.createNewDocument()
await sleep(1000)
await sidebar.createNewDocument()
expect(await sidebar.getNumberOfFiles()).toBe(3)
// Name the files
const fileName0 = getRandomName()
await sidebar.renameFile(0, fileName0)
const fileName1 = getRandomName()
await sidebar.renameFile(1, fileName1)
const fileName2 = getRandomName()
await sidebar.renameFile(2, fileName2)
// The pinned files are also sorted by recency (though not grouped by time)
// When we pin a file, we expect that it will move to the pinned files section
// but that the order of the pinned files will be the same as the order of the files
// Pin the second-most recent file...
await sidebar.pinFromFileMenu(1)
// And expect it to the the first (and only) pinned file
await expectBeforeAndAfterReload(async () => {
await expect(async () => {
await expect(
page.getByTestId('tla-file-link-pinned-0').getByText(fileName1, { exact: true })
).toBeVisible()
await expect(
page.getByTestId('tla-file-link-today-0').getByText(fileName0, { exact: true })
).toBeVisible()
await expect(
page.getByTestId('tla-file-link-today-1').getByText(fileName2, { exact: true })
).toBeVisible()
}).toPass()
}, page)
// Now we pin the most recent file...
await sidebar.pinFromFileMenu(0)
// And expect it to the first pinned file
await expectBeforeAndAfterReload(async () => {
await expect(async () => {
await expect(
page.getByTestId('tla-file-link-pinned-0').getByText(fileName0, { exact: true })
).toBeVisible()
await expect(
page.getByTestId('tla-file-link-pinned-1').getByText(fileName1, { exact: true })
).toBeVisible()
await expect(
page.getByTestId('tla-file-link-today-0').getByText(fileName2, { exact: true })
).toBeVisible()
}).toPass()
}, page)
// Now we unpin the most recent file...
await sidebar.unpinFromFileMenu(0)
await expectBeforeAndAfterReload(async () => {
await expect(async () => {
await expect(
page.getByTestId('tla-file-link-pinned-0').getByText(fileName1, { exact: true })
).toBeVisible()
await expect(
page.getByTestId('tla-file-link-today-0').getByText(fileName0, { exact: true })
).toBeVisible()
await expect(
page.getByTestId('tla-file-link-today-1').getByText(fileName2, { exact: true })
).toBeVisible()
}).toPass()
}, page)
})
})
// Menu bar
test('can rename a file name by clicking the name', async ({ editor, sidebar, page }) => {
const originalName = await editor.getCurrentFileName()
const newName = getRandomName()
await expect(async () => {
await sidebar.expectToContainText(originalName)
await sidebar.expectNotToContainText(newName)
}).toPass()
await editor.rename(newName)
await expectBeforeAndAfterReload(async () => {
await sidebar.expectToContainText(newName)
await sidebar.expectNotToContainText(originalName)
}, page)
})

View File

@@ -0,0 +1,47 @@
import { USERS } from '../../consts'
import { expect, test } from '../../fixtures/tla-test'
// Don't use stored credentials
test.use({ storageState: { cookies: [], origins: [] } })
test('can login', async ({ homePage, editor }) => {
await expect(homePage.signInButton).toBeVisible()
const user = USERS[test.info().parallelIndex]
await homePage.loginAs(user)
await editor.isLoaded()
await expect(homePage.signInButton).not.toBeVisible()
await expect(editor.sidebarToggle).toBeVisible()
})
test('can sign out', async ({ homePage, editor, sidebar }) => {
await test.step('Login', async () => {
const user = USERS[test.info().parallelIndex]
await homePage.loginAs(user)
})
await editor.isLoaded()
await homePage.expectSignInButtonNotVisible()
await editor.ensureSidebarOpen()
await sidebar.signOut()
await homePage.expectSignInButtonVisible()
})
test('shows anonymous SDK content', async ({ page, homePage }) => {
await homePage.expectSignInButtonVisible()
await expect(page.getByTestId('tla-anon-dotdev-link')).toBeVisible()
await page.getByTestId('tla-anon-dotdev-dismiss-button').click()
await expect(page.getByTestId('tla-anon-dotdev-link')).not.toBeVisible()
})
test('shows a comment button that prompts sign in', async ({ page, homePage, signInDialog }) => {
await homePage.expectSignInButtonVisible()
const commentButton = page.getByTestId('quick-actions.comment')
await expect(commentButton).toBeVisible()
await commentButton.click()
await signInDialog.expectInitialElements()
// The comment tool itself is never registered for signed-out visitors, so the button only opens
// the dialog — it can't drop the canvas into a half-mounted tool.
await expect(commentButton).toHaveAttribute('aria-pressed', 'false')
})

View File

@@ -0,0 +1,383 @@
import fs from 'fs'
import path from 'path'
import { getRandomName, openNewTab } from '../../fixtures/helpers'
import { expect, test } from '../../fixtures/tla-test'
test.beforeEach(async ({ context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
})
/* --------------------- Sharing -------------------- */
test.describe('signed in user on own file', () => {
test('default share state', async ({ shareMenu }) => {
await shareMenu.open()
expect(await shareMenu.isToggleChecked()).toBe(true)
expect(await shareMenu.getShareType()).toBe('Editor')
})
test('tabs work correctly', async ({ shareMenu }) => {
await shareMenu.open()
expect(await shareMenu.publishTabButton.isVisible()).toBe(true)
expect(await shareMenu.inviteTabButton.isVisible()).toBe(true)
expect(await shareMenu.exportTabButton.isVisible()).toBe(true)
expect(await shareMenu.anonShareTabButton.isVisible()).toBe(false)
// Starts on the invite tab
expect(await shareMenu.inviteTabPage.isVisible()).toBe(true)
expect(await shareMenu.exportTabPage.isVisible()).toBe(false)
expect(await shareMenu.publishTabPage.isVisible()).toBe(false)
expect(await shareMenu.anonShareTabPage.isVisible()).toBe(false)
// Can switch between tabs (export)
await shareMenu.exportTabButton.click()
expect(await shareMenu.inviteTabPage.isVisible()).toBe(false)
expect(await shareMenu.exportTabPage.isVisible()).toBe(true)
expect(await shareMenu.publishTabPage.isVisible()).toBe(false)
// Can switch between tabs (publish)
await shareMenu.publishTabButton.click()
expect(await shareMenu.inviteTabPage.isVisible()).toBe(false)
expect(await shareMenu.exportTabPage.isVisible()).toBe(false)
expect(await shareMenu.publishTabPage.isVisible()).toBe(true)
})
test('a select inside the menu closes without closing the menu', async ({ page, shareMenu }) => {
await shareMenu.open()
await shareMenu.exportTabButton.click()
await expect(shareMenu.exportTabPage).toBeVisible()
const themeSelect = page.locator('#tla-export-theme-select')
// Selecting an option closes the select but leaves the share menu open.
await themeSelect.click()
await expect(page.getByRole('listbox')).toBeVisible()
await page.getByRole('option').last().click()
await expect(page.getByRole('listbox')).toHaveCount(0)
await expect(shareMenu.exportTabPage).toBeVisible()
// Re-clicking the open select's trigger also closes only the select. The trigger
// is non-interactive while the select is open (Radix disables outside pointer
// events), so click its position directly rather than via the locator.
await themeSelect.click()
await expect(page.getByRole('listbox')).toBeVisible()
const box = await themeSelect.boundingBox()
if (!box) throw new Error('theme select not found')
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2)
await expect(page.getByRole('listbox')).toHaveCount(0)
await expect(shareMenu.exportTabPage).toBeVisible()
})
test('can unshare and reshare', async ({ page, browser, shareMenu }) => {
// Copy the link to the current file
await shareMenu.open()
expect(await shareMenu.isInviteButtonVisible()).toBe(true)
const url = await shareMenu.copyLink()
// Open link in an incognito window
const { newPage, newShareMenu, newContext } = await openNewTab(browser, {
url,
allowClipboard: true,
userProps: undefined,
})
// The second page should have the share button and not the error
await expect(newShareMenu.shareButton).toBeVisible()
await expect(newPage.getByTestId('tla-error')).not.toBeVisible()
// Now unshare it it...
await page.getByTestId('shared-link-shared-switch').click()
expect(await shareMenu.isToggleChecked()).toBe(false)
// Reload the second page
newPage.reload()
// The second page should have an error and not the share button
await expect(newShareMenu.shareButton).not.toBeVisible()
await expect(newPage.getByTestId('tla-error')).toBeVisible()
// Now reshare it...
await page.getByTestId('shared-link-shared-switch').click()
expect(await shareMenu.isToggleChecked()).toBe(true)
// Reload the second page again
newPage.reload()
// The second page should have the share button and not the error again
await expect(newShareMenu.shareButton).toBeVisible()
await expect(newPage.getByTestId('tla-error')).not.toBeVisible()
await newContext.close()
})
})
test.describe('signed in user on someone elses file', () => {
test('tabs work correctly', async ({ browser, sidebar, shareMenu }) => {
const newName = getRandomName()
await sidebar.renameFile(0, newName)
await shareMenu.open()
const url = await shareMenu.copyLink()
const parallelIndex = test.info().parallelIndex
// Open link in an incognito window
const { newShareMenu, newPage } = await openNewTab(browser, {
url,
allowClipboard: true,
userProps: { user: 'suppy', index: parallelIndex },
})
await expect(newPage.getByTestId('tla-sidebar').getByText(newName)).toBeVisible()
await newShareMenu.open()
expect(await newShareMenu.publishTabButton.isVisible()).toBe(false)
expect(await newShareMenu.inviteTabButton.isVisible()).toBe(false)
expect(await newShareMenu.exportTabButton.isVisible()).toBe(true)
expect(await newShareMenu.anonShareTabButton.isVisible()).toBe(true)
// Starts on the anon share tab
expect(await newShareMenu.inviteTabPage.isVisible()).toBe(false)
expect(await newShareMenu.exportTabPage.isVisible()).toBe(false)
expect(await newShareMenu.publishTabPage.isVisible()).toBe(false)
expect(await newShareMenu.anonShareTabPage.isVisible()).toBe(true)
// Can switch between tabs (export)
await newShareMenu.exportTabButton.click()
expect(await newShareMenu.inviteTabPage.isVisible()).toBe(false)
expect(await newShareMenu.exportTabPage.isVisible()).toBe(true)
expect(await newShareMenu.publishTabPage.isVisible()).toBe(false)
expect(await newShareMenu.anonShareTabPage.isVisible()).toBe(false)
})
})
test.describe('signed in user on published file', () => {
test('tabs work correctly', async ({ shareMenu, browser }) => {
// Publish the user's current file
await shareMenu.open()
expect(await shareMenu.isInviteButtonVisible()).toBe(true)
await shareMenu.publishFile()
const url = await shareMenu.copyLink()
// Open published file link in other user window
const { newShareMenu, newContext } = await openNewTab(browser, {
url,
allowClipboard: true,
userProps: { user: 'huppy', index: test.info().parallelIndex },
})
await expect(newShareMenu.shareButton).toBeVisible()
await newShareMenu.open()
expect(await newShareMenu.publishTabButton.isVisible()).toBe(false)
expect(await newShareMenu.inviteTabButton.isVisible()).toBe(false)
expect(await newShareMenu.exportTabButton.isVisible()).toBe(true)
expect(await newShareMenu.anonShareTabButton.isVisible()).toBe(true)
expect(await newShareMenu.publishTabPage.isVisible()).toBe(false)
expect(await newShareMenu.inviteTabPage.isVisible()).toBe(false)
expect(await newShareMenu.exportTabPage.isVisible()).toBe(false)
expect(await newShareMenu.anonShareTabPage.isVisible()).toBe(true) // show anon share tab page
// can copy the url
const copiedUrl = await newShareMenu.copyLink()
expect(new URL(copiedUrl).pathname).toBe(new URL(url).pathname)
// Switch to the export share tab
await newShareMenu.exportTabButton.click()
expect(await newShareMenu.exportTabPage.isVisible()).toBe(true)
expect(await newShareMenu.anonShareTabPage.isVisible()).toBe(false)
// Does not see the sidebar or the sidebar button
expect(await newShareMenu.page.getByTestId('tla-sidebar').count()).toBe(0)
expect(await newShareMenu.page.getByTestId('tla-sidebar-toggle').count()).toBe(0)
// Main user does see sidebar
expect(await shareMenu.page.getByTestId('tla-sidebar').count()).toBe(1)
expect(await shareMenu.page.getByTestId('tla-sidebar-toggle').count()).toBe(1)
await newContext.close()
})
})
test.describe('logged out user on scratch file', () => {
test('tabs work correctly', async ({ browser }) => {
const { newShareMenu, newContext } = await openNewTab(browser, {
allowClipboard: true,
userProps: undefined,
})
// In the incognito window, we are on the scratchpad
await expect(newShareMenu.shareButton).toBeVisible()
await newShareMenu.open()
expect(await newShareMenu.inviteTabButton.isVisible()).toBe(false)
expect(await newShareMenu.exportTabButton.isVisible()).toBe(true)
expect(await newShareMenu.publishTabButton.isVisible()).toBe(false)
expect(await newShareMenu.anonShareTabButton.isVisible()).toBe(false) // not shown on scratch
await newContext.close()
})
})
test.describe('logged out user on guest file', () => {
test('tabs work correctly', async ({ shareMenu, browser }) => {
// Share the signed in user's current file
await shareMenu.open()
expect(await shareMenu.isInviteButtonVisible()).toBe(true)
await shareMenu.shareFile()
const url = await shareMenu.copyLink()
// Open file link in an incognito window
const { newShareMenu, newContext } = await openNewTab(browser, {
url,
allowClipboard: true,
userProps: undefined,
})
await expect(newShareMenu.shareButton).toBeVisible()
await newShareMenu.open()
expect(await newShareMenu.publishTabButton.isVisible()).toBe(false)
expect(await newShareMenu.inviteTabButton.isVisible()).toBe(false)
expect(await newShareMenu.exportTabButton.isVisible()).toBe(true)
expect(await newShareMenu.anonShareTabButton.isVisible()).toBe(true)
expect(await newShareMenu.publishTabPage.isVisible()).toBe(false)
expect(await newShareMenu.inviteTabPage.isVisible()).toBe(false)
expect(await newShareMenu.exportTabPage.isVisible()).toBe(false)
expect(await newShareMenu.anonShareTabPage.isVisible()).toBe(true) // show anon share tab page
// can copy the url
const copiedUrl = await shareMenu.copyLink()
expect(copiedUrl).toBe(url)
// Switch to the export share tab
await newShareMenu.exportTabButton.click()
expect(await newShareMenu.exportTabPage.isVisible()).toBe(true)
expect(await newShareMenu.anonShareTabPage.isVisible()).toBe(false)
await newContext.close()
})
})
test.describe('logged out user on published file', () => {
test('tabs work correctly', async ({ shareMenu, browser }) => {
// Publish the user's current file
await shareMenu.open()
expect(await shareMenu.isInviteButtonVisible()).toBe(true)
await shareMenu.publishFile()
const url = await shareMenu.copyLink()
// Open published file link in an incognito window
const { newShareMenu, newContext, newEditor } = await openNewTab(browser, {
url,
allowClipboard: true,
userProps: undefined,
})
await expect(newShareMenu.shareButton).toBeVisible()
await newShareMenu.open()
expect(await newShareMenu.publishTabButton.isVisible()).toBe(false)
expect(await newShareMenu.inviteTabButton.isVisible()).toBe(false)
expect(await newShareMenu.exportTabButton.isVisible()).toBe(true)
expect(await newShareMenu.anonShareTabButton.isVisible()).toBe(true)
expect(await newShareMenu.publishTabPage.isVisible()).toBe(false)
expect(await newShareMenu.inviteTabPage.isVisible()).toBe(false)
expect(await newShareMenu.exportTabPage.isVisible()).toBe(false)
expect(await newShareMenu.anonShareTabPage.isVisible()).toBe(true) // show anon share tab page
// can copy the url
const copiedUrl = await shareMenu.copyLink()
expect(copiedUrl).toBe(url)
// Switch to the export share tab
await newShareMenu.exportTabButton.click()
expect(await newShareMenu.exportTabPage.isVisible()).toBe(true)
expect(await newShareMenu.anonShareTabPage.isVisible()).toBe(false)
// download the file
await newEditor.openPageMenu()
const downloadButton = newEditor.page.getByText('Download')
await expect(downloadButton).toBeVisible()
// check that the file downloaded
const downloadPromise = newEditor.page.waitForEvent('download')
await downloadButton.click()
const download = await downloadPromise
expect(download.suggestedFilename().endsWith('.tldr')).toBe(true)
await newContext.close()
})
})
/* ------------------- Exporting ------------------- */
test('can export a file as an image', async ({ page, shareMenu }) => {
await page.getByTestId('tools.rectangle').click()
await page.locator('.tl-background').click()
const downloadPromise = page.waitForEvent('download')
await shareMenu.open()
await shareMenu.exportFile()
const download = await downloadPromise
const suggestedFilename = download.suggestedFilename()
expect(suggestedFilename.endsWith('.png')).toBe(true)
const filePath = path.join('./test-results/', suggestedFilename)
await download.saveAs(filePath)
expect(fs.existsSync(filePath)).toBeTruthy()
const stats = fs.statSync(filePath)
expect(stats.size).toBeGreaterThan(0)
})
test('can follow a deep link to a never-seen file', async ({ editor, browser, shareMenu }) => {
const text = Math.random().toString(36).substring(2, 15)
await editor.createNewPage()
await editor.createTextShape(text)
await shareMenu.open()
expect(await shareMenu.isInviteButtonVisible()).toBe(true)
const url = await shareMenu.copyLink()
// close the share menu
await shareMenu.page.keyboard.press('Escape')
// create a new empty page
await editor.createNewPage()
const { newEditor } = await openNewTab(browser, {
url,
allowClipboard: true,
userProps: { user: 'suppy', index: test.info().parallelIndex },
})
await newEditor.expectShapesCount(1)
expect(newEditor.page.getByText(text).last()).toBeVisible()
})
test('can follow a deep link to an already-seen file', async ({ editor, shareMenu, browser }) => {
const text = Math.random().toString(36).substring(2, 15)
await shareMenu.open()
await shareMenu.inviteTabButton.click()
// close the share menu
await shareMenu.page.keyboard.press('Escape')
const { newEditor, newShareMenu } = await openNewTab(browser, {
url: editor.page.url(),
allowClipboard: true,
userProps: { user: 'suppy', index: test.info().parallelIndex },
})
await newEditor.createNewPage()
await newEditor.createTextShape(text)
await newEditor.expectShapesCount(1)
await newShareMenu.open()
await newShareMenu.anonShareTabButton.click()
const url = await newShareMenu.copyLink()
await newEditor.createNewPage()
await editor.page.goto(url)
await editor.expectShapesCount(1)
await expect(editor.page.getByText(text).last()).toBeVisible()
})

View File

@@ -0,0 +1,33 @@
import { expect, test } from '../../fixtures/tla-test'
const DOT_DEV_LOCAL_STORAGE_KEY = 'showDotDevLink'
const DOT_DEV_URL =
'https://tldraw.dev?utm_source=dotcom&utm_medium=organic&utm_campaign=sidebar-link'
test.describe('sidebar dot dev link', () => {
test.beforeEach(async ({ page, homePage, editor }) => {
await editor.ensureSidebarOpen()
await page.evaluate((key) => {
// eslint-disable-next-line tldraw/no-direct-storage
window.localStorage.removeItem(key)
}, DOT_DEV_LOCAL_STORAGE_KEY)
await page.reload()
await homePage.isLoaded()
await editor.ensureSidebarOpen()
})
test('is visible with expected link', async ({ page }) => {
const link = page.getByTestId('tla-sidebar-dotdev-link')
await expect(link).toBeVisible()
await expect(link).toHaveAttribute('href', DOT_DEV_URL)
})
test('clicking opens tldraw.dev in a new tab', async ({ page }) => {
const link = page.getByTestId('tla-sidebar-dotdev-link')
// Verify the link would open in a new tab
await expect(link).toHaveAttribute('target', '_blank')
})
})

View File

@@ -0,0 +1,64 @@
import { USERS } from '../../consts'
import { expect, test } from '../../fixtures/tla-test'
// Don't use stored credentials for these dialog-specific tests
test.use({ storageState: { cookies: [], origins: [] } })
test.describe('SignInDialog', () => {
test('can sign in via email code using the custom dialog', async ({
homePage,
editor,
signInDialog,
}) => {
const user = USERS[test.info().parallelIndex]
await homePage.expectSignInButtonVisible()
await homePage.signInButton.click()
await signInDialog.continueWithEmail(user)
await signInDialog.expectCodeStageVisible()
await signInDialog.fillCode('424242')
await editor.isLoaded()
await expect(homePage.signInButton).not.toBeVisible()
await expect(editor.sidebarToggle).toBeVisible()
})
test('resend is available on code stage', async ({ homePage, signInDialog }) => {
const user = USERS[test.info().parallelIndex]
await homePage.expectSignInButtonVisible()
await homePage.signInButton.click()
await signInDialog.continueWithEmail(user)
await signInDialog.expectCodeStageVisible()
await signInDialog.clickResend()
// Code input should still be visible and empty after resend
await expect(signInDialog.codeInput).toBeVisible()
})
// Dialog close behaviors
test('can close dialog during code entry stage', async ({ homePage, signInDialog, page }) => {
const user = USERS[test.info().parallelIndex]
await homePage.expectSignInButtonVisible()
await homePage.signInButton.click()
// Complete email stage
await signInDialog.continueWithEmail(user)
await signInDialog.expectCodeStageVisible()
// Close dialog without entering code
await page.keyboard.press('Escape')
// Verify dialog is closed and user remains signed out
await expect(signInDialog.codeInput).not.toBeVisible()
await homePage.expectSignInButtonVisible()
// Verify user can restart flow by reopening dialog
await homePage.signInButton.click()
await signInDialog.expectInitialElements()
})
})

View File

@@ -0,0 +1,117 @@
import fs from 'fs'
import { USERS } from '../../consts'
import { expect, test } from '../../fixtures/tla-test'
function validateTldrJson(json: unknown) {
expect(json).toMatchObject({
tldrawFileFormatVersion: 1,
schema: expect.objectContaining({ schemaVersion: expect.any(Number) }),
records: expect.arrayContaining([expect.objectContaining({ typeName: 'document' })]),
})
}
// ------------------------------------------------------------------ import --
test.describe('import from URL (signed in)', () => {
test.beforeEach(async ({ editor }) => {
await editor.isLoaded()
})
test('redirects to root when no url param is provided', async ({ page }) => {
await page.goto('http://localhost:3000/import')
// Should redirect away from /import immediately (signed-in users then get
// forwarded on to their most-recent file, so we can't assert a specific URL).
await expect(page).not.toHaveURL(/\/import/)
})
test('shows import-failed toast when the url returns a non-200 response', async ({ page }) => {
const badUrl = 'https://e2e-mock.tldraw.xyz/not-found.tldr'
await page.route(badUrl, (route) => route.fulfill({ status: 404 }))
await page.goto(`http://localhost:3000/import?url=${encodeURIComponent(badUrl)}`)
await expect(page).not.toHaveURL(/\/import/)
await expect(page.getByText('Import failed', { exact: true })).toBeVisible()
})
test('shows import-failed toast when the url returns invalid json', async ({ page }) => {
const badUrl = 'https://e2e-mock.tldraw.xyz/not-a-tldr-file.txt'
await page.route(badUrl, (route) =>
route.fulfill({
status: 200,
contentType: 'text/plain',
headers: { 'Access-Control-Allow-Origin': '*' },
body: 'this is not a tldraw file',
})
)
await page.goto(`http://localhost:3000/import?url=${encodeURIComponent(badUrl)}`)
await expect(page).not.toHaveURL(/\/import/)
await expect(page.getByText('Import failed', { exact: true })).toBeVisible()
})
})
test.describe('import from URL (signed out)', () => {
test.use({ storageState: { cookies: [], origins: [] } })
test('shows sign-in dialog; after email sign-in navigates to the imported file', async ({
page,
signInDialog,
editor,
importHelper,
}) => {
const user = USERS[test.info().parallelIndex]
await importHelper.mockUrl()
// Navigate to the import URL while signed out.
// import.tsx will store the redirect and show the sign-in dialog.
await importHelper.navigate()
await signInDialog.expectInitialElements()
await signInDialog.continueWithEmail(user)
await signInDialog.expectCodeStageVisible()
await signInDialog.fillCode('424242')
// After sign-in, the import flow runs and navigates to the new file.
await page.waitForURL(/\/f\//, { timeout: 20000 })
await editor.isLoaded()
})
})
// ---------------------------------------------------------------- download --
test.describe('download', () => {
test.beforeEach(async ({ editor }) => {
await editor.isLoaded()
})
test('can download a file that is not currently open from the sidebar', async ({
page,
sidebar,
editor,
}) => {
await editor.ensureSidebarOpen()
// Create a second file so the sidebar has two entries.
await sidebar.createNewDocument('download-test-secondary')
// Navigate to the older file (index 1 = the one that was open originally).
await sidebar.getFileLink('today', 1).click()
await editor.isLoaded()
// Download the newer file (index 0) which is not currently open.
const fileLink = sidebar.getFileLink('today', 0)
await fileLink.hover()
await fileLink.getByRole('button').click()
const downloadPromise = page.waitForEvent('download')
await page.getByRole('menuitem', { name: 'Download' }).click()
const download = await downloadPromise
const filePath = await download.path()
expect(filePath).toBeTruthy()
const json = JSON.parse(fs.readFileSync(filePath!, 'utf-8'))
validateTldrJson(json)
expect(download.suggestedFilename()).toContain('download-test-secondary')
expect(download.suggestedFilename()).toMatch(/\.tldr$/)
})
})

View File

@@ -0,0 +1,513 @@
import { OTHER_USERS } from '../../consts'
import { getRandomName, openNewTab } from '../../fixtures/helpers'
import { SignInDialog } from '../../fixtures/SignInDialog'
import { expect, test } from '../../fixtures/tla-test'
// The sidebar has a workspace switcher dropdown at the top (home workspace + the
// user's workspaces + a create item), action rows for the active non-home
// workspace (new board, invite teammates, workspace settings), and the active
// workspace's files below. Switching to a workspace opens its top file — first pinned,
// otherwise most recent (creating one if it's empty) — and creating a
// workspace switches to it.
test.describe('workspaces', () => {
test.beforeEach(async ({ editor }) => {
await editor.isLoaded()
await editor.ensureSidebarOpen()
})
test.describe('basic operations', () => {
test('a workspace can be created from the switcher dropdown', async ({ page, sidebar }) => {
const workspaceName = getRandomName()
await sidebar.createWorkspace(workspaceName)
// creating remains available from the workspace switcher dropdown
await sidebar.openWorkspaceSwitcher()
await expect(page.getByTestId('tla-create-workspace-menu-item')).toBeVisible()
await page.keyboard.press('Escape')
})
test('reopening the switcher right after switching keeps it open', async ({
page,
sidebar,
}) => {
// Regression test for the workspace switcher dismissing itself when reopened
// mid-switch. Selecting a workspace navigates immediately, but the target
// file's canvas loads asynchronously and (deep links are on for the file
// route) rewrites the URL with a `?d=` param once its editor mounts. As the
// incoming canvas mounts it steals focus, and that focus shift used to dismiss
// the just-reopened switcher.
const workspaceName = getRandomName()
await sidebar.createWorkspace(workspaceName)
const trigger = page.getByTestId('tla-workspace-switcher')
const homeItem = page.getByTestId('tla-workspace-switcher-home')
// Begin switching back to Home; the selection closes the menu.
await sidebar.openWorkspaceSwitcher()
await homeItem.click()
await expect(homeItem).toBeHidden()
// Reopen immediately, before the incoming canvas has finished loading. Use a
// single click, not sidebar.openWorkspaceSwitcher(), whose retry loop would
// mask a dismissal by simply reopening the menu.
await trigger.click()
await expect(homeItem).toBeVisible()
// The switcher must stay open while the incoming canvas finishes loading,
// rewrites the URL with `?d=`, and steals focus — that focus steal is what
// used to dismiss it.
await page.waitForURL(/[?&]d=/, { timeout: 15000 })
await sidebar.expectActiveHomeWorkspace()
await expect(homeItem).toBeVisible()
})
test('clicking a file while the switcher is open closes it and opens that file', async ({
page,
sidebar,
}) => {
const file1 = getRandomName()
const file2 = getRandomName()
const file3 = getRandomName()
await sidebar.createNewDocument(file1)
await sidebar.createNewDocument(file2)
await sidebar.createNewDocument(file3)
await sidebar.expectFileActive(file3)
await sidebar.openWorkspaceSwitcher()
const homeItem = page.getByTestId('tla-workspace-switcher-home')
await expect(homeItem).toBeVisible()
// Clicking a file link while the switcher is open should both dismiss the switcher
// and open that file — letting the click fall through to the sidebar is intended.
const menuBox = await page.locator('[role="menu"]').boundingBox()
if (!menuBox) throw new Error('Missing workspace switcher menu')
const target = await page.evaluate((menuBottom) => {
const links = Array.from(
document.querySelectorAll<HTMLElement>('[data-element="file-link"]')
)
for (const link of links) {
if (link.dataset.active === 'true') continue
const rect = link.getBoundingClientRect()
// Pick a link below the open menu so the click lands on the link, not the menu.
if (rect.top < menuBottom) continue
return {
name: link.querySelector('[data-testid*="-name"]')?.textContent?.trim() ?? '',
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
}
}
throw new Error('Could not find a non-active file link below the switcher menu')
}, menuBox.y + menuBox.height)
await page.mouse.click(target.x, target.y)
await expect(homeItem).toBeHidden()
await sidebar.expectFileActive(target.name)
})
test('closing the mobile sidebar closes open sidebar menus', async ({ page, sidebar }) => {
const fileName = getRandomName()
await sidebar.createNewDocument(fileName)
await page.setViewportSize({ width: 390, height: 844 })
const mobileToggle = page.getByTestId('tla-sidebar-toggle-mobile')
const mobileOverlay = page.getByTestId('tla-sidebar-overlay-mobile')
const closeMobileSidebar = async () => {
await mobileOverlay.click({ position: { x: 380, y: 422 } })
await expect(mobileOverlay).toBeHidden()
}
await expect(mobileToggle).toBeVisible()
await mobileToggle.click()
await expect(mobileOverlay).toBeVisible()
// The sidebar only CSS-hides (it doesn't unmount), so an open menu would otherwise
// stay open behind it. Closing the mobile sidebar must close the workspace switcher...
await sidebar.openWorkspaceSwitcher()
await expect(page.getByTestId('tla-workspace-switcher-home')).toBeVisible()
await closeMobileSidebar()
await expect(page.getByTestId('tla-workspace-switcher-home')).toBeHidden()
// ...and a file's context menu.
await mobileToggle.click()
await expect(mobileOverlay).toBeVisible()
const fileLink = sidebar.getFileByName(fileName)
await fileLink.hover()
await fileLink.getByRole('button').click()
await expect(page.getByRole('menuitem', { name: 'Rename' })).toBeVisible()
await closeMobileSidebar()
await expect(page.getByRole('menuitem', { name: 'Rename' })).toBeHidden()
})
test('clicking the canvas closes the workspace switcher without reaching the canvas', async ({
page,
sidebar,
}) => {
const shapeId = await page.evaluate(() => {
const editor = (window as any).editor
editor.createShapes([
{
type: 'geo',
x: 100,
y: 100,
props: { geo: 'rectangle', w: 100, h: 100 },
},
])
const shape = editor.getCurrentPageShapes()[0]
editor.select(shape.id)
return shape.id
})
await sidebar.openWorkspaceSwitcher()
const homeItem = page.getByTestId('tla-workspace-switcher-home')
await expect(homeItem).toBeVisible()
await page.mouse.click(600, 300)
await expect(homeItem).toBeHidden()
expect(
await page.evaluate(() => {
const editor = (window as any).editor
return editor.getSelectedShapeIds()
})
).toEqual([shapeId])
})
test('create file button creates in the active workspace', async ({ sidebar }) => {
const workspaceName = getRandomName()
const file1 = getRandomName()
const file2 = getRandomName()
const homeFileName = getRandomName()
await sidebar.createNewDocument(file1)
await sidebar.createNewDocument(homeFileName)
await sidebar.createWorkspace(workspaceName)
await sidebar.switchToHomeWorkspace()
await sidebar.moveFileToWorkspace(file1, workspaceName)
await sidebar.switchToWorkspace(workspaceName)
await sidebar.createNewDocument(file2)
await sidebar.expectActiveWorkspace(workspaceName)
const filesInWorkspace = await sidebar.getVisibleFiles()
expect(filesInWorkspace).toContain(file1)
expect(filesInWorkspace).toContain(file2)
await sidebar.switchToHomeWorkspace()
await sidebar.expectFileNotVisible(file2)
await sidebar.expectFileVisible(homeFileName)
})
})
test.describe('file operations', () => {
test('move file between workspaces via the menu', async ({ page, sidebar, editor }) => {
const workspace1 = getRandomName()
const workspace2 = getRandomName()
const file1 = getRandomName()
const file2 = getRandomName()
const homeFileName = getRandomName()
await sidebar.createNewDocument(file1)
await sidebar.createNewDocument(file2)
await sidebar.createNewDocument(homeFileName)
await sidebar.createWorkspace(workspace1)
await sidebar.createWorkspace(workspace2)
await sidebar.switchToHomeWorkspace()
await sidebar.moveFileToWorkspace(file1, workspace1)
await sidebar.moveFileToWorkspace(file2, workspace1)
await sidebar.switchToWorkspace(workspace1)
// make sure the file we move is not the active one
await sidebar.getFileByName(file2).click()
await sidebar.expectFileActive(file2)
await sidebar.moveFileToWorkspace(file1, workspace2)
await sidebar.expectFileNotVisible(file1)
await sidebar.expectFileVisible(file2)
await sidebar.switchToWorkspace(workspace2)
await sidebar.expectFileVisible(file1)
await page.reload()
await editor.isLoaded()
await editor.ensureSidebarOpen()
await sidebar.switchToWorkspace(workspace2)
await sidebar.expectFileVisible(file1)
await sidebar.switchToWorkspace(workspace1)
await sidebar.expectFileVisible(file2)
await sidebar.expectFileNotVisible(file1)
})
test('move file to Home', async ({ sidebar }) => {
const workspaceName = getRandomName()
const file1 = getRandomName()
const file2 = getRandomName()
const homeFileName = getRandomName()
await sidebar.createNewDocument(file1)
await sidebar.createNewDocument(file2)
await sidebar.createNewDocument(homeFileName)
await sidebar.createWorkspace(workspaceName)
await sidebar.switchToHomeWorkspace()
await sidebar.moveFileToWorkspace(file1, workspaceName)
await sidebar.moveFileToWorkspace(file2, workspaceName)
await sidebar.switchToWorkspace(workspaceName)
// make sure the file we move is not the active one
await sidebar.getFileByName(file2).click()
await sidebar.expectFileActive(file2)
await sidebar.moveFileToHome(file1)
await sidebar.expectFileNotVisible(file1)
await sidebar.expectFileVisible(file2)
await sidebar.switchToHomeWorkspace()
await sidebar.expectFileVisible(file1)
await sidebar.expectFileVisible(homeFileName)
})
test('move to menu is a checklist of every workspace', async ({ page, sidebar }) => {
const workspace1 = getRandomName()
const workspace2 = getRandomName()
const file1 = getRandomName()
await sidebar.createNewDocument(file1)
await sidebar.createWorkspace(workspace1)
await sidebar.createWorkspace(workspace2)
await sidebar.switchToHomeWorkspace()
const homeWorkspaceName = await sidebar.getHomeWorkspaceName()
// In Home: the file lives in the home workspace, which is checked; every other
// workspace is offered unchecked (an unchecked item's accessible name is
// just its label, so exact matching works for those).
await sidebar.openMoveToMenu(file1)
await expect(
page.getByRole('menuitemcheckbox', { name: homeWorkspaceName, checked: true })
).toBeVisible()
await expect(
page.getByRole('menuitemcheckbox', { name: workspace1, exact: true })
).toBeVisible()
await expect(
page.getByRole('menuitemcheckbox', { name: workspace2, exact: true })
).toBeVisible()
await sidebar.closeMoveToMenu()
await sidebar.moveFileToWorkspace(file1, workspace1)
// In workspace1: workspace1 is now the checked item; the home workspace and the
// other workspace are offered unchecked.
await sidebar.switchToWorkspace(workspace1)
await sidebar.openMoveToMenu(file1)
await expect(
page.getByRole('menuitemcheckbox', { name: workspace1, checked: true })
).toBeVisible()
await expect(
page.getByRole('menuitemcheckbox', { name: homeWorkspaceName, exact: true })
).toBeVisible()
await expect(
page.getByRole('menuitemcheckbox', { name: workspace2, exact: true })
).toBeVisible()
await sidebar.closeMoveToMenu()
})
test('pin and unpin files', async ({ page, sidebar, editor }) => {
const file1 = getRandomName()
const file2 = getRandomName()
const file3 = getRandomName()
await sidebar.createNewDocument(file1)
await sidebar.createNewDocument(file2)
await sidebar.createNewDocument(file3)
await sidebar.expectFileNotPinned(file1)
await sidebar.expectFileNotPinned(file2)
await sidebar.expectFileNotPinned(file3)
await sidebar.pinFile(file2)
await sidebar.expectFilePinned(file2)
let fileOrder = await sidebar.getVisibleFiles()
expect(fileOrder[0]).toBe(file2)
await sidebar.pinFile(file1)
await sidebar.expectFilePinned(file1)
fileOrder = await sidebar.getVisibleFiles()
const file1Index = fileOrder.indexOf(file1)
const file2Index = fileOrder.indexOf(file2)
const file3Index = fileOrder.indexOf(file3)
expect(file1Index).toBeLessThan(file3Index)
expect(file2Index).toBeLessThan(file3Index)
await sidebar.unpinFile(file2)
await sidebar.expectFileNotPinned(file2)
await sidebar.expectFilePinned(file1)
await page.reload()
await editor.isLoaded()
await editor.ensureSidebarOpen()
await sidebar.expectFilePinned(file1)
await sidebar.expectFileNotPinned(file2)
await sidebar.expectFileNotPinned(file3)
fileOrder = await sidebar.getVisibleFiles()
expect(fileOrder[0]).toBe(file1)
})
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)
// 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.expectFilePinned(file1)
})
test('deleting the active file in a workspace stays in the workspace', async ({
sidebar,
deleteFileDialog,
}) => {
const workspaceName = getRandomName()
const file1 = getRandomName()
const file2 = getRandomName()
const homeFileName = getRandomName()
await sidebar.createNewDocument(file1)
await sidebar.createNewDocument(file2)
await sidebar.createNewDocument(homeFileName)
await sidebar.createWorkspace(workspaceName)
await sidebar.switchToHomeWorkspace()
await sidebar.moveFileToWorkspace(file1, workspaceName)
await sidebar.moveFileToWorkspace(file2, workspaceName)
await sidebar.switchToWorkspace(workspaceName)
await sidebar.getFileByName(file2).click()
await sidebar.expectFileActive(file2)
await sidebar.deleteFileByName(file2)
await deleteFileDialog.expectIsVisible()
await deleteFileDialog.confirmDeletion()
await deleteFileDialog.expectIsNotVisible()
// We should land on the workspace's remaining file, not jump to the home workspace.
await sidebar.expectFileNotVisible(file2)
await sidebar.expectActiveWorkspace(workspaceName)
await sidebar.expectFileActive(file1)
})
})
test.describe('sharing', () => {
test.beforeEach(async ({ context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
})
test('invite already member user to workspace', async ({ sidebar, browser }) => {
const workspaceName = getRandomName()
const fileName = getRandomName()
const homeFileName = getRandomName()
await sidebar.createNewDocument(fileName)
await sidebar.createNewDocument(homeFileName)
await sidebar.createWorkspace(workspaceName)
await sidebar.switchToHomeWorkspace()
await sidebar.moveFileToWorkspace(fileName, workspaceName)
const inviteUrl = await sidebar.copyWorkspaceInviteLink(workspaceName)
const parallelIndex = test.info().parallelIndex
const { newSidebar, newEditor, newWorkspaceInviteDialog, newContext } = await openNewTab(
browser,
{
url: inviteUrl,
allowClipboard: true,
userProps: { user: 'suppy', index: parallelIndex },
}
)
await newEditor.isLoaded()
await newEditor.ensureSidebarOpen()
await newWorkspaceInviteDialog.acceptInvitation()
await newSidebar.expectWorkspaceVisible(workspaceName)
await newSidebar.expectFileVisible(fileName)
// Visiting the same invite link again lands the member back in the workspace
const newTab = await openNewTab(browser, {
url: inviteUrl,
allowClipboard: true,
userProps: { user: 'suppy', index: parallelIndex },
})
await newTab.newEditor.isLoaded()
await newTab.newEditor.ensureSidebarOpen()
await newTab.newSidebar.expectWorkspaceVisible(workspaceName)
await newTab.newSidebar.expectFileVisible(fileName)
await newTab.newContext.close()
await newContext.close()
})
test('signing in through the invite dialog completes the workspace join', async ({
sidebar,
browser,
}) => {
const workspaceName = getRandomName()
const fileName = getRandomName()
const homeFileName = getRandomName()
await sidebar.createNewDocument(fileName)
await sidebar.createNewDocument(homeFileName)
await sidebar.createWorkspace(workspaceName)
await sidebar.switchToWorkspace('Home')
await sidebar.moveFileToWorkspace(fileName, workspaceName)
const inviteUrl = await sidebar.copyWorkspaceInviteLink(workspaceName)
const parallelIndex = test.info().parallelIndex
const { newPage, newSidebar, newEditor, newWorkspaceInviteDialog, newContext } =
await openNewTab(browser, {
url: inviteUrl,
allowClipboard: true,
userProps: undefined,
})
// Signed out, the invite link shows the sign-in dialog naming the
// workspace. Sign in through it: the invite token survives the dialog, so
// the signed-in invite flow picks it up and offers the join afterwards.
await expect(newPage.locator('strong', { hasText: workspaceName })).toBeVisible()
const signInDialog = new SignInDialog(newPage)
await signInDialog.continueWithEmail(OTHER_USERS[parallelIndex])
await signInDialog.expectCodeStageVisible()
await signInDialog.fillCode('424242')
await newEditor.isLoaded()
await newEditor.ensureSidebarOpen()
await newWorkspaceInviteDialog.acceptInvitation()
await newSidebar.expectWorkspaceVisible(workspaceName)
await newSidebar.expectFileVisible(fileName)
await newContext.close()
})
})
})

View File

@@ -0,0 +1,38 @@
import { existsSync, readFileSync } from 'fs'
import { join } from 'path'
import { expectBeforeAndAfterReload } from '../fixtures/helpers'
import { expect, test } from '../fixtures/staging-test'
const testFilesPath = join(__dirname, '../fixtures/test-files.json')
let testFiles: string[] = []
if (existsSync(testFilesPath)) {
try {
testFiles = JSON.parse(readFileSync(testFilesPath, 'utf-8')) as string[]
} catch (error) {
console.warn('Failed to read test-files.json:', error)
testFiles = []
}
}
testFiles.forEach((fileId, _index) => {
const fileIdSuffix = fileId.replace('test_', '').substring(0, 4)
test(`should load test room ${fileIdSuffix} without errors and be able to edit it`, async ({
page,
editor,
}) => {
await page.goto(`https://staging.tldraw.com/f/${fileId}`)
await expect(page).toHaveURL(new RegExp(`.*/f/${fileId}`))
await expect(page.getByTestId('tla-file-name')).toBeVisible({ timeout: 10000 })
await expect(page.getByText('Something went wrong')).not.toBeVisible()
const initialShapeCount = await editor.getShapeCount()
await page.getByTestId('tools.rectangle').click()
await page.locator('.tl-background').click({ force: true })
await expectBeforeAndAfterReload(async () => {
await editor.expectShapesCount(initialShapeCount + 1)
}, page)
})
})

View File

@@ -0,0 +1,379 @@
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)
})
})

View File

@@ -0,0 +1,193 @@
import { test } from '../fixtures/scenario-test'
// Placeholder coverage for the scenarios described in apps/dotcom/workspaces-feature.md.
//
// These are intentionally skipped rather than empty passing tests. Each body names the
// behavior that should be asserted when the scenario is implemented.
test.describe.configure({ mode: 'parallel', timeout: 60_000 })
test.describe('workspace feature coverage placeholders', () => {
test.describe('workspace identity and settings', () => {
test.fixme('renames the home workspace without exposing team controls', async () => {
throw new Error(
'TODO: Rename the home workspace, assert the active file stays open, and assert invite, leave, delete, and member-management controls stay unavailable.'
)
})
test.fixme('syncs a non-home workspace rename to another member', async () => {
throw new Error(
'TODO: Have an owner rename a workspace while another member is active, then assert the member sees the new workspace label without navigation.'
)
})
test.fixme('prevents the last owner from leaving, being demoted, or being removed', async () => {
throw new Error(
'TODO: Exercise leave, role-change, and remove-member paths for the final owner and assert the workspace keeps at least one owner.'
)
})
test.fixme('demotes an owner to member and removes owner-only controls after sync', async () => {
throw new Error(
'TODO: Promote a member, demote them back to member, and assert rename, member-management, and delete-workspace controls disappear while file access remains.'
)
})
})
test.describe('workspace invites', () => {
test.fixme('lets a regular member regenerate the invite link', async () => {
throw new Error(
'TODO: Open settings as a non-owner member, regenerate the invite, and assert the member stays in the workspace with invite management available.'
)
})
test.fixme('rejects an old invite link after invite regeneration', async () => {
throw new Error(
'TODO: Capture an invite URL, regenerate the workspace invite, open the old URL as another user, and assert the expired-invite path appears.'
)
})
test.fixme('prompts signed-out invitees to sign in before accepting a workspace invite', async () => {
throw new Error(
'TODO: Open an invite URL signed out, complete sign-in and legal acceptance if needed, then accept and assert member-role access.'
)
})
})
test.describe('workspace file list sync', () => {
test.fixme('shows a file another member creates in the active workspace', async () => {
throw new Error(
'TODO: Keep one member active in a workspace, create a file from another member, and assert the viewer sees it without being navigated away.'
)
})
test.fixme('syncs another member file rename to the sidebar and document metadata', async () => {
throw new Error(
'TODO: Rename a shared workspace file from one member and assert another member sees the sidebar label and editor file title update.'
)
})
test.fixme('moves a file away before another member opens it', async () => {
throw new Error(
'TODO: Move a workspace file before another member opens it, covering both destination-member and non-destination-member outcomes.'
)
})
})
test.describe('moving open workspace files', () => {
test.fixme('keeps an open file connected when moved to another workspace the viewer belongs to', async () => {
throw new Error(
"TODO: Move the viewer's open file to a second shared workspace and assert the canvas stays editable, active workspace updates, and the file appears in the destination list."
)
})
test.fixme('falls back to home when an open file moves to a workspace the viewer does not belong to', async () => {
throw new Error(
"TODO: Move the viewer's open shared-edit file to a workspace they cannot see, then assert home is active while the canvas remains editable as a guest."
)
})
test.fixme('reconnects as read-only when an open file moves away with view-only sharing', async () => {
throw new Error(
"TODO: Move the viewer's open file to an unseen workspace with shared-link view access and assert editing controls are removed."
)
})
test.fixme('closes with forbidden when an open file moves away and is private', async () => {
throw new Error(
'TODO: Make the open file private, move it to an unseen workspace, and assert the viewer is disconnected with the private-file error.'
)
})
test.fixme('links a moved shared file into home after a later direct-link visit', async () => {
throw new Error(
'TODO: After a shared file moves to an unseen workspace, revisit its direct link and assert it appears as a home-workspace linked guest file.'
)
})
})
test.describe('removing files and workspaces', () => {
test.fixme("closes another member's open file with not found when the file is deleted", async () => {
throw new Error(
'TODO: Delete a workspace-owned file while another member has it open, then assert the open session moves to the not-found state.'
)
})
test.fixme('removes a linked file from a workspace without deleting the owned file', async () => {
throw new Error(
'TODO: Create a linked file entry, remove it from the linked workspace, and assert the owning workspace and direct shared access still work.'
)
})
test.fixme("deletes a workspace and closes members' open owned files with not found", async () => {
throw new Error(
'TODO: Delete a workspace while another member has an owned file open, then assert the workspace disappears and the open canvas gets not found.'
)
})
test.fixme('deletes a workspace without deleting files only linked into it', async () => {
throw new Error(
'TODO: Delete a workspace containing a linked file and assert the original file remains available from its owning workspace or shared link.'
)
})
})
test.describe('membership removal and leaving', () => {
test.fixme('revokes private open-file access when the current user is removed from a workspace', async () => {
throw new Error(
'TODO: Remove a member while they have a private workspace file open and assert the workspace disappears and the file shows forbidden.'
)
})
test.fixme('keeps shared-edit open-file access after the current user is removed from a workspace', async () => {
throw new Error(
'TODO: Remove a member while they have a shared-edit workspace file open and assert the workspace disappears while guest editing remains available.'
)
})
test.fixme('downgrades to read-only after the current user is removed from a workspace with view sharing', async () => {
throw new Error(
'TODO: Remove a member while they have a shared-view workspace file open and assert the workspace disappears while the canvas becomes read-only.'
)
})
test.fixme('syncs a leave-workspace membership loss to another open tab', async () => {
throw new Error(
'TODO: Leave a workspace in one tab and assert another tab for the same user loses private file access or continues as a guest for shared files.'
)
})
})
test.describe('sharing changes on workspace files', () => {
test.fixme('keeps workspace members connected when a file is unshared for guests', async () => {
throw new Error(
'TODO: Open a workspace file as both a member and a guest, unshare it, then assert the guest loses access while the member remains editable.'
)
})
test.fixme('keeps workspace members editable when a shared file changes from edit to view', async () => {
throw new Error(
'TODO: Open a workspace file as a member and a non-member guest, switch link access from edit to view, and assert only the guest becomes read-only.'
)
})
})
test.describe('workspace imports', () => {
test.fixme("imports a dropped canvas .tldr file into the current file's owning workspace", async () => {
throw new Error(
'TODO: Drop a .tldr file on the canvas while active in a non-home workspace and assert the imported file is created in that workspace.'
)
})
test.fixme('imports a sidebar-dropped .tldr file into home while viewing another workspace', async () => {
throw new Error(
'TODO: Drop a .tldr file on the sidebar while viewing a non-home workspace and assert the imported file appears in home, not the active workspace.'
)
})
test.fixme('imports a main-menu .tldr file into home while viewing another workspace', async () => {
throw new Error(
'TODO: Import a .tldr file from the main menu while viewing a non-home workspace and assert the imported file appears in home.'
)
})
})
})