Files
upstream-mirror a774ac673a
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
2026-09-23 16:12:52 +05:00

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'