mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
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>
|
||||
|
||||
+1
@@ -157,6 +157,7 @@ const RowEditor = ({
|
||||
|
||||
return (
|
||||
<SidePanel
|
||||
data-testid="side-panel-row-editor"
|
||||
hideFooter
|
||||
size="large"
|
||||
key="RowEditor"
|
||||
|
||||
+1
@@ -325,6 +325,7 @@ export const TableEditor = ({
|
||||
|
||||
return (
|
||||
<SidePanel
|
||||
data-testid="table-editor-side-panel"
|
||||
size="large"
|
||||
key="TableEditor"
|
||||
visible={visible}
|
||||
|
||||
+4
-1
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
{
|
||||
"name": "e2e-studio",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"e2e": "playwright test",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "nodenext",
|
||||
"jsx": "react",
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user