mirror of
https://github.com/tiddly-gittly/TidGi-Desktop.git
synced 2026-04-15 18:13:07 -07:00
* refactor: replace time-window echo prevention with mtime+size and content checks FileSystemWatcher: use recorded mtime+size (lastWriteStats) and saving-state flag (titlesBeingSaved) to skip own-write echoes, plus content identity fallback. Removes unreliable setTimeout-based exclude/scheduleFileInclusion. ipcServerRoutes: replace TTL Map with synchronous Set (ipcPendingTitles), attach changeCount as revision to change Observable. ipc-syncadaptor: use lastSavedRevisions for revision-based echo prevention, batch large syncs via requestIdleCallback. Also: EditWorkspace controlled input fixes, port field local state, agent framework config setConfig/persistConfig split, misc value ?? '' fixes. * Enhance e2e screenshots and test logging Improve end-to-end test reliability and diagnostics: - Capture window screenshots for non-BrowserView steps: add captureWindowScreenshot and use it in AfterStep to capture the Electron BrowserWindow via webContents.capturePage() when appropriate; keep existing captureScreenshot for BrowserView steps. (features/supports/webContentsViewHelper.ts, features/stepDefinitions/application.ts) - Prevent wiki restart race: wait for the wiki worker '[test-id-WIKI_WORKER_STARTED]' marker before restarting to avoid DoubleWikiInstanceError. (features/stepDefinitions/wiki.ts) - Make git-related test markers more visible by switching logger.debug → logger.info for git init/commit/sync/checkout/revert and git-log rendering markers, ensuring e2e tests can reliably detect these events. (src/services/git/index.ts, src/windows/GitLog/useGitLogData.ts) - Minor feature test tweak: wait for page to load in defaultWiki.feature before interacting with the editWorkspace window. These changes reduce flaky screenshots and timing races in tests and improve test marker visibility for e2e detection. * Enhance e2e screenshots and test logging Improve end-to-end test reliability and diagnostics: - Capture window screenshots for non-BrowserView steps: add captureWindowScreenshot and use it in AfterStep to capture the Electron BrowserWindow via webContents.capturePage() when appropriate; keep existing captureScreenshot for BrowserView steps. (features/supports/webContentsViewHelper.ts, features/stepDefinitions/application.ts) - Prevent wiki restart race: wait for the wiki worker '[test-id-WIKI_WORKER_STARTED]' marker before restarting to avoid DoubleWikiInstanceError. (features/stepDefinitions/wiki.ts) - Make git-related test markers more visible by switching logger.debug → logger.info for git init/commit/sync/checkout/revert and git-log rendering markers, ensuring e2e tests can reliably detect these events. (src/services/git/index.ts, src/windows/GitLog/useGitLogData.ts) - Minor feature test tweak: wait for page to load in defaultWiki.feature before interacting with the editWorkspace window. These changes reduce flaky screenshots and timing races in tests and improve test marker visibility for e2e detection. * Use startTransition to update port on change Import startTransition from React and update the port input handler to capture the raw value, update local display state, and defer parsing/setting the workspace port inside startTransition. The handler now parses an empty value as 0, validates the number (non-NaN and >= 0) before calling workspaceSetter, improving responsiveness by marking the workspace update as non-urgent. Also removed an obsolete inline comment. * Improve E2E, view sync, and wiki IPC robustness Multiple fixes and improvements across E2E tests, view management, wiki IPC, and workspace handling: - Docs: add SHOW_E2E_WINDOW env var note to allow visible Electron windows during manual E2E runs. - Features: simplify/adjust cross-window sync scenario and refine hibernation workspace selectors to avoid collisions with similarly named workspaces. - E2E steps: make AI-request assertion resilient by polling (backOff); replace brittle DOM-driven tiddler creation with direct TiddlyWiki API calls in browserView step definitions for reliability; add small UI click pause and longer click timeout in ui steps. - Timeouts: unify global timeout to 25s and derive Playwright short/log wait timeouts from it. - WebContents helpers: prefer the last child wiki view (active one) when multiple views exist and update comments. - WikiEmbedTabContent: wake workspace on mount and simplify cleanup to always clear custom bounds on unmount; handle errors. - Chat UI: hide TabListDropdown in split view. - Agent instances: default missing agentFrameworkID to 'basicPromptConcatHandler' for older definitions. - View service: add activelyShownViews set to avoid hiding views that were explicitly shown via showView(); clear stale custom bounds when showing views; avoid moving views offscreen when actively shown; ensure cleanup removes active flag. - IPC server routes: prefer URL-based workspace ID (resolve correct casing via workspace service) to handle cross-session routing; pass effective workspace ID to route handlers. - Wiki service: add startup timeout to avoid indefinite hangs waiting for worker boot message. - IPC sync adaptor: implement titlesBeingSaved/titlesBeingLoaded sets to prevent save-back and SSE echo issues; mark titles when saving/loading and suppress spurious saves. - Wiki worker IPC routes: replace per-subscriber addEventListener with a shared Subject and single change listener to avoid missed events and cross-window sync bugs; forward subject events to subscribers and log readiness. - Windows: keep windows hidden during tests by default but allow showing them when SHOW_E2E_WINDOW=1. - Workspaces: compute next insert order so new wiki workspaces appear at the top of regular workspaces (shift others down). Overall these changes reduce flakiness in tests, prevent cross-window echo and routing bugs, make view lifecycle handling safer, and improve developer ergonomics for debugging E2E runs. * fix(e2e): bypass system proxy for git HTTP operations in sync test Git clone/push/fetch to localhost was routed through the system proxy (port 1080), which returned 502 Bad Gateway. Add -c http.proxy= to disable proxy for all HTTP git commands in the sync test step definitions. * fix: address CI lint errors and Copilot review comments Lint fixes: - useOptimisticField: rename *Ref/*Fn vars to *Reference/*Function (unicorn/prevent-abbreviations) - wiki/index: rename args to arguments_ (unicorn/prevent-abbreviations) - FileSystemWatcher: use specific type assertion for tiddler fields to avoid no-base-to-string warning - webContentsViewHelper: rename loop var i to index - interface.ts: merge duplicate imports from same module (dprint) - ipc-syncadaptor: expand queueMicrotask callback (dprint) Copilot review fixes: - ipcServerRoutes: move subscription to outer scope so Observable teardown is returned synchronously from the constructor, preventing subscription leaks when observers are disposed before the async IIFE resolves - useOptimisticField: capture localValue at focus time; on blur only commit when user actually changed the value (not when serverValue updated while focused) - FileSystemWatcher.markSaveComplete: guard scheduleGitNotification behind non-empty absoluteFilePath to avoid spurious git notifications on error paths * fix(e2e): handle corrupt settings.json in cleanup and use filechooser intercept for image upload - tidgiMiniWindow cleanup: wrap readJson with try/catch so truncated/empty settings.json (race between app shutdown write and After hook read) is handled gracefully instead of throwing SyntaxError - application.ts: add 'I prepare to select file ... for file chooser' step that registers a Playwright one-shot filechooser handler BEFORE the click that triggers fileInput.click(). This prevents the native OS dialog from appearing entirely (the chooser is resolved directly with the supplied file). - talkWithAI.feature: replace two-step 'click then setInputFiles' with the new 'prepare filechooser click' pattern so no OS dialog is shown during the AI image attachment test
658 lines
25 KiB
TypeScript
658 lines
25 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: 3000 });
|
|
// Brief pause for the UI to settle after each close
|
|
await new Promise(resolve => setTimeout(resolve, 300));
|
|
} 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 ===');
|
|
});
|