fix(e2e): cross-protocol browser.execute() — works with both WebDriver + CDP
Root cause: WebDriverIO devtools protocol wraps functions in a polyfill that puts `return` inside eval() (not a function body) → "Illegal return". Fix: exec() wrapper in helpers/execute.ts converts function args to IIFE strings before passing to browser.execute(). Works identically on both WebDriver (Tauri) and CDP/devtools (Electrobun CEF). - 35 spec files updated (browser.execute → exec) - 4 config files updated (string-form expressions) - helpers/actions.ts + assertions.ts updated - 560 vitest + 116 cargo passing
This commit is contained in:
parent
407e49cc32
commit
6a8181f33a
42 changed files with 630 additions and 541 deletions
|
|
@ -1,11 +1,12 @@
|
|||
/**
|
||||
* Reusable test actions — common UI operations used across spec files.
|
||||
*
|
||||
* All actions use browser.execute() for DOM queries with fallback selectors
|
||||
* to support both Tauri and Electrobun UIs (WebKitGTK reliability pattern).
|
||||
* All actions use exec() (cross-protocol safe wrapper) for DOM queries with
|
||||
* fallback selectors to support both Tauri and Electrobun UIs.
|
||||
*/
|
||||
|
||||
import { browser } from '@wdio/globals';
|
||||
import { exec } from './execute.ts';
|
||||
import * as S from './selectors.ts';
|
||||
|
||||
/**
|
||||
|
|
@ -30,8 +31,7 @@ export async function waitForPort(port: number, timeout: number): Promise<void>
|
|||
|
||||
/** Open settings panel via gear icon click */
|
||||
export async function openSettings(): Promise<void> {
|
||||
// Try clicking settings button — may need multiple attempts on WebKitGTK
|
||||
await browser.execute(() => {
|
||||
await exec(() => {
|
||||
const btn = document.querySelector('[data-testid="settings-btn"]')
|
||||
?? document.querySelector('.sidebar-icon')
|
||||
?? document.querySelector('.rail-btn');
|
||||
|
|
@ -40,7 +40,7 @@ export async function openSettings(): Promise<void> {
|
|||
await browser.pause(300);
|
||||
|
||||
// Check if panel opened; if not, try keyboard shortcut (Ctrl+,)
|
||||
const opened = await browser.execute(() =>
|
||||
const opened = await exec(() =>
|
||||
document.querySelector('.sidebar-panel, .settings-drawer, .settings-panel') !== null,
|
||||
);
|
||||
if (!opened) {
|
||||
|
|
@ -48,19 +48,19 @@ export async function openSettings(): Promise<void> {
|
|||
await browser.pause(300);
|
||||
}
|
||||
|
||||
// Wait for either settings panel class (Tauri: .sidebar-panel, Electrobun: .settings-drawer)
|
||||
// Wait for either settings panel class
|
||||
await browser.waitUntil(
|
||||
async () =>
|
||||
browser.execute(() =>
|
||||
exec(() =>
|
||||
document.querySelector('.sidebar-panel, .settings-drawer, .settings-panel') !== null,
|
||||
) as Promise<boolean>,
|
||||
),
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
}
|
||||
|
||||
/** Close settings panel */
|
||||
export async function closeSettings(): Promise<void> {
|
||||
await browser.execute(() => {
|
||||
await exec(() => {
|
||||
const btn = document.querySelector('.settings-close')
|
||||
?? document.querySelector('.panel-close');
|
||||
if (btn) (btn as HTMLElement).click();
|
||||
|
|
@ -70,8 +70,7 @@ export async function closeSettings(): Promise<void> {
|
|||
|
||||
/** Switch to a settings category by index (0-based) */
|
||||
export async function switchSettingsCategory(index: number): Promise<void> {
|
||||
await browser.execute((idx: number) => {
|
||||
// Tauri: .settings-sidebar .sidebar-item | Electrobun: .settings-tab or .cat-btn
|
||||
await exec((idx: number) => {
|
||||
const tabs = document.querySelectorAll('.settings-sidebar .sidebar-item, .settings-tab, .cat-btn');
|
||||
if (tabs[idx]) (tabs[idx] as HTMLElement).click();
|
||||
}, index);
|
||||
|
|
@ -80,7 +79,7 @@ export async function switchSettingsCategory(index: number): Promise<void> {
|
|||
|
||||
/** Switch active group by clicking the nth group button (0-based) */
|
||||
export async function switchGroup(index: number): Promise<void> {
|
||||
await browser.execute((idx: number) => {
|
||||
await exec((idx: number) => {
|
||||
const groups = document.querySelectorAll('.group-btn:not(.add-group-btn)');
|
||||
if (groups[idx]) (groups[idx] as HTMLElement).click();
|
||||
}, index);
|
||||
|
|
@ -126,7 +125,7 @@ export async function closeSearch(): Promise<void> {
|
|||
|
||||
/** Add a new terminal tab by clicking the add button */
|
||||
export async function addTerminalTab(): Promise<void> {
|
||||
await browser.execute(() => {
|
||||
await exec(() => {
|
||||
const btn = document.querySelector('.tab-add-btn');
|
||||
if (btn) (btn as HTMLElement).click();
|
||||
});
|
||||
|
|
@ -135,8 +134,7 @@ export async function addTerminalTab(): Promise<void> {
|
|||
|
||||
/** Click a project-level tab (model, docs, files, etc.) */
|
||||
export async function clickProjectTab(tabName: string): Promise<void> {
|
||||
await browser.execute((name: string) => {
|
||||
// Tauri: .ptab | Electrobun: .project-tab or .tab-btn
|
||||
await exec((name: string) => {
|
||||
const tabs = document.querySelectorAll('.ptab, .project-tab, .tab-btn');
|
||||
for (const tab of tabs) {
|
||||
if ((tab as HTMLElement).textContent?.toLowerCase().includes(name.toLowerCase())) {
|
||||
|
|
@ -156,7 +154,7 @@ export async function waitForElement(selector: string, timeout = 5_000): Promise
|
|||
|
||||
/** Check if an element exists and is displayed (safe for optional elements) */
|
||||
export async function isVisible(selector: string): Promise<boolean> {
|
||||
return browser.execute((sel: string) => {
|
||||
return exec((sel: string) => {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) return false;
|
||||
const style = getComputedStyle(el);
|
||||
|
|
@ -166,7 +164,7 @@ export async function isVisible(selector: string): Promise<boolean> {
|
|||
|
||||
/** Get the display CSS value for an element (for display-toggle awareness) */
|
||||
export async function getDisplay(selector: string): Promise<string> {
|
||||
return browser.execute((sel: string) => {
|
||||
return exec((sel: string) => {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) return 'not-found';
|
||||
return getComputedStyle(el).display;
|
||||
|
|
@ -175,8 +173,7 @@ export async function getDisplay(selector: string): Promise<string> {
|
|||
|
||||
/** Open notification drawer by clicking bell */
|
||||
export async function openNotifications(): Promise<void> {
|
||||
await browser.execute(() => {
|
||||
// Tauri: .bell-btn | Electrobun: .notif-btn
|
||||
await exec(() => {
|
||||
const btn = document.querySelector('.notif-btn')
|
||||
?? document.querySelector('.bell-btn')
|
||||
?? document.querySelector('[data-testid="notification-bell"]');
|
||||
|
|
@ -187,8 +184,7 @@ export async function openNotifications(): Promise<void> {
|
|||
|
||||
/** Close notification drawer */
|
||||
export async function closeNotifications(): Promise<void> {
|
||||
await browser.execute(() => {
|
||||
// Tauri: .notification-center .backdrop | Electrobun: .notif-backdrop
|
||||
await exec(() => {
|
||||
const backdrop = document.querySelector('.notif-backdrop')
|
||||
?? document.querySelector('.notification-center .backdrop');
|
||||
if (backdrop) (backdrop as HTMLElement).click();
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
/**
|
||||
* Custom E2E assertions — domain-specific checks for Agent Orchestrator.
|
||||
*
|
||||
* Uses browser.execute() for DOM queries with dual selectors to support
|
||||
* both Tauri and Electrobun UIs (WebKitGTK reliability).
|
||||
* Uses exec() (cross-protocol safe wrapper) for DOM queries with dual
|
||||
* selectors to support both Tauri and Electrobun UIs.
|
||||
*/
|
||||
|
||||
import { browser, expect } from '@wdio/globals';
|
||||
import { exec } from './execute.ts';
|
||||
import * as S from './selectors.ts';
|
||||
|
||||
/** Assert that a project card with the given name is visible in the grid */
|
||||
export async function assertProjectVisible(name: string): Promise<void> {
|
||||
const found = await browser.execute((n: string) => {
|
||||
// Tauri: .project-box | Electrobun: .project-card | Both: .project-header
|
||||
const found = await exec((n: string) => {
|
||||
const cards = document.querySelectorAll('.project-box, .project-card, .project-header');
|
||||
for (const card of cards) {
|
||||
if (card.textContent?.includes(n)) return true;
|
||||
|
|
@ -31,7 +31,7 @@ export async function assertTerminalResponds(): Promise<void> {
|
|||
|
||||
/** Assert that a CSS custom property has changed after a theme switch */
|
||||
export async function assertThemeApplied(varName = '--ctp-base'): Promise<void> {
|
||||
const value = await browser.execute((v: string) => {
|
||||
const value = await exec((v: string) => {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(v).trim();
|
||||
}, varName);
|
||||
expect(value.length).toBeGreaterThan(0);
|
||||
|
|
@ -49,12 +49,12 @@ export async function assertSettingsPersist(selector: string): Promise<void> {
|
|||
export async function assertStatusBarComplete(): Promise<void> {
|
||||
await browser.waitUntil(
|
||||
async () =>
|
||||
browser.execute(() => {
|
||||
exec(() => {
|
||||
const el = document.querySelector('[data-testid="status-bar"]')
|
||||
?? document.querySelector('.status-bar');
|
||||
if (!el) return false;
|
||||
return getComputedStyle(el).display !== 'none';
|
||||
}) as Promise<boolean>,
|
||||
}),
|
||||
{ timeout: 10_000, timeoutMsg: 'Status bar not visible within 10s' },
|
||||
);
|
||||
}
|
||||
|
|
@ -65,7 +65,7 @@ export async function assertElementCount(
|
|||
expected: number,
|
||||
comparison: 'eq' | 'gte' | 'lte' = 'eq',
|
||||
): Promise<void> {
|
||||
const count = await browser.execute((sel: string) => {
|
||||
const count = await exec((sel: string) => {
|
||||
return document.querySelectorAll(sel).length;
|
||||
}, selector);
|
||||
|
||||
|
|
@ -78,7 +78,7 @@ export async function assertElementCount(
|
|||
|
||||
/** Assert an element has a specific CSS class */
|
||||
export async function assertHasClass(selector: string, className: string): Promise<void> {
|
||||
const hasIt = await browser.execute((sel: string, cls: string) => {
|
||||
const hasIt = await exec((sel: string, cls: string) => {
|
||||
const el = document.querySelector(sel);
|
||||
return el?.classList.contains(cls) ?? false;
|
||||
}, selector, className);
|
||||
|
|
|
|||
52
tests/e2e/helpers/execute.ts
Normal file
52
tests/e2e/helpers/execute.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* Cross-protocol browser.execute() wrapper.
|
||||
*
|
||||
* WebDriverIO's devtools protocol (CDP via puppeteer-core) breaks when
|
||||
* browser.execute() receives a function argument because:
|
||||
* 1. WebDriverIO prepends a polyfill before `return (fn).apply(null, arguments)`
|
||||
* 2. The devtools executeScript trims the script, finds no leading `return`,
|
||||
* and passes it directly to eval()
|
||||
* 3. eval() fails with "Illegal return statement" because `return` is outside
|
||||
* a function body (the polyfill lines precede it)
|
||||
*
|
||||
* Fix: always pass a string expression to browser.execute(). Arguments are
|
||||
* JSON-serialized and inlined into the script — no reliance on the `arguments`
|
||||
* object which is protocol-dependent.
|
||||
*
|
||||
* Works identically with:
|
||||
* - WebDriver protocol (Tauri via tauri-driver)
|
||||
* - devtools/CDP protocol (Electrobun via CEF)
|
||||
*/
|
||||
|
||||
import { browser } from '@wdio/globals';
|
||||
|
||||
/**
|
||||
* Execute a function in the browser, cross-protocol safe.
|
||||
*
|
||||
* Usage mirrors browser.execute():
|
||||
* exec(() => document.title)
|
||||
* exec((sel) => document.querySelector(sel) !== null, '.my-class')
|
||||
* exec((a, b) => a + b, 1, 2)
|
||||
*/
|
||||
export async function exec<R>(fn: (...args: any[]) => R, ...args: any[]): Promise<R> {
|
||||
const fnStr = fn.toString();
|
||||
const serializedArgs = args.map(a => JSON.stringify(a)).join(', ');
|
||||
// Wrap as an IIFE expression — no `return` at the top level
|
||||
const script = `return (${fnStr})(${serializedArgs})`;
|
||||
return browser.execute(script) as Promise<R>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip a test programmatically — works with both protocols.
|
||||
*
|
||||
* Mocha's this.skip() requires a non-arrow `function()` context. In
|
||||
* WebDriverIO hooks (beforeTest), `this` may not carry the Mocha context
|
||||
* with devtools protocol. This helper uses the same mechanism but is
|
||||
* callable from any context that has the Mocha `this`.
|
||||
*
|
||||
* Usage inside `it('...', async function () { ... })`:
|
||||
* if (condition) { skipTest(this); return; }
|
||||
*/
|
||||
export function skipTest(ctx: Mocha.Context): void {
|
||||
ctx.skip();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue