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
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:
32
apps/dotcom/client/e2e/README.md
Normal file
32
apps/dotcom/client/e2e/README.md
Normal 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.
|
||||
7
apps/dotcom/client/e2e/consts.ts
Normal file
7
apps/dotcom/client/e2e/consts.ts
Normal 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`
|
||||
)
|
||||
68
apps/dotcom/client/e2e/fixtures/Database.ts
Normal file
68
apps/dotcom/client/e2e/fixtures/Database.ts
Normal 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]
|
||||
}
|
||||
34
apps/dotcom/client/e2e/fixtures/DeleteFileDialog.ts
Normal file
34
apps/dotcom/client/e2e/fixtures/DeleteFileDialog.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
95
apps/dotcom/client/e2e/fixtures/Editor.ts
Normal file
95
apps/dotcom/client/e2e/fixtures/Editor.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
30
apps/dotcom/client/e2e/fixtures/ErrorPages.ts
Normal file
30
apps/dotcom/client/e2e/fixtures/ErrorPages.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
103
apps/dotcom/client/e2e/fixtures/HomePage.ts
Normal file
103
apps/dotcom/client/e2e/fixtures/HomePage.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
51
apps/dotcom/client/e2e/fixtures/ImportHelper.ts
Normal file
51
apps/dotcom/client/e2e/fixtures/ImportHelper.ts
Normal 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)}`)
|
||||
}
|
||||
}
|
||||
169
apps/dotcom/client/e2e/fixtures/ShareMenu.ts
Normal file
169
apps/dotcom/client/e2e/fixtures/ShareMenu.ts
Normal 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())
|
||||
}
|
||||
}
|
||||
649
apps/dotcom/client/e2e/fixtures/Sidebar.ts
Normal file
649
apps/dotcom/client/e2e/fixtures/Sidebar.ts
Normal 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())
|
||||
}
|
||||
}
|
||||
66
apps/dotcom/client/e2e/fixtures/SignInDialog.ts
Normal file
66
apps/dotcom/client/e2e/fixtures/SignInDialog.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
29
apps/dotcom/client/e2e/fixtures/WorkspaceInviteDialog.ts
Normal file
29
apps/dotcom/client/e2e/fixtures/WorkspaceInviteDialog.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
121
apps/dotcom/client/e2e/fixtures/helpers.ts
Normal file
121
apps/dotcom/client/e2e/fixtures/helpers.ts
Normal 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()
|
||||
}
|
||||
695
apps/dotcom/client/e2e/fixtures/scenario-test.ts
Normal file
695
apps/dotcom/client/e2e/fixtures/scenario-test.ts
Normal 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'
|
||||
39
apps/dotcom/client/e2e/fixtures/staging-test.ts
Normal file
39
apps/dotcom/client/e2e/fixtures/staging-test.ts
Normal 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'
|
||||
203
apps/dotcom/client/e2e/fixtures/tla-test.ts
Normal file
203
apps/dotcom/client/e2e/fixtures/tla-test.ts
Normal 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 })
|
||||
49
apps/dotcom/client/e2e/global-staging.setup.ts
Normal file
49
apps/dotcom/client/e2e/global-staging.setup.ts
Normal 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()
|
||||
})
|
||||
9
apps/dotcom/client/e2e/global.setup.ts
Normal file
9
apps/dotcom/client/e2e/global.setup.ts
Normal 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()
|
||||
})
|
||||
28
apps/dotcom/client/e2e/global.teardown.ts
Normal file
28
apps/dotcom/client/e2e/global.teardown.ts
Normal 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
|
||||
35
apps/dotcom/client/e2e/tests/auth.scenario.spec.ts
Normal file
35
apps/dotcom/client/e2e/tests/auth.scenario.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
25
apps/dotcom/client/e2e/tests/client-too-old.scenario.spec.ts
Normal file
25
apps/dotcom/client/e2e/tests/client-too-old.scenario.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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$/)
|
||||
})
|
||||
})
|
||||
60
apps/dotcom/client/e2e/tests/invite-signin.scenario.spec.ts
Normal file
60
apps/dotcom/client/e2e/tests/invite-signin.scenario.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
86
apps/dotcom/client/e2e/tests/legacy-routes.scenario.spec.ts
Normal file
86
apps/dotcom/client/e2e/tests/legacy-routes.scenario.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
285
apps/dotcom/client/e2e/tests/sharing-live.scenario.spec.ts
Normal file
285
apps/dotcom/client/e2e/tests/sharing-live.scenario.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
110
apps/dotcom/client/e2e/tests/smoke/cookie-consent.spec.ts
Normal file
110
apps/dotcom/client/e2e/tests/smoke/cookie-consent.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
35
apps/dotcom/client/e2e/tests/smoke/database.spec.ts
Normal file
35
apps/dotcom/client/e2e/tests/smoke/database.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
162
apps/dotcom/client/e2e/tests/smoke/homepage.spec.ts
Normal file
162
apps/dotcom/client/e2e/tests/smoke/homepage.spec.ts
Normal 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)
|
||||
})
|
||||
47
apps/dotcom/client/e2e/tests/smoke/not-logged-in.spec.ts
Normal file
47
apps/dotcom/client/e2e/tests/smoke/not-logged-in.spec.ts
Normal 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')
|
||||
})
|
||||
383
apps/dotcom/client/e2e/tests/smoke/sharing.spec.ts
Normal file
383
apps/dotcom/client/e2e/tests/smoke/sharing.spec.ts
Normal 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()
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
64
apps/dotcom/client/e2e/tests/smoke/signin-dialog.spec.ts
Normal file
64
apps/dotcom/client/e2e/tests/smoke/signin-dialog.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
117
apps/dotcom/client/e2e/tests/smoke/test-import-download.spec.ts
Normal file
117
apps/dotcom/client/e2e/tests/smoke/test-import-download.spec.ts
Normal 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$/)
|
||||
})
|
||||
})
|
||||
513
apps/dotcom/client/e2e/tests/smoke/workspaces.spec.ts
Normal file
513
apps/dotcom/client/e2e/tests/smoke/workspaces.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
38
apps/dotcom/client/e2e/tests/staging.spec.ts
Normal file
38
apps/dotcom/client/e2e/tests/staging.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
379
apps/dotcom/client/e2e/tests/ui.scenario.spec.ts
Normal file
379
apps/dotcom/client/e2e/tests/ui.scenario.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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.'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user