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,32 @@
# Dotcom E2E conventions
Issue #9185 is moving dotcom tests toward user scenarios instead of isolated UI smoke tests. New collaborative dotcom coverage should prefer the scenario fixture in `fixtures/scenario-test.ts`.
## Scenario tests
- Name scenario files `*.scenario.spec.ts`. They run in the `chromium-scenarios` project with `fullyParallel: true`.
- Run the ordinary local/CI dotcom suite with `yarn e2e-dotcom` from the repo root, or `yarn workspace dotcom e2e` from this workspace. This runs the scenario project only.
- Run only the scenario suite without clearing stored auth with `yarn workspace dotcom e2e-scenarios`.
- Run the preserved legacy smoke suite with `yarn e2e-dotcom-smoke` from the repo root, or `yarn workspace dotcom e2e-smoke` from this workspace. These specs live in `tests/smoke`, still use the reset-based `chromium` project, and are intentionally separate from the default runner. They do not run on CI; migrate coverage out of `tests/smoke` into scenario tests as the follow-ups below are unblocked.
- Run both projects together with `yarn e2e-dotcom-all` from the repo root, or `yarn workspace dotcom e2e-all` from this workspace, when comparing old and new coverage.
- Use named actors from the fixture: `owner`, `member`, and `visitor`.
- Use `scenario.name('label')` for files and workspaces so test data is unique to the run.
- Do not reset the whole database or shared users in scenario tests. Stable Clerk test accounts are reused by worker, while files and workspaces are namespaced by scenario.
- Scenario actors use later Clerk test accounts than the legacy `chromium` project so explicit all-project runs can share a command while legacy tests still reset their own users.
- Actor contexts and pages are owned by the fixture and closed automatically.
- Use `scenario.createPersonalFile`, `scenario.createSharedFile`, `scenario.createGuestEditFile`, `scenario.createGuestViewFile`, `scenario.createPublishedFile`, `scenario.importFileFromUrl`, `scenario.downloadFileFromSidebar`, `scenario.setSharedLinkType`, `scenario.createWorkspaceWithMember`, and `scenario.createWorkspaceWithRemovedMember` for common setup.
- Use `scenario.createLegacyRouteFixture` for legacy `/r`, `/ro`, `/v`, `/s`, and history route setup. It creates namespaced debug-only worker data instead of relying on shared production-like fixtures.
- Use direct database setup only for preconditions that are expensive or impossible through the UI, such as enabling workspace flags. Keep the behavior under test in UI actions.
- Prefer live assertions across already-open windows. Reload-based checks are still useful for persistence, but they should not be the only proof for collaboration, permission, or membership behavior.
- Wait for readiness through actor helpers and page objects. `actor.goto` waits for the canvas, auth state, app store hydration when a signed-in app is present, mounted editor, file room connection, and visitor access metadata. Use the narrower `actor.waitForAuthLoaded`, `actor.waitForAppStoreHydrated`, `actor.waitForEditorReady`, `actor.waitForFileRoomConnected`, `actor.waitForVisitorAccessMetadata`, and `actor.waitForSessionClosed` helpers when a test needs to prove a specific transition. Avoid new sleeps unless the product intentionally waits.
## Auth follow-ups
The remaining auth edge cases are still tracked by issue #9185. Keep them out of the scenario project until they have Clerk-level mocking or stable account state that can run without per-test shared-user reset.
Follow-up migration groups:
- Auth legal acceptance and analytics consent: replace route-level Clerk response patching with a stable Clerk test account state or a mocked Clerk client boundary.
- Auth verification and resend behavior: isolate Clerk network failures, malformed responses, cooldown behavior, and code input behavior without depending on shared signed-in user cleanup.
- OAuth auth flows: add a Clerk/OAuth boundary that can exercise Google sign-in and legal acceptance without external redirects.
- Auth response edge cases: cover completed sessions, inconsistent `missing_fields` payloads, and missing email-code factor data once Clerk responses can be mocked below the route layer.

View File

@@ -0,0 +1,7 @@
export const NUMBER_OF_USERS = 8
export const USERS = Array.from({ length: NUMBER_OF_USERS }).map(
(_, i) => `huppy+clerk_test${i + 1}@tldraw.com`
)
export const OTHER_USERS = Array.from({ length: NUMBER_OF_USERS }).map(
(_, i) => `suppy+clerk_test${i + 1}@tldraw.com`
)

View File

@@ -0,0 +1,68 @@
import fs from 'fs'
import { Page } from '@playwright/test'
import { DB } from '@tldraw/dotcom-shared'
import { Kysely, PostgresDialect, sql } from 'kysely'
import pg from 'pg'
import { OTHER_USERS, USERS } from '../consts'
import { getStorageStateFileName } from './helpers'
const db = new Kysely<DB>({
dialect: new PostgresDialect({
pool: new pg.Pool({
connectionString: 'postgresql://user:password@127.0.0.1:6432/postgres',
application_name: 'migrate',
idleTimeoutMillis: 10_000,
max: 10,
}),
}),
log: ['error'],
})
export class Database {
constructor(
readonly page: Page | null,
private parallelIndex: number
) {}
async reset() {
await this.cleanUpUser(true)
await this.cleanUpUser(false)
}
getEmail(isOther: boolean = false) {
return getTestUserEmail(this.parallelIndex, isOther ? 'suppy' : 'huppy')
}
async getUserId(isOther: boolean = false) {
return await this.getUserIdByEmail(this.getEmail(isOther))
}
async getUserIdByEmail(email: string) {
const dbUser = await sql<{
id: string
}>`SELECT id FROM public.user WHERE email = ${email ?? ''}`.execute(db)
if (!dbUser.rows[0]) return
return dbUser.rows[0].id
}
private async cleanUpUser(isOther: boolean) {
const fileName = getStorageStateFileName(this.parallelIndex, isOther ? 'suppy' : 'huppy')
if (!fs.existsSync(fileName)) return
const id = await this.getUserId(isOther)
if (!id) return
try {
// eslint-disable-next-line no-restricted-globals
await fetch(`http://localhost:3000/api/app/__test__/user/${id}/prepare-for-test`, {
method: 'POST',
})
} catch (e) {
console.error('Error', e)
}
}
}
export type TestUser = 'huppy' | 'suppy'
export function getTestUserEmail(index: number, user: TestUser) {
return user === 'suppy' ? OTHER_USERS[index] : USERS[index]
}

View File

@@ -0,0 +1,34 @@
import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
export class DeleteFileDialog {
public readonly dialog: Locator
public readonly confirmButton: Locator
public readonly cancelButton: Locator
constructor(public readonly page: Page) {
this.dialog = this.page.getByRole('dialog', { name: 'Delete file' })
this.confirmButton = this.dialog.getByRole('button', { name: 'Delete' })
this.cancelButton = this.dialog.getByRole('button', { name: 'Cancel' })
}
async isVisible() {
return this.dialog.isVisible()
}
async expectIsVisible() {
await expect(this.dialog).toBeVisible()
}
async expectIsNotVisible() {
await expect(this.dialog).not.toBeVisible()
}
async confirmDeletion() {
await this.confirmButton.click()
}
async cancelDeletion() {
await this.cancelButton.click()
}
}

View File

@@ -0,0 +1,95 @@
import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import { sleep } from './helpers'
import { Sidebar } from './Sidebar'
import { step } from './tla-test'
export class Editor {
public readonly sidebarToggle: Locator
private readonly fileName: Locator
private readonly shapes: Locator
private readonly pageMenu: Locator
constructor(
public readonly page: Page,
private readonly sidebar: Sidebar
) {
this.sidebarToggle = this.page.getByTestId('tla-sidebar-toggle')
this.fileName = this.page.getByTestId('tla-file-name')
this.shapes = this.page.locator('.tl-shape')
this.pageMenu = this.page.getByTestId('tla-main-menu')
}
async toggleSidebar() {
await this.sidebarToggle.click()
await sleep(500)
}
@step
async ensureSidebarOpen() {
const visible = await this.sidebar.isVisible()
if (!visible) {
await this.toggleSidebar()
}
await this.sidebar.expectIsVisible()
}
@step
async ensureSidebarClosed() {
const visible = await this.sidebar.isVisible()
if (visible) {
await this.toggleSidebar()
}
await this.sidebar.expectIsNotVisible()
}
async isLoaded() {
await expect(this.sidebarToggle).toBeVisible()
}
async getShapeCount() {
return await this.shapes.count()
}
@step
async expectShapesCount(expected: number, timeout?: number) {
await expect(this.shapes).toHaveCount(expected, { timeout })
}
async getCurrentFileName() {
const text = await this.fileName.innerText()
// The UI replaces spaces with \u00a0 to prevent line-breaking; normalise back.
return text.replace(/\u00a0/g, ' ')
}
@step
async rename(newName: string) {
await this.fileName.click()
await this.page.getByTestId('tla-file-name-input').fill(newName)
await this.page.keyboard.press('Enter')
}
@step
async openPageMenu() {
await this.pageMenu.click()
}
@step
async createNewPage() {
await this.page.getByTestId('page-menu.button').click()
await expect(this.page.getByTestId('page-menu.create')).toBeVisible()
await expect(this.page.getByTestId('page-menu.item').first()).toBeVisible()
const count = await this.page.getByTestId('page-menu.item').count()
await this.page.getByTestId('page-menu.create').click()
await expect(this.page.getByTestId('page-menu.item')).toHaveCount(count + 1)
await this.page.keyboard.press('Enter')
await this.page.keyboard.press('Escape')
}
@step
async createTextShape(text: string) {
await this.page.getByTestId('tools.select').click()
await this.page.locator('.tl-background').click({ clickCount: 2 })
await this.page.locator('div[contenteditable="true"]').fill(text)
}
}

View File

@@ -0,0 +1,30 @@
import { Locator, Page } from '@playwright/test'
import { expect } from '../fixtures/tla-test'
export class ErrorPage {
public readonly notFound: Locator
public readonly pageNotFound: Locator
public readonly privateFile: Locator
constructor(public readonly page: Page) {
this.notFound = this.page.getByRole('heading', { name: 'Not found' })
this.pageNotFound = this.page.getByRole('heading', { name: 'Page not found' })
this.privateFile = this.page.getByRole('heading', { name: 'Private file' })
}
async expectNotFoundVisible() {
await expect(this.notFound).toBeVisible()
}
async expectNotFoundNotVisible() {
await expect(this.notFound).not.toBeVisible()
}
async expectPageNotFoundVisible() {
await expect(this.pageNotFound).toBeVisible()
}
async expectPrivateFileVisible() {
await expect(this.privateFile).toBeVisible()
}
}

View File

@@ -0,0 +1,103 @@
import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import { Editor } from './Editor'
import { step } from './tla-test'
const rootUrl = 'http://localhost:3000/'
export class HomePage {
public readonly signInButton: Locator
public readonly tldrawEditor: Locator
public readonly tldrawCanvas: Locator
constructor(
private readonly page: Page,
private readonly editor: Editor
) {
this.signInButton = this.page.getByTestId('tla-sign-in-button')
this.tldrawEditor = this.page.getByTestId('tla-editor')
this.tldrawCanvas = this.page.getByTestId('canvas')
}
@step
async loginAs(email: string) {
if (this.page.url() !== rootUrl) {
await this.goto()
}
await this.page.waitForLoadState('domcontentloaded')
const isSideBarToggleVisible =
(await this.editor.sidebarToggle.isVisible().catch(() => false)) ?? false
if (isSideBarToggleVisible) return
await expect(this.signInButton).toBeVisible()
await this.signInButton.click({ force: true })
await this.page.getByTestId('tla-identifier-input').fill(email)
await this.page.getByTestId('tla-continue-with-email-button').click()
// will go to
await this.page.getByTestId('tla-verification-code-input').fill('424242')
// Wait till we're on a file page, e.g. /f/:someId
await this.page.waitForURL(new RegExp(`${rootUrl}f/.*`))
await expect(async () => {
await expect(this.page.getByTestId('tla-sidebar-toggle')).toBeVisible()
}).toPass()
}
async loginWithEmailAndPassword(email: string, password: string) {
const isSideBarToggleVisible = await this.editor.sidebarToggle.isVisible()
// We are already signed in
if (isSideBarToggleVisible) return
await expect(this.signInButton).toBeVisible()
await this.signInButton.click()
await this.page.getByLabel('Email address').fill(email)
// Note: This method is for staging/production Clerk flows that use passwords
// The password field appears after clicking continue on email
await this.page.getByTestId('tla-continue-with-email-button').click()
await this.page.waitForTimeout(500)
await this.page.getByRole('textbox', { name: 'Password' }).fill(password)
// After entering password, look for any Continue button (Clerk's default buttons don't have test IDs)
await this.page.getByRole('button', { name: 'Continue', exact: true }).click()
await this.page.waitForTimeout(1000)
await this.handleTermsIfNeeded()
await expect(async () => {
await expect(this.page.getByTestId('tla-sidebar-toggle')).toBeVisible()
}).toPass()
}
private async handleTermsIfNeeded() {
const acceptAndContinueButton = this.page.getByTestId('tla-accept-and-continue-button')
// Wait a bit for the dialog to potentially appear
await this.page.waitForTimeout(500)
if ((await acceptAndContinueButton.count()) === 0) return
await expect(acceptAndContinueButton).toBeVisible()
await expect(acceptAndContinueButton).toBeEnabled()
await acceptAndContinueButton.click()
// Wait for the button click to complete
await this.page.waitForTimeout(500)
}
async expectSignInButtonVisible() {
await expect(async () => {
await expect(this.signInButton).toBeVisible()
}).toPass()
}
async expectSignInButtonNotVisible() {
await expect(async () => {
await expect(this.signInButton).not.toBeVisible()
}).toPass()
}
async goto(url = rootUrl) {
await this.page.goto(url, { waitUntil: 'load' })
}
async isLoaded() {
// Swat away the terms dialog if it appears.
await this.handleTermsIfNeeded()
await expect(async () => {
await expect(this.tldrawEditor).toBeVisible({ timeout: 10000 })
await expect(this.tldrawCanvas).toBeVisible({ timeout: 10000 })
}).toPass()
}
}

View File

@@ -0,0 +1,51 @@
import type { Page } from '@playwright/test'
import { step } from './tla-test'
export const IMPORT_URL = 'https://e2e-mock.tldraw.xyz/test-import.tldr'
// Sequences match the current record versions so no migrations run on import,
// which would otherwise overwrite fields (e.g. AddName sets name='').
const MINIMAL_TLDR_FILE = {
tldrawFileFormatVersion: 1,
schema: {
schemaVersion: 2,
sequences: { 'com.tldraw.document': 2, 'com.tldraw.page': 1 } as Record<string, number>,
},
records: [
{
typeName: 'document',
id: 'document:document',
name: 'e2e import test',
gridSize: 10,
meta: {},
},
{
typeName: 'page',
id: 'page:page',
index: 'a1',
meta: {},
name: 'Page 1',
},
],
}
export class ImportHelper {
constructor(private readonly page: Page) {}
@step
async mockUrl(url = IMPORT_URL) {
await this.page.route(url, (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
headers: { 'Access-Control-Allow-Origin': '*' },
body: JSON.stringify(MINIMAL_TLDR_FILE),
})
)
}
@step
async navigate(url = IMPORT_URL) {
await this.page.goto(`http://localhost:3000/import?url=${encodeURIComponent(url)}`)
}
}

View File

@@ -0,0 +1,169 @@
import type { Locator, Page } from '@playwright/test'
import { expect, step } from './tla-test'
type ShareMenuTab = 'invite' | 'export' | 'publish'
export class ShareMenu {
public readonly shareButton: Locator
public readonly exportTabButton: Locator
public readonly inviteTabButton: Locator
public readonly publishTabButton: Locator
public readonly anonShareTabButton: Locator
public readonly exportTabPage: Locator
public readonly inviteTabPage: Locator
public readonly publishTabPage: Locator
public readonly anonShareTabPage: Locator
public readonly publishChangesButton: Locator
public readonly copyLinkButton: Locator
public readonly exportImageButton: Locator
public readonly tabs: { anonShare: Locator; invite: Locator; export: Locator; publish: Locator }
constructor(public readonly page: Page) {
this.shareButton = this.page.getByTestId('tla-share-button')
this.exportTabButton = this.page.getByTestId('tla-share-tab-button-export')
this.inviteTabButton = this.page.getByTestId('tla-share-tab-button-share')
this.publishTabButton = this.page.getByTestId('tla-share-tab-button-publish')
this.anonShareTabButton = this.page.getByTestId('tla-share-tab-button-anon-share')
this.exportTabPage = this.page.getByTestId('tla-share-tab-page-export')
this.inviteTabPage = this.page.getByTestId('tla-share-tab-page-share')
this.publishTabPage = this.page.getByTestId('tla-share-tab-page-publish')
this.anonShareTabPage = this.page.getByTestId('tla-share-tab-page-anon-share')
this.publishChangesButton = this.page.getByRole('button', {
name: 'Publish changes',
exact: true,
})
this.copyLinkButton = this.page.getByRole('button', { name: 'Copy link' })
this.exportImageButton = this.page.getByRole('button', { name: 'Export image' })
this.tabs = {
anonShare: this.anonShareTabButton,
invite: this.inviteTabButton,
export: this.exportTabButton,
publish: this.publishTabButton,
}
this.shareFile = this.shareFile.bind(this)
this.unshareFile = this.unshareFile.bind(this)
this.publishFile = this.publishFile.bind(this)
this.unpublishFile = this.unpublishFile.bind(this)
}
async open() {
await this.shareButton.click()
}
async isInviteButtonVisible() {
return await this.inviteTabButton.isVisible()
}
async share(tab: ShareMenuTab) {
await this.ensureTabSelected(tab)
if (!(await this.isToggleChecked())) {
await this.page.getByRole('switch').check()
}
}
async unshare(tab: ShareMenuTab) {
await this.ensureTabSelected(tab)
if (await this.isToggleChecked()) {
await this.page.getByRole('switch').uncheck()
}
}
@step
async shareFile() {
await this.share('invite')
}
@step
async unshareFile() {
await this.unshare('invite')
}
@step
async publishFile() {
await this.share('publish')
}
@step
async unpublishFile() {
await this.unshare('publish')
}
async isToggleChecked() {
return await this.page.getByRole('switch').isChecked()
}
@step
async copyLink() {
const sentinel = `__copy_link_pending_${Date.now()}__`
await this.page.evaluate(async (value) => {
await navigator.clipboard.writeText(value)
}, sentinel)
await this.copyLinkButton.click()
let url = ''
await expect(async () => {
url = await this.page.evaluate(async () => await navigator.clipboard.readText())
expect(url).not.toBe(sentinel)
expect(url).toBeTruthy()
const parsed = new URL(url)
expect(parsed.origin).toBe('http://localhost:3000')
// Share menu links should point to file/publish routes, not group invite routes.
expect(parsed.pathname.startsWith('/f/') || parsed.pathname.startsWith('/p/')).toBe(true)
}).toPass({ timeout: 5000 })
return url
}
@step
async openShareMenuAndCopyInviteLink() {
await this.open()
await this.inviteTabButton.click()
await this.ensureTabSelected('invite')
return await this.copyLink()
}
@step
async openShareMenuAndCopyPublishedLink() {
await this.open()
await this.publishTabButton.click()
await this.ensureTabSelected('publish')
return await this.copyLink()
}
@step
async exportFile() {
await this.ensureTabSelected('export')
await this.exportImageButton.click()
}
@step
async isTabSelected(tab: ShareMenuTab) {
const attr = await this.tabs[tab].getAttribute('data-active')
return attr === 'true'
}
@step
async ensureTabSelected(tab: ShareMenuTab) {
if (await this.isTabSelected(tab)) return
const locator = this.tabs[tab]
await locator.click()
}
@step
async publishChanges() {
await this.ensureTabSelected('publish')
await this.publishChangesButton.click()
}
async getShareType() {
return (await this.page.waitForSelector('[data-testid="shared-link-type-select"]'))
.textContent()
.then((text) => text?.trim())
}
}

View File

