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
696 lines
22 KiB
TypeScript
696 lines
22 KiB
TypeScript
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'
|