Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
de0f07c
feat(protocol): add WebViewInfo, WebViewBridge, and WebViewSession types
gmegidish May 10, 2026
a2c68f3
feat(query-engine): add webview strategy kind
gmegidish May 10, 2026
4ba901a
feat(core): add WebViewLocator, Page stub, and screen.getByWebView()
gmegidish May 10, 2026
8ed5a83
feat(core): implement Page class and WebLocator stub
gmegidish May 10, 2026
afe7eaa
feat(core): Playwright-compatible DOM selector engine and WebLocator …
gmegidish May 10, 2026
6029b75
feat(core): add PageAssertions and WebLocatorAssertions to expect()
gmegidish May 10, 2026
09684e4
refactor(expect): WebLocatorAssertions extends LocatorAssertions
gmegidish May 10, 2026
6ef547d
style(expect): restore multi-line inline functions for readability
gmegidish May 10, 2026
1928a59
feat(tests): add unit tests for Page and WebLocator (step 7)
gmegidish May 10, 2026
571a2e5
Merge origin/main into feat-adding-webview; thread stepFn through web…
gmegidish Jun 3, 2026
35ebda1
feat(webview): mobilecli webView bridge, page goBack/goForward, iOS p…
gmegidish Jun 3, 2026
71b4f4c
refactor(core): share step/eval helpers and drop dead WebViewSession …
gmegidish Jun 3, 2026
0e08b8b
refactor(core): dedupe assertion helpers, share webview test fakes, t…
gmegidish Jun 3, 2026
a3983e3
fix test so it uses local mobilewright without npx
gmegidish Jun 4, 2026
02bde8b
Merge remote-tracking branch 'origin/main' into feat-adding-webview
gmegidish Jun 4, 2026
71c1c14
fix(core): resolve webview locators uniquely, reset regex state, hono…
gmegidish Jun 4, 2026
f3b1882
fix(core): scope chained web locators and reset regex state in waitFo…
gmegidish Jun 4, 2026
7798f42
fix(core): assert element exists before web locator actions instead o…
gmegidish Jun 4, 2026
19b32fb
test(core): add regression tests for webview resolution, regex state,…
gmegidish Jun 4, 2026
d876764
upgrade mobilecli
gmegidish Jun 4, 2026
3b522c1
test(core): cover webview locator nth/first, chaining, and page() err…
gmegidish Jun 5, 2026
c95aa9f
fixed tests
gmegidish Jun 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
376 changes: 376 additions & 0 deletions packages/mobilewright-core/src/dom-selector-engine.ts

Large diffs are not rendered by default.

136 changes: 122 additions & 14 deletions packages/mobilewright-core/src/expect.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { Locator } from './locator.js';
import type { Locator, StepFn } from './locator.js';
import { LocatorError } from './locator.js';
import { Page } from './page.js';
import { WebLocator } from './web-locator.js';
import { retryUntil } from './poll.js';
import { filterStack, captureLocation } from './stackTrace.js';

Expand All @@ -10,38 +12,56 @@ export interface ExpectOptions {
}

/**
* Playwright-style expect for mobile locators and plain values.
* Playwright-style expect for mobile locators, web locators, pages, and plain values.
*
* Usage:
* expect(locator).toBeVisible()
* expect(locator).not.toBeVisible()
* expect(locator).toHaveText('Hello')
* expect(page).toHaveURL(/dashboard/)
* expect(webLocator).toHaveText('Hello')
* expect(42).toBe(42)
*/
export function expect(actual: Page): PageAssertions;
export function expect(actual: WebLocator): WebLocatorAssertions;
export function expect(actual: Locator): LocatorAssertions;
export function expect<T>(actual: T): ValueAssertions<T>;
export function expect(actual: unknown): any {
if (actual instanceof Page) return new PageAssertions(actual, false);
if (actual instanceof WebLocator) return new WebLocatorAssertions(actual, false);
if (actual && typeof actual === 'object' && 'tap' in actual && 'getText' in actual) {
return new LocatorAssertions(actual as Locator, false);
}
return new ValueAssertions(actual, false);
}

