From 044a5755524e1cb48aebf894db9d701150446ad0 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Wed, 19 Nov 2025 09:14:13 -0500 Subject: [PATCH] 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 --- .../__tests__/DeleteBucketModal.test.tsx | 2 +- .../__tests__/SupportFormPage.test.tsx | 22 ++- .../DeleteConfirmationDialogs.tsx | 2 +- .../SidePanelEditor/RowEditor/RowEditor.tsx | 1 + .../TableEditor/TableEditor.tsx | 1 + .../SQLEditorNavV2/UnshareSnippetModal.tsx | 5 +- e2e/studio/env.config.ts | 2 +- e2e/studio/features/_global.setup.ts | 4 +- e2e/studio/features/database.spec.ts | 22 +-- e2e/studio/features/sql-editor.spec.ts | 22 ++- e2e/studio/features/table-editor.spec.ts | 82 ++++---- e2e/studio/package.json | 1 + e2e/studio/playwright.config.ts | 6 +- e2e/studio/tsconfig.json | 2 + e2e/studio/utils/once-per-file.ts | 175 ++++++++++++++++++ e2e/studio/utils/test.ts | 4 +- .../ui/src/components/SidePanel/SidePanel.tsx | 19 +- 17 files changed, 291 insertions(+), 81 deletions(-) create mode 100644 e2e/studio/utils/once-per-file.ts diff --git a/apps/studio/components/interfaces/Storage/__tests__/DeleteBucketModal.test.tsx b/apps/studio/components/interfaces/Storage/__tests__/DeleteBucketModal.test.tsx index aa407b64a63..8ec31935b78 100644 --- a/apps/studio/components/interfaces/Storage/__tests__/DeleteBucketModal.test.tsx +++ b/apps/studio/components/interfaces/Storage/__tests__/DeleteBucketModal.test.tsx @@ -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` }) diff --git a/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx b/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx index fd7ea831601..6dcb98600a1 100644 --- a/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx +++ b/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx @@ -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(() => { diff --git a/apps/studio/components/interfaces/TableGridEditor/DeleteConfirmationDialogs.tsx b/apps/studio/components/interfaces/TableGridEditor/DeleteConfirmationDialogs.tsx index ad47117abb0..b68e8230141 100644 --- a/apps/studio/components/interfaces/TableGridEditor/DeleteConfirmationDialogs.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/DeleteConfirmationDialogs.tsx @@ -261,7 +261,7 @@ const DeleteConfirmationDialogs = ({ }} onConfirm={onConfirmDeleteTable} > -
+

Are you sure you want to delete the selected table? This action cannot be undone.

diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.tsx index c84c8b5b32e..935915f6e7e 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.tsx @@ -157,6 +157,7 @@ const RowEditor = ({ return ( -
    +
    • Project members will no longer be able to view this query. diff --git a/e2e/studio/env.config.ts b/e2e/studio/env.config.ts index e7d451460d6..bb743583f85 100644 --- a/e2e/studio/env.config.ts +++ b/e2e/studio/env.config.ts @@ -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') diff --git a/e2e/studio/features/_global.setup.ts b/e2e/studio/features/_global.setup.ts index 187e72fb8fb..b0e0cc82880 100644 --- a/e2e/studio/features/_global.setup.ts +++ b/e2e/studio/features/_global.setup.ts @@ -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, }) diff --git a/e2e/studio/features/database.spec.ts b/e2e/studio/features/database.spec.ts index d90b1100dae..4e69ef126e7 100644 --- a/e2e/studio/features/database.spec.ts +++ b/e2e/studio/features/database.spec.ts @@ -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() diff --git a/e2e/studio/features/sql-editor.spec.ts b/e2e/studio/features/sql-editor.spec.ts index 46b56cd8e83..2312e5f5e8f 100644 --- a/e2e/studio/features/sql-editor.spec.ts +++ b/e2e/studio/features/sql-editor.spec.ts @@ -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 diff --git a/e2e/studio/features/table-editor.spec.ts b/e2e/studio/features/table-editor.spec.ts index dfb5116bfb3..20151da746b 100644 --- a/e2e/studio/features/table-editor.spec.ts +++ b/e2e/studio/features/table-editor.spec.ts @@ -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() diff --git a/e2e/studio/package.json b/e2e/studio/package.json index e88c363639a..e788802b57b 100644 --- a/e2e/studio/package.json +++ b/e2e/studio/package.json @@ -1,6 +1,7 @@ { "name": "e2e-studio", "version": "1.0.0", + "type": "module", "main": "index.js", "scripts": { "e2e": "playwright test", diff --git a/e2e/studio/playwright.config.ts b/e2e/studio/playwright.config.ts index d64ba62916a..b99cb159e61 100644 --- a/e2e/studio/playwright.config.ts +++ b/e2e/studio/playwright.config.ts @@ -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 diff --git a/e2e/studio/tsconfig.json b/e2e/studio/tsconfig.json index fa766b8295d..332d2ddf7bd 100644 --- a/e2e/studio/tsconfig.json +++ b/e2e/studio/tsconfig.json @@ -1,5 +1,7 @@ { "compilerOptions": { + "target": "ES2022", + "module": "nodenext", "jsx": "react", "skipLibCheck": true, "esModuleInterop": true diff --git a/e2e/studio/utils/once-per-file.ts b/e2e/studio/utils/once-per-file.ts new file mode 100644 index 00000000000..298b7a08bdc --- /dev/null +++ b/e2e/studio/utils/once-per-file.ts @@ -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, + { timeoutMs = 120_000 } = {} +): Promise => { + 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 => { + 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 {} + } +} diff --git a/e2e/studio/utils/test.ts b/e2e/studio/utils/test.ts index 0aa9754ed7e..267f4644cf9 100644 --- a/e2e/studio/utils/test.ts +++ b/e2e/studio/utils/test.ts @@ -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, }) diff --git a/packages/ui/src/components/SidePanel/SidePanel.tsx b/packages/ui/src/components/SidePanel/SidePanel.tsx index b1d9608229f..8c0f9d53c9e 100644 --- a/packages/ui/src/components/SidePanel/SidePanel.tsx +++ b/packages/ui/src/components/SidePanel/SidePanel.tsx @@ -104,6 +104,14 @@ const SidePanel = ({ open = open || visible + const { + onOpenAutoFocus, + onCloseAutoFocus, + onEscapeKeyDown, + onPointerDownOutside, + onInteractOutside, + } = props + return ( {triggerElement && {triggerElement}} @@ -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}
      }
      {children}