first commit
This commit is contained in:
350
tests/e2e/features/plugins/pages/plugins.page.ts
Normal file
350
tests/e2e/features/plugins/pages/plugins.page.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
import { Locator, Page, expect } from '@playwright/test'
|
||||
import { BasePage } from '../../../core/pages/base.page'
|
||||
import { waitForNetworkIdle } from '../../../core/utils/test-helpers'
|
||||
|
||||
export interface PluginConfig {
|
||||
name: string
|
||||
path?: string
|
||||
type?: string
|
||||
enabled?: boolean
|
||||
config?: Record<string, any>
|
||||
}
|
||||
|
||||
export class PluginsPage extends BasePage {
|
||||
readonly sidebar: Locator
|
||||
readonly table: Locator // Alias for sidebar (plugins page doesn't have a traditional table)
|
||||
readonly pluginList: Locator
|
||||
readonly createBtn: Locator
|
||||
readonly sheet: Locator
|
||||
readonly nameInput: Locator
|
||||
readonly pathInput: Locator
|
||||
readonly enabledToggle: Locator
|
||||
readonly saveBtn: Locator
|
||||
readonly cancelBtn: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page)
|
||||
// Plugins page has a sidebar with plugin buttons, not a table
|
||||
// The sidebar contains the "Plugins" label and plugin list
|
||||
this.sidebar = page.locator('div').filter({ hasText: /^Plugins$/ }).locator('..').first()
|
||||
this.table = this.sidebar // Alias for backward compatibility
|
||||
this.pluginList = page.locator('button[type="button"]').filter({ has: page.locator('svg.lucide-puzzle') })
|
||||
this.createBtn = page.getByRole('button').filter({
|
||||
hasText: /Install New Plugin/i
|
||||
}).or(
|
||||
page.locator('button').filter({ has: page.locator('svg.lucide-plus') }).filter({ hasText: /Install/i })
|
||||
)
|
||||
this.sheet = page.locator('[role="dialog"]')
|
||||
this.nameInput = page.getByLabel(/Name/i).or(page.locator('input[name="name"]'))
|
||||
this.pathInput = page.getByLabel(/Path/i).or(page.locator('input[name="path"]'))
|
||||
this.enabledToggle = page.locator('button[role="switch"]')
|
||||
this.saveBtn = page.getByRole('button', { name: /Install Plugin/i }).or(
|
||||
page.getByRole('button', { name: /Update Plugin/i })
|
||||
)
|
||||
this.cancelBtn = page.getByRole('button', { name: /Cancel/i }).filter({
|
||||
hasNot: page.locator('svg.lucide-trash-2')
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the plugins page
|
||||
*/
|
||||
async goto(): Promise<void> {
|
||||
await this.page.goto('/workspace/plugins')
|
||||
await waitForNetworkIdle(this.page)
|
||||
// Wait for create button or empty state to be visible (indicates page loaded).
|
||||
// Use .first() so the locator resolves to one element when both are visible (strict mode).
|
||||
await this.page.getByTestId('plugins-create-button')
|
||||
.or(this.page.getByTestId('plugins-empty-state'))
|
||||
.first()
|
||||
.waitFor({ state: 'visible', timeout: 10000 })
|
||||
// Ensure sheet is closed (in case it was left open from previous test)
|
||||
await this.ensureSheetClosed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the plugin sheet is closed
|
||||
*/
|
||||
async ensureSheetClosed(): Promise<void> {
|
||||
const isVisible = await this.sheet.isVisible().catch(() => false)
|
||||
if (isVisible) {
|
||||
// Try clicking cancel button first
|
||||
const cancelVisible = await this.cancelBtn.isVisible().catch(() => false)
|
||||
if (cancelVisible) {
|
||||
await this.cancelBtn.click()
|
||||
// Wait for sheet to close after cancel click
|
||||
await expect(this.sheet).not.toBeVisible({ timeout: 5000 }).catch(() => {})
|
||||
}
|
||||
|
||||
// Double-check: if still visible, try Escape key
|
||||
const stillVisible = await this.sheet.isVisible().catch(() => false)
|
||||
if (stillVisible) {
|
||||
await this.page.keyboard.press('Escape')
|
||||
// Wait for sheet to close after Escape
|
||||
await expect(this.sheet).not.toBeVisible({ timeout: 5000 }).catch(() => {})
|
||||
}
|
||||
|
||||
// Final check: wait for sheet to be detached or not visible
|
||||
await this.page.waitForFunction(() => {
|
||||
const sheet = document.querySelector('[role="dialog"]')
|
||||
return !sheet || window.getComputedStyle(sheet).display === 'none'
|
||||
}, { timeout: 3000 }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plugin button locator by name (plugins are shown as buttons in sidebar)
|
||||
*/
|
||||
getPluginButton(name: string): Locator {
|
||||
// Find button that contains the plugin name and has a Puzzle icon
|
||||
return this.page.locator('button[type="button"]')
|
||||
.filter({ hasText: name })
|
||||
.filter({ has: this.page.locator('svg.lucide-puzzle') })
|
||||
.first()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a plugin exists in the sidebar
|
||||
*/
|
||||
async pluginExists(name: string): Promise<boolean> {
|
||||
return await this.getPluginButton(name).count() > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the count of plugins in the sidebar
|
||||
*/
|
||||
async getPluginCount(): Promise<number> {
|
||||
// Check if it's empty state (no plugin list, only empty state is shown)
|
||||
const emptyState = this.page.getByTestId('plugins-empty-state')
|
||||
const isEmptyVisible = await emptyState.isVisible().catch(() => false)
|
||||
if (isEmptyVisible) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const buttons = this.page.getByTestId('plugin-list-item')
|
||||
const count = await buttons.count()
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new plugin.
|
||||
* Returns true if the plugin was created successfully, false if the backend rejected it (e.g. .so load failure).
|
||||
*/
|
||||
async createPlugin(config: PluginConfig): Promise<boolean> {
|
||||
await this.dismissToasts()
|
||||
// Ensure sheet is closed before starting
|
||||
await this.ensureSheetClosed()
|
||||
|
||||
await this.createBtn.waitFor({ state: 'visible' })
|
||||
await this.createBtn.click()
|
||||
await expect(this.sheet).toBeVisible({ timeout: 5000 })
|
||||
await this.waitForSheetAnimation()
|
||||
|
||||
// Scope inputs to the dialog sheet to avoid matching background form inputs
|
||||
// (PluginsView shows existing plugin form in background with same field names)
|
||||
const sheetNameInput = this.sheet.getByRole('textbox', { name: /Plugin Name/i })
|
||||
const sheetPathInput = this.sheet.getByRole('textbox', { name: /Plugin Path/i })
|
||||
|
||||
// Fill name (required)
|
||||
await sheetNameInput.waitFor({ state: 'visible' })
|
||||
await sheetNameInput.fill(config.name)
|
||||
|
||||
// Fill path (required) - use the path from config
|
||||
await sheetPathInput.waitFor({ state: 'visible' })
|
||||
const pluginPath = config.path || '/tmp/bifrost-test-plugin.so'
|
||||
await sheetPathInput.fill(pluginPath)
|
||||
|
||||
// Note: enabled state is set to true by default when creating,
|
||||
// and can't be changed during creation (only during edit)
|
||||
|
||||
// Save
|
||||
await this.saveBtn.waitFor({ state: 'visible' })
|
||||
await this.saveBtn.click()
|
||||
|
||||
// Wait for either a success toast or an error toast (backend may fail to load .so)
|
||||
const successToast = this.getToast('success')
|
||||
const errorToast = this.getToast('error')
|
||||
await successToast.or(errorToast).waitFor({ state: 'visible', timeout: 15000 })
|
||||
|
||||
const hasError = await errorToast.isVisible().catch(() => false)
|
||||
if (hasError) {
|
||||
// Backend rejected plugin creation (e.g. failed to load .so)
|
||||
console.warn(`[Plugin] Backend error on create "${config.name}" — plugin was not created`)
|
||||
await this.dismissToasts()
|
||||
// Sheet may stay open on error — close it manually
|
||||
await this.ensureSheetClosed()
|
||||
await waitForNetworkIdle(this.page)
|
||||
return false
|
||||
}
|
||||
|
||||
await this.dismissToasts()
|
||||
|
||||
// Wait for sheet to close with multiple checks
|
||||
await expect(this.sheet).not.toBeVisible({ timeout: 10000 })
|
||||
await this.ensureSheetClosed() // Double-check it's closed
|
||||
await waitForNetworkIdle(this.page)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit an existing plugin
|
||||
* Note: Name cannot be changed after creation (it's read-only in edit mode)
|
||||
* Only enabled state, path, and config can be updated
|
||||
*/
|
||||
async editPlugin(name: string, updates: Partial<PluginConfig>): Promise<void> {
|
||||
await this.dismissToasts()
|
||||
// Ensure sheet is closed before starting
|
||||
await this.ensureSheetClosed()
|
||||
|
||||
// Click on the plugin button to select it (this opens the edit view)
|
||||
const pluginBtn = this.getPluginButton(name)
|
||||
await pluginBtn.waitFor({ state: 'visible' })
|
||||
await pluginBtn.click()
|
||||
|
||||
// Wait for the plugin details view to load
|
||||
await waitForNetworkIdle(this.page)
|
||||
|
||||
// The form is in PluginsView - wait for it to be visible
|
||||
const form = this.page.locator('form')
|
||||
await form.waitFor({ state: 'visible', timeout: 5000 })
|
||||
|
||||
// Update enabled state if provided
|
||||
if (updates.enabled !== undefined) {
|
||||
const toggle = this.page.locator('button[role="switch"]').first()
|
||||
await toggle.waitFor({ state: 'visible' })
|
||||
const isChecked = await toggle.getAttribute('data-state') === 'checked'
|
||||
if (isChecked !== updates.enabled) {
|
||||
await toggle.click()
|
||||
}
|
||||
}
|
||||
|
||||
// Update path if provided
|
||||
if (updates.path) {
|
||||
const pathInput = this.page.getByLabel(/Path/i).or(this.page.locator('input[name="path"]'))
|
||||
await pathInput.waitFor({ state: 'visible' })
|
||||
await pathInput.clear()
|
||||
await pathInput.fill(updates.path)
|
||||
}
|
||||
|
||||
// Wait for Save Changes button to be enabled (form.isDirty must be true)
|
||||
const saveBtn = this.page.getByRole('button', { name: /Save Changes/i })
|
||||
await expect(saveBtn).toBeEnabled({ timeout: 5000 })
|
||||
await saveBtn.click()
|
||||
|
||||
await this.waitForSuccessToast()
|
||||
await this.dismissToasts()
|
||||
await waitForNetworkIdle(this.page)
|
||||
// Ensure sheet is closed after edit
|
||||
await this.ensureSheetClosed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a plugin
|
||||
*/
|
||||
async deletePlugin(name: string): Promise<void> {
|
||||
await this.dismissToasts()
|
||||
// Ensure sheet is closed before starting
|
||||
await this.ensureSheetClosed()
|
||||
|
||||
// Click on the plugin button to select it (this opens the details view)
|
||||
const pluginBtn = this.getPluginButton(name)
|
||||
await pluginBtn.waitFor({ state: 'visible' })
|
||||
await pluginBtn.click()
|
||||
|
||||
// Wait for the plugin details view to load
|
||||
await waitForNetworkIdle(this.page)
|
||||
|
||||
// Find delete button in the PluginsView (has Trash2Icon)
|
||||
const deleteBtn = this.page.getByRole('button', { name: /Delete Plugin/i })
|
||||
await deleteBtn.waitFor({ state: 'visible' })
|
||||
await deleteBtn.click()
|
||||
|
||||
// Wait for the AlertDialog confirmation to appear (uses role="alertdialog")
|
||||
const alertDialog = this.page.locator('[role="alertdialog"]')
|
||||
await alertDialog.waitFor({ state: 'visible', timeout: 5000 })
|
||||
|
||||
// Click the confirm Delete button inside the AlertDialog
|
||||
const confirmBtn = alertDialog.getByRole('button', { name: /^Delete$/i })
|
||||
await confirmBtn.waitFor({ state: 'visible' })
|
||||
await confirmBtn.click()
|
||||
|
||||
await this.waitForSuccessToast('deleted')
|
||||
await this.dismissToasts()
|
||||
await waitForNetworkIdle(this.page)
|
||||
// Ensure sheet is closed after delete
|
||||
await this.ensureSheetClosed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle plugin enabled state
|
||||
*/
|
||||
async togglePluginEnabled(name: string): Promise<void> {
|
||||
await this.dismissToasts()
|
||||
// Ensure sheet is closed before starting
|
||||
await this.ensureSheetClosed()
|
||||
|
||||
// Click on the plugin button to select it (this opens the details view)
|
||||
const pluginBtn = this.getPluginButton(name)
|
||||
await pluginBtn.waitFor({ state: 'visible' })
|
||||
await pluginBtn.click()
|
||||
|
||||
// Wait for the plugin details view to load
|
||||
await waitForNetworkIdle(this.page)
|
||||
|
||||
// Find toggle switch in the PluginsView form
|
||||
const toggle = this.page.locator('button[role="switch"]').first()
|
||||
await toggle.waitFor({ state: 'visible' })
|
||||
await toggle.click()
|
||||
|
||||
// Wait for the Save Changes button to become enabled (form.isDirty must be true)
|
||||
const saveBtn = this.page.getByRole('button', { name: /Save Changes/i })
|
||||
await expect(saveBtn).toBeEnabled({ timeout: 5000 })
|
||||
await saveBtn.click()
|
||||
|
||||
await this.waitForSuccessToast()
|
||||
await this.dismissToasts()
|
||||
await waitForNetworkIdle(this.page)
|
||||
// Ensure sheet is closed after toggle
|
||||
await this.ensureSheetClosed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plugin enabled state
|
||||
*/
|
||||
async getPluginEnabledState(name: string): Promise<boolean> {
|
||||
// Ensure sheet is closed before starting
|
||||
await this.ensureSheetClosed()
|
||||
|
||||
// Click on the plugin button to select it
|
||||
const pluginBtn = this.getPluginButton(name)
|
||||
await pluginBtn.waitFor({ state: 'visible' })
|
||||
await pluginBtn.click()
|
||||
|
||||
// Wait for the plugin details view to load
|
||||
await waitForNetworkIdle(this.page)
|
||||
|
||||
// Find toggle switch in the PluginsView form
|
||||
const toggle = this.page.locator('button[role="switch"]').first()
|
||||
let result = false
|
||||
if (await toggle.count() > 0) {
|
||||
const dataState = await toggle.getAttribute('data-state')
|
||||
result = dataState === 'checked'
|
||||
}
|
||||
await this.ensureSheetClosed()
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel plugin creation/edit
|
||||
*/
|
||||
async cancelPlugin(): Promise<void> {
|
||||
if (await this.sheet.isVisible()) {
|
||||
await this.cancelBtn.click()
|
||||
await expect(this.sheet).not.toBeVisible({ timeout: 5000 })
|
||||
await this.page.waitForTimeout(300) // Small delay for animation
|
||||
}
|
||||
// Double-check it's closed
|
||||
await this.ensureSheetClosed()
|
||||
}
|
||||
}
|
||||
20
tests/e2e/features/plugins/plugins-test-helper.ts
Normal file
20
tests/e2e/features/plugins/plugins-test-helper.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { existsSync } from 'fs'
|
||||
import { join, resolve } from 'path'
|
||||
|
||||
// Same location as Makefile build-test-plugin and global setup (repo root tmp/)
|
||||
const REPO_ROOT = resolve(__dirname, '..', '..', '..', '..')
|
||||
const TEST_PLUGIN_PATH = join(REPO_ROOT, 'tmp', 'bifrost-test-plugin.so')
|
||||
|
||||
/**
|
||||
* Gets the test plugin path.
|
||||
* The plugin is built by global setup / make build-test-plugin at repo_root/tmp/bifrost-test-plugin.so.
|
||||
*/
|
||||
export function ensureTestPluginExists(): string {
|
||||
if (!existsSync(TEST_PLUGIN_PATH)) {
|
||||
throw new Error(
|
||||
`Test plugin not found at ${TEST_PLUGIN_PATH}. ` +
|
||||
`Please build it first: make build-test-plugin (from repo root)`
|
||||
)
|
||||
}
|
||||
return TEST_PLUGIN_PATH
|
||||
}
|
||||
35
tests/e2e/features/plugins/plugins.data.ts
Normal file
35
tests/e2e/features/plugins/plugins.data.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { PluginConfig } from './pages/plugins.page'
|
||||
import { ensureTestPluginExists } from './plugins-test-helper'
|
||||
|
||||
/**
|
||||
* Sanitize plugin name to only contain letters, numbers, hyphens, and underscores
|
||||
*/
|
||||
function sanitizePluginName(name: string): string {
|
||||
return name
|
||||
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
||||
.replace(/[^A-Za-z0-9-_]/g, '') // Remove any invalid characters
|
||||
.replace(/-+/g, '-') // Replace multiple hyphens with single hyphen
|
||||
.replace(/^-|-$/g, '') // Remove leading/trailing hyphens
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the test plugin path (builds if necessary)
|
||||
*/
|
||||
let testPluginPath: string | null = null
|
||||
function getTestPluginPath(): string {
|
||||
if (!testPluginPath) {
|
||||
testPluginPath = ensureTestPluginExists()
|
||||
}
|
||||
return testPluginPath
|
||||
}
|
||||
|
||||
export function createPluginData(overrides: Partial<PluginConfig> = {}): PluginConfig {
|
||||
const baseName = overrides.name || `test-plugin-${Date.now()}`
|
||||
const pluginPath = overrides.path || getTestPluginPath()
|
||||
|
||||
return {
|
||||
name: sanitizePluginName(baseName),
|
||||
path: pluginPath,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
367
tests/e2e/features/plugins/plugins.spec.ts
Normal file
367
tests/e2e/features/plugins/plugins.spec.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
import { expect, test } from '../../core/fixtures/base.fixture'
|
||||
import { createPluginData } from './plugins.data'
|
||||
import { ensureTestPluginExists } from './plugins-test-helper'
|
||||
|
||||
// Track created plugins for cleanup
|
||||
const createdPlugins: string[] = []
|
||||
|
||||
test.describe('Plugins', () => {
|
||||
test.beforeEach(async ({ pluginsPage }) => {
|
||||
await pluginsPage.goto()
|
||||
// Ensure sheet is closed before each test (in case it was left open)
|
||||
await pluginsPage.ensureSheetClosed()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ pluginsPage }) => {
|
||||
// Clean up any plugins created during tests
|
||||
for (const pluginName of [...createdPlugins]) {
|
||||
try {
|
||||
const exists = await pluginsPage.pluginExists(pluginName)
|
||||
if (exists) {
|
||||
await pluginsPage.deletePlugin(pluginName)
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
// Clear the array
|
||||
createdPlugins.length = 0
|
||||
})
|
||||
|
||||
test.describe('Plugin Display', () => {
|
||||
test('should display plugins table', async ({ pluginsPage }) => {
|
||||
// Ensure at least one plugin exists so the table (sidebar) is shown
|
||||
const count = await pluginsPage.getPluginCount()
|
||||
if (count === 0) {
|
||||
const pluginData = createPluginData({ name: `e2e-display-table-${Date.now()}` })
|
||||
const created = await pluginsPage.createPlugin(pluginData)
|
||||
if (!created) {
|
||||
test.skip(true, 'Backend rejected plugin creation (failed to load .so)')
|
||||
return
|
||||
}
|
||||
createdPlugins.push(pluginData.name)
|
||||
}
|
||||
// Plugins page has a sidebar (aliased as table), not a traditional table
|
||||
await expect(pluginsPage.table).toBeVisible()
|
||||
})
|
||||
|
||||
test('should display create plugin button', async ({ pluginsPage }) => {
|
||||
await expect(pluginsPage.createBtn).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show empty state or plugin list', async ({ pluginsPage }) => {
|
||||
const count = await pluginsPage.getPluginCount()
|
||||
const emptyState = pluginsPage.page.getByTestId('plugins-empty-state')
|
||||
const isEmptyStateVisible = await emptyState.isVisible().catch(() => false)
|
||||
|
||||
if (count === 0) {
|
||||
expect(isEmptyStateVisible).toBe(true)
|
||||
} else {
|
||||
expect(count).toBeGreaterThan(0)
|
||||
expect(isEmptyStateVisible).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('CRUD Operations', () => {
|
||||
test('should create a basic plugin', async ({ pluginsPage }) => {
|
||||
const pluginData = createPluginData({
|
||||
name: `e2e-test-plugin-${Date.now()}`,
|
||||
})
|
||||
createdPlugins.push(pluginData.name) // Track for cleanup
|
||||
|
||||
const created = await pluginsPage.createPlugin(pluginData)
|
||||
if (!created) {
|
||||
test.skip(true, 'Backend rejected plugin creation (failed to load .so)')
|
||||
return
|
||||
}
|
||||
|
||||
const pluginExists = await pluginsPage.pluginExists(pluginData.name)
|
||||
expect(pluginExists).toBe(true)
|
||||
})
|
||||
|
||||
test('should create a disabled plugin', async ({ pluginsPage }) => {
|
||||
const pluginData = createPluginData({
|
||||
name: `disabled-plugin-${Date.now()}`,
|
||||
enabled: false,
|
||||
})
|
||||
createdPlugins.push(pluginData.name) // Track for cleanup
|
||||
|
||||
const created = await pluginsPage.createPlugin(pluginData)
|
||||
if (!created) {
|
||||
test.skip(true, 'Backend rejected plugin creation (failed to load .so)')
|
||||
return
|
||||
}
|
||||
|
||||
const pluginExists = await pluginsPage.pluginExists(pluginData.name)
|
||||
expect(pluginExists).toBe(true)
|
||||
|
||||
// Note: Plugins are created with enabled=true by default
|
||||
// We need to disable it after creation
|
||||
const initialState = await pluginsPage.getPluginEnabledState(pluginData.name)
|
||||
expect(initialState).toBe(true) // Created enabled by default
|
||||
|
||||
// Now disable it
|
||||
await pluginsPage.togglePluginEnabled(pluginData.name)
|
||||
const isEnabled = await pluginsPage.getPluginEnabledState(pluginData.name)
|
||||
expect(isEnabled).toBe(false)
|
||||
})
|
||||
|
||||
test('should toggle plugin enabled state', async ({ pluginsPage }) => {
|
||||
const originalName = `edit-test-plugin-${Date.now()}`
|
||||
const pluginData = createPluginData({ name: originalName })
|
||||
createdPlugins.push(originalName) // Track for cleanup
|
||||
|
||||
const created = await pluginsPage.createPlugin(pluginData)
|
||||
if (!created) {
|
||||
test.skip(true, 'Backend rejected plugin creation (failed to load .so)')
|
||||
return
|
||||
}
|
||||
|
||||
// Verify plugin exists; we verify editability via enabled-state toggling (name is read-only after creation)
|
||||
const pluginExists = await pluginsPage.pluginExists(originalName)
|
||||
expect(pluginExists).toBe(true)
|
||||
|
||||
// Toggle enabled state to verify the plugin is editable
|
||||
const initialState = await pluginsPage.getPluginEnabledState(originalName)
|
||||
await pluginsPage.togglePluginEnabled(originalName)
|
||||
const newState = await pluginsPage.getPluginEnabledState(originalName)
|
||||
expect(newState).not.toBe(initialState)
|
||||
})
|
||||
|
||||
test('should delete plugin', async ({ pluginsPage }) => {
|
||||
const pluginData = createPluginData({
|
||||
name: `delete-test-plugin-${Date.now()}`,
|
||||
})
|
||||
createdPlugins.push(pluginData.name) // Track for cleanup (in case test fails before delete)
|
||||
|
||||
const created = await pluginsPage.createPlugin(pluginData)
|
||||
if (!created) {
|
||||
test.skip(true, 'Backend rejected plugin creation (failed to load .so)')
|
||||
return
|
||||
}
|
||||
|
||||
// Verify it exists
|
||||
let pluginExists = await pluginsPage.pluginExists(pluginData.name)
|
||||
expect(pluginExists).toBe(true)
|
||||
|
||||
// Delete it
|
||||
await pluginsPage.deletePlugin(pluginData.name)
|
||||
|
||||
// Verify it's gone
|
||||
pluginExists = await pluginsPage.pluginExists(pluginData.name)
|
||||
expect(pluginExists).toBe(false)
|
||||
})
|
||||
|
||||
test('should change plugin enabled state when toggled', async ({ pluginsPage }) => {
|
||||
const pluginData = createPluginData({
|
||||
name: `toggle-test-plugin-${Date.now()}`,
|
||||
enabled: true,
|
||||
})
|
||||
createdPlugins.push(pluginData.name) // Track for cleanup
|
||||
|
||||
const created = await pluginsPage.createPlugin(pluginData)
|
||||
if (!created) {
|
||||
test.skip(true, 'Backend rejected plugin creation (failed to load .so)')
|
||||
return
|
||||
}
|
||||
|
||||
// Get initial state
|
||||
const initialState = await pluginsPage.getPluginEnabledState(pluginData.name)
|
||||
|
||||
// Toggle state
|
||||
await pluginsPage.togglePluginEnabled(pluginData.name)
|
||||
|
||||
// Verify state changed
|
||||
const newState = await pluginsPage.getPluginEnabledState(pluginData.name)
|
||||
expect(newState).not.toBe(initialState)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Form Validation', () => {
|
||||
test('should require name for plugin', async ({ pluginsPage }) => {
|
||||
await pluginsPage.dismissToasts()
|
||||
await pluginsPage.createBtn.click()
|
||||
await expect(pluginsPage.sheet).toBeVisible()
|
||||
await pluginsPage.waitForSheetAnimation()
|
||||
|
||||
// Save button should be disabled when name is empty
|
||||
await expect(pluginsPage.saveBtn).toBeDisabled()
|
||||
|
||||
await pluginsPage.cancelPlugin()
|
||||
})
|
||||
|
||||
test('should cancel plugin creation', async ({ pluginsPage }) => {
|
||||
await pluginsPage.createBtn.click()
|
||||
await expect(pluginsPage.sheet).toBeVisible()
|
||||
|
||||
// Fill some data (scope to sheet to avoid matching background form)
|
||||
const testName = `cancelled-plugin-${Date.now()}`
|
||||
const sheetNameInput = pluginsPage.sheet.getByRole('textbox', { name: /Plugin Name/i })
|
||||
await sheetNameInput.fill(testName)
|
||||
|
||||
// Cancel
|
||||
await pluginsPage.cancelPlugin()
|
||||
|
||||
// Sheet should close
|
||||
await expect(pluginsPage.sheet).not.toBeVisible()
|
||||
|
||||
// Plugin should not exist
|
||||
const pluginExists = await pluginsPage.pluginExists(testName)
|
||||
expect(pluginExists).toBe(false)
|
||||
})
|
||||
|
||||
test('should open and close plugin sheet', async ({ pluginsPage }) => {
|
||||
// Open sheet
|
||||
await pluginsPage.createBtn.click()
|
||||
await expect(pluginsPage.sheet).toBeVisible()
|
||||
|
||||
// Close sheet
|
||||
await pluginsPage.cancelPlugin()
|
||||
await expect(pluginsPage.sheet).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Error Handling', () => {
|
||||
test('should handle duplicate plugin name gracefully', async ({ pluginsPage }) => {
|
||||
const pluginName = `duplicate-test-${Date.now()}`
|
||||
const pluginData = createPluginData({ name: pluginName })
|
||||
createdPlugins.push(pluginName) // Track for cleanup
|
||||
|
||||
// Create first plugin
|
||||
const created = await pluginsPage.createPlugin(pluginData)
|
||||
if (!created) {
|
||||
test.skip(true, 'Backend rejected plugin creation (failed to load .so)')
|
||||
return
|
||||
}
|
||||
expect(await pluginsPage.pluginExists(pluginName)).toBe(true)
|
||||
|
||||
// Try to create duplicate
|
||||
await pluginsPage.dismissToasts()
|
||||
await pluginsPage.createBtn.click()
|
||||
await expect(pluginsPage.sheet).toBeVisible()
|
||||
await pluginsPage.waitForSheetAnimation()
|
||||
// Scope to sheet to avoid matching background form
|
||||
const sheetNameInput = pluginsPage.sheet.getByRole('textbox', { name: /Plugin Name/i })
|
||||
const sheetPathInput = pluginsPage.sheet.getByRole('textbox', { name: /Plugin Path/i })
|
||||
await sheetNameInput.fill(pluginName)
|
||||
await sheetPathInput.fill(ensureTestPluginExists()) // Path is required; use same path as build
|
||||
await pluginsPage.saveBtn.click()
|
||||
|
||||
// Duplicate creation should be rejected: either sheet stays open with error OR error toast appears
|
||||
const inlineError = pluginsPage.sheet.locator('[role="alert"], .text-destructive').first()
|
||||
const errorToast = pluginsPage.page.locator('[data-sonner-toast][data-type="error"]').first()
|
||||
await inlineError.or(errorToast).waitFor({ state: 'visible', timeout: 10000 })
|
||||
const hasInlineError = await inlineError.isVisible().catch(() => false)
|
||||
|
||||
if (hasInlineError) {
|
||||
await expect(pluginsPage.sheet).toBeVisible()
|
||||
await expect(inlineError).toBeVisible()
|
||||
await pluginsPage.cancelPlugin()
|
||||
} else {
|
||||
await expect(errorToast).toBeVisible()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Plugin Configuration', () => {
|
||||
test('should view plugin details', async ({ pluginsPage }) => {
|
||||
const pluginData = createPluginData({
|
||||
name: `view-details-${Date.now()}`,
|
||||
})
|
||||
createdPlugins.push(pluginData.name)
|
||||
|
||||
const created = await pluginsPage.createPlugin(pluginData)
|
||||
if (!created) {
|
||||
test.skip(true, 'Backend rejected plugin creation (failed to load .so)')
|
||||
return
|
||||
}
|
||||
|
||||
// Click on the plugin to view details
|
||||
const pluginItem = pluginsPage.page.locator('button').filter({ hasText: pluginData.name })
|
||||
await pluginItem.click()
|
||||
|
||||
// Should see plugin details/form
|
||||
const detailsVisible = await pluginsPage.page.locator('form, [role="tabpanel"]').isVisible().catch(() => false)
|
||||
expect(detailsVisible).toBe(true)
|
||||
})
|
||||
|
||||
test('should edit plugin configuration', async ({ pluginsPage }) => {
|
||||
const pluginData = createPluginData({
|
||||
name: `edit-config-${Date.now()}`,
|
||||
})
|
||||
createdPlugins.push(pluginData.name)
|
||||
|
||||
const created = await pluginsPage.createPlugin(pluginData)
|
||||
if (!created) {
|
||||
test.skip(true, 'Backend rejected plugin creation (failed to load .so)')
|
||||
return
|
||||
}
|
||||
|
||||
// Get initial enabled state
|
||||
const initialState = await pluginsPage.getPluginEnabledState(pluginData.name)
|
||||
|
||||
// Toggle enabled state (a form of editing)
|
||||
await pluginsPage.togglePluginEnabled(pluginData.name)
|
||||
|
||||
// State should have changed
|
||||
const newState = await pluginsPage.getPluginEnabledState(pluginData.name)
|
||||
expect(newState).toBe(!initialState)
|
||||
})
|
||||
|
||||
test('should validate plugin path', async ({ pluginsPage }) => {
|
||||
await pluginsPage.createBtn.click()
|
||||
await expect(pluginsPage.sheet).toBeVisible()
|
||||
await pluginsPage.waitForSheetAnimation()
|
||||
|
||||
// Fill name
|
||||
const sheetNameInput = pluginsPage.sheet.getByRole('textbox', { name: /Plugin Name/i })
|
||||
await sheetNameInput.fill(`path-validation-${Date.now()}`)
|
||||
|
||||
// Fill invalid path (doesn't start with / or http)
|
||||
const sheetPathInput = pluginsPage.sheet.getByRole('textbox', { name: /Plugin Path/i })
|
||||
await sheetPathInput.fill('invalid-path-no-extension')
|
||||
|
||||
// Validation should disable the save button
|
||||
await expect(pluginsPage.saveBtn).toBeDisabled()
|
||||
|
||||
// Validation message should be visible
|
||||
const validationMessage = pluginsPage.sheet.getByText(/valid absolute file path|valid path/i)
|
||||
await expect(validationMessage).toBeVisible()
|
||||
|
||||
await pluginsPage.cancelPlugin()
|
||||
})
|
||||
|
||||
test('should handle invalid plugin path', async ({ pluginsPage }) => {
|
||||
await pluginsPage.createBtn.click()
|
||||
await expect(pluginsPage.sheet).toBeVisible()
|
||||
await pluginsPage.waitForSheetAnimation()
|
||||
|
||||
const sheetNameInput = pluginsPage.sheet.getByRole('textbox', { name: /Plugin Name/i })
|
||||
const sheetPathInput = pluginsPage.sheet.getByRole('textbox', { name: /Plugin Path/i })
|
||||
|
||||
await sheetNameInput.fill(`invalid-plugin-${Date.now()}`)
|
||||
await sheetPathInput.fill('/nonexistent/path/to/plugin.so')
|
||||
|
||||
// Try to save - this might succeed (path not validated until load) or fail
|
||||
await pluginsPage.saveBtn.click()
|
||||
|
||||
// Wait for response
|
||||
await pluginsPage.page.waitForTimeout(1000)
|
||||
|
||||
// Invalid path: sheet may close with error toast or stay open with error
|
||||
const inlineError = pluginsPage.sheet.locator('[role="alert"], .text-destructive').first()
|
||||
const errorToast = pluginsPage.page.locator('[data-sonner-toast][data-type="error"]').first()
|
||||
await inlineError.or(errorToast).waitFor({ state: 'visible', timeout: 10000 })
|
||||
const hasInlineError = await inlineError.isVisible().catch(() => false)
|
||||
|
||||
if (hasInlineError) {
|
||||
await expect(pluginsPage.sheet).toBeVisible()
|
||||
await expect(inlineError).toBeVisible()
|
||||
await pluginsPage.cancelPlugin()
|
||||
} else {
|
||||
await expect(errorToast).toBeVisible()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user