TidGi-Desktop/features/stepDefinitions/ui.ts
lin onetwo 64cc965a4a
Feat/discuss note with agent (#685)
* Add wiki tiddler attachment support to agent chat

Implements the ability to attach wiki tiddlers to agent chat messages. Updates the UI to allow selection of tiddlers from active wiki workspaces, fetches and renders tiddler content as plain text, and appends it to the user message sent to the AI. Includes e2e tests, updates to store actions, service interfaces, and prompt concatenation logic to support this feature.

* fix: callback not useCallback cause autocomplete panel flash

* Add wiki tiddler attachments to message bubbles

Message bubbles now display attached wiki tiddlers as clickable chips, allowing users to navigate directly to the referenced tiddler in the appropriate workspace. Metadata handling and persistence for wiki tiddlers has been updated to include workspaceId, and tests have been added to verify the new UI behavior. The chat view also now closes the TiddlyWiki sidebar for better focus when navigating from a selection.

* Support split view navigation for wiki tiddler attachments

Adds isSplitView prop to ChatTabContent and related components to distinguish between split view and normal tab modes. Wiki tiddler attachment navigation now uses a different strategy in split view, opening tiddlers directly in the browser view. Updates types and tests to reflect the new behavior, and improves robustness of response handling in several places.

* docs: move to .github/instructions/testing.instructions.md

* test: view loading slow on mac

* refactor(e2e): move wiki load steps to Background in talkWithAI.feature; remove all sidebar close delays and polling, only set state when TiddlyWiki is ready; clean up code and logs for sidebar auto-close in split view

* docs: make test inst shorter

* lint

* refactor(view): slim ViewService, move menu to separate file, orchestrate view logic in WorkspaceViewService, update all callers, fix lint floating promise, all unit and e2e tests pass

* fix: add data-testid to attachment listbox for E2E test

- Add slotProps to MUI Autocomplete to ensure attachment-listbox is rendered with correct test-id
- Fix E2E test timeout when waiting for attachment listbox element

* lint

* put 'Talk with AI' menu on top and attachment i18n

Introduce a reusable createTalkWithAIMenuItems helper to build "Talk with AI" menu entries (default agent + other agents submenu) and integrate it into workspace menu generation. Add new i18n keys for Agent.Attachment and WikiEmbed across locales and update UI to use translation keys (remove hardcoded fallback strings). Improve chat input/attachment behavior: expose a test-id for the attachment listbox, use i18n for labels/placeholders, and tweak input component wiring. Fix Cucumber step handling by normalizing expected newline sequences and safely handling empty message content. Also adjust memo deps in SortableWorkspaceSelectorButton to include id.

* feat: enhance AI interaction in workspace context menu with local trigger support

* feat: add tool approval and timeout settings

- Introduced ToolApprovalConfig and related types for managing tool execution approvals.
- Implemented WebFetch and ZxScript tools for fetching web content and executing scripts, respectively.
- Added token estimation utilities for context window management.
- Enhanced ModelInfo interface with context window size and max output tokens.
- Created API Retry Utility for handling transient failures with exponential backoff.
- Updated AIAgent preferences section to include Tool Approval & Timeout Settings dialog.
- Developed ToolApprovalSettingsDialog for configuring tool-specific approval rules and retry settings.
- Modified vitest configuration to support aliasing for easier imports and stubbing.

* Refactor agent instance tools and services for improved modularity and maintainability

- Extracted type definitions and tool registry from defineTool.ts into separate files (defineToolTypes.ts, toolRegistry.ts) to reduce file size and enhance importability.
- Implemented a retry mechanism in ExternalAPIService for stream creation to handle transient failures.
- Updated ToolApprovalSettingsDialog to persist settings using localStorage instead of a preference service.
- Created agentMessagePersistence.ts and agentRepository.ts to manage agent message and instance CRUD operations, reducing the size of AgentInstanceService.
- Added a progress tracker document (AgentTODO.md) for the ongoing enhancement plan of the TidGi Agent.

* feat: add AgentSwitcher component for agent definition switching

- Implemented AgentSwitcher component with dropdown functionality for selecting agent definitions.
- Integrated loading of agent definitions on dropdown open.
- Added visual feedback for current selection and disabled state.

feat: create ToolResultRenderer for generic tool result messages

- Developed ToolResultRenderer to handle rendering of <functions_result> messages.
- Included collapsible parameters and result display with error handling.
- Added truncation for long results in collapsed view.

test: add comprehensive tests for MessageRenderer components

- Implemented tests for AskQuestionRenderer, ToolResultRenderer, ToolApprovalRenderer, and BaseMessageRenderer.
- Ensured proper rendering and functionality for various message types and states.
- Included pattern routing tests for MessageRenderer.

feat: introduce TurnActionBar for action management in agent turns

- Created TurnActionBar component for managing actions like rollback, retry, delete, and copy.
- Integrated visual feedback for file changes and rollback status.
- Added functionality for copying agent responses and full conversation to clipboard.

feat: implement askQuestionPending for managing user responses

- Developed infrastructure for handling pending ask-question requests.
- Implemented promise-based blocking until user responds to agent questions.
- Added timeout handling for ask-question requests.

* feat: Implement background task management for agent instances

- Added functionality to restore heartbeat timers and alarms for active agents upon service initialization.
- Introduced methods to retrieve active background tasks and cancel them via the UI.
- Enhanced alarm clock tool to persist alarm data in the database, ensuring alarms survive app restarts.
- Updated agent instance schema to include scheduled alarm data.
- Modified prompt concatenation logic to support context window size for message history.
- Removed system prompt parameter from model parameters schema and related components.
- Improved UI to display and manage background tasks, including heartbeat and alarm details.

* feat: Implement Scheduled Tasks Management and Background Task Settings

- Add feature for managing scheduled tasks for agents, including viewing, adding, and editing tasks.
- Create tests for agent repository and background task settings APIs.
- Introduce ScheduledTaskManager for unified scheduling of tasks with interval, at, and cron schedules.
- Implement edit-agent-definition tool for modifying agent configurations, including heartbeat and prompt settings.
- Ensure tasks persist across app restarts and respect active hours filtering.

* Update wiki

* fix(security): harden htmlToText against XSS via encoded tags

- Use tolerant script/style regex that handles </script > with spaces
- Stop decoding &lt;/&gt; entities to prevent reintroducing HTML tags
- Decode &amp; last to avoid double-unescaping (&amp;lt;  &lt;  <)
- Fixes all 4 CodeQL findings (bad filtering, incomplete sanitization,
  double escaping, incomplete multi-character sanitization)

* fix: lint and format errors (eslint naming, dprint formatting, stub classes)

* fix: exclude tidgi.config.json from template copy and fix log marker pattern

- Skip tidgi.config.json when copying wiki template to prevent overriding workspace name
- Change waitForLogMarker default pattern from 'wiki-' to '*' to match actual log filenames
- Filter tidgi.config.json in e2e step too (fs.copy in wiki creation step)

* perf(e2e): merge 5 scheduledTask scenarios into 2 to reduce app restarts

* perf(e2e): merge crossWindowSync and streamingStatus scenarios to reduce CI time

* ci: increase test timeout to 20min for larger e2e scenario count

* perf(e2e): merge agent scenarios and enable parallel on CI

- Merge agent.feature wiki-search + wiki-operation into one scenario
- Merge agent.feature create-agent-from-newtab + create-agent-from-fallback into one
- Enable cucumber parallel: 2 on CI (7GB RAM, dynamic ports for mock servers)
- Total scenarios: 66 -> 61

* fix: cleanup MCP client processes when deleting or closing agent

* ci: disable parallel (CPU contention), increase timeout to 30min for 61 scenarios

* perf(e2e): merge 3 preference background-task scenarios into 1, increase timeout to 45min

- Preference alarm/heartbeat CRUD merged into single scenario (saves 2 app restarts)
- Total scenarios: 59 (was 66)
- CI timeout 45min for the expanded e2e suite

* fix: restructure e2e scenarios, fix subscription leak and debounce cleanup

- Restructure agent/talkWithAI/streamingStatus feature files for reliability
- Fix talkWithAI scenarios missing mock server startup and app launch
- Remove duplicate subscription in handleSwitchAgent (useEffect already handles it)
- Clean up debounced update functions when agent is deleted/closed
- Use agentId:messageId key for debounced functions to enable per-agent cleanup

* fix(e2e): remove :last-child selectors broken by TurnActionBar, fix tab/dialog selectors, increase CI timeout to 25min

* fix: use const for non-reassigned variable (lint)

* fix(e2e): fix close-all-tabs opacity issue, scheduledTask undefined steps, MUI multiline textarea targeting

* fix(e2e): use tab-list-dropdown to close all tabs, fix selector for actual TabListDropdown component

* fix(e2e): add timing waits for BrowserView repositioning and git log UI refresh

* fix(e2e): make 'should not see' step wait for element to disappear instead of instant check

* fix(e2e): increase executeInBrowserView default timeout from 500ms to 2000ms
2026-03-09 04:07:39 +08:00

656 lines
24 KiB
TypeScript

import { DataTable, Then, When } from '@cucumber/cucumber';
import { backOff } from 'exponential-backoff';
import { parseDataTableRows } from '../supports/dataTable';
import { getWikiTestRootPath } from '../supports/paths';
import { PLAYWRIGHT_SHORT_TIMEOUT, PLAYWRIGHT_TIMEOUT } from '../supports/timeouts';
import type { ApplicationWorld } from './application';
const UI_BACKOFF_OPTIONS = {
numOfAttempts: 15,
startingDelay: 200,
timeMultiple: 1,
maxDelay: 200,
};
When('I wait for {float} seconds', async function(seconds: number) {
await new Promise(resolve => setTimeout(resolve, seconds * 1000));
});
/**
* Wait with a reason for documentation and debugging
* The reason parameter is used in the Gherkin feature file for documentation purposes
*/
When('I wait for {float} seconds for {string}', async function(seconds: number, _reason: string) {
await new Promise(resolve => setTimeout(resolve, seconds * 1000));
});
When('I wait for the page to load completely', async function(this: ApplicationWorld) {
if (this.appLaunchPromise) {
try {
await this.appLaunchPromise;
} catch (error) {
throw new Error(
`Failed to launch TidGi application: ${error as Error}. You should run \`pnpm run test:prepare-e2e\` before running the tests to ensure the app is built, and build with binaries like "dugite" and "tiddlywiki", see scripts/afterPack.js for more details.`,
);
} finally {
this.appLaunchPromise = undefined;
}
}
let currentWindow = this.currentWindow;
if ((!currentWindow || currentWindow.isClosed()) && this.app) {
currentWindow = await this.app.firstWindow({ timeout: PLAYWRIGHT_TIMEOUT });
this.mainWindow = this.mainWindow ?? currentWindow;
this.currentWindow = currentWindow;
}
await currentWindow?.waitForLoadState('domcontentloaded', { timeout: PLAYWRIGHT_TIMEOUT });
// Some startup pages keep background activity and never reach networkidle quickly.
try {
await currentWindow?.waitForLoadState('networkidle', { timeout: PLAYWRIGHT_SHORT_TIMEOUT });
} catch {
// Ignore networkidle timeout when DOM is already ready.
}
});
Then('I should see a(n) {string} element with selector {string}', async function(this: ApplicationWorld, elementComment: string, selector: string) {
await backOff(
async () => {
let currentWindow = this.currentWindow;
if ((!currentWindow || currentWindow.isClosed()) && this.app) {
currentWindow = await this.app.firstWindow({ timeout: PLAYWRIGHT_SHORT_TIMEOUT });
this.mainWindow = this.mainWindow ?? currentWindow;
this.currentWindow = currentWindow;
}
if (!currentWindow) {
throw new Error('No current window is available');
}
const element = await currentWindow.$(selector);
if (!element) {
throw new Error('Element not found yet');
}
const isVisible = await element.isVisible();
if (!isVisible) {
throw new Error(`Element "${elementComment}" with selector "${selector}" is not visible`);
}
},
UI_BACKOFF_OPTIONS,
).catch((error: unknown) => {
throw new Error(`Failed to find ${elementComment} with selector "${selector}": ${error as Error}`);
});
});
Then('I should see {string} elements with selectors:', async function(this: ApplicationWorld, _elementDescriptions: string, dataTable: DataTable) {
const currentWindow = this.currentWindow;
if (!currentWindow) {
throw new Error('No current window is available');
}
const rows = dataTable.raw();
const dataRows = parseDataTableRows(rows, 2);
const errors: string[] = [];
if (dataRows[0]?.length !== 2) {
throw new Error('Table must have exactly 2 columns: | element description | selector |');
}
// Check all elements in parallel for better performance
await Promise.all(dataRows.map(async ([elementComment, selector]) => {
try {
await currentWindow.waitForSelector(selector, { timeout: PLAYWRIGHT_TIMEOUT });
const isVisible = await currentWindow.isVisible(selector);
if (!isVisible) {
errors.push(`Element "${elementComment}" with selector "${selector}" is not visible`);
}
} catch (error) {
errors.push(`Failed to find "${elementComment}" with selector "${selector}": ${error as Error}`);
}
}));
if (errors.length > 0) {
throw new Error(`Failed to find elements:\n${errors.join('\n')}`);
}
});
Then('I should not see a(n) {string} element with selector {string}', async function(this: ApplicationWorld, elementComment: string, selector: string) {
const currentWindow = this.currentWindow;
if (!currentWindow) {
throw new Error('No current window is available');
}
try {
const element = currentWindow.locator(selector).first();
// Wait for element to be hidden/detached (handles race conditions after state changes)
await element.waitFor({ state: 'hidden', timeout: PLAYWRIGHT_TIMEOUT });
} catch (error) {
if (error instanceof Error && error.message.includes('timeout')) {
// Element still visible after timeout — get parent HTML for debugging
let parentHtml = '';
try {
const element = currentWindow.locator(selector).first();
const parent = element.locator('xpath=..');
parentHtml = await parent.evaluate((node) => node.outerHTML);
} catch {
parentHtml = 'Failed to get parent HTML';
}
throw new Error(
`Element "${elementComment}" with selector "${selector}" should not be visible but was found\n` +
`Parent element HTML:\n${parentHtml}`,
);
}
// Other errors (element not in DOM at all) are expected — pass
}
});
Then('I should not see {string} elements with selectors:', async function(this: ApplicationWorld, _elementDescriptions: string, dataTable: DataTable) {
const currentWindow = this.currentWindow;
if (!currentWindow) {
throw new Error('No current window is available');
}
const rows = dataTable.raw();
const dataRows = parseDataTableRows(rows, 2);
const errors: string[] = [];
if (dataRows[0]?.length !== 2) {
throw new Error('Table must have exactly 2 columns: | element description | selector |');
}
// Check all elements
for (const [elementComment, selector] of dataRows) {
try {
const element = currentWindow.locator(selector).first();
const count = await element.count();
if (count > 0) {
const isVisible = await element.isVisible();
if (isVisible) {
errors.push(`Element "${elementComment}" with selector "${selector}" should not be visible but was found`);
}
}
// Element not found or not visible - this is expected
} catch (error) {
// If the error is our custom error, rethrow it
if (error instanceof Error && error.message.includes('should not be visible')) {
errors.push(error.message);
}
// Otherwise, element not found is expected - continue
}
}
if (errors.length > 0) {
throw new Error(`Failed to verify elements are not visible:\n${errors.join('\n')}`);
}
});
When('I click on a(n) {string} element with selector {string}', async function(this: ApplicationWorld, elementComment: string, selector: string) {
const targetWindow = await this.getWindow('current');
if (!targetWindow) {
throw new Error(`Window "current" is not available`);
}
try {
await targetWindow.waitForSelector(selector, { timeout: PLAYWRIGHT_TIMEOUT });
const isVisible = await targetWindow.isVisible(selector);
if (!isVisible) {
throw new Error(`Element "${elementComment}" with selector "${selector}" is not visible`);
}
await targetWindow.click(selector);
} catch (error) {
throw new Error(`Failed to find and click ${elementComment} with selector "${selector}" in current window: ${error as Error}`);
}
});
When('I click on {string} elements with selectors:', async function(this: ApplicationWorld, _elementDescriptions: string, dataTable: DataTable) {
const targetWindow = await this.getWindow('current');
if (!targetWindow) {
throw new Error('Window "current" is not available');
}
const rows = dataTable.raw();
const dataRows = parseDataTableRows(rows, 2);
const errors: string[] = [];
if (dataRows[0]?.length !== 2) {
throw new Error('Table must have exactly 2 columns: | element description | selector |');
}
// Click elements sequentially (not in parallel) to maintain order and avoid race conditions
for (const [elementComment, selector] of dataRows) {
try {
await targetWindow.waitForSelector(selector, { timeout: PLAYWRIGHT_TIMEOUT });
const isVisible = await targetWindow.isVisible(selector);
if (!isVisible) {
errors.push(`Element "${elementComment}" with selector "${selector}" is not visible`);
continue;
}
await targetWindow.click(selector);
} catch (error) {
errors.push(`Failed to find and click "${elementComment}" with selector "${selector}": ${error as Error}`);
}
}
if (errors.length > 0) {
throw new Error(`Failed to click elements:\n${errors.join('\n')}`);
}
});
When('I right-click on a(n) {string} element with selector {string}', async function(this: ApplicationWorld, elementComment: string, selector: string) {
const targetWindow = await this.getWindow('current');
if (!targetWindow) {
throw new Error(`Window "current" is not available`);
}
try {
await targetWindow.waitForSelector(selector, { timeout: PLAYWRIGHT_TIMEOUT });
const isVisible = await targetWindow.isVisible(selector);
if (!isVisible) {
throw new Error(`Element "${elementComment}" with selector "${selector}" is not visible`);
}
await targetWindow.click(selector, { button: 'right' });
} catch (error) {
throw new Error(`Failed to find and right-click ${elementComment} with selector "${selector}" in current window: ${error as Error}`);
}
});
When('I click all {string} elements matching selector {string}', async function(this: ApplicationWorld, elementComment: string, selector: string) {
const win = this.currentWindow;
if (!win) throw new Error('No active window available to click elements');
// Wait for at least one element to appear in DOM (even if hidden)
try {
await win.locator(selector).first().waitFor({ state: 'attached', timeout: PLAYWRIGHT_TIMEOUT });
} catch {
throw new Error(`No elements found for ${elementComment} with selector "${selector}" within timeout`);
}
const locator = win.locator(selector);
const count = await locator.count();
// Single-pass reverse iteration to avoid index shift issues
for (let index = count - 1; index >= 0; index--) {
try {
await locator.nth(index).scrollIntoViewIfNeeded().catch(() => {});
await locator.nth(index).click({ force: true, timeout: 500 });
} catch (error) {
throw new Error(`Failed to click ${elementComment} at index ${index} with selector "${selector}": ${error as Error}`);
}
}
});
When('I type {string} in {string} element with selector {string}', async function(this: ApplicationWorld, text: string, elementComment: string, selector: string) {
const currentWindow = this.currentWindow;
if (!currentWindow) {
throw new Error('No current window is available');
}
// Replace {tmpDir} placeholder with actual test root path
const actualText = text.replace('{tmpDir}', getWikiTestRootPath(this));
try {
await currentWindow.waitForSelector(selector, { timeout: PLAYWRIGHT_TIMEOUT });
const element = currentWindow.locator(selector);
await element.fill(actualText);
} catch (error) {
throw new Error(`Failed to type in ${elementComment} element with selector "${selector}": ${error as Error}`);
}
});
When('I type in {string} elements with selectors:', async function(this: ApplicationWorld, elementDescriptions: string, dataTable: DataTable) {
const currentWindow = this.currentWindow;
if (!currentWindow) {
throw new Error('No current window is available');
}
const descriptions = elementDescriptions.split(' and ').map(d => d.trim());
const rows = dataTable.raw();
const dataRows = parseDataTableRows(rows, 2);
const errors: string[] = [];
if (descriptions.length !== dataRows.length) {
throw new Error(`Mismatch: ${descriptions.length} element descriptions but ${dataRows.length} text/selector pairs provided`);
}
// Type in elements sequentially to maintain order
for (let index = 0; index < dataRows.length; index++) {
const [text, selector] = dataRows[index];
const elementComment = descriptions[index];
// Replace {tmpDir} placeholder with actual test root path
const actualText = text.replace('{tmpDir}', getWikiTestRootPath(this));
try {
await currentWindow.waitForSelector(selector, { timeout: PLAYWRIGHT_TIMEOUT });
const element = currentWindow.locator(selector);
await element.fill(actualText);
} catch (error) {
errors.push(`Failed to type in "${elementComment}" with selector "${selector}": ${error as Error}`);
}
}
if (errors.length > 0) {
throw new Error(`Failed to type in some elements:\n${errors.join('\n')}`);
}
});
When('I clear text in {string} element with selector {string}', async function(this: ApplicationWorld, elementComment: string, selector: string) {
const currentWindow = this.currentWindow;
if (!currentWindow) {
throw new Error('No current window is available');
}
try {
await currentWindow.waitForSelector(selector, { timeout: PLAYWRIGHT_TIMEOUT });
const element = currentWindow.locator(selector);
await element.clear();
} catch (error) {
throw new Error(`Failed to clear text in ${elementComment} element with selector "${selector}": ${error as Error}`);
}
});
When('the window title should contain {string}', async function(this: ApplicationWorld, expectedTitle: string) {
const currentWindow = this.currentWindow;
if (!currentWindow) {
throw new Error('No current window is available');
}
try {
const title = await currentWindow.title();
if (!title.includes(expectedTitle)) {
throw new Error(`Window title "${title}" does not contain "${expectedTitle}"`);
}
} catch (error) {
throw new Error(`Failed to check window title: ${error as Error}`);
}
});
// Generic keyboard action
When('I press {string} key', async function(this: ApplicationWorld, key: string) {
const currentWindow = this.currentWindow;
if (!currentWindow) {
throw new Error('No current window is available');
}
await currentWindow.keyboard.press(key);
});
// Generic window switching - sets currentWindow state for subsequent operations
// You may need to wait a second before switch, otherwise window's URL may not set yet.
When('I switch to {string} window', async function(this: ApplicationWorld, windowType: string) {
if (!this.app) {
throw new Error('Application is not available');
}
const targetWindow = await this.getWindow(windowType);
if (targetWindow) {
this.currentWindow = targetWindow; // Set currentWindow state
} else {
throw new Error(`Could not find ${windowType} window`);
}
});
// Switch to the newest/latest window (useful for OAuth popups)
When('I switch to the newest window', async function(this: ApplicationWorld) {
if (!this.app) {
throw new Error('Application is not available');
}
const allWindows = this.app.windows().filter(p => !p.isClosed());
if (allWindows.length === 0) {
throw new Error('No windows available');
}
// The newest window is the last one in the array
const newestWindow = allWindows[allWindows.length - 1];
this.currentWindow = newestWindow;
});
// Generic window closing
When('I close {string} window', async function(this: ApplicationWorld, windowType: string) {
if (!this.app) {
throw new Error('Application is not available');
}
const targetWindow = await this.getWindow(windowType);
if (targetWindow) {
await targetWindow.close();
} else {
throw new Error(`Could not find ${windowType} window to close`);
}
});
When('I press the key combination {string}', async function(this: ApplicationWorld, keyCombo: string) {
const currentWindow = this.currentWindow;
if (!currentWindow) {
throw new Error('No current window is available');
}
// Convert CommandOrControl to platform-specific key
let platformKeyCombo = keyCombo;
if (keyCombo.includes('CommandOrControl')) {
// Prefer explicit platform detection: use 'Meta' only on macOS (darwin),
// otherwise default to 'Control'. This avoids assuming non-Windows/Linux
// is always macOS.
if (process.platform === 'darwin') {
platformKeyCombo = keyCombo.replace('CommandOrControl', 'Meta');
} else {
platformKeyCombo = keyCombo.replace('CommandOrControl', 'Control');
}
}
// Use dispatchEvent to trigger document-level keydown events
// This ensures the event is properly captured by React components listening to document events
// The testKeyboardShortcutFallback in test environment expects key to match the format used in shortcuts
await currentWindow.evaluate((keyCombo) => {
const parts = keyCombo.split('+');
let mainKey = parts[parts.length - 1];
const modifiers = parts.slice(0, -1);
// For single letter keys, match the case sensitivity used by the shortcut system
// Shift+Key -> uppercase, otherwise lowercase
if (mainKey.length === 1) {
mainKey = modifiers.includes('Shift') ? mainKey.toUpperCase() : mainKey.toLowerCase();
}
const event = new KeyboardEvent('keydown', {
key: mainKey,
code: mainKey.length === 1 ? `Key${mainKey.toUpperCase()}` : mainKey,
ctrlKey: modifiers.includes('Control'),
metaKey: modifiers.includes('Meta'),
shiftKey: modifiers.includes('Shift'),
altKey: modifiers.includes('Alt'),
bubbles: true,
cancelable: true,
});
document.dispatchEvent(event);
}, platformKeyCombo);
});
When('I select {string} from MUI Select with test id {string}', async function(this: ApplicationWorld, optionValue: string, testId: string) {
const currentWindow = this.currentWindow;
if (!currentWindow) {
throw new Error('No current window is available');
}
try {
// Find the element with the test-id (could be input directly, or a wrapper div for MUI TextField)
const directInputSelector = `input[data-testid="${testId}"]`;
const wrapperSelector = `[data-testid="${testId}"]`;
// Try input first, then fall back to wrapper
const hasDirectInput = await currentWindow.locator(directInputSelector).count() > 0;
const containerSelector = hasDirectInput ? directInputSelector : wrapperSelector;
await currentWindow.waitForSelector(containerSelector, { timeout: PLAYWRIGHT_TIMEOUT });
// Click the combobox to open the dropdown
const clicked = await currentWindow.evaluate((testId) => {
// Try direct input match first
const element: Element | null = document.querySelector(`input[data-testid="${testId}"]`);
let searchRoot: Element | null = element?.parentElement ?? null;
// If not found, try wrapper element (MUI TextField with select prop)
if (!element) {
searchRoot = document.querySelector(`[data-testid="${testId}"]`);
}
if (!searchRoot) return { success: false, error: 'Element not found' };
const combobox = searchRoot.querySelector('[role="combobox"]');
if (!combobox) {
return {
success: false,
error: 'Combobox not found',
parentHTML: searchRoot.outerHTML.substring(0, 500),
};
}
combobox.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
combobox.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
(combobox as HTMLElement).click();
return { success: true };
}, testId);
if (!clicked.success) {
throw new Error(`Failed to click: ${JSON.stringify(clicked)}`);
}
// Wait a bit for the menu to appear
await currentWindow.waitForTimeout(500);
// Wait for the menu to appear
await currentWindow.waitForSelector('[role="listbox"]', { timeout: PLAYWRIGHT_SHORT_TIMEOUT });
// Try to click on the option with the specified value (data-value attribute)
// If not found, try to find by text content
const optionClicked = await currentWindow.evaluate((optionValue) => {
// First try: Find by data-value attribute
const optionByValue = document.querySelector(`[role="option"][data-value="${optionValue}"]`);
if (optionByValue) {
(optionByValue as HTMLElement).click();
return { success: true, method: 'data-value' };
}
// Second try: Find by text content (case-insensitive)
const allOptions = Array.from(document.querySelectorAll('[role="option"]'));
const optionByText = allOptions.find(option => {
const text = option.textContent?.trim().toLowerCase();
return text === optionValue.toLowerCase();
});
if (optionByText) {
(optionByText as HTMLElement).click();
return { success: true, method: 'text-content' };
}
// Return available options for debugging
return {
success: false,
availableOptions: allOptions.map(opt => ({
text: opt.textContent?.trim(),
value: opt.getAttribute('data-value'),
})),
};
}, optionValue);
if (!optionClicked.success) {
throw new Error(
`Could not find option "${optionValue}". Available options: ${JSON.stringify(optionClicked.availableOptions)}`,
);
}
// Wait for the menu to close
await currentWindow.waitForSelector('[role="listbox"]', { state: 'hidden', timeout: PLAYWRIGHT_SHORT_TIMEOUT });
} catch (error) {
throw new Error(`Failed to select option "${optionValue}" from MUI Select with test id "${testId}": ${String(error)}`);
}
});
// Debug step to print current DOM structure
When('I print current DOM structure', async function(this: ApplicationWorld) {
const currentWindow = this.currentWindow;
if (!currentWindow) {
throw new Error('No current window is available');
}
const html = await currentWindow.evaluate(() => {
return document.body.innerHTML;
});
console.log('=== Current DOM Structure ===');
console.log(html.substring(0, 5000)); // Print first 5000 characters
console.log('=== End DOM Structure ===');
});
// Debug step to print DOM structure of a specific element
When('I print DOM structure of element with selector {string}', async function(this: ApplicationWorld, selector: string) {
const currentWindow = this.currentWindow;
if (!currentWindow) {
throw new Error('No current window is available');
}
try {
await currentWindow.waitForSelector(selector, { timeout: PLAYWRIGHT_SHORT_TIMEOUT });
const elementInfo = await currentWindow.evaluate((sel) => {
const element = document.querySelector(sel);
if (!element) {
return { found: false };
}
return {
found: true,
outerHTML: element.outerHTML,
innerHTML: element.innerHTML,
attributes: Array.from(element.attributes).map(attribute => ({
name: attribute.name,
value: attribute.value,
})),
children: Array.from(element.children).map(child => ({
tagName: child.tagName,
className: child.className,
id: child.id,
attributes: Array.from(child.attributes).map(attribute => ({
name: attribute.name,
value: attribute.value,
})),
})),
};
}, selector);
if (!elementInfo.found) {
console.log(`=== Element "${selector}" not found ===`);
return;
}
console.log(`=== DOM Structure of "${selector}" ===`);
console.log('Attributes:', JSON.stringify(elementInfo.attributes, null, 2));
console.log('\nChildren:', JSON.stringify(elementInfo.children, null, 2));
console.log('\nOuter HTML (first 2000 chars):');
console.log((elementInfo.outerHTML ?? '').substring(0, 2000));
console.log('=== End DOM Structure ===');
} catch (error) {
console.log(`Error inspecting element "${selector}": ${String(error)}`);
}
});
// Debug step to print all window URLs
When('I print all window URLs', async function(this: ApplicationWorld) {
if (!this.app) {
throw new Error('Application is not available');
}
const allWindows = this.app.windows();
console.log(`=== Total windows: ${allWindows.length} ===`);
for (let index = 0; index < allWindows.length; index++) {
const win = allWindows[index];
try {
const url = win.url();
const title = await win.title();
const isClosed = win.isClosed();
console.log(`Window ${index}: URL=${url}, Title=${title}, Closed=${isClosed}`);
} catch (error) {
console.log(`Window ${index}: Error getting info - ${String(error)}`);
}
}
console.log('=== End Window List ===');
});