Files
windmill/frontend/e2e/db-manager.spec.ts
Diego Imbert a384b4c23d Playwright E2E (#7520)
* clean plate

* npm i

* log in e2e

* global setup login

* set license key

* Revert "set license key"

This reverts commit 86d5db2c48.

* create datatable test

* fix wrong pg_creds

* data table + db manager e2e test

* DbManagerPage class

* small refactor

* create resource test + improvements

* text db manager in resources

* Factor test logic in classes

* refactoring

* refacto

* alter table test

* alter table e2e test

* set schema in test

* nits

* fix wrong schema var

* Correct setup and parallelization

* reducedMotion

* tests passing headless !

* bigger timeout

* start e2e docker compose

* e2e runs on all databases

* nit test uid fix

* refactp

* stash

* Better Workspace Storage settings

* minio setup

* nit

* nit

* super nit

* Permission settings in modal

* badge indicator

* Fetch alter table metadata much faster

* Upgrade duckdb to 1.4.3

* Ducklake tests

* Disable transactional DDL for Ducklake (bug on their side)

* git ignore env

* bigquery tests passes

* getJsonEnv

* load coldef in parallel

* Make Bigquery schema fetching much faster

* makeLoadTableMetaDataQuery for entire db in bigquery

* refactor getDbSchemas to avoid assignment side effect

* fix col def

* Better loading state mgmt

* snowflake

* fix snowflake primary keys

* Test CI

* fix setTimeout type

* remove type node

* test e2e ci

* Revert "test e2e ci"

This reverts commit bf98a755dc.

* remove ci

* fix snowflake pk query in alternate schemas

* nit wait for coldefs

* nit snowflake

* Snowflake fk fix

* UNPROCESSABLE_ENTITY instead of INTERNAL_ERROR

* nits

* fix alter pk in snowflake

* yet other fixes

* snowflake tests pass

* nits
2026-01-22 16:20:01 +00:00

336 lines
11 KiB
TypeScript

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()
}