diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index a8d6ea2f44..49e878f13c 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -167,39 +167,6 @@ jobs: ${{ steps.meta-ee-public.outputs.labels }} org.opencontainers.image.licenses=Windmill-Enterprise-License - # disabled until we make it 100% reliable and add more meaningful tests - # playwright: - # runs-on: [self-hosted, new] - # needs: [build] - # services: - # postgres: - # image: postgres - # env: - # POSTGRES_DB: windmill - # POSTGRES_USER: admin - # POSTGRES_PASSWORD: changeme - # ports: - # - 5432:5432 - # options: >- - # --health-cmd pg_isready - # --health-interval 10s - # --health-timeout 5s - # --health-retries 5 - # steps: - # - uses: actions/checkout@v4 - # - name: "Docker" - # run: echo "::set-output name=id::$(docker run --network=host --rm -d -p 8000:8000 --privileged -it -e DATABASE_URL=postgres://admin:changeme@localhost:5432/windmill -e BASE_INTERNAL_URL=http://localhost:8000 ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest)" - # id: docker-container - # - uses: actions/setup-node@v3 - # with: - # node-version: 16 - # - name: "Playwright run" - # timeout-minutes: 2 - # run: cd frontend && npm ci @playwright/test && npx playwright install && export BASE_URL=http://localhost:8000 && npm run test - # - name: "Clean up" - # run: docker kill ${{ steps.docker-container.outputs.id }} - # if: always() - attach_amd64_binary_to_release: needs: [build, build_ee] runs-on: ubicloud diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 144963b12b..a749b8cec8 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -64,7 +64,9 @@ use crate::{ concurrency_groups::join_concurrency_key, db::{ApiAuthed, DB}, triggers::trigger_helpers::RunnableId, - users::{get_scope_tags, require_owner_of_path, require_path_read_access_for_preview, OptAuthed}, + users::{ + get_scope_tags, require_owner_of_path, require_path_read_access_for_preview, OptAuthed, + }, utils::{check_scopes, content_plain, require_super_admin}, }; use anyhow::Context; @@ -3210,10 +3212,22 @@ pub async fn get_resume_urls_internal( "{base_url}/approve/{w_id}/{target_job_id}/{resume_id}/{signature}{approver_query}" ), cancel: build_resume_url( - "cancel", &w_id, &target_job_id, &resume_id, &signature, &approver_query, &base_url, + "cancel", + &w_id, + &target_job_id, + &resume_id, + &signature, + &approver_query, + &base_url, ), resume: build_resume_url( - "resume", &w_id, &target_job_id, &resume_id, &signature, &approver_query, &base_url, + "resume", + &w_id, + &target_job_id, + &resume_id, + &signature, + &approver_query, + &base_url, ), }; @@ -5156,7 +5170,7 @@ pub fn result_to_response(result: Box, success: bool) -> error::Result if success { StatusCode::OK } else { - StatusCode::INTERNAL_SERVER_ERROR + StatusCode::UNPROCESSABLE_ENTITY }, Json(result), ) @@ -5170,7 +5184,7 @@ pub fn result_to_response(result: Box, success: bool) -> error::Result }) .unwrap_or_else(|| { if !success { - Ok(StatusCode::INTERNAL_SERVER_ERROR) + Ok(StatusCode::UNPROCESSABLE_ENTITY) } else if result_value.is_some() { Ok(StatusCode::OK) } else { @@ -5220,7 +5234,7 @@ pub fn result_to_response(result: Box, success: bool) -> error::Result if success { StatusCode::OK } else { - StatusCode::INTERNAL_SERVER_ERROR + StatusCode::UNPROCESSABLE_ENTITY }, Json(result), ) diff --git a/frontend/.gitignore b/frontend/.gitignore index 2e81ffd6f8..9795c44865 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -17,3 +17,12 @@ ui_builder_serve/ src/lib/components/copilot/chat/__tests__/flow/results/ src/lib/components/copilot/chat/__tests__/app/results/ .npmrc + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/playwright/.auth/ +e2e/auth.json +e2e/*.png \ No newline at end of file diff --git a/frontend/e2e/DbManagerPage.ts b/frontend/e2e/DbManagerPage.ts new file mode 100644 index 0000000000..fb709d77ac --- /dev/null +++ b/frontend/e2e/DbManagerPage.ts @@ -0,0 +1,519 @@ +// Assume the db manager was already opened + +import { expect, Locator, Page } from '@playwright/test' +import { getDbFeatures } from '../src/lib/components/apps/components/display/dbtable/dbFeatures' +import { ConfirmationModal, Dropdown, Toast } from './utils' +import { DbInput, DbType } from '../src/lib/components/dbTypes' +import { DB_TYPES } from '../src/lib/consts' + +export async function runDbManagerSimpleCRUDTest(page: Page, dbType: _DbType) { + let dbManager = new DbManagerPage(page) + await dbManager.expectToBeVisible() + + let friendTableName = identifier(dbType, `friend_${Date.now()}`) + + // Create table + const tableEditor = await dbManager.openCreateTableDrawer() + await tableEditor.setTableName(friendTableName) + await tableEditor.addColumn(identifier(dbType, 'name'), getDbDatatype(dbType, 'TEXT')) + await tableEditor.getColumn('id').delete() // remove default id column + await tableEditor.createTable() + + await Toast.expectSuccess(page, `${friendTableName} created`) + + // Select and work with the table + await dbManager.selectTable(friendTableName) + + // Insert a row + const insertDrawer = await dbManager.openInsertDrawer() + await insertDrawer.fillField(identifier(dbType, 'name'), 'Alice') + await insertDrawer.insert() + + await Toast.expectSuccess(page, 'Row inserted') + + // Verify and edit the cell + const dataGrid = dbManager.dataGrid() + await dataGrid.expectCellValue('Alice') + await dataGrid.editCell('Alice', 'Bob') + + await Toast.expectSuccess(page, 'Value updated') + await dataGrid.expectCellValue('Bob') + + // Delete the table + const actionsMenu = await dbManager.openActionsMenu(friendTableName) + await actionsMenu.deleteTable() + + await Toast.expectSuccess(page, `Table '${friendTableName}' deleted successfully`) +} + +export async function runDbManagerAlterTableTest(page: Page, dbType: _DbType) { + let dbFeatures = getDbFeatures(getDbInput(dbType)) + let dbManager = new DbManagerPage(page) + let timestamp = Date.now() + await dbManager.expectToBeVisible() + + if (dbFeatures.schemas) { + const schemaName = identifier(dbType, `schema_${timestamp}`) + await dbManager.setCurrentSchema(schemaName, { create: true }) + } + + // Create friend table + let friendTableName = identifier(dbType, `friend_${timestamp}`) + let tableEditor = await dbManager.openCreateTableDrawer() + await tableEditor.setTableName(friendTableName) + let friendIdCol = await tableEditor.getColumn('id') // deafult id column + await friendIdCol.setType(getDbDatatype(dbType, 'INT')) + if (dbFeatures.primaryKeys) { + friendIdCol.setPrimaryKey(true) + } else { + await expect(await friendIdCol.primaryKeyCheckbox()).toBeHidden() + } + await tableEditor.addColumn(identifier(dbType, 'name'), getDbDatatype(dbType, 'TEXT')) + await tableEditor.addColumn(identifier(dbType, 'created_at'), getDbDatatype(dbType, 'TIMESTAMP')) + await tableEditor.createTable() + await Toast.expectSuccess(page, `${friendTableName} created`) + await dbManager.selectTable(friendTableName) // Ensure the view refreshed + + // Create message table + let messageTableName = identifier(dbType, `message_${timestamp}`) + tableEditor = await dbManager.openCreateTableDrawer() + await tableEditor.setTableName(messageTableName) + let messageIdCol = await tableEditor.getColumn('id') // deafult id column + await messageIdCol.setType(getDbDatatype(dbType, 'INT')) + if (dbFeatures.primaryKeys) messageIdCol.setPrimaryKey(true) + await tableEditor.addColumn(identifier(dbType, 'friend_id'), getDbDatatype(dbType, 'INT')) + let contentColumn = await tableEditor.addColumn( + identifier(dbType, 'content'), + getDbDatatype(dbType, 'TEXT') + ) + await contentColumn.setSettings({ nullable: true }) + await tableEditor.addColumn(identifier(dbType, 'created_at'), getDbDatatype(dbType, 'TIMESTAMP')) + if (dbFeatures.foreignKeys) { + await tableEditor.addForeignKey( + friendTableName, + identifier(dbType, 'friend_id'), + identifier(dbType, 'id'), + dbFeatures.enforcedForeignKeys ? { onDelete: 'Cascade', onUpdate: 'Cascade' } : undefined + ) + } else { + await expect(tableEditor.foreignKeySection()).toBeHidden() + } + await tableEditor.createTable() + await Toast.expectSuccess(page, `${messageTableName} created`) + await page.waitForTimeout(100) + + // Alter message table + await (await dbManager.openActionsMenu(messageTableName)).alterTable() + await tableEditor.expectNoChangesDetected() + let postsTableName = identifier(dbType, `posts_${timestamp}`) + let friendCol = tableEditor.getColumn(identifier(dbType, 'friend_id')) + let createdAtCol = tableEditor.getColumn(identifier(dbType, 'created_at')) + let idCol = tableEditor.getColumn(identifier(dbType, 'id')) + + // Predicate checks + if (dbFeatures.primaryKeys) { + await idCol.checkPrimaryKeyIs(true) + await createdAtCol.checkPrimaryKeyIs(false) + await contentColumn.checkPrimaryKeyIs(false) + await friendCol.checkPrimaryKeyIs(false) + await friendCol.checkSettingsIs({ + nullable: false, + defaultValue: dbFeatures.defaultValues ? '' : undefined + }) + } + + // Apply alterations + await tableEditor.setTableName(postsTableName) + await idCol.delete() + await friendCol.setName(identifier(dbType, 'person_id')) + if (dbType !== 'bigquery' && dbType !== 'snowflake') { + await friendCol.setType(getDbDatatype(dbType, 'BIGINT')) + } + if (dbType !== 'snowflake') { + // Snowflake does not support altering default values + await friendCol.setSettings({ + defaultValue: dbFeatures.defaultValues ? '123' : undefined, + nullable: false + }) + } + if (dbFeatures.primaryKeys && dbType !== 'bigquery') { + // Bigquery cannot rename a table with primary keys + await friendCol.setPrimaryKey(true) + await createdAtCol.setPrimaryKey(true) + } + if (dbFeatures.foreignKeys) await tableEditor.deleteForeignKey() + await tableEditor.alterTable() + await Toast.expectSuccess(page, `${messageTableName} updated`) // uses old table name + await page.waitForTimeout(100) + + // Verify alterations + await dbManager.selectTable(postsTableName) // Ensure the view refreshed + await (await dbManager.openActionsMenu(postsTableName)).alterTable() + await tableEditor.expectNoChangesDetected() + tableEditor = new TableEditorDrawer(page) + await idCol.checkNotExists() + await friendCol.checkNameIs(identifier(dbType, 'person_id')) + if (dbType !== 'bigquery' && dbType !== 'snowflake') { + await friendCol.checkTypeIs(getDbDatatype(dbType, 'BIGINT')) + } + if (dbFeatures.defaultValues && dbType !== 'snowflake') { + await friendCol.checkSettingsIs({ defaultValue: /123/ }) + } + await createdAtCol.checkTypeIs( + dbType === 'snowflake' ? 'TIMESTAMP_NTZ' : getDbDatatype(dbType, 'TIMESTAMP') + ) + await createdAtCol.checkNameIs(identifier(dbType, 'created_at')) + if (dbFeatures.primaryKeys && dbType !== 'bigquery') { + await friendCol.checkPrimaryKeyIs(true) + await createdAtCol.checkPrimaryKeyIs(true) + await contentColumn.checkPrimaryKeyIs(false) + } +} + +export class DbManagerPage { + page: Page + + constructor(page: Page) { + this.page = page + } + + async setCurrentSchema(schemaName: string, options?: { create?: boolean }) { + const schemaSelect = this.dbManager().locator('input[id="db-schema-select"]') + await schemaSelect.click() + await schemaSelect.fill(schemaName) + const option = Dropdown.getOption(this.page, schemaName).or( + Dropdown.getOption(this.page, 'Add new', { exact: false }) + ) + await option.click() + if (options?.create) { + await ConfirmationModal.confirm(this.page, '#db-create-schema-confirmation-modal', 'Create') + } + } + + dbManager = () => this.page.locator('#db-manager-drawer') + expectToBeVisible = () => expect(this.dbManager()).toBeVisible() + + async openCreateTableDrawer(): Promise { + await this.dbManager().locator('button:has-text("New table")').click() + const tableEditor = new TableEditorDrawer(this.page) + await expect(tableEditor.tableEditor()).toBeVisible() + return tableEditor + } + + async selectTable(tableName: string) { + const tableKey = this.page.locator('.db-manager-table-key', { hasText: tableName }) + await expect(tableKey).toBeVisible() + await tableKey.click() + } + + async openInsertDrawer(): Promise { + await this.dbManager().locator('button:has-text("Insert")').click() + return new InsertRowDrawer(this.page) + } + + dataGrid(): DataGrid { + return new DataGrid(this.page, this.dbManager()) + } + + async openActionsMenu(tableName: string): Promise { + const actionsBtn = this.dbManager().locator(`#db-manager-table-actions-${tableName}`) + await expect(actionsBtn).toBeVisible() + await actionsBtn.click() + return new TableActionsMenu(this.page) + } +} + +class TableEditorDrawer { + page: Page + + constructor(page: Page) { + this.page = page + } + + tableEditor = () => this.page.locator('#db-table-editor-drawer') + columnsSection = () => this.tableEditor().locator('#columns-section') + foreignKeySection = () => this.page.locator('#foreign-keys-section') + + async setTableName(name: string) { + const nameInput = this.tableEditor().locator('label:has-text("Name")').locator('input') + await nameInput.fill(name) + } + + async addColumn(columnName: string, columnType: string) { + const columnsSection = this.columnsSection() + const addColumnButton = columnsSection.locator('button:has-text("Add")') + await addColumnButton.click() + + // Set name before creating Column because it's identified by name + const newColRow = columnsSection.locator('tr').nth(-2) + const newColNameInput = newColRow.locator('td').nth(0).locator('input') + await newColNameInput.fill(columnName) + + let column = this.getColumn(columnName) + await column.setType(columnType) + return column + } + + async createTable() { + await this.tableEditor().locator('button:has-text("Create table")').click() + await ConfirmationModal.confirm(this.page, '#db-table-editor-confirmation-modal', 'Create') + } + + async alterTable() { + await this.tableEditor().locator('button:has-text("Alter table")').click() + await ConfirmationModal.confirm(this.page, '#db-table-editor-confirmation-modal', 'Alter') + } + + async deleteForeignKey() { + // TODO: do not assume a single foreign key + const fkSection = this.foreignKeySection() + const deleteBtn = fkSection.locator('.fk-delete-btn') + await deleteBtn.click() + } + + async addForeignKey( + referencedTable: string, + fromCol: string, + toCol: string, + options?: { onDelete?: string; onUpdate?: string } + ) { + const fkSection = this.foreignKeySection() + const addFkButton = fkSection.locator('button:has-text("Add")') + await addFkButton.click() + const lastFk = fkSection.locator('tr').nth(-2) + + await Dropdown.selectOption(this.page, lastFk.locator('input.fk-table-select'), referencedTable) + await Dropdown.selectOption(this.page, lastFk.locator('.fk-source-col-select input'), fromCol) + await Dropdown.selectOption(this.page, lastFk.locator('.fk-target-col-select input'), toCol) + + const fkSettings = lastFk.locator('.fk-settings-btn') + if (options?.onDelete || options?.onUpdate) { + await fkSettings.click() + if (options?.onDelete) { + const onDeleteSelect = this.page.locator('select.fk-on-delete-select') + await onDeleteSelect.selectOption({ label: options.onDelete }) + } + if (options?.onUpdate) { + const onUpdateSelect = this.page.locator('select.fk-on-update-select') + await onUpdateSelect.selectOption({ label: options.onUpdate }) + } + // Close the popover + await fkSettings.click() + } + } + + async expectNoChangesDetected() { + const btn = this.tableEditor().locator(`button:has-text("No changes detected")`) + return await expect(btn).toBeVisible() + } + + getColumn(columnName: string): Column { + return new Column(this.page, this.columnsSection(), columnName) + } +} + +class Column { + columnsSection: Locator + page: Page + columnName: string + + constructor(page: Page, columnsSection: Locator, columnName: string) { + this.page = page + this.columnsSection = columnsSection + this.columnName = columnName + } + + async rowOrUndefined(): Promise { + const rows = await this.columnsSection.locator('tr:has(input)').all() + for (const row of rows) { + const val = await row.locator('input').first().inputValue() + if (val === this.columnName) return row + } + } + row = async () => { + let row = await this.rowOrUndefined() + if (!row) { + await this.page.waitForTimeout(500) + row = await this.rowOrUndefined() + } + if (!row) throw new Error(`Column with name ${this.columnName} not found`) + return row + } + + primaryKeyCheckbox = async () => (await this.row()).locator('input.primary-key-checkbox') + + async setName(columnName: string) { + const newColNameInput = (await this.row()).locator('td').nth(0).locator('input') + await newColNameInput.fill(columnName) + this.columnName = columnName + } + + async checkNameIs(columnName: string) { + const newColNameInput = (await this.row()).locator('td').nth(0).locator('input') + await expect(newColNameInput).toHaveValue(columnName) + } + + async setType(columnType: string) { + await Dropdown.selectOption( + this.page, + (await this.row()).locator('td').nth(1).locator('input'), + columnType + ) + } + + async checkTypeIs(columnType: string) { + const newColTypeSelect = (await this.row()).locator('td').nth(1).locator('input') + await expect(newColTypeSelect).toHaveValue(new RegExp(`^${columnType}$`, 'i')) + } + + async delete() { + const deleteBtn = (await this.row()).locator('button.delete-column-btn') + await deleteBtn.click() + await this.page.waitForTimeout(50) + } + + async setPrimaryKey(isPrimaryKey: boolean) { + let primaryKeyCheckbox = await this.primaryKeyCheckbox() + const isChecked = await primaryKeyCheckbox.isChecked() + if (isChecked !== isPrimaryKey) { + await primaryKeyCheckbox.click() + } + } + + async checkPrimaryKeyIs(isPrimaryKey: boolean) { + let primaryKeyCheckbox = await this.primaryKeyCheckbox() + const isChecked = await primaryKeyCheckbox.isChecked() + expect(isChecked).toBe(isPrimaryKey) + } + + async setSettings(options: { nullable?: boolean; defaultValue?: string }) { + const settingsBtn = (await this.row()).locator('.settings-menu-btn') + await settingsBtn.click() + + if (options.defaultValue !== undefined) { + const defaultValueInput = this.page.locator('input.default-value') + await defaultValueInput.fill(options.defaultValue) + } + + if (options.nullable !== undefined) { + const nullableCheckbox = this.page.locator('input.nullable-checkbox') + const isChecked = await nullableCheckbox.isChecked() + if (isChecked !== options.nullable) { + await nullableCheckbox.click() + } + } + // Close the popover + await settingsBtn.click() + } + + async checkSettingsIs(options: { nullable?: boolean; defaultValue?: string | RegExp }) { + const settingsBtn = (await this.row()).locator('.settings-menu-btn') + await settingsBtn.click() + if (options.defaultValue !== undefined) { + const defaultValueInput = this.page.locator('input.default-value') + await expect(defaultValueInput).toHaveValue(options.defaultValue) + } + + if (options.nullable !== undefined) { + const nullableCheckbox = this.page.locator('input.nullable-checkbox') + const isChecked = await nullableCheckbox.isChecked() + expect(isChecked).toBe(options.nullable) + } + // Close the popover + await settingsBtn.click() + } + + async checkNotExists() { + expect(await this.rowOrUndefined()).toBeUndefined() + } +} + +class InsertRowDrawer { + page: Page + + constructor(page: Page) { + this.page = page + } + + drawer = () => this.page.locator('#insert-row-drawer') + + async fillField(fieldName: string, value: string) { + await expect(this.drawer()).toBeVisible() + // For now, assumes single field - could be enhanced to handle multiple fields + await this.drawer().locator('textarea').fill(value, { force: true }) + } + + async insert() { + await this.drawer().locator('button:has-text("Insert")').click() + } +} + +class DataGrid { + page: Page + dbManager: Locator + + constructor(page: Page, dbManager: Locator) { + this.page = page + this.dbManager = dbManager + } + + async expectCellValue(value: string) { + const cell = this.dbManager.locator('.ag-cell-value', { hasText: value }) + await expect(cell).toBeVisible() + } + + async editCell(oldValue: string, newValue: string) { + const cell = this.dbManager.locator('.ag-cell-value', { hasText: oldValue }) + await cell.dblclick() + + const cellEditor = this.dbManager.locator('.ag-cell-editor input') + await cellEditor.fill(newValue) + await cellEditor.press('Enter') + } +} + +class TableActionsMenu { + page: Page + + constructor(page: Page) { + this.page = page + } + + async deleteTable() { + await this.page.locator('button:has-text("Delete table")').click() + await ConfirmationModal.confirm( + this.page, + '#db-manager-delete-table-confirmation-modal', + 'Delete' + ) + } + + async alterTable() { + await this.page.locator('button:has-text("Alter table")').click() + } +} + +type _DbType = Exclude | 'ducklake' +function getDbInput(dbType: _DbType): DbInput { + if (dbType === 'ducklake') { + return { type: 'ducklake', ducklake: '' } + } else { + return { type: 'database', resourceType: dbType, resourcePath: '' } + } +} + +// Ensure exact casing of datatype as per DB_TYPES +function getDbDatatype(dbType: _DbType, datatype: string): string { + if (dbType === 'ms_sql_server' && datatype.toLowerCase() === 'timestamp') datatype = 'datetime2' + if (dbType === 'bigquery' && datatype.toLowerCase() === 'text') datatype = 'string' + if (dbType === 'bigquery' && datatype.toLowerCase() === 'int') datatype = 'int64' + if (dbType === 'snowflake' && datatype.toLowerCase() === 'text') datatype = 'varchar' + const allDataTypes = DB_TYPES[dbType == 'ducklake' ? 'duckdb' : dbType] || [] + return allDataTypes.find((dt) => dt.toLowerCase() === datatype.toLowerCase()) || datatype +} + +function identifier(dbType: _DbType, baseName: string): string { + baseName = baseName.replace(/[^a-zA-Z0-9_]/g, '_').trim() + if (dbType === 'snowflake') return baseName.toUpperCase() + return baseName +} diff --git a/frontend/e2e/db-manager.spec.ts b/frontend/e2e/db-manager.spec.ts new file mode 100644 index 0000000000..2ed328cb13 --- /dev/null +++ b/frontend/e2e/db-manager.spec.ts @@ -0,0 +1,335 @@ +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() +} diff --git a/frontend/e2e/global-setup.ts b/frontend/e2e/global-setup.ts new file mode 100644 index 0000000000..db27741a5a --- /dev/null +++ b/frontend/e2e/global-setup.ts @@ -0,0 +1,70 @@ +// global-setup.ts +import { chromium, FullConfig } from '@playwright/test' + +async function globalSetup(config: FullConfig) { + process.env.TEST_UNIQUE_ID = Date.now().toString() + + const browser = await chromium.launch() + const context = await browser.newContext({ + permissions: ['clipboard-read', 'clipboard-write'] + }) + const page = await context.newPage() + + // Use baseURL from config + const baseURL = config.projects[0].use.baseURL || 'http://localhost:3000' + await page.goto(baseURL) + + // Wait for and fill the email input + const emailInput = page.locator('input#email[type="email"]') + await emailInput.waitFor({ state: 'visible' }) + await emailInput.fill('admin@windmill.dev') + + // Wait for and fill the password input + const passwordInput = page.locator('input#password[type="password"]') + await passwordInput.waitFor({ state: 'visible' }) + await passwordInput.fill('changeme') + + // Click the sign-in button + const signInButton = page.locator('button:has-text("Sign in")') + await signInButton.click() + + // Handle potential first-time user flow + await page.waitForURL(/\/user\/(first-time|workspaces)/) + if (page.url().includes('/user/first-time')) { + const skipButton = page.locator('button:has-text("Skip")') + await skipButton.click() + } + + // Wait for navigation to workspaces page + await page.waitForURL('**/user/workspaces') + + // Click on the "Admins" workspace + const adminsWorkspace = page.locator('text=Admins').first() + await adminsWorkspace.waitFor({ state: 'visible' }) + await adminsWorkspace.click() + + // Wait for workspace to load + await page.waitForURL('**/') + + if (!process.env.SKIP_HUB_SYNC) { + await page.locator('#home-search-input').waitFor({ state: 'visible' }) + await page.locator('#home-search-input').fill('u/admin/hub_sync') + + const hubSyncBtn = page.locator('text=u/admin/hub_sync').first() + await hubSyncBtn.waitFor({ state: 'visible' }) + await hubSyncBtn.click() + + const runBtn = page.locator('#run-form-run-button') + await runBtn.waitFor({ state: 'visible' }) + await runBtn.click() + await page.waitForURL(/\/run\/.+/) + await page.locator('text=Success').waitFor({ timeout: 30000, state: 'visible' }) + } + // Save the authenticated state + await context.storageState({ path: './e2e/auth.json' }) + await browser.close() +} + +export default globalSetup + +declare const process: any // ignore TS errors diff --git a/frontend/e2e/utils.ts b/frontend/e2e/utils.ts new file mode 100644 index 0000000000..44ba3359e1 --- /dev/null +++ b/frontend/e2e/utils.ts @@ -0,0 +1,46 @@ +import { expect, Locator, Page } from '@playwright/test' + +export class Toast { + static async expectSuccess(page: Page, message: string) { + const toast = page.locator(`.toast-success:has-text("${message}")`) + await expect(toast).toBeVisible({ timeout: 10000 }) + } + + static async expectError(page: Page, message: string) { + const toast = page.locator(`.toast-error:has-text("${message}")`) + await expect(toast).toBeVisible({ timeout: 10000 }) + } +} + +export class ConfirmationModal { + static async confirm(page: Page, modalSelector: string, buttonText: string) { + const confirmBtn = page.locator(`${modalSelector} button:has-text("${buttonText}")`) + await confirmBtn.click() + await page.locator(modalSelector).waitFor({ state: 'hidden' }) + } + + static async expectText(page: Page, modalSelector: string, text: string) { + const modal = page.locator(modalSelector) + await expect(modal).toHaveText(text) + } +} + +export const prettify = (s: string) => (s.charAt(0).toUpperCase() + s.slice(1)).replace(/_/g, ' ') + +export class Dropdown { + static getOption(page: Page, optionText: string, { exact = true } = {}) { + if (exact) return page.locator(`.select-dropdown-open li:has(:text-is("${optionText}"))`) + else return page.locator(`.select-dropdown-open li:has-text("${optionText}")`) + } + static async selectOption( + page: Page, + selectInput: Locator, + optionText: string, + { exact = true } = {} + ) { + await selectInput.click() + await selectInput.fill(optionText) + const option = this.getOption(page, optionText, { exact }) + await option.click() + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 04a2a2ce30..095782c6b5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -99,7 +99,7 @@ "@hey-api/openapi-ts": "^0.43.0", "@melt-ui/pp": "^0.3.2", "@melt-ui/svelte": "^0.86.2", - "@playwright/test": "^1.34.3", + "@playwright/test": "^1.57.0", "@sveltejs/adapter-static": "^3.0.6", "@sveltejs/kit": "^2.49.2", "@sveltejs/package": "^2.3.7", @@ -113,7 +113,6 @@ "@types/vscode": "^1.83.5", "@typescript-eslint/eslint-plugin": "^5.59.8", "@typescript-eslint/parser": "^5.60.0", - "@vitest/browser-playwright": "^4.0.10", "@zerodevx/svelte-toast": "^0.9.6", "autoprefixer": "^10.4.13", "cssnano": "^6.0.1", @@ -125,7 +124,6 @@ "json-refs": "^3.0.15", "json-schema-to-zod": "^2.7.0", "path-browserify": "^1.0.1", - "playwright": "^1.56.1", "postcss": "^8.4.49", "postcss-load-config": "^4.0.1", "prettier": "^3.1.0", @@ -260,7 +258,6 @@ "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", @@ -276,7 +273,6 @@ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -822,7 +818,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": "^14 || ^16 || >=18" }, @@ -1332,6 +1327,7 @@ "integrity": "sha512-Jer+M7DgIwT5IHfTayb4Iw/fkkxWNmC/mqn/nMh9JrbPbkxmyabfLQnhJ+JDn5HK77f84j34lubO3iqFtYAfMg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@floating-ui/core": "^1.3.1", "@floating-ui/dom": "^1.4.5", @@ -1456,13 +1452,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.0.tgz", - "integrity": "sha512-Tzh95Twig7hUwwNe381/K3PggZBZblKUe2wv25oIpzWLr6Z0m4KgV1ZVIjnR6GM9ANEqjZD7XsZEa6JL/7YEgg==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", + "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.56.0" + "playwright": "1.57.0" }, "bin": { "playwright": "cli.js" @@ -1471,53 +1467,6 @@ "node": ">=18" } }, - "node_modules/@playwright/test/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/@playwright/test/node_modules/playwright": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.0.tgz", - "integrity": "sha512-X5Q1b8lOdWIE4KAoHpW3SE8HvUB+ZZsUoN64ZhjnN8dOb1UpujxBtENGiZFE+9F/yhzJwYa+ca3u43FeLbboHA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.56.0" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/@playwright/test/node_modules/playwright-core": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.0.tgz", - "integrity": "sha512-1SXl7pMfemAMSDn5rkPeZljxOCYAmQnYLBTExuh6E8USHXGSX3dx6lYZN/xPpTz1vimXmPA9CDnILvmJaB8aSQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -2154,6 +2103,7 @@ "integrity": "sha512-Vp3zX/qlwerQmHMP6x0Ry1oY7eKKRcOWGc2P59srOp4zcqyn+etJyQpELgOi4+ZSUgteX8Y387NuwruLgGXLUQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", @@ -2231,6 +2181,7 @@ "integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "debug": "^4.4.1", @@ -2757,8 +2708,7 @@ "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/ms": { "version": "2.1.0", @@ -2771,8 +2721,7 @@ "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/semver": { "version": "7.7.1", @@ -2842,6 +2791,7 @@ "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", @@ -3009,6 +2959,7 @@ "integrity": "sha512-zedtczX688KehaIaAv7m25CeDLb0gBtAOa2Oi1G1cqvSO5aLSVfH6lpZMJLW8BKYuWMxLQc9/5GYoM+jgvGIrw==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { "@vitest/mocker": "4.0.15", "@vitest/utils": "4.0.15", @@ -3032,6 +2983,7 @@ "integrity": "sha512-94yVpDbb+ykiT7mK6ToonGnq2GIHEQGBTZTAzGxBGQXcVNCh54YKC2/WkfaDzxy0m6Kgw05kq3FYHKHu+wRdIA==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { "@vitest/browser": "4.0.15", "@vitest/mocker": "4.0.15", @@ -3056,6 +3008,7 @@ "integrity": "sha512-CZ28GLfOEIFkvCFngN8Sfx5h+Se0zN+h4B7yOsPVCcgtiO7t5jt9xQh2E1UkFep+eb9fjyMfuC5gBypwb07fvQ==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { "@vitest/spy": "4.0.15", "estree-walker": "^3.0.3", @@ -3083,6 +3036,7 @@ "integrity": "sha512-CZ28GLfOEIFkvCFngN8Sfx5h+Se0zN+h4B7yOsPVCcgtiO7t5jt9xQh2E1UkFep+eb9fjyMfuC5gBypwb07fvQ==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { "@vitest/spy": "4.0.15", "estree-walker": "^3.0.3", @@ -3314,6 +3268,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3367,6 +3322,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -3512,7 +3468,6 @@ "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -3540,7 +3495,6 @@ "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -3633,8 +3587,7 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-2.0.0.tgz", "integrity": "sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/base64-js": { "version": "1.5.1", @@ -3761,6 +3714,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -3958,7 +3912,6 @@ "integrity": "sha512-Rjs1H+A9R+Ig+4E/9oyB66UC5Mj9Xq3N//vcLf2WzgdTi/3gUu3Z9KoqmlrEG4VuuLK8wJHofxzdQXz/knhiYg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "camelcase": "^6.3.0", "map-obj": "^4.1.0", @@ -3978,7 +3931,6 @@ "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -3992,7 +3944,6 @@ "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -4006,7 +3957,6 @@ "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", "dev": true, "license": "(MIT OR CC0-1.0)", - "peer": true, "engines": { "node": ">=10" }, @@ -4115,6 +4065,7 @@ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", "license": "MIT", + "peer": true, "dependencies": { "@kurkle/color": "^0.3.0" }, @@ -4355,7 +4306,6 @@ "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", @@ -4420,7 +4370,6 @@ "integrity": "sha512-IQOkD3hbR5KrN93MtcYuad6YPuTSUhntLHDuLEbFWE+ff2/XSZNdZG+LcbbIW5AXKg/WFIfYItIzVoHngHXZzA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12 || >=16" } @@ -4686,6 +4635,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -4739,6 +4689,7 @@ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.21.0" }, @@ -4773,7 +4724,6 @@ "integrity": "sha512-VfxadyCECXgQlkoEAjeghAr5gY3Hf+IKjKb+X8tGVDtveCjN+USwprd2q3QXBR9T1+x2DG0XZF5/w+7HAtSaXA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -4787,7 +4737,6 @@ "integrity": "sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "decamelize": "^1.1.0", "map-obj": "^1.0.0" @@ -4805,7 +4754,6 @@ "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -4816,7 +4764,6 @@ "integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5302,7 +5249,6 @@ "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "is-arrayish": "^0.2.1" } @@ -5390,6 +5336,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5936,7 +5883,6 @@ "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 4.9.1" } @@ -6422,7 +6368,6 @@ "integrity": "sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "global-prefix": "^3.0.0" }, @@ -6436,7 +6381,6 @@ "integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ini": "^1.3.5", "kind-of": "^6.0.2", @@ -6452,7 +6396,6 @@ "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "isexe": "^2.0.0" }, @@ -6502,8 +6445,7 @@ "resolved": "https://registry.npmjs.org/globjoin/-/globjoin-0.1.4.tgz", "integrity": "sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/gopd": { "version": "1.2.0", @@ -6580,7 +6522,6 @@ "integrity": "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -6802,7 +6743,6 @@ "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "lru-cache": "^6.0.0" }, @@ -6816,7 +6756,6 @@ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "yallist": "^4.0.0" }, @@ -6829,8 +6768,7 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/html-tags": { "version": "3.3.1", @@ -6838,7 +6776,6 @@ "integrity": "sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" }, @@ -6922,7 +6859,6 @@ "integrity": "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -6943,7 +6879,6 @@ "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -7015,8 +6950,7 @@ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/is-binary-path": { "version": "2.1.0", @@ -7121,7 +7055,6 @@ "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -7132,7 +7065,6 @@ "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -7231,8 +7163,7 @@ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/js-yaml": { "version": "4.1.0", @@ -7268,8 +7199,7 @@ "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json-refs": { "version": "3.0.15", @@ -7388,7 +7318,6 @@ "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -7967,8 +7896,7 @@ "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.uniq": { "version": "4.5.0", @@ -8036,7 +7964,6 @@ "integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" }, @@ -8087,7 +8014,6 @@ "integrity": "sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" @@ -8328,7 +8254,6 @@ "integrity": "sha512-/d+PQ4GKmGvM9Bee/DPa8z3mXs/pkvJE2KEThngVNOqtmljC6K7NMPxtc2JeZYTmpWb9k/TmxjeL18ez3h7vCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/minimist": "^1.2.2", "camelcase-keys": "^7.0.0", @@ -8356,7 +8281,6 @@ "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", "dev": true, "license": "(MIT OR CC0-1.0)", - "peer": true, "engines": { "node": ">=10" }, @@ -9050,7 +8974,6 @@ "integrity": "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "arrify": "^1.0.1", "is-plain-obj": "^1.1.0", @@ -9129,6 +9052,7 @@ "resolved": "https://registry.npmjs.org/@codingame/monaco-vscode-editor-api/-/monaco-vscode-editor-api-25.0.0.tgz", "integrity": "sha512-uiY06RTWFo2WZdh6OybkLlDhuG+8LlkjUDpr9/wW55uucqHo4X8fx4XKEtD98cscC+6FKQkbG2yyUiOJ/npHOw==", "license": "MIT", + "peer": true, "dependencies": { "@codingame/monaco-vscode-api": "25.0.0" } @@ -9365,7 +9289,6 @@ "integrity": "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "hosted-git-info": "^4.0.1", "is-core-module": "^2.5.0", @@ -9711,7 +9634,6 @@ "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", @@ -9922,6 +9844,7 @@ "integrity": "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==", "dev": true, "license": "ISC", + "optional": true, "dependencies": { "pngjs": "^7.0.0" }, @@ -10010,6 +9933,7 @@ "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", "dev": true, "license": "MIT", + "optional": true, "engines": { "node": ">=14.19.0" } @@ -10034,6 +9958,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -10222,6 +10147,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "lilconfig": "^3.0.0", "yaml": "^2.3.4" @@ -10611,8 +10537,7 @@ "resolved": "https://registry.npmjs.org/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.6.tgz", "integrity": "sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/postcss-safe-parser": { "version": "6.0.0", @@ -10788,6 +10713,7 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -11092,7 +11018,6 @@ "integrity": "sha512-X1Fu3dPuk/8ZLsMhEj5f4wFAF0DWoK7qhGJvgaijocXxBmSToKfbFtqbxMO7bVjNA1dmE5huAzjXj/ey86iw9Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/normalize-package-data": "^2.4.0", "normalize-package-data": "^3.0.2", @@ -11112,7 +11037,6 @@ "integrity": "sha512-snVCqPczksT0HS2EC+SxUndvSzn6LRCwpfSvLrIfR5BKDQQZMaI6jPRC9dYvYFDRAuFEAnkwww8kBBNE/3VvzQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "find-up": "^5.0.0", "read-pkg": "^6.0.0", @@ -11131,7 +11055,6 @@ "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", "dev": true, "license": "(MIT OR CC0-1.0)", - "peer": true, "engines": { "node": ">=10" }, @@ -11145,7 +11068,6 @@ "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", "dev": true, "license": "(MIT OR CC0-1.0)", - "peer": true, "engines": { "node": ">=10" }, @@ -11188,7 +11110,6 @@ "integrity": "sha512-tYkDkVVtYkSVhuQ4zBgfvciymHaeuel+zFKXShfDnFP5SyVEP7qo70Rf1jTOTCx3vGNAbnEi/xFkcfQVMIBWag==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "indent-string": "^5.0.0", "strip-indent": "^4.0.0" @@ -11825,7 +11746,6 @@ "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", @@ -11902,7 +11822,6 @@ "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "spdx-expression-parse": "^3.0.0", "spdx-license-ids": "^3.0.0" @@ -11913,8 +11832,7 @@ "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", "dev": true, - "license": "CC-BY-3.0", - "peer": true + "license": "CC-BY-3.0" }, "node_modules/spdx-expression-parse": { "version": "3.0.1", @@ -11922,7 +11840,6 @@ "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" @@ -11933,8 +11850,7 @@ "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.22.tgz", "integrity": "sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==", "dev": true, - "license": "CC0-1.0", - "peer": true + "license": "CC0-1.0" }, "node_modules/sprintf-js": { "version": "1.0.3", @@ -12028,7 +11944,6 @@ "integrity": "sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -12054,8 +11969,7 @@ "resolved": "https://registry.npmjs.org/style-search/-/style-search-0.1.0.tgz", "integrity": "sha512-Dj1Okke1C3uKKwQcetra4jSuk0DqbzbYtXipzFlFMZtowbF1x7BKJwB9AayVMyFARvU8EDrZdcax4At/452cAg==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/style-to-object": { "version": "0.4.4", @@ -12104,7 +12018,6 @@ "integrity": "sha512-78O4c6IswZ9TzpcIiQJIN49K3qNoXTM8zEJzhaTE/xRTCZswaovSEVIa/uwbOltZrk16X4jAxjaOhzz/hTm1Kw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@csstools/css-parser-algorithms": "^2.3.1", "@csstools/css-tokenizer": "^2.2.0", @@ -12187,7 +12100,6 @@ } ], "license": "MIT-0", - "peer": true, "engines": { "node": "^14 || ^16 || >=18" }, @@ -12201,7 +12113,6 @@ "integrity": "sha512-TfW7/1iI4Cy7Y8L6iqNdZQVvdXn0f8B4QcIXmkIbtTIe/Okm/nSlHb4IwGzRVOd3WfSieCgvf5cMzEfySAIl0g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "flat-cache": "^3.2.0" }, @@ -12214,8 +12125,7 @@ "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.29.0.tgz", "integrity": "sha512-Ne7wqW7/9Cz54PDt4I3tcV+hAyat8ypyOGzYRJQfdxnnjeWsTxt1cy8pjvvKeI5kfXuyvULyeeAvwvvtAX3ayQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/stylelint/node_modules/postcss-selector-parser": { "version": "6.1.2", @@ -12238,7 +12148,6 @@ "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -12373,7 +12282,6 @@ "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" @@ -12403,6 +12311,7 @@ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.39.12.tgz", "integrity": "sha512-CEzwxFuEycokU8K8CE/OuwVbmei+ivu2HvBGYIdASfMa1hCRSNr4RRkzNSvbAvu6h+BOig2CsZTAEY+WKvwZpA==", "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -12498,21 +12407,6 @@ } } }, - "node_modules/svelte-check/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/svelte-eslint-parser": { "version": "0.43.0", "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz", @@ -12700,8 +12594,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/svg-tags/-/svg-tags-1.0.0.tgz", "integrity": "sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==", - "dev": true, - "peer": true + "dev": true }, "node_modules/svgo": { "version": "3.3.2", @@ -12752,7 +12645,6 @@ "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "ajv": "^8.0.1", "lodash.truncate": "^4.4.2", @@ -12780,6 +12672,7 @@ "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -13022,6 +12915,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "devOptional": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -13084,7 +12978,6 @@ "integrity": "sha512-jRKj0n0jXWo6kh62nA5TEh3+4igKDXLvzBJcPpiizP7oOolUrYIxmVBG9TOtHYFHoddUk6YvAkGeGoSVTXfQXQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -13183,6 +13076,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13397,7 +13291,6 @@ "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "spdx-correct": "^3.0.0", "spdx-expression-parse": "^3.0.0" @@ -13452,6 +13345,7 @@ "integrity": "sha512-5hI5NCJwKBGtzWtdKB3c2fOEpI77Iaa0z4mSzZPU1cJ/OqrGbFafm90edVCd7T9Snz+Sh09TMAv4EQqyVLzuEg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@oxc-project/runtime": "0.101.0", "fdir": "^6.5.0", @@ -13564,6 +13458,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "devOptional": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -13577,6 +13472,7 @@ "integrity": "sha512-n1RxDp8UJm6N0IbJLQo+yzLZ2sQCDyl1o0LeugbPWf8+8Fttp29GghsQBjYJVmWq3gBFfe9Hs1spR44vovn2wA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.15", "@vitest/mocker": "4.0.15", @@ -14205,6 +14101,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -14228,6 +14125,7 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -14727,7 +14625,6 @@ "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^4.0.1" @@ -14736,28 +14633,6 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/xml-utils": { "version": "1.10.2", "resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz", @@ -14945,7 +14820,6 @@ "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", "dev": true, "license": "ISC", - "peer": true, "engines": { "node": ">=10" } @@ -14965,6 +14839,7 @@ "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.27.tgz", "integrity": "sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==", "license": "MIT", + "peer": true, "dependencies": { "lib0": "^0.2.99" }, @@ -15000,6 +14875,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/package.json b/frontend/package.json index 7afeef6bd9..7ecb0d3d8f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,16 +15,16 @@ "generate-backend-client": "openapi-ts --input ../backend/windmill-api/openapi.yaml --output ./src/lib/gen --useOptions --enums javascript --format false", "generate-backend-client-mac": "openapi-ts --input ../backend/windmill-api/openapi.yaml --output ./src/lib/gen --useOptions --enums javascript", "pretest": "tsc --incremental -p tests/tsconfig.json", - "test": "playwright test --config=tests-out/playwright.config.js && npm run test:unit -- --run", "filter-classes": "node filterTailwindClasses.js", - "test:unit": "vitest" + "test:unit": "vitest", + "test:e2e": "playwright test" }, "devDependencies": { "@floating-ui/core": "^1.3.1", "@hey-api/openapi-ts": "^0.43.0", "@melt-ui/pp": "^0.3.2", "@melt-ui/svelte": "^0.86.2", - "@playwright/test": "^1.34.3", + "@playwright/test": "^1.57.0", "@sveltejs/adapter-static": "^3.0.6", "@sveltejs/kit": "^2.49.2", "@sveltejs/package": "^2.3.7", @@ -38,7 +38,6 @@ "@types/vscode": "^1.83.5", "@typescript-eslint/eslint-plugin": "^5.59.8", "@typescript-eslint/parser": "^5.60.0", - "@vitest/browser-playwright": "^4.0.10", "@zerodevx/svelte-toast": "^0.9.6", "autoprefixer": "^10.4.13", "cssnano": "^6.0.1", @@ -50,7 +49,6 @@ "json-refs": "^3.0.15", "json-schema-to-zod": "^2.7.0", "path-browserify": "^1.0.1", - "playwright": "^1.56.1", "postcss": "^8.4.49", "postcss-load-config": "^4.0.1", "prettier": "^3.1.0", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000000..b5a9ae5867 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,72 @@ +import { defineConfig, devices } from '@playwright/test' + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// import dotenv from 'dotenv'; +// import path from 'path'; +// dotenv.config({ path: path.resolve(__dirname, '.env') }); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + globalSetup: './e2e/global-setup', + testDir: './e2e', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + timeout: 90 * 1000, + expect: { timeout: 25 * 1000 }, + use: { + actionTimeout: 25 * 1000, + + /* Base URL to use in actions like `await page.goto('')`. */ + baseURL: process.env.BASE_URL || 'http://localhost:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + + storageState: './e2e/auth.json', + + screenshot: 'only-on-failure', + + contextOptions: { + reducedMotion: 'reduce' + } + }, + testMatch: /.*\.spec\.ts/, + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] } + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] } + }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] } + } + ] + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://localhost:3000', + // reuseExistingServer: !process.env.CI, + // }, +}) + +declare const process: any diff --git a/frontend/src/lib/components/ApiConnectForm.svelte b/frontend/src/lib/components/ApiConnectForm.svelte index 9bc2fc222c..73da863251 100644 --- a/frontend/src/lib/components/ApiConnectForm.svelte +++ b/frontend/src/lib/components/ApiConnectForm.svelte @@ -145,6 +145,7 @@ options={{ right: 'As JSON' }} + class="as-json-toggle" /> {#if resourceType == 'postgresql'} diff --git a/frontend/src/lib/components/AppConnectDrawer.svelte b/frontend/src/lib/components/AppConnectDrawer.svelte index fa7393bcfc..d8ad4cd389 100644 --- a/frontend/src/lib/components/AppConnectDrawer.svelte +++ b/frontend/src/lib/components/AppConnectDrawer.svelte @@ -46,6 +46,7 @@ > diff --git a/frontend/src/lib/components/DBManager.svelte b/frontend/src/lib/components/DBManager.svelte index f482a0dafd..0e87fbb5ac 100644 --- a/frontend/src/lib/components/DBManager.svelte +++ b/frontend/src/lib/components/DBManager.svelte @@ -1,18 +1,10 @@ @@ -247,20 +241,20 @@