@@ -0,0 +1,649 @@
import type { Locator, Page } from '@playwright/test'
import { MAX_WORKSPACE_NAME_LENGTH } from '@tldraw/dotcom-shared'
import { expect, step } from './tla-test'
// The createWorkspace/updateWorkspace mutators clamp names to MAX_WORKSPACE_NAME_LENGTH, so the
// stored (and therefore displayed and synced) name diverges from a longer requested name. Scenario
// ids alone run close to that limit, so workspace helpers must match against the clamped form or
// they search for a name the app never renders.
function clampWorkspaceName(name: string) {
return name.trim().slice(0, MAX_WORKSPACE_NAME_LENGTH)
}
// Cross-client Zero sync (accepting an invite, being removed, a workspace being deleted) delivers
// workspace-membership rows asynchronously and with variable latency on a busy CI machine. This is
// the budget for the data-layer wait that actually gates on that sync; it is intentionally generous
// while still bounded, so a genuine sync regression fails with a clear signal rather than hanging.
const WORKSPACE_MEMBERSHIP_SYNC_TIMEOUT = 30_000
export class Sidebar {
public readonly fileLink = '[data-element="file-link"]'
public readonly sidebarLayout: Locator
public readonly sidebar: Locator
public readonly sidebarLogo: Locator
public readonly createFileButton: Locator
public readonly userSettingsMenu: Locator
public readonly helpMenu: Locator
public readonly themeButton: Locator
public readonly darkModeButton: Locator
public readonly signOutButton: Locator
constructor(public readonly page: Page) {
this.sidebarLayout = this.page.getByTestId('tla-sidebar-layout')
this.sidebar = this.page.getByTestId('tla-sidebar')
this.sidebarLogo = this.page.getByTestId('tla-sidebar-logo-icon')
this.createFileButton = this.page.getByTestId('tla-create-file')
this.userSettingsMenu = this.page.getByTestId('tla-sidebar-user-settings-trigger')
this.helpMenu = this.userSettingsMenu
this.themeButton = this.page.getByTestId('dialog-sub.theme-button')
this.darkModeButton = this.page.getByRole('menuitemcheckbox', { name: 'Dark', exact: true })
this.signOutButton = this.page.getByTestId('dialog.sign-out')
}
async isVisible() {
return this.sidebarLogo.isVisible()
}
async expectIsVisible() {
await expect(this.sidebarLogo).toBeInViewport()
}
async expectIsNotVisible() {
await expect(this.sidebarLogo).not.toBeInViewport()
}
async createNewDocument(name?: string) {
const numDocuments = await this.getNumberOfFiles()
const previousUrl = this.page.url()
await this.createFileButton.click()
const input = this.page.getByTestId('tla-sidebar-rename-input')
await expect(input).toBeVisible()
await expect(input).toBeFocused()
if (name) {
await input.fill(name)
}
await Promise.all([
this.page.waitForURL(
(url) => url.toString() !== previousUrl && url.pathname.startsWith('/f/'),
{ timeout: 10000 }
),
this.page.keyboard.press('Enter'),
])
await expect.poll(() => this.getNumberOfFiles()).toBe(numDocuments + 1)
// the create button has a 1000ms throttle - wait so the next creation isn't swallowed
await this.page.waitForTimeout(1100)
}
async getNumberOfFiles() {
return (await this.page.$$(this.fileLink)).length
}
async openUserSettingsMenu() {
await this.userSettingsMenu.hover()
await this.userSettingsMenu.click()
// Wait for the dropdown content to mount before child-item clicks race the open animation.
await expect(this.page.getByRole('menu')).toBeVisible()
}
@step
async setDarkMode() {
await this.openUserSettingsMenu()
await this.themeButton.hover()
await this.darkModeButton.click()
}
@step
async openLanguageMenu(languageButtonText: string) {
// need the editor to be mounted for the menu to be available
await expect(this.page.getByTestId('canvas')).toBeVisible()
await this.openUserSettingsMenu()
await this.page.getByText(languageButtonText).hover()
}
@step
async expectLanguageToBe(languageButtonText: string, language: string) {
await this.openLanguageMenu(languageButtonText)
await expect(async () => {
const checkedAttribute = await this.page
.getByRole('menuitemcheckbox', { name: language })
.getAttribute('data-state')
expect(checkedAttribute).toBe('checked')
}).toPass()
}
@step
async setLanguage(languageButtonText: string, language: string) {
await this.openLanguageMenu(languageButtonText)
await this.page.getByRole('menuitemcheckbox', { name: language }).click()
await this.page.keyboard.press('Escape')
}
@step
async signOut() {
await this.openUserSettingsMenu()
await this.signOutButton.click()
}
async expectToContainText(text: string) {
await expect(this.sidebarLayout).toContainText(text)
}
async expectNotToContainText(text: string) {
await expect(this.sidebarLayout).not.toContainText(text)
}
private getTestId(section: string, index: number, suffix?: string) {
return `tla-file-link-${section}-${index}${suffix ? `-${suffix}` : ''}`
}
getFirstFileLink() {
return this.getFileLink('today', 0)
}
getFileLink(section: string, index: number) {
return this.page.getByTestId(this.getTestId(section, index))
}
async getFirstFileName() {
return await this.getFileName(0)
}
async getFileName(index: number) {
return await this.page
.getByTestId(this.getTestId('today', index, 'name'))
.innerText({ timeout: 5000 })
}
@step
private async openFileMenu(fileLink: Locator) {
await fileLink?.hover()
const button = fileLink.getByRole('button')
await button?.click()
}
@step
async deleteFile(index: number) {
await this.openFileMenu(this.getFileLink('today', index))
await this.deleteFromFileMenu()
}
@step
private async deleteFromFileMenu() {
await this.page.getByRole('menuitem', { name: 'Delete' }).click()
}
@step
async renameFile(index: number, newName: string) {
const fileLink = this.getFileLink('today', index)
await this.openFileMenu(fileLink)
await this.renameFromFileMenu(newName)
}
@step
async renameFileByName(fileName: string, newName: string) {
await this.openFileMenuByName(fileName)
await this.renameFromFileMenu(newName)
}
@step
async renameFromFileMenu(name: string) {
await this.page.getByRole('menuitem', { name: 'Rename' }).click()
const input = this.page.getByTestId('tla-sidebar-rename-input')
await input.fill(name)
await this.page.keyboard.press('Enter')
}
@step
private async duplicateFromFileMenu(name?: string) {
await this.page.getByRole('menuitem', { name: 'Duplicate' }).click()
const input = this.page.getByTestId('tla-sidebar-rename-input')
await expect(input).toBeVisible()
await expect(input).toBeFocused()
if (name) {
await input.fill(name)
}
await this.page.keyboard.press('Enter')
}
@step
async pinFromFileMenu(index: number) {
const fileLink = this.getFileLink('today', index)
await this.openFileMenu(fileLink)
await this.page.getByRole('menuitem', { name: 'Pin' }).click()
}
@step
async unpinFromFileMenu(index: number) {
const fileLink = this.getFileLink('pinned', index)
await this.openFileMenu(fileLink)
await this.page.getByRole('menuitem', { name: 'Unpin' }).click()
}
@step
async duplicateFile(index: number, name?: string) {
const fileLink = this.getFileLink('today', index)
await this.openFileMenu(fileLink)
await this.duplicateFromFileMenu(name)
}
@step
async copyFileLinkFromFileMenu() {
await this.page.getByRole('menuitem', { name: 'Copy link' }).click()
}
// The app writes to the clipboard asynchronously after the copy action, so reading it once can
// return a stale or empty value. Poll until the clipboard holds a valid URL (optionally matching
// an expected path) to avoid races.
private async readClipboardUrl(pathPattern?: RegExp): Promise<string> {
let value = ''
await expect(async () => {
value = await this.page.evaluate(() => navigator.clipboard.readText())
const url = new URL(value)
if (pathPattern) expect(url.pathname).toMatch(pathPattern)
}).toPass({ timeout: 10000 })
return value
}
@step
async copyFileLink(index: number) {
const fileLink = this.getFileLink('today', index)
await this.openFileMenu(fileLink)
await this.copyFileLinkFromFileMenu()
return await this.readClipboardUrl(/^\/f\//)
}
@step
async copyFileLinkByName(name: string): Promise<string> {
const fileLink = this.getFileByName(name)
await this.openFileMenu(fileLink)
await this.copyFileLinkFromFileMenu()
return await this.readClipboardUrl(/^\/f\//)
}
async getAfterElementStyle(element: Locator, property: string): Promise<string> {
return element.evaluate((el, property) => {
return window.getComputedStyle(el, '::after').getPropertyValue(property)
}, property)
}
async isHinted(fileLink: Locator): Promise<boolean> {
const backgroundColor = await this.getAfterElementStyle(fileLink, 'background-color')
return backgroundColor === 'rgba(9, 11, 12, 0.043)'
}
@step
async closeAccountMenu() {
await this.page.keyboard.press('Escape')
}
// Workspace-related methods
// A single attempt at opening the switcher: click only when it isn't already open, then confirm
// the menu content mounted (Home is always present). Callers that also act on a menu item should
// run this *inside* their own retry loop alongside that action — re-renders and navigation that
// fire while the app settles after a cross-client change can dismiss the menu between opening it
// and acting, so the open and the action have to be retried together, not in sequence.
private async ensureWorkspaceSwitcherOpen() {
const home = this.page.getByTestId('tla-workspace-switcher-home')
if (!(await home.isVisible())) {
await this.page.getByTestId('tla-workspace-switcher').click()
}
await expect(home).toBeVisible({ timeout: 2000 })
}
@step
async openWorkspaceSwitcher() {
await expect(() => this.ensureWorkspaceSwitcherOpen()).toPass({ timeout: 15000 })
}
@step
async createWorkspace(name: string) {
name = clampWorkspaceName(name)
// Creating a workspace happens from the workspace switcher dropdown.
await this.openWorkspaceSwitcher()
await this.page.getByTestId('tla-create-workspace-menu-item').click()
const input = this.page.getByPlaceholder('Workspace name')
await expect(input).toBeVisible()
await input.fill(name)
await this.page.getByRole('button', { name: 'Create workspace' }).click()
// Creating a workspace switches to it and opens its seeded welcome file. That file
// arrives named, so (unlike a blank file) there is no inline rename to dismiss.
await expect(async () => {
const activeName = await this.page.getByTestId('tla-active-workspace-name').innerText()
if (activeName !== name) {
await this.switchToWorkspace(name)
}
await this.expectActiveWorkspace(name)
}).toPass({ timeout: 20000 })
}
getWorkspaceLink(name: string) {
return this.page
.locator('[data-element="workspace-link"]')
.filter({ hasText: clampWorkspaceName(name) })
}
async getHomeWorkspaceName() {
const name = await this.page.evaluate(() => {
const app = (window as any).app
const homeWorkspaceId = app?.getHomeWorkspaceId?.()
return app?.getWorkspaceMembership?.(homeWorkspaceId)?.group?.name
})
if (!name) throw new Error('Could not resolve home workspace name')
return name
}
@step
async expectWorkspaceVisible(name: string) {
name = clampWorkspaceName(name)
// Gate on cross-client sync at the data layer first — immune to dropdown churn — then assert
// the switcher renders the workspace. Re-open and re-check together: the membership can be
// fully synced (the member may even be active in the workspace) yet the assertion still fails
// because a settling re-render dismissed the menu, and a closed switcher has no link items.
await this.waitForWorkspaceMembershipSync(name, true)
await expect(async () => {
await this.ensureWorkspaceSwitcherOpen()
await expect(this.getWorkspaceLink(name)).toBeVisible({ timeout: 2000 })
}).toPass({ timeout: 20000 })
await this.page.keyboard.press('Escape')
}
@step
async expectWorkspaceNotVisible(name: string) {
name = clampWorkspaceName(name)
// Workspace removal (member removed, workspace deleted) reaches an active member via
// cross-client sync too. Wait for the membership to leave the data layer first, then confirm
// the switcher no longer lists it. Keep the menu open while checking (Home is always present)
// so the absence can't pass vacuously against a dropdown that closed under us mid-settle.
await this.waitForWorkspaceMembershipSync(name, false)
await expect(async () => {
await this.ensureWorkspaceSwitcherOpen()
await expect(this.getWorkspaceLink(name)).toHaveCount(0)
}).toPass({ timeout: 20000 })
await this.page.keyboard.press('Escape')
}
/**
* Poll the app's data layer until a workspace membership with the given name is present (or
* absent). The membership backs the workspace switcher reactively via `getWorkspaceMemberships`,
* so once it settles the UI follows immediately — this lets callers gate on cross-client sync
* without depending on the dropdown being open at the moment the row arrives.
*/
private async waitForWorkspaceMembershipSync(name: string, present: boolean) {
name = clampWorkspaceName(name)
await expect
.poll(
() =>
this.page.evaluate((workspaceName) => {
const memberships = (window as any).app?.getWorkspaceMemberships?.() ?? []
return memberships.some((m: any) => m?.group?.name === workspaceName)
}, name),
{ timeout: WORKSPACE_MEMBERSHIP_SYNC_TIMEOUT }
)
.toBe(present)
}
@step
async expectActiveWorkspace(name: string) {
name = clampWorkspaceName(name)
// Switching workspaces navigates to (or creates) a file in the target before the active name
// updates, so allow the suite's cross-client budget rather than the default 5s.
await expect(this.page.getByTestId('tla-active-workspace-name')).toHaveText(name, {
timeout: 10000,
})
}
@step
async expectActiveHomeWorkspace() {
await this.expectActiveWorkspace(await this.getHomeWorkspaceName())
}
@step
async switchToWorkspace(name: string) {
name = clampWorkspaceName(name)
// Re-open and click together: a settling re-render can dismiss the menu between opening it and
// clicking the workspace, leaving the click waiting on an item that no longer exists.
await expect(async () => {
await this.ensureWorkspaceSwitcherOpen()
await this.getWorkspaceLink(name).click({ timeout: 2000 })
}).toPass({ timeout: 20000 })
await this.expectActiveWorkspace(name)
}
@step
async switchToHomeWorkspace() {
// The home workspace is renameable, so target its stable switcher item rather than a label.
await expect(async () => {
await this.ensureWorkspaceSwitcherOpen()
await this.page.getByTestId('tla-workspace-switcher-home').click({ timeout: 2000 })
}).toPass({ timeout: 20000 })
await this.expectActiveHomeWorkspace()
}
@step
async openWorkspaceSettings(name: string) {
name = clampWorkspaceName(name)
// The settings action lives in the active workspace's action rows, so
// switch to the workspace first if needed.
const activeName = await this.page.getByTestId('tla-active-workspace-name').innerText()
if (activeName !== name) {
await this.switchToWorkspace(name)
}
await this.page.getByTestId('tla-sidebar-workspace-settings').click()
}
@step
async renameWorkspace(oldName: string, newName: string) {
newName = clampWorkspaceName(newName)
await this.openWorkspaceSettings(oldName)
// Find the name input and change it (use placeholder as user sees it)
const input = this.page.getByPlaceholder('Workspace name')
await expect(input).toBeVisible()
await input.fill(newName)
// Close the dialog
await this.page.getByRole('button', { name: 'Close' }).click()
}
@step
async deleteWorkspace(name: string) {
await this.openWorkspaceSettings(name)
// Delete lives on the Settings tab of the Manage workspace dialog.
await this.page.getByRole('tab', { name: 'Settings' }).click()
await this.page.getByRole('button', { name: 'Delete workspace', exact: true }).click()
// Confirm in the confirmation dialog, whose button is just "Delete".
await this.page.getByRole('button', { name: 'Delete', exact: true }).click()
}
@step
async copyWorkspaceInviteLink(name: string): Promise<string> {
// The invite link lives in the Manage workspace dialog and is only exposed via
// the Copy button (no visible URL field), so copy it and read it back from the
// clipboard. The invite secret can load asynchronously, so poll until valid.
await this.openWorkspaceSettings(name)
const dialog = this.page.getByRole('dialog', { name: 'Manage workspace' })
let inviteUrl = ''
await expect(async () => {
await dialog.getByRole('button', { name: 'Copy invite link' }).click()
inviteUrl = await this.readClipboardUrl(/^\/invite\//)
}).toPass({ timeout: 10000 })
await this.page.getByRole('button', { name: 'Close' }).click()
return inviteUrl
}
// File visibility methods
getFileByName(fileName: string) {
return this.page.locator('[data-element="file-link"]').filter({ hasText: fileName })
}
@step
async expectFileVisible(fileName: string) {
// A file can appear in a member's list via cross-client sync (workspace files, shared guest
// files), so allow the same propagation budget the rest of the suite uses rather than the
// default 5s assertion timeout.
await expect(this.getFileByName(fileName)).toBeVisible({
timeout: WORKSPACE_MEMBERSHIP_SYNC_TIMEOUT,
})
}
@step
async expectFileNotVisible(fileName: string) {
// File removal (deletion, losing workspace access) also propagates via cross-client sync.
await expect(this.getFileByName(fileName)).not.toBeVisible({
timeout: WORKSPACE_MEMBERSHIP_SYNC_TIMEOUT,
})
}
@step
private async openFileMenuByName(fileName: string) {
const fileLink = this.getFileByName(fileName)
await fileLink.hover()
const button = fileLink.getByRole('button')
await button.click()
}
@step
async dragFileToUnpinnedSection(fileName: string) {
const fileElement = this.getFileByName(fileName)
const fileBox = await fileElement.boundingBox()
const lastFile = this.sidebar.locator('[data-drop-target-id^="file:"]').last()
const lastBox = await lastFile.boundingBox()
if (!fileBox || !lastBox) throw new Error('Could not get bounding boxes')
// Move to file
await this.page.mouse.move(fileBox.x + fileBox.width / 2, fileBox.y + fileBox.height / 2)
// Press and hold
await this.page.mouse.down()
// Small delay to let browser detect drag intent
await this.page.waitForTimeout(100)
// Drop on the unpinned part of the list, below the pin zone
await this.page.mouse.move(lastBox.x + lastBox.width / 2, lastBox.y + lastBox.height / 2, {
steps: 10,
})
// Small delay before release
await this.page.waitForTimeout(50)
// Release
await this.page.mouse.up()
}
@step
async pinFile(fileName: string) {
await this.openFileMenuByName(fileName)
await this.page.getByRole('menuitem', { name: 'Pin' }).click()
}
@step
async unpinFile(fileName: string) {
await this.openFileMenuByName(fileName)
await this.page.getByRole('menuitem', { name: 'Unpin' }).click()
}
@step
async expectFilePinned(fileName: string) {
const fileLink = this.getFileByName(fileName)
await expect(fileLink).toHaveAttribute('data-is-pinned', 'true')
}
@step
async expectFileNotPinned(fileName: string) {
const fileLink = this.getFileByName(fileName)
await expect(fileLink).toHaveAttribute('data-is-pinned', 'false')
}
@step
async expectFileActive(fileName: string) {
const fileLink = this.getFileByName(fileName)
await expect(fileLink).toHaveAttribute('data-active', 'true')
}
@step
async expectFileNotActive(fileName: string) {
const fileLink = this.getFileByName(fileName)
await expect(fileLink).toHaveAttribute('data-active', 'false')
}
/** The names of the files currently shown in the file list (the active workspace's files). */
async getVisibleFiles(): Promise<string[]> {
const fileLinks = this.sidebar.locator('[data-element="file-link"]')
const count = await fileLinks.count()
const fileNames: string[] = []
for (let i = 0; i < count; i++) {
const fileName = await fileLinks.nth(i).locator('[data-testid*="-name"]').innerText()
fileNames.push(fileName)
}
return fileNames
}
@step
async deleteFileByName(fileName: string) {
await this.openFileMenuByName(fileName)
await this.deleteFromFileMenu()
}
@step
async duplicateFileByName(fileName: string, newName?: string) {
await this.openFileMenuByName(fileName)
await this.duplicateFromFileMenu(newName)
}
/**
* One attempt at opening the file menu and its move-to submenu. Every wait in here is
* short and bounded so a failed attempt throws quickly and the caller's retry loop gets to
* re-open the file menu — the open click itself is what flakes (the sidebar can re-render
* mid-click and swallow it), so any recovery must include re-clicking, not just re-waiting.
*/
private async tryOpenMoveToMenu(fileName: string) {
// Close whatever menu/submenu a previous attempt left behind.
await this.page.keyboard.press('Escape')
await this.page.keyboard.press('Escape')
await this.openFileMenuByName(fileName)
const moveToButton = this.page.getByTestId('dialog-sub.move-to-workspace-button')
await expect(moveToButton).toBeVisible({ timeout: 2000 })
await moveToButton.hover({ force: true })
await expect(this.page.getByTestId('dialog-sub.move-to-workspace-content')).toBeVisible({
timeout: 1000,
})
}
@step
async openMoveToMenu(fileName: string) {
await expect(() => this.tryOpenMoveToMenu(fileName)).toPass({ timeout: 20000 })
}
@step
async closeMoveToMenu() {
await this.page.keyboard.press('Escape')
await this.page.keyboard.press('Escape')
}
@step
async moveFileToWorkspace(fileName: string, targetWorkspaceName: string) {
targetWorkspaceName = clampWorkspaceName(targetWorkspaceName)
// The move-to menu is a checklist: each destination is a checkbox item, and the
// destination we're moving to is never the current (checked) one, so its accessible
// name is just the workspace name (an unchecked item adds no "checked" prefix).
await expect(async () => {
await this.tryOpenMoveToMenu(fileName)
await this.page
.getByRole('menuitemcheckbox', { name: targetWorkspaceName, exact: true })
.click({ timeout: 2000 })
}).toPass({ timeout: 20000 })
}
@step
async moveFileToHome(fileName: string) {
await this.moveFileToWorkspace(fileName, await this.getHomeWorkspaceName())
}
}

View File

@@ -0,0 +1,66 @@
import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import { step } from './tla-test'
export class SignInDialog {
public readonly googleButton: Locator
public readonly emailInput: Locator
public readonly continueWithEmailButton: Locator
public readonly codeInput: Locator
public readonly resendButton: Locator
public readonly acceptAndContinueButton: Locator
constructor(private readonly page: Page) {
this.googleButton = this.page.getByTestId('tla-google-sign-in-button')
this.emailInput = this.page.getByLabel('Email address')
this.continueWithEmailButton = this.page.getByTestId('tla-continue-with-email-button')
this.codeInput = this.page.locator('#tla-verification-code')
this.resendButton = this.page.getByTestId('tla-resend-code-button')
this.acceptAndContinueButton = this.page.getByTestId('tla-accept-and-continue-button')
}
@step
async expectInitialElements() {
await expect(this.googleButton).toBeVisible()
await expect(this.emailInput).toBeVisible()
await expect(this.continueWithEmailButton).toBeVisible()
}
@step
async continueWithEmail(email: string) {
await this.emailInput.fill(email)
await expect(this.continueWithEmailButton).toBeEnabled()
await this.continueWithEmailButton.click()
}
@step
async expectCodeStageVisible() {
await expect(this.codeInput).toBeVisible()
}
@step
async fillCode(code: string) {
await this.codeInput.fill(code)
}
@step
async clickResend() {
await expect(this.resendButton).toBeVisible()
await this.resendButton.click()
}
@step
async expectTermsStageVisible() {
await expect(this.acceptAndContinueButton).toBeVisible()
}
@step
async expectAcceptAndContinueDisabled() {
await expect(this.acceptAndContinueButton).toBeDisabled()
}
@step
async acceptAndContinue() {
await this.acceptAndContinueButton.click()
}
}

View File

@@ -0,0 +1,29 @@
import type { Locator, Page } from '@playwright/test'
import { expect, step } from './tla-test'
export class WorkspaceInviteDialog {
public readonly dialog: Locator
public readonly acceptButton: Locator
constructor(public readonly page: Page) {
this.dialog = this.page.getByText('You have been invited to join')
this.acceptButton = this.page.getByRole('button', { name: 'Accept invitation' })
}
async isVisible() {
return this.dialog.isVisible()
}
async expectIsVisible() {
await expect(this.dialog).toBeVisible()
}
async expectIsNotVisible() {
await expect(this.dialog).not.toBeVisible()
}
@step
async acceptInvitation() {
await this.acceptButton.click()
}
}

View File

@@ -0,0 +1,121 @@
import fs from 'fs'
import path from 'path'
import { Browser, BrowserContext, Page, test } from '@playwright/test'
import { Editor } from './Editor'
import { ErrorPage } from './ErrorPages'
import { HomePage } from './HomePage'
import { ShareMenu } from './ShareMenu'
import { Sidebar } from './Sidebar'
import { WorkspaceInviteDialog } from './WorkspaceInviteDialog'
export type UserName = 'huppy' | 'suppy'
type UserProps = { user: UserName; index: number } | undefined
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
export async function openNewTab(
browser: Browser,
opts: { url?: string; userProps: UserProps; allowClipboard?: boolean }
) {
return await test.step('open new incognito page', async () => {
const { url, userProps, allowClipboard } = opts
let newContext: BrowserContext
if (userProps === undefined) {
newContext = await browser.newContext({ storageState: undefined })
} else {
const storageStateFileName = getStorageStateFileName(userProps.index, userProps.user)
const storageState = JSON.parse(fs.readFileSync(storageStateFileName, 'utf-8'))
newContext = await browser.newContext({ storageState })
}
if (allowClipboard) {
await newContext.grantPermissions(['clipboard-read', 'clipboard-write'])
}
const newPage = await newContext.newPage()
const {
newSidebar,
newEditor,
newHomePage,
newShareMenu,
newWorkspaceInviteDialog,
errorPage,
} = createFixtures(newPage)
if (url) {
await newPage.goto(url)
} else {
await newHomePage.goto()
}
await newHomePage.isLoaded()
return {
newPage,
newContext,
newSidebar,
newHomePage,
newEditor,
newShareMenu,
newWorkspaceInviteDialog,
errorPage,
}
})
}
export function getStorageStateFileName(index: number, user: UserName) {
return path.join(__dirname, `../.auth/${user}${index + 1}.json`)
}
export function areUrlsEqual(
url1: string,
url2: string,
opts: { ignoreSearch?: boolean } = { ignoreSearch: true }
) {
if (opts.ignoreSearch) {
const url1Obj = new URL(url1)
const url2Obj = new URL(url2)
return url1Obj.origin === url2Obj.origin && url1Obj.pathname === url2Obj.pathname
}
return url1 === url2
}
export function createFixtures(page: Page) {
const newSidebar = new Sidebar(page)
const newEditor = new Editor(page, newSidebar)
const newHomePage = new HomePage(page, newEditor)
const newShareMenu = new ShareMenu(page)
const newWorkspaceInviteDialog = new WorkspaceInviteDialog(page)
const errorPage = new ErrorPage(page)
return { newSidebar, newEditor, newHomePage, newShareMenu, newWorkspaceInviteDialog, errorPage }
}
export function getRandomName() {
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
let result = ''
for (let i = 0; i < 10; i++) {
const randomIndex = Math.floor(Math.random() * characters.length)
result += characters[randomIndex]
}
return result
}
const PROPAGATE_CHANGES_TIMEOUT = 100
/**
* Ensures that the provided expectations are met both before and after a page reload.
*
* @param fn - An asynchronous function containing the expectations to be tested.
* @param page - The Playwright Page object representing the browser page.
*
* The function performs the following steps:
* 1. Executes the provided expectations and ensures they pass.
* 2. Waits for a specified timeout to allow optimistic changes to propagate to the server.
* 3. Reloads the page.
* 4. Executes the provided expectations again and ensures they pass.
*/
export async function expectBeforeAndAfterReload(fn: () => Promise<void>, page: Page) {
await fn()
await sleep(PROPAGATE_CHANGES_TIMEOUT)
await page.reload()
const { newHomePage } = createFixtures(page)
await newHomePage.isLoaded()
await fn()
}

View File

@@ -0,0 +1,695 @@
import { createHash } from 'crypto'
import fs from 'fs'
import { setupClerkTestingToken } from '@clerk/testing/playwright'
import { expect, test as base } from '@playwright/test'
import type { Browser, BrowserContext, Download, Locator, Page, TestInfo } from '@playwright/test'
import { MAX_WORKSPACE_NAME_LENGTH } from '@tldraw/dotcom-shared'
import { NUMBER_OF_USERS } from '../consts'
import { Database, getTestUserEmail } from './Database'
import { DeleteFileDialog } from './DeleteFileDialog'
import { Editor } from './Editor'
import { ErrorPage } from './ErrorPages'
import { getStorageStateFileName } from './helpers'
import { HomePage } from './HomePage'
import { ImportHelper } from './ImportHelper'
import { ShareMenu } from './ShareMenu'
import { Sidebar } from './Sidebar'
import { SignInDialog } from './SignInDialog'
import { WorkspaceInviteDialog } from './WorkspaceInviteDialog'
type SignedInActorName = 'owner' | 'member'
type DotcomActorName = SignedInActorName | 'visitor'
type SharedLinkType = 'edit' | 'view' | 'no-access'
type WorkspaceMemberRole = 'owner' | 'member'
const SCENARIO_USER_POOL_START = 4
const ROOT_URL = 'http://localhost:3000'
const MENU_INTERACTION_TIMEOUT = 5_000
export async function selectTlaMenuOption(page: Page, select: Locator, optionLabel: string) {
await select.click({ timeout: MENU_INTERACTION_TIMEOUT })
const openListbox = page.locator('[role="listbox"][data-state="open"]')
await expect(openListbox).toBeVisible({ timeout: MENU_INTERACTION_TIMEOUT })
await openListbox
.getByRole('option', { name: optionLabel, exact: true })
.click({ timeout: MENU_INTERACTION_TIMEOUT })
await expect(openListbox).not.toBeVisible({ timeout: MENU_INTERACTION_TIMEOUT })
}
interface SignedInActorAccount {
email: string
storageState: string
}
interface ScenarioWorkerFixtures {
actorAccounts: Record<SignedInActorName, SignedInActorAccount>
}
interface ScenarioFixtures {
actors: DotcomActors
owner: DotcomActor
member: DotcomActor
visitor: DotcomActor
scenario: DotcomScenario
setupAndCleanup: void
}
interface LegacyRouteFixture {
slug: string
readonlySlug: string
legacyReadonlySlug: string
snapshotSlug: string
historyTimestamp: string
urls: {
room: string
readonly: string
legacyReadonly: string
snapshot: string
history: string
historySnapshot: string
}
}
export class DotcomActor {
readonly sidebar: Sidebar
readonly editor: Editor
readonly homePage: HomePage
readonly shareMenu: ShareMenu
readonly deleteFileDialog: DeleteFileDialog
readonly errorPage: ErrorPage
readonly importHelper: ImportHelper
readonly signInDialog: SignInDialog
readonly workspaceInviteDialog: WorkspaceInviteDialog
constructor(
readonly name: DotcomActorName,
readonly page: Page,
readonly context: BrowserContext,
readonly email?: string
) {
this.sidebar = new Sidebar(page)
this.editor = new Editor(page, this.sidebar)
this.homePage = new HomePage(page, this.editor)
this.shareMenu = new ShareMenu(page)
this.deleteFileDialog = new DeleteFileDialog(page)
this.errorPage = new ErrorPage(page)
this.importHelper = new ImportHelper(page)
this.signInDialog = new SignInDialog(page)
this.workspaceInviteDialog = new WorkspaceInviteDialog(page)
}
async goto(url = 'http://localhost:3000/') {
await this.homePage.goto(url)
await this.waitForAppReady()
}
async waitForAppReady() {
await this.homePage.isLoaded()
await this.waitForEditorReady()
await this.waitForAuthLoaded()
await this.waitForAppStoreHydrated()
await this.waitForFileRoomConnected()
await this.waitForVisitorAccessMetadata()
}
async waitForAuthLoaded() {
await this.page.waitForFunction(
([isSignedIn]) => {
const app = (window as any).app
const editor = (window as any).editor
const path = window.location.pathname
const isAppRoute = path === '/' || path.startsWith('/f/')
const isLegacyReadonlyRoute = path.startsWith('/ro/') || path.startsWith('/v/')
const hasMountedCanvas = !!document.querySelector('[data-testid="canvas"]')
if (isSignedIn && isAppRoute) return !!app?.getUser?.()
if (isSignedIn && isLegacyReadonlyRoute)
return !!app?.getUser?.() || !!editor || hasMountedCanvas
if (isSignedIn) return !!app?.getUser?.() || !!editor
return (
!!document.querySelector('[data-testid="tla-sign-in-button"]') ||
!!document.querySelector('[data-testid="tla-sidebar-toggle"]')
)
},
[!!this.email]
)
}
async waitForAppStoreHydrated() {
if (!this.email) return
await this.page.waitForFunction(() => {
const app = (window as any).app
const path = window.location.pathname
const isAppRoute = path === '/' || path.startsWith('/f/')
if (!isAppRoute) return true
return !!app?.getUser?.() && Array.isArray(app?.getUserFileStates?.())
})
}
async waitForEditorReady() {
await this.page.waitForFunction(() => {
const editor = (window as any).editor
if (editor?.getCurrentPageId?.()) return true
const path = window.location.pathname
const hasMountedCanvas = !!document.querySelector('[data-testid="canvas"]')
return hasMountedCanvas && (path.startsWith('/ro/') || path.startsWith('/v/'))
})
}
async waitForFileRoomConnected() {
await this.page.waitForFunction(() => {
const editor = (window as any).editor
if (!editor?.store) {
const path = window.location.pathname
return (
!!document.querySelector('[data-testid="canvas"]') &&
(path.startsWith('/ro/') || path.startsWith('/v/'))
)
}
const status = editor.store.status
return status === undefined || status === 'synced-remote'
})
}
async waitForVisitorAccessMetadata() {
await this.page.waitForFunction(() => {
const editor = (window as any).editor
if (!editor?.getIsReadonly) {
const path = window.location.pathname
return (
!!document.querySelector('[data-testid="canvas"]') &&
(path.startsWith('/ro/') || path.startsWith('/v/'))
)
}
// Calling getIsReadonly proves the mounted editor has received the
// current access mode for signed-out visitors and signed-in guests.
return typeof editor.getIsReadonly() === 'boolean'
})
}
async waitForSessionClosed(timeout = 20000) {
await expect
.poll(
async () => {
return await this.page.evaluate(() => {
const editor = (window as any).editor
return editor?.getCollaborators?.().length ?? 0
})
},
{ timeout }
)
.toBe(0)
}
async getCollaboratorCount() {
return await this.page.evaluate(() => (window as any).editor?.getCollaborators?.().length ?? 0)
}
async expectCollaboratorCount(count: number, timeout = 10000) {
await expect.poll(() => this.getCollaboratorCount(), { timeout }).toBe(count)
}
async getIsReadonly() {
return await this.page.evaluate(() => {
const editorReadonly = (window as any).editor?.getIsReadonly?.()
if (typeof editorReadonly === 'boolean') return editorReadonly
const path = window.location.pathname
return (
!!document.querySelector('[data-testid="canvas"]') &&
(path.startsWith('/ro/') || path.startsWith('/v/'))
)
})
}
async expectReadonly(readonly: boolean, timeout = 10000) {
await expect.poll(() => this.getIsReadonly(), { timeout }).toBe(readonly)
}
async close() {
await this.context.close()
}
}
class DotcomActors {
private readonly openActors: DotcomActor[] = []
constructor(
private readonly browser: Browser,
private readonly actorAccounts: Record<SignedInActorName, SignedInActorAccount>
) {}
async open(
name: DotcomActorName,
opts: { url?: string; allowClipboard?: boolean; goto?: boolean } = {}
) {
const account = name === 'visitor' ? undefined : this.actorAccounts[name]
const context = await this.browser.newContext({
storageState: account?.storageState,
})
if (opts.allowClipboard ?? true) {
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
}
const page = await context.newPage()
await setupClerkTestingToken({ page })
const actor = new DotcomActor(name, page, context, account?.email)
this.openActors.push(actor)
if (opts.goto ?? true) {
await actor.goto(opts.url)
}
return actor
}
async closeAll() {
await Promise.allSettled(this.openActors.splice(0).map((actor) => actor.close()))
}
}
class DotcomScenario {
readonly id: string
private readonly database: Database
constructor(private readonly testInfo: TestInfo) {
this.id = getScenarioId(testInfo)
// Scenario actors live in the scenario user pool, so the database must use the same index
// for its index-based helpers to target the right Clerk accounts.
this.database = new Database(null, getScenarioUserIndex(testInfo.parallelIndex))
}
name(label: string) {
return `${this.id} ${label}`
}
async createPersonalFile(actor: DotcomActor, fileName = this.name('file')) {
await actor.editor.ensureSidebarOpen()
await this.switchToHomeIfAvailable(actor)
await actor.sidebar.createNewDocument(fileName)
await actor.sidebar.expectFileActive(fileName)
return { fileName, url: actor.page.url() }
}
async createSharedFile(
actor: DotcomActor,
linkType: Exclude<SharedLinkType, 'no-access'> = 'edit',
fileName = this.name(`${linkType} shared file`)
) {
const file = await this.createPersonalFile(actor, fileName)
const sharedUrl = await this.shareFile(actor, linkType)
return { ...file, sharedUrl, linkType }
}
async createGuestFile(opts: {
owner: DotcomActor
guest: DotcomActor
linkType: Exclude<SharedLinkType, 'no-access'>
fileName?: string
}) {
const file = await this.createSharedFile(opts.owner, opts.linkType, opts.fileName)
await opts.guest.goto(file.sharedUrl)
return file
}
async createGuestEditFile(owner: DotcomActor, guest: DotcomActor, fileName?: string) {
return await this.createGuestFile(
fileName === undefined
? { owner, guest, linkType: 'edit' }
: { owner, guest, fileName, linkType: 'edit' }
)
}
async createGuestViewFile(owner: DotcomActor, guest: DotcomActor, fileName?: string) {
return await this.createGuestFile(
fileName === undefined
? { owner, guest, linkType: 'view' }
: { owner, guest, fileName, linkType: 'view' }
)
}
async createRectangle(actor: DotcomActor) {
await actor.page.getByTestId('tools.rectangle').click()
await actor.page.locator('.tl-background').click()
await actor.editor.expectShapesCount(1)
}
async shareFile(actor: DotcomActor, linkType: Exclude<SharedLinkType, 'no-access'> = 'edit') {
await actor.shareMenu.open()
await this.setSharedLinkType(actor, linkType)
const url = await actor.shareMenu.copyLink()
await actor.page.keyboard.press('Escape')
return url
}
async setSharedLinkType(actor: DotcomActor, linkType: SharedLinkType) {
if (!(await actor.shareMenu.inviteTabButton.isVisible().catch(() => false))) {
await actor.shareMenu.open()
}
await actor.shareMenu.ensureTabSelected('invite')
if (linkType === 'no-access') {
await actor.page.getByRole('switch').uncheck()
return
}
await actor.page.getByRole('switch').check()
const select = actor.page.getByTestId('shared-link-type-select')
const expectedLabel = linkType === 'edit' ? 'Editor' : 'Viewer'
if ((await select.innerText()) !== expectedLabel) {
await selectTlaMenuOption(actor.page, select, expectedLabel)
}
await expect(select).toHaveText(expectedLabel)
}
async publishFile(actor: DotcomActor) {
await actor.shareMenu.open()
await actor.shareMenu.publishFile()
const url = await actor.shareMenu.copyLink()
await actor.page.keyboard.press('Escape')
return url
}
async createPublishedFile(actor: DotcomActor, fileName = this.name('published file')) {
const file = await this.createPersonalFile(actor, fileName)
const publishedUrl = await this.publishFile(actor)
return { ...file, publishedUrl }
}
async importFileFromUrl(actor: DotcomActor, url?: string) {
await actor.importHelper.mockUrl(url)
await actor.importHelper.navigate(url)
await actor.page.waitForURL(/\/f\//)
await actor.waitForAppReady()
return { fileName: await actor.editor.getCurrentFileName(), url: actor.page.url() }
}
async downloadFileFromSidebar(actor: DotcomActor, fileName: string): Promise<Download> {
await actor.editor.ensureSidebarOpen()
const fileLink = actor.sidebar.getFileByName(fileName)
await fileLink.hover()
await fileLink.getByRole('button').click()
const downloadPromise = actor.page.waitForEvent('download')
await actor.page.getByRole('menuitem', { name: 'Download' }).click()
return await downloadPromise
}
async publishChanges(actor: DotcomActor) {
if (!(await actor.shareMenu.publishTabButton.isVisible().catch(() => false))) {
await actor.shareMenu.open()
}
await actor.shareMenu.publishChanges()
await actor.page.keyboard.press('Escape')
}
async createLegacyRouteFixture(actor: DotcomActor): Promise<LegacyRouteFixture> {
const slug = this.id.slice(0, 96)
let fixture: Omit<LegacyRouteFixture, 'urls'> | null = null
let lastError = ''
for (let attempt = 0; attempt < 30; attempt++) {
const response = await actor.page.request.post(`${ROOT_URL}/api/app/__test__/legacy-room`, {
data: {
slug,
readonlySlug: `${slug}-ro`,
legacyReadonlySlug: `${slug}-v`,
snapshotSlug: `v2_c_${slug}`,
historyTimestamp: '2020-01-02T03:04:05.000Z',
},
})
if (response.ok()) {
fixture = (await response.json()) as Omit<LegacyRouteFixture, 'urls'>
break
}
lastError = `${response.status()} ${await response.text()}`
await actor.page.waitForTimeout(500)
}
if (!fixture) throw new Error(`Legacy route fixture was not created: ${lastError}`)
return {
...fixture,
urls: {
room: `${ROOT_URL}/r/${fixture.slug}`,
readonly: `${ROOT_URL}/ro/${fixture.readonlySlug}`,
legacyReadonly: `${ROOT_URL}/v/${fixture.legacyReadonlySlug}`,
snapshot: `${ROOT_URL}/s/${fixture.snapshotSlug}`,
history: `${ROOT_URL}/r/${fixture.slug}/history`,
historySnapshot: `${ROOT_URL}/r/${fixture.slug}/history/${fixture.historyTimestamp}`,
},
}
}
async createWorkspaceWithMember(opts: {
owner: DotcomActor
member: DotcomActor
workspaceName?: string
fileName?: string
}) {
const invite = await this.createPendingWorkspaceInvite(opts)
await opts.member.goto(invite.inviteUrl)
await opts.member.editor.ensureSidebarOpen()
await opts.member.workspaceInviteDialog.acceptInvitation()
await opts.member.sidebar.expectWorkspaceVisible(invite.workspaceName)
await opts.member.sidebar.switchToWorkspace(invite.workspaceName)
await opts.member.sidebar.expectFileVisible(invite.fileName)
return invite
}
async createWorkspaceWithRemovedMember(opts: {
owner: DotcomActor
member: DotcomActor
workspaceName?: string
fileName?: string
}) {
const workspace = await this.createWorkspaceWithMember(opts)
await this.removeWorkspaceMember({
owner: opts.owner,
workspaceName: workspace.workspaceName,
memberUserId: workspace.memberUserId,
})
return workspace
}
async createPendingWorkspaceInvite(opts: {
owner: DotcomActor
member: DotcomActor
workspaceName?: string
fileName?: string
}) {
// The createWorkspace mutator clamps names to MAX_WORKSPACE_NAME_LENGTH, so mirror that here:
// otherwise a long scenario id makes the stored (and displayed) name diverge from what the
// sidebar helpers search for, and the workspace link never matches.
const workspaceName = (opts.workspaceName ?? this.name('workspace'))
.trim()
.slice(0, MAX_WORKSPACE_NAME_LENGTH)
const fileName = opts.fileName ?? this.name('workspace file')
await this.goToAndOpenSidebar(opts.owner)
await this.goToAndOpenSidebar(opts.member)
if (!opts.member.email) throw new Error('Workspace member actor is not signed in')
const memberUserId = await this.database.getUserIdByEmail(opts.member.email)
if (!memberUserId) throw new Error(`Member user not found: ${opts.member.email}`)
await opts.owner.editor.ensureSidebarOpen()
await this.switchToHomeIfAvailable(opts.owner)
const { fileId } = await opts.owner.page.evaluate(
async ({ workspaceName, fileName }) => {
const app = (window as any).app
const uniqueId = () => {
const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz-'
const bytes = crypto.getRandomValues(new Uint8Array(21))
return Array.from(bytes, (byte) => alphabet[byte & 63]).join('')
}
const workspaceId = uniqueId()
const fileId = uniqueId()
// Await server acknowledgement (not just optimistic apply) so the file exists
// server-side before we navigate to it. These promises never reject — a rejected
// mutation resolves with {type: 'error'} — so check the result or a failure
// surfaces later as an unrelated timeout.
const workspaceRes = await app.z.mutate.createWorkspace({
id: workspaceId,
name: workspaceName,
}).server
if (workspaceRes.type !== 'success') {
throw new Error(`createWorkspace rejected: ${JSON.stringify(workspaceRes)}`)
}
const fileRes = await app.z.mutate.createFile({
fileId,
workspaceId,
name: fileName,
createSource: null,
time: Date.now(),
}).server
if (fileRes.type !== 'success') {
throw new Error(`createFile rejected: ${JSON.stringify(fileRes)}`)
}
return { fileId }
},
{ workspaceName, fileName }
)
await opts.owner.goto(`${ROOT_URL}/f/${fileId}`)
await opts.owner.editor.ensureSidebarOpen()
const inviteUrl = await opts.owner.sidebar.copyWorkspaceInviteLink(workspaceName)
return { workspaceName, fileName, inviteUrl, memberUserId }
}
async removeWorkspaceMember(opts: {
owner: DotcomActor
workspaceName: string
memberUserId: string
}) {
await opts.owner.sidebar.openWorkspaceSettings(opts.workspaceName)
await selectTlaMenuOption(
opts.owner.page,
opts.owner.page.locator(`[id="workspace-member-role-${opts.memberUserId}"]`),
'Remove'
)
// The remove confirmation dialog's button is just "Remove".
await opts.owner.page.getByRole('button', { name: 'Remove', exact: true }).click()
await opts.owner.page.keyboard.press('Escape')
}
async setWorkspaceMemberRole(opts: {
owner: DotcomActor
workspaceName: string
memberUserId: string
role: WorkspaceMemberRole
}) {
await opts.owner.sidebar.openWorkspaceSettings(opts.workspaceName)
const memberRoleSelect = opts.owner.page.locator(
`[id="workspace-member-role-${opts.memberUserId}"]`
)
const roleLabel = opts.role === 'owner' ? 'Owner' : 'Member'
await selectTlaMenuOption(opts.owner.page, memberRoleSelect, roleLabel)
await expect(memberRoleSelect).toHaveText(roleLabel)
await opts.owner.page.keyboard.press('Escape')
}
async goToAndOpenSidebar(actor: DotcomActor) {
if (!actor.email) throw new Error(`Actor ${actor.name} is not signed in`)
await actor.goto()
await actor.editor.ensureSidebarOpen()
}
private async switchToHomeIfAvailable(actor: DotcomActor) {
if (
await actor.page
.getByTestId('tla-workspace-switcher')
.isVisible()
.catch(() => false)
) {
await actor.sidebar.switchToHomeWorkspace()
}
}
}
export const test = base.extend<ScenarioFixtures, ScenarioWorkerFixtures>({
actorAccounts: [
async ({ browser }, testUse, workerInfo) => {
const userIndex = getScenarioUserIndex(workerInfo.parallelIndex)
const owner = await ensureStorageState(browser, userIndex, 'huppy')
const member = await ensureStorageState(browser, userIndex, 'suppy')
await testUse({ owner, member })
},
{ scope: 'worker' },
],
setupAndCleanup: [
async ({ actorAccounts: _actorAccounts }, testUse, testInfo) => {
const database = new Database(null, getScenarioUserIndex(testInfo.parallelIndex))
await database.reset()
await testUse()
},
{ auto: true },
],
actors: async ({ browser, actorAccounts }, testUse) => {
const actors = new DotcomActors(browser, actorAccounts)
await testUse(actors)
await actors.closeAll()
},
owner: async ({ actors }, testUse) => {
await testUse(await actors.open('owner'))
},
member: async ({ actors }, testUse) => {
await testUse(await actors.open('member'))
},
visitor: async ({ actors }, testUse) => {
await testUse(await actors.open('visitor'))
},
scenario: async ({ browser: _browser }, testUse, testInfo) => {
await testUse(new DotcomScenario(testInfo))
},
})
async function ensureStorageState(
browser: Browser,
parallelIndex: number,
user: 'huppy' | 'suppy'
): Promise<SignedInActorAccount> {
const storageState = getStorageStateFileName(parallelIndex, user)
const email = getTestUserEmail(parallelIndex, user)
if (fs.existsSync(storageState)) {
return { email, storageState }
}
await base.step(`Sign in ${user} actor`, async () => {
const page = await browser.newPage({ storageState: undefined })
await setupClerkTestingToken({ page })
const sidebar = new Sidebar(page)
const editor = new Editor(page, sidebar)
const homePage = new HomePage(page, editor)
await homePage.loginAs(email)
await expect(page.getByTestId('tla-sidebar-layout')).toBeVisible()
await page.context().storageState({ path: storageState })
await page.close()
})
return { email, storageState }
}
function getScenarioId(testInfo: TestInfo) {
const fullSlug = testInfo.titlePath
.slice(1)
.join(' ')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
// Keep the id compact: workspace names are capped at MAX_WORKSPACE_NAME_LENGTH, and a name is
// `${id} ${label}`, so a long id leaves no room for a distinguishing label — two labels that
// differ only past the cap would collide once the mutator clamps the stored name. A short
// readable prefix keeps debugging tractable; a hash of the full title preserves the cross-test
// uniqueness that a truncated prefix alone would lose.
const titleHash = createHash('sha1').update(fullSlug).digest('hex').slice(0, 6)
const slug = `${fullSlug.slice(0, 24).replace(/-+$/g, '')}-${titleHash}`
const runId = process.env.GITHUB_RUN_ID ?? Date.now().toString(36)
return `e2e-${runId}-w${testInfo.parallelIndex}-x${testInfo.repeatEachIndex}-r${testInfo.retry}-${slug}`
}
function getScenarioUserIndex(parallelIndex: number) {
const userIndex = SCENARIO_USER_POOL_START + parallelIndex
if (userIndex >= NUMBER_OF_USERS) {
throw new Error(
`Not enough Clerk test users for scenario worker ${parallelIndex}. Add more test users or lower the Playwright worker count.`
)
}
return userIndex
}
export { expect } from '@playwright/test'

View File

@@ -0,0 +1,39 @@
import { setupClerkTestingToken } from '@clerk/testing/playwright'
import { test as base } from '@playwright/test'
import { Editor } from './Editor'
import { HomePage } from './HomePage'
import { Sidebar } from './Sidebar'
interface StagingFixtures {
editor: Editor
sidebar: Sidebar
homePage: HomePage
setupAndCleanup: void
}
const STAGING_URL = 'https://staging.tldraw.com/'
export const test = base.extend<StagingFixtures>({
sidebar: async ({ page }, testUse) => {
await testUse(new Sidebar(page))
},
editor: async ({ page, sidebar }, testUse) => {
await testUse(new Editor(page, sidebar))
},
homePage: async ({ page, editor }, testUse) => {
await testUse(new HomePage(page, editor))
},
setupAndCleanup: [
async ({ page, homePage }, testUse) => {
await setupClerkTestingToken({ page })
await homePage.goto(STAGING_URL)
await homePage.isLoaded()
await testUse()
},
{ auto: true },
],
})
export { expect } from '@playwright/test'

View File

@@ -0,0 +1,203 @@
import fs from 'fs'
import { setupClerkTestingToken } from '@clerk/testing/playwright'
import { test as base, expect } from '@playwright/test'
import { OTHER_USERS, USERS } from '../consts'
import { Database } from './Database'
import { DeleteFileDialog } from './DeleteFileDialog'
import { Editor } from './Editor'
import { getStorageStateFileName } from './helpers'
import { HomePage } from './HomePage'
import { ImportHelper } from './ImportHelper'
import { ShareMenu } from './ShareMenu'
import { Sidebar } from './Sidebar'
import { SignInDialog } from './SignInDialog'
import { WorkspaceInviteDialog } from './WorkspaceInviteDialog'
interface TlaFixtures {
homePage: HomePage
editor: Editor
sidebar: Sidebar
shareMenu: ShareMenu
database: Database
deleteFileDialog: DeleteFileDialog
workspaceInviteDialog: WorkspaceInviteDialog
signInDialog: SignInDialog
importHelper: ImportHelper
setupAndCleanup: void
retry(fn: () => Promise<void>): Promise<void>
}
interface TlaWorkerFixtures {
workerStorageState: string
}
export const test = base.extend<TlaFixtures, TlaWorkerFixtures>({
sidebar: async ({ page }, testUse) => {
await testUse(new Sidebar(page))
},
editor: async ({ page, sidebar }, testUse) => {
await testUse(new Editor(page, sidebar))
},
homePage: async ({ page, editor }, testUse) => {
await testUse(new HomePage(page, editor))
},
database: async ({ page }, testUse) => {
await testUse(new Database(page, test.info().parallelIndex))
},
shareMenu: async ({ page }, testUse) => {
await testUse(new ShareMenu(page))
},
deleteFileDialog: async ({ page }, testUse) => {
await testUse(new DeleteFileDialog(page))
},
workspaceInviteDialog: async ({ page }, testUse) => {
await testUse(new WorkspaceInviteDialog(page))
},
signInDialog: async ({ page }, testUse) => {
await testUse(new SignInDialog(page))
},
importHelper: async ({ page }, testUse) => {
await testUse(new ImportHelper(page))
},
// This is an auto fixture which makes sure that we are on the home page when the test starts
// and that we clean up when the tests completes
setupAndCleanup: [
async ({ page, homePage, database }, testUse) => {
// All the code before testUse is run before each test
// Make sure we don't get marked as a bot
// https://clerk.com/docs/testing/playwright/overview
await setupClerkTestingToken({ page })
await database.reset()
await homePage.goto()
await homePage.isLoaded()
await testUse()
},
{ auto: true },
],
storageState: ({ workerStorageState }, testUse) => testUse(workerStorageState),
workerStorageState: [
async ({ browser }, testUse) => {
const id = test.info().parallelIndex
const fileName = getStorageStateFileName(id, 'huppy')
if (fs.existsSync(fileName)) {
// Reuse existing authentication state if any.
await testUse(fileName)
return
}
for (const user of ['huppy' as const, 'suppy' as const]) {
const fileName = getStorageStateFileName(id, user)
// Important: make sure we authenticate in a clean environment by unsetting storage state.
const page = await browser.newPage({ storageState: undefined })
let email: string
if (user === 'huppy') {
email = USERS[id]
} else {
email = OTHER_USERS[id]
}
const sidebar = new Sidebar(page)
const editor = new Editor(page, sidebar)
const homePage = new HomePage(page, editor)
await homePage.loginAs(email)
await expect(page.getByTestId('tla-sidebar-layout')).toBeVisible()
await page.context().storageState({ path: fileName })
await page.close()
}
await testUse(fileName)
},
{ scope: 'worker' },
],
})
export { expect } from '@playwright/test'
/** Stage 3 / TS 5.2+ method decorator context (subset; avoids coupling to a specific TS lib version). */
interface StepMethodDecoratorContext {
kind: 'method'
name: string | symbol
}
function isStage3MethodDecoratorContext(value: unknown): value is StepMethodDecoratorContext {
return (
typeof value === 'object' &&
value !== null &&
'kind' in value &&
(value as StepMethodDecoratorContext).kind === 'method'
)
}
/** Legacy method decorator (`experimentalDecorators`). */
export function step<T extends (...args: any[]) => any>(
target: object,
propertyKey: string | symbol,
descriptor: TypedPropertyDescriptor<T>
): TypedPropertyDescriptor<T>
/** Stage 3 method decorator (e.g. Node `--no-strip-types`). */
export function step<T extends (...args: any[]) => any>(
originalMethod: T,
context: StepMethodDecoratorContext
): T
/**
* Wraps page-object methods in `test.step` for clearer Playwright reports.
* Supports both legacy (`experimentalDecorators`) and Stage 3 method decorators
* (e.g. when tests run under Node's `--no-strip-types` + modern emit).
*/
export function step(
target: object | ((...args: any[]) => any),
propertyKeyOrContext: string | symbol | StepMethodDecoratorContext,
descriptor?: TypedPropertyDescriptor<(...args: any[]) => any>
): any {
if (
descriptor === undefined &&
isStage3MethodDecoratorContext(propertyKeyOrContext) &&
typeof target === 'function'
) {
const original = target as (...args: any[]) => any
const ctx = propertyKeyOrContext
const key = typeof ctx.name === 'string' ? ctx.name : String(ctx.name)
return async function (this: object, ...args: any[]) {
return await test.step(`${(this as { constructor: { name: string } }).constructor.name}.${key}`, async () => {
return original.apply(this, args)
})
}
}
if (!descriptor?.value) {
throw new Error(
'step: expected a legacy method decorator with descriptor.value, or a Stage 3 method decorator'
)
}
const propertyKey = propertyKeyOrContext as string
const original = descriptor.value
descriptor.value = async function (this: object, ...args: any[]) {
return await test.step(`${(this as { constructor: { name: string } }).constructor.name}.${String(propertyKey)}`, async () => {
return original.apply(this, args)
})
}
return descriptor
}
export function repeatTest(
name: string,
fn: (...args: any) => Promise<void>,
{ times = 5, only = false } = {}
) {
const getName = (i: number) => `${name} (${i + 1} of ${times})`
for (let i = 0; i < times; i++) {
if (only) {
// eslint-disable-next-line tldraw/no-focused-tests
test.only(getName(i), fn)
} else {
test(getName(i), fn)
}
}
return
}
repeatTest.only = (name: string, fn: (...args: any) => Promise<void>, { times = 5 } = {}) =>
repeatTest(name, fn, { times, only: true })

View File

@@ -0,0 +1,49 @@
import fs from 'fs'
import path from 'path'
import { clerkSetup } from '@clerk/testing/playwright'
import { chromium, expect, test as setup } from '@playwright/test'
import { Editor } from './fixtures/Editor'
import { HomePage } from './fixtures/HomePage'
import { Sidebar } from './fixtures/Sidebar'
// eslint-disable-next-line no-empty-pattern
setup('clerk setup', async ({}) => {
await clerkSetup()
})
setup('global staging setup', async () => {
const stagingStoragePath = path.join(__dirname, '.auth/staging.json')
if (fs.existsSync(stagingStoragePath)) {
console.warn('Staging auth already exists, skipping setup')
return
}
const email = process.env.STAGING_TEST_EMAIL
const password = process.env.STAGING_TEST_PASSWORD
if (!email || !password) {
throw new Error(
'STAGING_TEST_EMAIL and STAGING_TEST_PASSWORD not set, skipping staging auth setup'
)
}
const browser = await chromium.launch()
const page = await browser.newPage()
const sidebar = new Sidebar(page)
const editor = new Editor(page, sidebar)
const homePage = new HomePage(page, editor)
await homePage.goto('https://staging.tldraw.com/')
await homePage.loginWithEmailAndPassword(email, password)
await expect(page.getByTestId('tla-sidebar-layout')).toBeVisible()
const authDir = path.dirname(stagingStoragePath)
if (!fs.existsSync(authDir)) {
fs.mkdirSync(authDir, { recursive: true })
}
await page.context().storageState({ path: stagingStoragePath })
await browser.close()
})

View File

@@ -0,0 +1,9 @@
import { clerkSetup } from '@clerk/testing/playwright'
import { test as setup } from '@playwright/test'
// delete the e2e/auth directory
// eslint-disable-next-line no-empty-pattern
setup('clerk setup', async ({}) => {
await clerkSetup()
})

View File

@@ -0,0 +1,28 @@
/* eslint-disable no-console */
import { spawnSync } from 'child_process'
import path from 'path'
/**
* In CI the Playwright `webServer` boots the full dotcom dev stack (`VITE_PREVIEW=1 yarn dev-app`),
* which process-compose runs as host processes plus the postgres container. Playwright reaps the
* process-compose process it spawned, but the postgres container is daemon-managed and can survive,
* so `yarn dev-app:clean` removes the container, its volume, and the rest of the dev state.
*
* Gated to CI: locally `reuseExistingServer` means we are sharing the developer's own running stack,
* which we must not tear down.
*/
async function globalTeardown() {
if (!process.env.CI) return
const repoRoot = path.join(__dirname, '../../../../')
const result = spawnSync('yarn', ['dev-app:clean'], { cwd: repoRoot, stdio: 'inherit' })
if (result.error) {
console.warn(`Could not run yarn dev-app:clean: ${result.error.message}`)
} else if (result.status !== 0) {
console.warn(
`yarn dev-app:clean exited with ${result.signal ? `signal ${result.signal}` : `code ${result.status}`}; dev state may have leaked`
)
}
}
export default globalTeardown

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.'
)
})
})
})