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 && }
{children}