@specific.dev/spectest 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/daemon.ts CHANGED
@@ -3946,7 +3946,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
3946
3946
  // An attached session starts mid-page (no navigate event will fire) —
3947
3947
  // stamp the inherited URL so the dashboard can still label the replay.
3948
3948
  if (attached && session.record.initialUrl === undefined) {
3949
- session.record.initialUrl = browser.url;
3949
+ session.record.initialUrl = browser.url();
3950
3950
  }
3951
3951
  browserDetaches.push(async () => {
3952
3952
  await detach();
@@ -3976,7 +3976,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
3976
3976
  });
3977
3977
  if (safeAreaInsets) session.record.safeAreaInsets = safeAreaInsets;
3978
3978
  if (attached && session.record.initialUrl === undefined) {
3979
- session.record.initialUrl = mobile.url;
3979
+ session.record.initialUrl = mobile.url();
3980
3980
  }
3981
3981
  browserDetaches.push(async () => {
3982
3982
  await detach();
@@ -4268,7 +4268,7 @@ async function evalCode(
4268
4268
  session.record.safeAreaInsets = browser.safeAreaInsets;
4269
4269
  }
4270
4270
  if (attached && session.record.initialUrl === undefined) {
4271
- session.record.initialUrl = browser.url;
4271
+ session.record.initialUrl = browser.url();
4272
4272
  }
4273
4273
  browserDetaches.push(async () => {
4274
4274
  await detach();
@@ -4298,7 +4298,7 @@ async function evalCode(
4298
4298
  });
4299
4299
  if (safeAreaInsets) session.record.safeAreaInsets = safeAreaInsets;
4300
4300
  if (attached && session.record.initialUrl === undefined) {
4301
- session.record.initialUrl = mobile.url;
4301
+ session.record.initialUrl = mobile.url();
4302
4302
  }
4303
4303
  browserDetaches.push(async () => {
4304
4304
  await detach();
package/src/index.ts CHANGED
@@ -44,15 +44,29 @@ export { SQL, type SqlClient, type SqlOptions } from "./sql.js";
44
44
  export { RedisClient, type RedisClientLike, type RedisOptions } from "./redis.js";
45
45
  export { S3Client, type S3ClientLike, type S3File, type S3ClientOptions } from "./s3.js";
46
46
 
47
- export type { Browser, BrowserOptions } from "./browser.js";
47
+ export type { Browser, BrowserOptions, Keyboard, Mouse, Touchscreen } from "./browser.js";
48
48
 
49
49
  import type { Browser, BrowserOptions } from "./browser.js";
50
50
 
51
+ // Playwright-native locators — the select-then-act surface shared by
52
+ // `ctx.browser()` and `ctx.mobile()`. `Locator` mirrors playwright-core's
53
+ // Locator (getBy*/filter/first/nth/click/fill/textContent/…, STRICT mode).
54
+ export type {
55
+ Locator,
56
+ GetByRoleOptions,
57
+ GetByTextOptions,
58
+ FilterOptions,
59
+ ClickOptions,
60
+ BoundingBox,
61
+ } from "./locator.js";
62
+ import type { Locator } from "./locator.js";
63
+ import { isLocator, getLocatorProbe, DEFAULT_ACTION_TIMEOUT_MS } from "./locator.js";
64
+
51
65
  // Mobile (Expo / React Native Web) surface: a phone-emulated session driven
52
- // with locators + touch gestures, replayed inside a phone bezel. Opened with
53
- // `ctx.mobile(ctx.svc.app)` where the app is registered via the `expo()`
54
- // component.
55
- export type { Mobile, MobileLocator, MobileApp } from "./mobile.js";
66
+ // with the same locators + touch gestures, replayed inside a phone bezel.
67
+ // Opened with `ctx.mobile(ctx.svc.app)` where the app is registered via the
68
+ // `expo()` component.
69
+ export type { Mobile, MobileApp } from "./mobile.js";
56
70
  import type { Mobile, MobileApp } from "./mobile.js";
57
71
 
58
72
  export type { Terminal, TerminalOpts, TerminalResult } from "./terminal.js";
@@ -1082,9 +1096,9 @@ export interface TestContext<
1082
1096
  * services: { app: expo() }
1083
1097
  * // in a test:
1084
1098
  * const m = await ctx.mobile(ctx.svc.app);
1085
- * await m.getByTestId("email").typeText("a@b.com");
1086
- * await m.getByText("Sign in").tap();
1087
- * await m.getByText(/Welcome/).assertVisible();
1099
+ * await m.getByTestId("email").fill("a@b.com");
1100
+ * await m.getByRole("button", { name: "Sign in" }).tap();
1101
+ * await expect(m.getByText(/Welcome/)).toBeVisible();
1088
1102
  * ```
1089
1103
  *
1090
1104
  * The session emulates the latest iPhone (viewport + DPR + mobile UA +
@@ -1820,7 +1834,49 @@ export interface Expectation extends Matchers {
1820
1834
  not: Matchers;
1821
1835
  }
1822
1836
 
1823
- export function expect(actual: Provenanced, message?: string): Expectation {
1837
+ /**
1838
+ * Auto-retrying web-first assertions for a {@link Locator} — Playwright's
1839
+ * `expect(locator)` matchers. Each polls the element until it passes or a
1840
+ * deadline elapses (default 5 s, `{ timeout }` overrides) and records an
1841
+ * assertion event just like a value `expect`. `await` them — they are async.
1842
+ */
1843
+ export interface LocatorMatchers {
1844
+ /** The element is present and visible. */
1845
+ toBeVisible(opts?: { timeout?: number }): Promise<void>;
1846
+ /** The element is absent or hidden. */
1847
+ toBeHidden(opts?: { timeout?: number }): Promise<void>;
1848
+ /** The element's (trimmed) text equals `expected` (or matches a RegExp). */
1849
+ toHaveText(expected: string | RegExp, opts?: { timeout?: number }): Promise<void>;
1850
+ /** The element's text contains `expected`. */
1851
+ toContainText(expected: string, opts?: { timeout?: number }): Promise<void>;
1852
+ /** The input's value equals `expected` (or matches a RegExp). */
1853
+ toHaveValue(expected: string | RegExp, opts?: { timeout?: number }): Promise<void>;
1854
+ /** The locator resolves to exactly `expected` elements. */
1855
+ toHaveCount(expected: number, opts?: { timeout?: number }): Promise<void>;
1856
+ toBeEnabled(opts?: { timeout?: number }): Promise<void>;
1857
+ toBeDisabled(opts?: { timeout?: number }): Promise<void>;
1858
+ toBeChecked(opts?: { timeout?: number }): Promise<void>;
1859
+ }
1860
+
1861
+ export interface LocatorAssertion extends LocatorMatchers {
1862
+ /** Negate every matcher (retries until the negated condition holds). */
1863
+ not: LocatorMatchers;
1864
+ }
1865
+
1866
+ // `expect(locator)` returns the async web-first matchers; `expect(value)` the
1867
+ // synchronous value matchers. The Locator overload is listed first so a
1868
+ // locator (an object with no `unwrap`, hence not `Provenanced`) resolves to it.
1869
+ export function expect(actual: Locator, message?: string): LocatorAssertion;
1870
+ export function expect(actual: Provenanced, message?: string): Expectation;
1871
+ export function expect(
1872
+ actual: Provenanced | Locator,
1873
+ message?: string,
1874
+ ): Expectation | LocatorAssertion {
1875
+ if (isLocator(actual)) return buildLocatorAssertion(actual, message);
1876
+ return expectValue(actual, message);
1877
+ }
1878
+
1879
+ function expectValue(actual: Provenanced, message?: string): Expectation {
1824
1880
  // The first parameter is typed to the {@link Provenanced} family so a raw
1825
1881
  // value (`expect(res.status === 200)`, `expect(2 + 2)`) is a *compile error* —
1826
1882
  // every assertion that reaches the timeline this way carries a provenance link
@@ -1843,6 +1899,182 @@ export function expect(actual: Provenanced, message?: string): Expectation {
1843
1899
  return buildMatchers(adoptNullishTag(actual), false, message);
1844
1900
  }
1845
1901
 
1902
+ // ── expect(locator): auto-retrying web-first matchers ─────────────────────
1903
+
1904
+ /** Poll interval for locator matchers. */
1905
+ const LOCATOR_POLL_MS = 50;
1906
+
1907
+ function buildLocatorAssertion(loc: Locator, message?: string): LocatorAssertion {
1908
+ return Object.assign(buildLocatorMatchers(loc, false, message), {
1909
+ not: buildLocatorMatchers(loc, true, message),
1910
+ });
1911
+ }
1912
+
1913
+ function buildLocatorMatchers(
1914
+ loc: Locator,
1915
+ negated: boolean,
1916
+ message?: string,
1917
+ ): LocatorMatchers {
1918
+ const probe = getLocatorProbe(loc);
1919
+
1920
+ // Poll `check` (a silent, non-recorded read) until the desired condition
1921
+ // holds or the deadline elapses, then record ONE assertion event (like a
1922
+ // value expect) and throw on failure. `check` returns whether the base
1923
+ // condition is satisfied plus the observed value for the timeline.
1924
+ const run = async (
1925
+ matcher: string,
1926
+ timeout: number | undefined,
1927
+ check: () => Promise<{ satisfied: boolean; actual: unknown }>,
1928
+ describe: (actual: unknown) => string,
1929
+ expected?: unknown,
1930
+ ): Promise<void> => {
1931
+ const deadline = Date.now() + (timeout ?? DEFAULT_ACTION_TIMEOUT_MS);
1932
+ let actual: unknown;
1933
+ for (;;) {
1934
+ let satisfied: boolean;
1935
+ try {
1936
+ const r = await check();
1937
+ satisfied = r.satisfied;
1938
+ actual = r.actual;
1939
+ } catch (err) {
1940
+ if (Date.now() < deadline) {
1941
+ await new Promise((r) => setTimeout(r, LOCATOR_POLL_MS));
1942
+ continue;
1943
+ }
1944
+ satisfied = false;
1945
+ actual = `<error: ${(err as Error)?.message ?? String(err)}>`;
1946
+ }
1947
+ const passed = satisfied !== negated;
1948
+ if (passed) {
1949
+ recordAssertion({
1950
+ matcher,
1951
+ negated,
1952
+ passed: true,
1953
+ actual: safeSerialize(actual),
1954
+ expected: expected === undefined ? undefined : safeSerialize(expected),
1955
+ message,
1956
+ });
1957
+ return;
1958
+ }
1959
+ if (Date.now() >= deadline) {
1960
+ const msg = describe(actual);
1961
+ recordAssertion({
1962
+ matcher,
1963
+ negated,
1964
+ passed: false,
1965
+ actual: safeSerialize(actual),
1966
+ expected: expected === undefined ? undefined : safeSerialize(expected),
1967
+ error: msg,
1968
+ message,
1969
+ });
1970
+ throw new ExpectationError(msg);
1971
+ }
1972
+ await new Promise((r) => setTimeout(r, LOCATOR_POLL_MS));
1973
+ }
1974
+ };
1975
+
1976
+ const not = negated ? " not" : "";
1977
+ const matchesText = (v: string, expected: string | RegExp): boolean =>
1978
+ expected instanceof RegExp ? expected.test(v) : v === expected;
1979
+
1980
+ return {
1981
+ toBeVisible: (opts) =>
1982
+ run(
1983
+ "toBeVisible",
1984
+ opts?.timeout,
1985
+ async () => {
1986
+ const v = await probe.isVisible();
1987
+ return { satisfied: v, actual: v };
1988
+ },
1989
+ () => `expected ${probe.label}${not} to be visible`,
1990
+ ),
1991
+ toBeHidden: (opts) =>
1992
+ run(
1993
+ "toBeHidden",
1994
+ opts?.timeout,
1995
+ async () => {
1996
+ const v = await probe.isVisible();
1997
+ return { satisfied: !v, actual: v };
1998
+ },
1999
+ () => `expected ${probe.label}${not} to be hidden`,
2000
+ ),
2001
+ toHaveText: (expected, opts) =>
2002
+ run(
2003
+ "toHaveText",
2004
+ opts?.timeout,
2005
+ async () => {
2006
+ const t = (await probe.textContent(opts?.timeout)) ?? "";
2007
+ return { satisfied: matchesText(t.trim(), expected), actual: t };
2008
+ },
2009
+ (a) => `expected ${probe.label}${not} to have text ${fmt(expected)}, got ${fmt(a)}`,
2010
+ expected instanceof RegExp ? String(expected) : expected,
2011
+ ),
2012
+ toContainText: (expected, opts) =>
2013
+ run(
2014
+ "toContainText",
2015
+ opts?.timeout,
2016
+ async () => {
2017
+ const t = (await probe.textContent(opts?.timeout)) ?? "";
2018
+ return { satisfied: t.includes(expected), actual: t };
2019
+ },
2020
+ (a) => `expected ${probe.label}${not} to contain text ${fmt(expected)}, got ${fmt(a)}`,
2021
+ expected,
2022
+ ),
2023
+ toHaveValue: (expected, opts) =>
2024
+ run(
2025
+ "toHaveValue",
2026
+ opts?.timeout,
2027
+ async () => {
2028
+ const v = await probe.inputValue(opts?.timeout);
2029
+ return { satisfied: matchesText(v, expected), actual: v };
2030
+ },
2031
+ (a) => `expected ${probe.label}${not} to have value ${fmt(expected)}, got ${fmt(a)}`,
2032
+ expected instanceof RegExp ? String(expected) : expected,
2033
+ ),
2034
+ toHaveCount: (expected, opts) =>
2035
+ run(
2036
+ "toHaveCount",
2037
+ opts?.timeout,
2038
+ async () => {
2039
+ const c = await probe.count();
2040
+ return { satisfied: c === expected, actual: c };
2041
+ },
2042
+ (a) => `expected ${probe.label}${not} to have count ${expected}, got ${fmt(a)}`,
2043
+ expected,
2044
+ ),
2045
+ toBeEnabled: (opts) =>
2046
+ run(
2047
+ "toBeEnabled",
2048
+ opts?.timeout,
2049
+ async () => {
2050
+ const v = await probe.isEnabled(opts?.timeout);
2051
+ return { satisfied: v, actual: v };
2052
+ },
2053
+ () => `expected ${probe.label}${not} to be enabled`,
2054
+ ),
2055
+ toBeDisabled: (opts) =>
2056
+ run(
2057
+ "toBeDisabled",
2058
+ opts?.timeout,
2059
+ async () => {
2060
+ const v = await probe.isEnabled(opts?.timeout);
2061
+ return { satisfied: !v, actual: v };
2062
+ },
2063
+ () => `expected ${probe.label}${not} to be disabled`,
2064
+ ),
2065
+ toBeChecked: (opts) =>
2066
+ run(
2067
+ "toBeChecked",
2068
+ opts?.timeout,
2069
+ async () => {
2070
+ const v = await probe.isChecked(opts?.timeout);
2071
+ return { satisfied: v, actual: v };
2072
+ },
2073
+ () => `expected ${probe.label}${not} to be checked`,
2074
+ ),
2075
+ };
2076
+ }
2077
+
1846
2078
  /**
1847
2079
  * Assert on a value with **no provenance** — a computed number, a raw
1848
2080
  * WebSocket frame, anything that didn't flow from a recorded op. `message` is