import { test, expect, Page, Locator } from '@playwright/test' import { runDbManagerAlterTableTest, runDbManagerSimpleCRUDTest } from './DbManagerPage' import { DbType } from '../src/lib/components/dbTypes' import { Dropdown, Toast, prettify } from './utils' const resourceByDbType = { postgresql: getJsonEnv('POSTGRESQL_RESOURCE') ?? { host: 'postgres_e2e', port: 5432, dbname: 'test_db', user: 'test_user', password: 'postgres_password', sslmode: 'disable' }, mysql: getJsonEnv('MYSQL_RESOURCE') ?? { host: 'mysql_e2e', port: 3306, user: 'test_user', database: 'test_db', password: 'test_password', ssl: false }, oracledb: getJsonEnv('ORACLEDB_RESOURCE') ?? { user: 'test_user', password: 'test_password', database: 'oracle_e2e:1521/test_db' }, ms_sql_server: getJsonEnv('MS_SQL_SERVER_RESOURCE') ?? { host: 'mssql_e2e', user: 'sa', password: 'MsSql_Pass123!', port: 1433, dbname: 'master', instance_name: '', trust_cert: true, ca_cert: '', encrypt: true }, bigquery: getJsonEnv('BIGQUERY_RESOURCE'), snowflake: getJsonEnv('SNOWFLAKE_RESOURCE') } as const test.describe('Database resources', () => { for (const dbType of ['postgresql', 'mysql', 'bigquery', 'ms_sql_server', 'snowflake'] as const) { test.describe(prettify(dbType), () => { test.skip( resourceByDbType[dbType] === undefined, `No resource config for ${dbType}, set ${dbType.toUpperCase()}_RESOURCE` ) test(`simple CRUD with DB Manager`, async ({ page }) => { await setupNewResourceAndOpenDbManager(page, dbType) await runDbManagerSimpleCRUDTest(page, dbType) }) test(`Alter table with DB Manager`, async ({ page }) => { await setupNewResourceAndOpenDbManager(page, dbType) await runDbManagerAlterTableTest(page, dbType) }) }) } }) test.describe('Data tables', () => { test('simple CRUD with DB Manager', async ({ page }) => { await openDataTableDbManager(page) await runDbManagerSimpleCRUDTest(page, 'postgresql') }) test('Alter table with DB Manager', async ({ page }) => { await openDataTableDbManager(page) await runDbManagerAlterTableTest(page, 'postgresql') }) }) test.describe('Ducklake', () => { // Avoid race condition when saving settings. // Be careful when adding new tests here. // TODO : find a way to run these tests in parallel (setup for all storage resources ?) test.describe.configure({ mode: 'serial' }) test('simple CRUD with DB Manager', async ({ page }) => { await openDucklakeDbManager(page, 's3') await runDbManagerSimpleCRUDTest(page, 'ducklake') }) test('Alter table with DB Manager', async ({ page }) => { await openDucklakeDbManager(page, 's3') await runDbManagerAlterTableTest(page, 'ducklake') }) }) async function openDucklakeDbManager(page: Page, resource_type: StorageResourceType) { let { storage } = await setupWsStorage(page, resource_type) await page.goto('/workspace_settings?tab=windmill_lfs') const timestamp = Date.now() await page.locator('button:has-text("New Ducklake")').click() let lastRow = page.locator('.ducklake-settings-table').locator('tr').nth(-2) await lastRow.locator('input.ducklake-name').fill(`ducklake_${timestamp}`) await lastRow.locator('.ducklake-workspace-storage-select').click() await Dropdown.getOption(page, storage, { exact: false }).click() await lastRow.locator('input.ducklake-storage-data-path').fill(`ducklake_${timestamp}`) await setupCustomInstanceDb(lastRow, page, storage) await page.locator('button:has-text("Save ducklake settings")').click() if (await page.locator('text=Some databases are not setup').isVisible()) { await page.locator('button:has-text("Save anyway")').click() } await Toast.expectSuccess(page, 'Ducklake settings saved successfully') await lastRow.locator('button:has-text("Manage")').click() } async function openDataTableDbManager(page: Page) { await page.goto('/workspace_settings?tab=windmill_data_tables') await setupNewDataTable(page, `datatable_${process.env.TEST_UNIQUE_ID}`) const datatableId = `datatable_${process.env.TEST_UNIQUE_ID}` let table = page.locator('table') await table.waitFor({ state: 'visible' }) let rows = await table.locator('tr:has(input[id="name"])').all() for (const row of rows) { const val = await row.locator('input[id="name"]').inputValue() if (val === datatableId) { await row.locator('button:has-text("Manage")').click() return } } throw new Error(`Could not find datatable row ${datatableId}`) } declare const process: any // ignore TS errors async function setupNewDataTable(page: Page, datatableId: string) { // Check if datatable already exists let table = page.locator('table') await table.waitFor({ state: 'visible' }) let rows = await table.locator('tr:has(input[id="name"])').all() for (const row of rows) { const val = await row.locator('input[id="name"]').inputValue() // Don't setup again if it already exists (saving the settings creates race condition) if (val === datatableId) return } // Click on 'New Data Table' button const newDataTableButton = page.locator('button:has-text("New Data Table")') await newDataTableButton.click() // Find the second-to-last row in the table (last row contains the button) const lastRow = table.locator('tr').nth(-2) // Fill the name input with generated ID const nameInput = lastRow.locator('input[id="name"]') await nameInput.fill(datatableId) // Verify database type is 'Instance' const databaseTypeSelect = lastRow.locator('input[id="database-type-select"]') await expect(databaseTypeSelect).toHaveValue('Instance') await setupCustomInstanceDb(lastRow, page, datatableId) const saveBtn = page.locator('button:has-text("Save")') await saveBtn.click() if (await page.locator('text=Some databases are not setup').isVisible()) { await page.locator('button:has-text("Save anyway")').click() } // Verify success toast appears await Toast.expectSuccess(page, 'saved successfully') return { datatableId } } async function setupCustomInstanceDb(row: Locator, page: Page, name: string) { // Click on custom instance DB select and add new database const customInstanceDbSelect = row.locator('input[id="custom-instance-db-select"]') await customInstanceDbSelect.waitFor({ state: 'visible' }) await customInstanceDbSelect.click() await customInstanceDbSelect.fill(name) // Check if the database already exists in the dropdown if (await Dropdown.getOption(page, name).isVisible()) { await Dropdown.getOption(page, name).click() return } // Click on the "Add new:" button const addNewButton = page.locator(`button:has-text("Add new: ")`) await addNewButton.waitFor({ state: 'visible' }) await addNewButton.click() // Click on Setup button const setupButton = row.locator('button:has-text("Setup")') await setupButton.waitFor({ state: 'visible' }) await setupButton.click() // Wait for popover to appear and click run setup button const runSetupButton = page.locator('button[id="run-custom-instance-db-setup-button"]') await runSetupButton.waitFor({ state: 'visible' }) await runSetupButton.click() const confirmBtn = page.locator('button:has-text("Setup database")') await confirmBtn.waitFor({ state: 'visible' }) await confirmBtn.click() // Verify success toast appears await Toast.expectSuccess(page, 'Setup successful') const closeModalBtn = page.locator('button[id="modal-close-button"]') await closeModalBtn.click() } function getJsonEnv(name: string): object | undefined { const env = process.env[name] if (env) return JSON.parse(env) } const wsStorageResources = { s3: { bucket: 'test-bucket', region: 'us-east-1', useSSL: false, endPoint: 'minio-e2e:9000', accessKey: 'minioadmin', pathStyle: true, secretKey: 'minioadmin' } } const storageResourceTypeDropdownLabels = { s3: 'S3', azure_blob: 'Azure Blob', s3_aws_oidc: 'AWS OIDC', azure_workload_identity: 'Azure Workload Identity', gcloud_storage: 'Google Cloud Storage' } type StorageResourceType = keyof typeof wsStorageResources async function setupWsStorage( page: Page, resource_type: StorageResourceType ): Promise<{ storage: string }> { let { resourceName } = await setupNewResource( page, resource_type, wsStorageResources[resource_type] ) let storage = `${resource_type}_storage_e2e` await page.goto('/workspace_settings?tab=windmill_lfs') await page.locator('.storage-settings-table').waitFor({ state: 'visible' }) let rows = await page .locator('.storage-settings-table tr:has(input.secondary-storage-name-input)') .all() for (const row of rows) { const val = await row.locator('input.secondary-storage-name-input').inputValue() if (val === storage) return { storage } } // Click on the Add Storage button await page.locator('button:has-text("Add secondary storage")').click() let lastRow = page.locator('.storage-settings-table tr').nth(-2) // Fill the name input with generated ID const nameInput = lastRow.locator('input.secondary-storage-name-input') await nameInput.fill(storage) // Select resource type await lastRow.locator('#storage-resource-type-select').click() const dropdownResourceTypeLabel = storageResourceTypeDropdownLabels[resource_type] ?? resource_type await Dropdown.getOption(page, dropdownResourceTypeLabel).click() // Select resource await lastRow.locator('#resource-picker-select').click() await Dropdown.getOption(page, resourceName, { exact: false }).click() await page.locator('button:has-text("Save storage settings")').click() await Toast.expectSuccess(page, 'storage settings changed') return { storage } } async function setupNewResource( page: Page, resourceType: string, resourceObj: object ): Promise<{ resourceName: string }> { const resourceName = `${resourceType}_e2e` await page.goto('/resources') await page.locator('table').waitFor({ state: 'visible' }) if (await page.locator(`table tr:has-text("${resourceName}")`).isVisible()) return { resourceName } const newDataTableButton = page.locator('button:text-is("Add resource")') await newDataTableButton.click() const addResourceDrawer = page.locator('#add-resource-drawer') await expect(addResourceDrawer).toBeVisible() const searchInput = addResourceDrawer.locator('input#search-resource-type') await searchInput.fill(resourceType) const resourceTypeBtn = addResourceDrawer.locator(`button:has(:text-is("${resourceType}"))`) await resourceTypeBtn.waitFor({ state: 'visible' }) await resourceTypeBtn.click() const asJsonToggle = addResourceDrawer.locator('label.as-json-toggle') await expect(asJsonToggle).toBeVisible() await asJsonToggle.check() await addResourceDrawer.locator('input#path').fill(resourceName) const jsonEditor = page.locator('.simple-editor .view-lines') await expect(jsonEditor).toBeVisible() await jsonEditor.click({ clickCount: 4 }) // Select all existing text console.log('Pasting resource config:', resourceObj) await page.evaluate((c) => navigator.clipboard.writeText(c), JSON.stringify(resourceObj)) await page.keyboard.press('ControlOrMeta+V') const saveButton = addResourceDrawer.locator('button:has-text("Save")') await saveButton.click() await Toast.expectSuccess(page, 'Saved resource') return { resourceName } } async function setupNewResourceAndOpenDbManager(page: Page, dbType: DbType) { let { resourceName } = await setupNewResource(page, dbType, resourceByDbType[dbType]) const resourceRow = page.locator(`table tr:has-text("${resourceName}")`) await expect(resourceRow).toBeVisible() const manageButton = resourceRow.locator('button:has-text("Manage")') await manageButton.click() }