test: parallelize table editor tests (#40404)

Table editor tests were being run serially because they would otherwise
stomp all over each other in setup. But this made them really slow. This
PR changes them to run in parallel, and makes sure the setup step runs
only once by using a file lock.

Also cleans up miscellaneous other sources of flakiness in tests.

Co-authored-by: Ali Waseem <waseema393@gmail.com>
This commit is contained in:
CharisandAli Waseem authored and GitHub committed 2025-11-19 07:14:13 -07:00
1 parent 11107a4781
commit 044a575552
17 files changed
+291 -81

No files matched your search

@@ -110,7 +110,7 @@ describe(`DeleteBucketModal`, () => {
await userEvent.click(openButton)
await screen.findByRole(`dialog`)
const input = screen.getByLabelText(/Type/)
const input = screen.getByPlaceholderText(/Type/)
await userEvent.type(input, `test`)
const confirmButton = screen.getByRole(`button`, { name: `Delete bucket` })
@@ -655,10 +655,13 @@ describe('SupportFormPage', () => {
renderSupportFormPage()
await waitFor(() => {
expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1')
expect(getProjectSelector(screen)).toHaveTextContent('Project 1')
})
await waitFor(
() => {
expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1')
expect(getProjectSelector(screen)).toHaveTextContent('Project 1')
},
{ timeout: 5_000 }
)
await selectCategoryOption(screen, 'Dashboard bug')
await waitFor(() => {
@@ -748,10 +751,13 @@ describe('SupportFormPage', () => {
renderSupportFormPage()
await waitFor(() => {
expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1')
expect(getProjectSelector(screen)).toHaveTextContent('Project 1')
})
await waitFor(
() => {
expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1')
expect(getProjectSelector(screen)).toHaveTextContent('Project 1')
},
{ timeout: 5_000 }
)
await selectCategoryOption(screen, 'APIs and client libraries')
await waitFor(() => {
@@ -261,7 +261,7 @@ const DeleteConfirmationDialogs = ({
}}
onConfirm={onConfirmDeleteTable}
>
<div className="space-y-4">
<div data-testid="confirm-delete-table-modal" className="space-y-4">
<p className="text-sm text-foreground-light">
Are you sure you want to delete the selected table? This action cannot be undone.
</p>
@@ -157,6 +157,7 @@ const RowEditor = ({
return (
<SidePanel
data-testid="side-panel-row-editor"
hideFooter
size="large"
key="RowEditor"
@@ -325,6 +325,7 @@ export const TableEditor = ({
return (
<SidePanel
data-testid="table-editor-side-panel"
size="large"
key="TableEditor"
visible={visible}
@@ -83,7 +83,10 @@ export const UnshareSnippetModal = ({
description: 'Only you will have access to this query',
}}
>
<ul className="text-sm text-foreground-light space-y-5">
<ul
data-testid="confirm-unshare-snippet-modal"
className="text-sm text-foreground-light space-y-5"
>
<li className="flex gap-3">
<EyeOffIcon />
<span>Project members will no longer be able to view this query.</span>
+1 -1
View File
@@ -18,4 +18,4 @@ export const env = {
process.env.VERCEL_AUTOMATION_BYPASS_SELFHOSTED_STUDIO || 'false',
}
export const STORAGE_STATE_PATH = path.join(__dirname, './playwright/.auth/user.json')
export const STORAGE_STATE_PATH = path.join(import.meta.dirname, './playwright/.auth/user.json')
+2 -2
View File
@@ -1,7 +1,7 @@
import { expect, test as setup } from '@playwright/test'
import dotenv from 'dotenv'
import path from 'path'
import { env, STORAGE_STATE_PATH } from '../env.config'
import { env, STORAGE_STATE_PATH } from '../env.config.js'
/**
* Run any setup tasks for the tests.
@@ -9,7 +9,7 @@ import { env, STORAGE_STATE_PATH } from '../env.config'
*/
dotenv.config({
path: path.resolve(__dirname, '..', '.env.local'),
path: path.resolve(import.meta.dirname, '..', '.env.local'),
override: true,
})
+8 -14
View File
@@ -1,12 +1,12 @@
import { expect, Page } from '@playwright/test'
import { env } from '../env.config'
import { test } from '../utils/test'
import { toUrl } from '../utils/to-url'
import { env } from '../env.config.js'
import { test } from '../utils/test.js'
import { toUrl } from '../utils/to-url.js'
import {
createApiResponseWaiter,
waitForApiResponse,
waitForDatabaseToLoad,
createApiResponseWaiter,
} from '../utils/wait-for-response'
} from '../utils/wait-for-response.js'
const databaseTableName = 'pw_database_table'
const databaseTableNameNew = 'pw_database_table_new'
@@ -386,9 +386,7 @@ test.describe.serial('Database', () => {
const triggerRow = await page.getByRole('row', { name: databaseTriggerName })
await triggerRow.getByRole('button', { name: 'More options' }).click()
await page.getByRole('menuitem', { name: 'Delete trigger' }).click()
await page
.getByRole('textbox', { name: `Type ${databaseTriggerName} to confirm.` })
.fill(databaseTriggerName)
await page.getByPlaceholder('Type in name of trigger').fill(databaseTriggerName)
await page.getByRole('button', { name: `Delete trigger ${databaseTriggerName}` }).click()
await expect(
page.getByText(`Successfully removed ${databaseTriggerName}`),
@@ -441,9 +439,7 @@ test.describe.serial('Database', () => {
// delete trigger
await updatedTriggerRow.getByRole('button', { name: 'More options' }).click()
await page.getByRole('menuitem', { name: 'Delete trigger' }).click()
await page
.getByRole('textbox', { name: `Type ${databaseTriggerNameUpdated} to confirm.` })
.fill(databaseTriggerNameUpdated)
await page.getByPlaceholder('Type in name of trigger').fill(databaseTriggerNameUpdated)
await page
.getByRole('button', { name: `Delete trigger ${databaseTriggerNameUpdated}` })
.click()
@@ -791,9 +787,7 @@ END;`)
// delete function
await updatedFunctionRow.getByRole('button', { name: 'More options' }).click()
await page.getByRole('menuitem', { name: 'Delete function' }).click()
await page
.getByRole('textbox', { name: `Type ${databaseFunctionNameUpdated} to confirm.` })
.fill(databaseFunctionNameUpdated)
await page.getByPlaceholder('Type in name of function').fill(databaseFunctionNameUpdated)
await page
.getByRole('button', { name: `Delete function ${databaseFunctionNameUpdated}` })
.click()
+15 -7
View File
@@ -1,11 +1,11 @@
import { expect, Page } from '@playwright/test'
import fs from 'fs'
import { isCLI } from '../utils/is-cli'
import { test } from '../utils/test'
import { toUrl } from '../utils/to-url'
import { waitForApiResponse } from '../utils/wait-for-response'
import { waitForApiResponseWithTimeout } from '../utils/wait-for-response-with-timeout'
import { resetLocalStorage } from '../utils/reset-local-storage'
import { isCLI } from '../utils/is-cli.js'
import { resetLocalStorage } from '../utils/reset-local-storage.js'
import { test } from '../utils/test.js'
import { toUrl } from '../utils/to-url.js'
import { waitForApiResponseWithTimeout } from '../utils/wait-for-response-with-timeout.js'
import { waitForApiResponse } from '../utils/wait-for-response.js'
const sqlSnippetName = 'pw_sql_snippet'
const sqlSnippetNameDuplicate = 'pw_sql_snippet (Duplicate)'
@@ -122,10 +122,14 @@ test.describe('SQL Editor', () => {
await page.locator('.view-lines').click()
await page.keyboard.press('ControlOrMeta+KeyA')
await page.keyboard.type(`select 'hello world';`)
const sqlMutationPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=', {
method: 'POST',
})
await page.getByTestId('sql-run-button').click()
await sqlMutationPromise
// verify the result
await waitForApiResponse(page, 'pg-meta', ref, 'query?key=', { method: 'POST' })
await expect(page.getByRole('gridcell', { name: 'hello world' })).toBeVisible()
// SQL written in the editor should not be the previous query.
@@ -353,7 +357,11 @@ hello world`)
await sharedSnippet.getByText(sqlSnippetNameShare).click({ button: 'right' })
await page.getByRole('menuitem', { name: 'Unshare query with team' }).click()
await expect(page.getByRole('heading', { name: 'Confirm to unshare query:' })).toBeVisible()
const unsharePromise = waitForApiResponse(page, 'projects', ref, 'content', { method: 'PUT' })
await page.getByRole('button', { name: 'Unshare query', exact: true }).click()
await unsharePromise
await expect(page.getByTestId('confirm-unshare-snippet-modal')).not.toBeVisible()
await expect(sharedSnippet.getByText(sqlSnippetNameShare, { exact: true })).not.toBeVisible()
// clear SQL snippet
+47 -35
View File
@@ -1,16 +1,17 @@
import { expect, Locator, Page } from '@playwright/test'
import fs from 'fs'
import path from 'path'
import { isCLI } from '../utils/is-cli'
import { resetLocalStorage } from '../utils/reset-local-storage'
import { test } from '../utils/test'
import { toUrl } from '../utils/to-url'
import { isCLI } from '../utils/is-cli.js'
import { releaseFileOnceCleanup, withFileOnceSetup } from '../utils/once-per-file.js'
import { resetLocalStorage } from '../utils/reset-local-storage.js'
import { test } from '../utils/test.js'
import { toUrl } from '../utils/to-url.js'
import { waitForApiResponseWithTimeout } from '../utils/wait-for-response-with-timeout.js'
import {
waitForApiResponse,
waitForGridDataToLoad,
waitForTableToLoad,
} from '../utils/wait-for-response'
import { waitForApiResponseWithTimeout } from '../utils/wait-for-response-with-timeout'
} from '../utils/wait-for-response.js'
const tableNamePrefix = 'pw_table'
const columnName = 'pw_column'
@@ -66,6 +67,7 @@ const createTable = async (page: Page, ref: string, tableName: string) => {
)
await page.getByRole('button', { name: 'Save' }).click()
await Promise.all([createTablePromise, tablesPromise, entitiesPromise])
await page.waitForSelector('[data-testid="table-editor-side-panel"]', { state: 'detached' })
await expect(
page.getByRole('button', { name: `View ${tableName}`, exact: true }),
'Table should be visible after creation'
@@ -85,6 +87,7 @@ const deleteTable = async (page: Page, ref: string, tableName: string) => {
const revalidatePromise = waitForApiResponse(page, 'pg-meta', ref, `query?key=entity-types-`)
await page.getByRole('button', { name: 'Delete' }).click()
await Promise.all([apiPromise, revalidatePromise])
await expect(page.getByTestId('confirm-delete-table-modal')).not.toBeVisible()
}
const deleteEnumIfExist = async (page: Page, ref: string, enumName: string) => {
@@ -104,36 +107,47 @@ const deleteEnumIfExist = async (page: Page, ref: string, enumName: string) => {
await waitForApiResponse(page, 'pg-meta', ref, 'query?key=', { method: 'POST' })
}
test.describe.serial('table editor', () => {
test.describe('table editor', () => {
test.beforeAll(async ({ browser, ref }) => {
const ctx = await browser.newContext()
const page = await ctx.newPage()
await withFileOnceSetup(import.meta.url, async () => {
const ctx = await browser.newContext()
const page = await ctx.newPage()
const loadPromise = waitForTableToLoad(page, ref)
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
await loadPromise
const loadPromise = waitForTableToLoad(page, ref)
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
await loadPromise
const viewButtons = page.getByRole('button', { name: /^View / })
const names = await Promise.all(
(await viewButtons.all()).map(async (btn) => {
const ariaLabel = await btn.getAttribute('aria-label')
const name = ariaLabel ? ariaLabel.replace(/^View\s+/, '').trim() : ''
return name
})
)
const tablesToDelete = names.filter((tableName) => tableName.startsWith(tableNamePrefix))
const viewButtons = page.getByRole('button', { name: /^View / })
const names = await Promise.all(
(await viewButtons.all()).map(async (btn) => {
const ariaLabel = await btn.getAttribute('aria-label')
const name = ariaLabel ? ariaLabel.replace(/^View\s+/, '').trim() : ''
return name
})
)
const tablesToDelete = names.filter((tableName) => tableName.startsWith(tableNamePrefix))
for (const tableName of tablesToDelete) {
await deleteTable(page, ref, tableName)
}
for (const tableName of tablesToDelete) {
await deleteTable(page, ref, tableName)
await expect
.poll(async () => {
return await page.getByLabel(`View ${tableName}`, { exact: true }).count()
})
.toBe(0)
}
})
})
test.beforeEach(async ({ page, ref }) => {
const loadPromise = waitForTableToLoad(page, ref)
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
page.goto(toUrl(`/project/${ref}/editor?schema=public`))
await loadPromise
})
test.afterAll(async () => {
await releaseFileOnceCleanup(import.meta.url)
})
test('sidebar actions works as expected', async ({ page, ref }) => {
const tableNameActions = 'pw_table_actions'
const tableNameActionsDuplicate = 'pw_table_actions_duplicate'
@@ -184,9 +198,6 @@ test.describe.serial('table editor', () => {
await expect(
page.getByLabel(`View ${tableNameActionsDuplicate}`, { exact: true })
).toBeVisible()
await deleteTable(page, ref, tableNameActionsDuplicate)
await deleteTable(page, ref, tableNameActions)
})
test('switching schemas work as expected', async ({ page, ref }) => {
@@ -286,12 +297,12 @@ test.describe.serial('table editor', () => {
).toBeVisible({
timeout: 50000,
})
await expect(page.getByTestId('table-editor-side-panel')).not.toBeVisible()
// Wait for the grid to be visible and data to be loaded
await expect(
page.getByRole('grid'),
'Grid should be visible after inserting data'
).toBeVisible()
await expect(page.getByRole('grid'), 'Grid should be visible after inserting data').toBeVisible(
{ timeout: 10_000 }
)
await expect(page.getByRole('columnheader', { name: enum_name })).toBeVisible()
// insert row with enum value
@@ -299,6 +310,7 @@ test.describe.serial('table editor', () => {
await page.getByText('Insert a new row into').click()
await page.getByRole('combobox').selectOption('value1')
await page.getByTestId('action-bar-save-row').click()
await expect(page.getByTestId('side-panel-row-editor')).not.toBeVisible()
await expect(page.getByRole('gridcell', { name: 'value1' })).toBeVisible()
// insert row with another enum value
@@ -306,7 +318,7 @@ test.describe.serial('table editor', () => {
await page.getByText('Insert a new row into').click()
await page.getByRole('combobox').selectOption('value2')
await page.getByTestId('action-bar-save-row').click()
await expect(page.getByRole('gridcell', { name: 'value2' })).toBeVisible()
await expect(page.getByRole('gridcell', { name: 'value2' })).toBeVisible({ timeout: 10_000 })
// delete enum and enum table
await deleteTable(page, ref, tableNameEnum)
@@ -560,7 +572,7 @@ test.describe.serial('table editor', () => {
await page.waitForURL(/\/editor\/\d+\?schema=public$/)
// importing 50 data via csv file
const csvFilePath = path.join(__dirname, 'files', 'table-editor-import-file.csv')
const csvFilePath = path.join(import.meta.dirname, 'files', 'table-editor-import-file.csv')
await page.getByRole('button', { name: 'Import data from CSV' }).click()
await page.getByRole('tab', { name: 'Upload CSV' }).click()
await page.setInputFiles('input[type="file"]', csvFilePath)
@@ -571,7 +583,7 @@ test.describe.serial('table editor', () => {
await expect(page.getByText('50 records')).toBeVisible()
// importing 51 data via paste text
const filePath = path.join(__dirname, 'files', 'table-editor-import-paste.txt')
const filePath = path.join(import.meta.dirname, 'files', 'table-editor-import-paste.txt')
const fileContent = fs.readFileSync(filePath, 'utf-8')
await page.getByTestId('table-editor-insert-new-row').click()
await page.getByRole('menuitem', { name: 'Import data from CSV' }).click()
+1
View File
@@ -1,6 +1,7 @@
{
"name": "e2e-studio",
"version": "1.0.0",
"type": "module",
"main": "index.js",
"scripts": {
"e2e": "playwright test",
+2 -4
View File
@@ -1,11 +1,9 @@
import { defineConfig } from '@playwright/test'
import { env, STORAGE_STATE_PATH } from './env.config'
import dotenv from 'dotenv'
import path from 'path'
import { env, STORAGE_STATE_PATH } from './env.config.js'
dotenv.config({
path: path.resolve(__dirname, '.env.local'),
})
dotenv.config({ path: path.resolve(import.meta.dirname, '.env.local') })
const IS_CI = !!process.env.CI
+2
View File
@@ -1,5 +1,7 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "nodenext",
"jsx": "react",
"skipLibCheck": true,
"esModuleInterop": true
+175
View File
@@ -0,0 +1,175 @@
import crypto from 'node:crypto'
import fs, { type FileHandle } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = path.join(os.tmpdir(), 'playwright-locks')
const sha1 = (str: string) => crypto.createHash('sha1').update(str).digest('hex')
const uniqueWorkerId = () => {
const workerIndex = process.env.TEST_WORKER_INDEX
const hostname = os.hostname()
const pid = process.pid
const uniquePart = crypto.randomBytes(4).toString('hex')
return `${hostname}-${pid}-${workerIndex}-${uniquePart}`
}
const keyFromModuleUrl = (moduleUrl: string) => {
const absPath = fileURLToPath(moduleUrl)
const key = sha1(absPath)
const dir = path.join(ROOT, key.slice(0, 2), key.slice(2))
return { absPath, key, dir }
}
const exists = async (path: string) => {
try {
await fs.access(path)
return true
} catch {
return false
}
}
const getCodeFromUnknownError = (err: unknown): string | undefined => {
if (err && typeof err === 'object' && 'code' in err) {
return String(err.code)
}
}
export const withFileOnceSetup = async (
currentModuleUrl: string,
fn: () => Promise<void>,
{ timeoutMs = 120_000 } = {}
): Promise<void> => {
const { absPath, dir } = keyFromModuleUrl(currentModuleUrl)
const lockfile = path.join(dir, `setup.lock`)
const doneFile = path.join(dir, `setup.done.json`)
const leasesDir = path.join(dir, `leases`)
const leaseId = uniqueWorkerId()
const myLease = path.join(leasesDir, leaseId)
await fs.mkdir(leasesDir, { recursive: true })
// Acquire a lease for this worker on this test file
await fs.writeFile(myLease, '')
// Fast path: another worker already finished setup
if (await exists(doneFile)) {
return
}
// Try to acquire the setup lock
let fd: FileHandle | null = null
try {
fd = await fs.open(lockfile, 'wx')
} catch (err: unknown) {
if (getCodeFromUnknownError(err) !== 'EEXIST') {
// Drop lease and bail
try {
await fs.unlink(myLease)
} catch {}
throw err
}
// Another worker is doing setup, wait for done
const start = Date.now()
const baseSleepMs = 500
const incrementMs = 100
let attempt = 0
while (!(await exists(doneFile))) {
if (Date.now() - start > timeoutMs) {
// Drop lease and bail
try {
await fs.unlink(myLease)
} catch {}
throw new Error(`Timed out waiting for once-per-file setup in ${absPath}`)
}
const jitter = (Math.random() - 0.5) * 100
const sleep = baseSleepMs + incrementMs * Math.pow(2, attempt++) + jitter
await new Promise((resolve) => setTimeout(resolve, sleep))
}
}
// We have the lock, perform setup and mark done
try {
if (!(await exists(doneFile))) {
await fn()
await fs.writeFile(
doneFile,
JSON.stringify({ at: new Date().toISOString(), file: absPath, pid: process.pid }, null, 2)
)
}
} finally {
try {
await fd?.close()
} catch {}
try {
await fs.unlink(lockfile)
} catch {}
}
}
export const releaseFileOnceCleanup = async (currentModuleUrl: string): Promise<void> => {
const { dir } = keyFromModuleUrl(currentModuleUrl)
const leasesDir = path.join(dir, `leases`)
const doneFile = path.join(dir, `setup.done.json`)
const cleanupLock = path.join(dir, `cleanup.lock`)
// Remove all leases associated with this worker
try {
const entries = await fs.readdir(leasesDir)
await Promise.all(
entries.map(async (name) => {
if (name.includes(`-${process.pid}-`)) {
try {
await fs.unlink(path.join(leasesDir, name))
} catch {}
}
})
)
} catch {}
// Try to acquire cleanup lock
let fd: FileHandle | null = null
try {
await fs.mkdir(dir, { recursive: true })
fd = await fs.open(cleanupLock, 'wx')
} catch {
// Another worker is cleaning up; we're done
return
}
try {
// Only delete if no leases remain
let remaining = 0
try {
const entries = await fs.readdir(leasesDir)
remaining = entries.length
} catch {}
if (remaining === 0) {
// Remove done marker and empty directories
try {
await fs.unlink(doneFile)
} catch {}
try {
await fs.rmdir(leasesDir)
} catch {}
// Attempt to remove the key directory if empty
try {
await fs.rmdir(dir)
} catch {}
}
} finally {
try {
await fd?.close()
} catch {}
try {
await fs.unlink(cleanupLock)
} catch {}
}
}
+2 -2
View File
@@ -1,10 +1,10 @@
import { test as base } from '@playwright/test'
import dotenv from 'dotenv'
import path from 'path'
import { env } from '../env.config'
import { env } from '../env.config.js'
dotenv.config({
path: path.resolve(__dirname, '../.env.local'),
path: path.resolve(import.meta.dirname, '../.env.local'),
override: true,
})
@@ -104,6 +104,14 @@ const SidePanel = ({
open = open || visible
const {
onOpenAutoFocus,
onCloseAutoFocus,
onEscapeKeyDown,
onPointerDownOutside,
onInteractOutside,
} = props
return (
<Dialog.Root open={open} onOpenChange={handleOpenChange} defaultOpen={defaultOpen}>
{triggerElement && <Dialog.Trigger asChild>{triggerElement}</Dialog.Trigger>}
@@ -117,15 +125,16 @@ const SidePanel = ({
__styles.align[align],
className && className,
].join(' ')}
onOpenAutoFocus={props.onOpenAutoFocus}
onCloseAutoFocus={props.onCloseAutoFocus}
onEscapeKeyDown={props.onEscapeKeyDown}
onPointerDownOutside={props.onPointerDownOutside}
onOpenAutoFocus={onOpenAutoFocus}
onCloseAutoFocus={onCloseAutoFocus}
onEscapeKeyDown={onEscapeKeyDown}
onPointerDownOutside={onPointerDownOutside}
onInteractOutside={(event) => {
const isToast = (event.target as Element)?.closest('#toast')
if (isToast) event.preventDefault()
if (props.onInteractOutside) props.onInteractOutside(event)
if (onInteractOutside) onInteractOutside(event)
}}
{...props}
>
{header && <header className={__styles.header}>{header}</header>}
<div className={__styles.contents}>{children}</div>