TidGi-Desktop/features/stepDefinitions/ui.ts
lin onetwo b76fc17794
Chore/upgrade (#646)
* docs: deps

* Update dependencies and type usage for AI features

Upgraded multiple dependencies in package.json and pnpm-lock.yaml, including @ai-sdk, @mui, react, and others for improved compatibility and performance. Changed type usage from CoreMessage to ModelMessage in mockOpenAI.test.ts to align with updated ai package. No functional changes to application logic.

* feat: i18n

* feat: test oauth login and use PKCE

* fix: use ollama-ai-provider-v2

* test: github and mock oauth2 login

* test: gitea login

* Refactor context menu cleanup and error message

Moved context menu cleanup for OAuth window to a single closed event handler in Authentication service. Simplified error message formatting in ContextService for missing keys.

* lint: AI fix

* Add tsx as a dev dependency and update scripts

Replaced usage of 'pnpm dlx tsx' with direct 'tsx' command in development and test scripts for improved reliability. Added 'tsx' to devDependencies in package.json.
2025-10-23 23:42:06 +08:00

468 lines
18 KiB
TypeScript

import { DataTable, Then, When } from '@cucumber/cucumber';
import type { ApplicationWorld } from './application';
When('I wait for {float} seconds', async function(this: ApplicationWorld, seconds: number) {
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: 30000 });
});
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: 10000 });
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 descriptions = elementDescriptions.split(' and ').map(d => d.trim());
const rows = dataTable.raw();
const errors: string[] = [];
if (descriptions.length !== rows.length) {
throw new Error(`Mismatch: ${descriptions.length} element descriptions but ${rows.length} selectors provided`);
}
// Check all elements in parallel for better performance
await Promise.all(rows.map(async ([selector], index) => {
const elementComment = descriptions[index];
try {
await currentWindow.waitForSelector(selector, { timeout: 10000 });
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 descriptions = elementDescriptions.split(' and ').map(d => d.trim());
const rows = dataTable.raw();
const errors: string[] = [];
if (descriptions.length !== rows.length) {
throw new Error(`Mismatch: ${descriptions.length} element descriptions but ${rows.length} selectors provided`);
}
// Check all elements
for (let index = 0; index < rows.length; index++) {
const [selector] = rows[index];
const elementComment = descriptions[index];
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: 10000 });
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 descriptions = elementDescriptions.split(' and ').map(d => d.trim());
const rows = dataTable.raw();
const errors: string[] = [];
if (descriptions.length !== rows.length) {
throw new Error(`Mismatch: ${descriptions.length} element descriptions but ${rows.length} selectors provided`);
}
// Click elements sequentially (not in parallel) to maintain order and avoid race conditions
for (let index = 0; index < rows.length; index++) {
const [selector] = rows[index];
const elementComment = descriptions[index];
try {
await targetWindow.waitForSelector(selector, { timeout: 10000 });
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: 10000 });
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');
}
try {
await currentWindow.waitForSelector(selector, { timeout: 10000 });
const element = currentWindow.locator(selector);
await element.fill(text);
} catch (error) {
throw new Error(`Failed to type in ${elementComment} element with selector "${selector}": ${error as Error}`);
}
});
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: 10000 });
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: 10000 });
// 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: 5000 });
// 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: 5000 });
} catch (error) {
throw new Error(`Failed to select option "${optionValue}" from MUI Select with test id "${testId}": ${String(error)}`);
}
});