TidGi-Desktop/features/stepDefinitions/ui.ts
lin onetwo a712b2ff51
Fix/misc bug (#679)
* Create ErrorDuringRelease.md

* Enforce test timeouts and add root tiddler scenario

Set global and step timeouts to 5s (local) and 10s (CI) across cucumber config and step definitions to standardize test execution times. Add a new scenario to verify root tiddler configuration and content loading after restart. Enhance start-e2e-app script to accept or auto-detect test scenario names and pass them to the app.

* Improve error handling for window and view initialization

Enhanced error reporting and handling when browser windows are not ready or fail to register in windowService. Updated focus logic to dynamically retrieve the current browser window, improving reliability during workspace hibernation and wake-up scenarios.

* Remove AfterAll hook and add --exit to e2e tests

Eliminates the AfterAll hook that forced process exit in cleanup.ts to prevent hanging after tests. Adds the --exit flag to the cucumber-js command in the e2e test script to ensure proper test process termination.

* Add step to restart workspace in wiki tests

Introduces a new step definition 'I restart workspace {string}' to programmatically restart a wiki workspace during tests. Updates the root tiddler scenario to use this step for verifying lazy-load behavior after workspace restart, improving test reliability and clarity.

* Centralize and standardize E2E test timeouts

Extracted timeout values into features/supports/timeouts.ts and replaced hardcoded timeouts in step definitions with named constants. This ensures consistent timeout handling across local and CI environments, reduces duplication, and clarifies intent. Also improved workspace update logic to check watch-fs state before restart and cleaned up related log marker handling.

* Improve i18n coverage and add Windows installer log access

Expanded and unified i18n keys for error messages and UI labels across multiple languages. Refactored code to remove hardcoded default values from translation calls. Added a Developer Tools option to open the Windows installer log folder (SquirrelTemp) when running on Windows. Introduced a placeholder file to preserve dynamic i18n keys for error messages.

* Initialize Tidgi mini window before workspace views

Moved the initialization of the Tidgi mini window to occur before initializing all workspace views in main.ts to ensure correct view creation. Added a clarifying comment in DeveloperTools.tsx regarding the SquirrelSetup.log path.

* Refactor Tidgi mini window initialization logic

Tidgi mini window creation now only creates the window; view creation is deferred to initializeAllWorkspaceView. Updated related comments and logging for clarity. Also fixed formatting in French translations and improved documentation for error handling during release.

* Add model feature chips to model selection UI

Introduces a ModelFeatureChip component to visually display model features in the model selector and new model dialog. Updates defaultProviders to include new models with features, and enhances the UI to show feature chips for each model, improving clarity for users selecting models.

* Add image attachment support to chat messages

This update enables users to attach image files to chat messages, including UI changes for file selection and preview, backend persistence of attachments, and prompt concatenation logic to include images in AI requests. It also adds error handling and i18n for model vision support, updates message rendering to display images, and improves logging and API validation for vision-capable models.

* Improve streaming status handling for agent messages

Adds a failsafe to clear streaming status when an agent reaches a terminal state and refines logic to prevent marking completed messages as streaming. Also updates message stream completion in AgentInstanceService to ensure proper cleanup and delivery of IPC messages. Includes new feature tests for message streaming status and image upload scenarios.

* Add cross-window sync feature and test steps

Introduces a new feature file for cross-window synchronization scenarios. Adds step definitions to open workspaces in new windows and execute TiddlyWiki code programmatically. Removes obsolete wiki.ts.backup file and updates agentActions for related actions.

* feat(sync): fix cross-window synchronization via SSE

- Remove overly aggressive echo prevention in backend that blocked all SSE updates
- Backend now forwards all wiki change events to subscribers
- Add comprehensive cross-window sync tests verifying bidirectional updates
- Test main->new window sync and new->main window sync scenarios
- Version bump to 0.13.0-prerelease19

* Improve file attachment handling in chat and tests

Refactors file input handling in chat tests to use Playwright's setInputFiles, updates message sending types to support optional file attachments, and enhances file metadata persistence and logging. Adjusts test expectations and UI logic to better handle and display image attachments, and clarifies combobox value assertions in ExternalAPI tests.

* Add file input validation and improve i18n messages

Added image type and size validation (10MB limit) to file input in InputContainer. Improved image preview logic. Updated French, Japanese, and Russian translations with new error messages for missing/default model and vision support. Enhanced type safety in promptConcatWithImage tests and messagePersistence logging. Fixed race condition in ExternalAPIService lazy initialization. Updated CommitDetailsPanel to use common cancel translation key.

* review

* Update browserView.ts

* Update timeouts.ts

* Update cucumber.config.js

* Update cucumber.config.js

* Move global timeout config to separate module

Extracted global timeout setup from cucumber.config.js to features/supports/timeout-config.ts using setDefaultTimeout. This ensures the timeout is set via code rather than config, improving clarity and maintainability.

* Update newAgent.feature
2026-01-26 02:43:27 +08:00

604 lines
22 KiB
TypeScript

import { DataTable, Then, When } from '@cucumber/cucumber';
import { parseDataTableRows } from '../supports/dataTable';
import { getWikiTestRootPath } from '../supports/paths';
import { PLAYWRIGHT_SHORT_TIMEOUT, PLAYWRIGHT_TIMEOUT } from '../supports/timeouts';
import type { ApplicationWorld } from './application';
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) {
const currentWindow = this.currentWindow;
await currentWindow?.waitForLoadState('networkidle', { timeout: PLAYWRIGHT_TIMEOUT });
});
Then('I should see a(n) {string} element with selector {string}', async function(this: ApplicationWorld, elementComment: string, selector: string) {
const currentWindow = this.currentWindow;
try {
await currentWindow?.waitForSelector(selector, { timeout: PLAYWRIGHT_TIMEOUT });
const isVisible = await currentWindow?.isVisible(selector);
if (!isVisible) {
throw new Error(`Element "${elementComment}" with selector "${selector}" is not visible`);
}
} catch (error) {
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();
const count = await element.count();
if (count > 0) {
const isVisible = await element.isVisible();
if (isVisible) {
// Get parent element HTML for debugging
let parentHtml = '';
try {
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}`,
);
}
}
// 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')) {
throw error;
}
// Otherwise, element not found is expected - pass the test
}
});
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');
const locator = win.locator(selector);
const count = await locator.count();
if (count === 0) {
throw new Error(`No elements found for ${elementComment} with selector "${selector}"`);
}
// 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 hidden input element with the test-id
const inputSelector = `input[data-testid="${testId}"]`;
await currentWindow.waitForSelector(inputSelector, { timeout: PLAYWRIGHT_TIMEOUT });
// Try to click using Playwright's click on the div with role="combobox"
// According to your HTML structure, the combobox is a sibling of the input
const clicked = await currentWindow.evaluate((testId) => {
const input = document.querySelector(`input[data-testid="${testId}"]`);
if (!input) return { success: false, error: 'Input not found' };
const parent = input.parentElement;
if (!parent) return { success: false, error: 'Parent not found' };
// Find all elements in parent
const combobox = parent.querySelector('[role="combobox"]');
if (!combobox) {
return {
success: false,
error: 'Combobox not found',
parentHTML: parent.outerHTML.substring(0, 500),
};
}
// Trigger both mousedown and click events
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 ===');
});