// Minimal interface satisfied by both Locator and WebLocator (after getText/getValue aliases).
interface LocatorLike {
isVisible(opts?: { timeout?: number }): Promise<boolean>;
isEnabled(opts?: { timeout?: number }): Promise<boolean>;
isChecked(opts?: { timeout?: number }): Promise<boolean>;
isSelected?(opts?: { timeout?: number }): Promise<boolean>;
isFocused?(opts?: { timeout?: number }): Promise<boolean>;
getText(opts?: { timeout?: number }): Promise<string>;
getValue(opts?: { timeout?: number }): Promise<string>;
count(): Promise<number>;
expectTimeout?: number;
_stepFn?: StepFn | null;
}

class LocatorAssertions {
constructor(
private readonly locator: Locator,
private readonly negated: boolean,
protected readonly locator: LocatorLike,
protected readonly negated: boolean,
) {}

get not(): LocatorAssertions {
return new LocatorAssertions(this.locator, !this.negated);
get not(): this {
return new (this.constructor as any)(this.locator, !this.negated) as this;
}

private assertionTimeout(opts?: ExpectOptions): number {
return opts?.timeout ?? this.locator.expectTimeout ?? DEFAULT_TIMEOUT;
}

private _wrapAssertion<T>(method: string, fn: () => Promise<T>): Promise<T> {
protected _wrapAssertion<T>(method: string, fn: () => Promise<T>): Promise<T> {
const stepFn = this.locator._stepFn;
const title = this.negated ? `expect.not.${method}()` : `expect.${method}()`;
if (stepFn) {
Expand Down Expand Up @@ -83,13 +103,13 @@ class LocatorAssertions {

async toBeSelected(opts?: ExpectOptions): Promise<void> {
return this._wrapAssertion('toBeSelected', async () => {
await this.assertBoolean('selected', () => this.locator.isSelected({ timeout: 0 }), opts);
await this.assertBoolean('selected', () => this.locator.isSelected!({ timeout: 0 }), opts);
});
}

async toBeFocused(opts?: ExpectOptions): Promise<void> {
return this._wrapAssertion('toBeFocused', async () => {
await this.assertBoolean('focused', () => this.locator.isFocused({ timeout: 0 }), opts);
await this.assertBoolean('focused', () => this.locator.isFocused!({ timeout: 0 }), opts);
});
}

Expand Down Expand Up @@ -194,7 +214,7 @@ class LocatorAssertions {
});
}

private async assertBoolean(
protected async assertBoolean(
name: string,
poll: () => Promise<boolean>,
opts?: ExpectOptions,
Expand All @@ -209,7 +229,7 @@ class LocatorAssertions {
);
}

private async assertText(
protected async assertText(
predicate: (text: string) => boolean,
expected: string | RegExp,
opts?: ExpectOptions,
Expand Down Expand Up @@ -241,7 +261,7 @@ class LocatorAssertions {
);
}

private async retryAssertion<T>(
protected async retryAssertion<T>(
poll: () => Promise<T>,
predicate: (value: T) => boolean,
timeout: number,
Expand Down Expand Up @@ -401,6 +421,94 @@ class ValueAssertions<T> {
}
}

// ─── PageAssertions ───────────────────────────────────────────

class PageAssertions {
constructor(
private readonly page: Page,
private readonly negated: boolean,
) {}

get not(): PageAssertions {
return new PageAssertions(this.page, !this.negated);
}

private _wrapAssertion<T>(method: string, fn: () => Promise<T>): Promise<T> {
const stepFn = this.page._stepFn;
const title = this.negated ? `expect.not.${method}()` : `expect.${method}()`;
if (stepFn) {
const location = captureLocation();
return stepFn(title, fn as () => Promise<unknown>, location) as Promise<T>;
}
return fn();
}

async toHaveURL(url: string | RegExp, opts?: ExpectOptions): Promise<void> {
return this._wrapAssertion('toHaveURL', async () => {
let last = '';
await this.retryAssertion(
async () => { try { last = await this.page.url(); } catch { last = ''; } return last; },
(current) => url instanceof RegExp ? url.test(current) : current === url,
opts?.timeout ?? DEFAULT_TIMEOUT,
() => `Expected page URL to ${this.negated ? 'not ' : ''}match "${url}", but got "${last}"`,
);
});
}

async toHaveTitle(title: string | RegExp, opts?: ExpectOptions): Promise<void> {
return this._wrapAssertion('toHaveTitle', async () => {
let last = '';
await this.retryAssertion(
async () => { try { last = await this.page.title(); } catch { last = ''; } return last; },
(current) => title instanceof RegExp ? title.test(current) : current === title,
opts?.timeout ?? DEFAULT_TIMEOUT,
() => `Expected page title to ${this.negated ? 'not ' : ''}match "${title}", but got "${last}"`,
);
});
}

private async retryAssertion<T>(
poll: () => Promise<T>,
predicate: (value: T) => boolean,
timeout: number,
failMessage: string | (() => string),
): Promise<void> {
try {
await retryUntil(poll, (v) => this.negated ? !predicate(v) : predicate(v), timeout, failMessage);
} catch (e) {
throw new ExpectError(e instanceof Error ? e.message : String(e));
}
}
}

// ─── WebLocatorAssertions ─────────────────────────────────────
// Extends LocatorAssertions — only adds toHaveAttribute (web-only).
// All other matchers (toBeVisible, toHaveText, toHaveCount, etc.) are inherited.

class WebLocatorAssertions extends LocatorAssertions {
constructor(private readonly webLocator: WebLocator, negated: boolean) {
super(webLocator, negated);
}

async toHaveAttribute(name: string, expected: string | RegExp, opts?: ExpectOptions): Promise<void> {
return this._wrapAssertion('toHaveAttribute', async () => {
let last: string | null = null;
await this.retryAssertion(
async () => {
try { last = await this.webLocator.getAttribute(name, { timeout: 0 }); } catch { last = null; }
return last;
},
(value) => {
if (value === null) { return false; }
return expected instanceof RegExp ? expected.test(value) : value === expected;
},
opts?.timeout ?? DEFAULT_TIMEOUT,
() => `Expected element to ${this.negated ? 'not ' : ''}have attribute "${name}" = "${expected}", but got "${last}"`,
);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

function fmt(value: unknown): string {
return typeof value === 'string' ? `"${value}"` : String(value);
}
Expand Down
3 changes: 3 additions & 0 deletions packages/mobilewright-core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
export { Locator, LocatorError, type LocatorOptions, type ScrollIntoViewOptions } from './locator.js';
export { WebViewLocator } from './webview-locator.js';
export { Screen } from './screen.js';
export { Device, type DeviceOptions } from './device.js';
export { Page } from './page.js';
export { WebLocator, type WebLocatorStrategy } from './web-locator.js';
export { expect, ExpectError, type ExpectOptions } from './expect.js';
export { queryAll, type LocatorStrategy } from './query-engine.js';
export { sleep } from './sleep.js';
Expand Down
10 changes: 5 additions & 5 deletions packages/mobilewright-core/src/locator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,16 @@ export class Locator {
_stepFn: StepFn | null = null;

constructor(
private readonly driver: MobilewrightDriver,
private readonly strategy: LocatorStrategy,
private readonly options: LocatorOptions = {},
protected readonly driver: MobilewrightDriver,
protected readonly strategy: LocatorStrategy,
protected readonly options: LocatorOptions = {},
) {}

get expectTimeout(): number | undefined {
return this.options.expectTimeout;
}

private async _step<T>(title: string, fn: () => Promise<T>): Promise<T> {
protected async _step<T>(title: string, fn: () => Promise<T>): Promise<T> {
if (this._stepFn) {
const location = captureLocation();
return this._stepFn(title, fn as () => Promise<unknown>, location) as Promise<T>;
Expand Down Expand Up @@ -77,7 +77,7 @@ export class Locator {
return this.child({ kind: 'placeholder', value: placeholder, exact: opts?.exact });
}

private child(childStrategy: LocatorStrategy): Locator {
protected child(childStrategy: LocatorStrategy): Locator {
const loc = new Locator(
this.driver,
{ kind: 'chain', parent: this.strategy, child: childStrategy },
Expand Down
Loading
Loading