@specific.dev/spectest 0.21.0 → 0.23.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/package.json +1 -1
- package/src/browser.ts +371 -240
- package/src/daemon.ts +4 -4
- package/src/index.ts +277 -9
- package/src/locator.ts +594 -0
- package/src/mobile.ts +34 -359
- package/src/recorder.ts +11 -14
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.
|
|
53
|
-
// `ctx.mobile(ctx.svc.app)` where the app is registered via the
|
|
54
|
-
// component.
|
|
55
|
-
export type { Mobile,
|
|
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").
|
|
1086
|
-
* await m.
|
|
1087
|
-
* await m.getByText(/Welcome/).
|
|
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 +
|
|
@@ -1594,6 +1608,32 @@ export interface DefinedEnvironment<
|
|
|
1594
1608
|
project(opts: ProjectOpts<S, F>): Project<S, F>;
|
|
1595
1609
|
}
|
|
1596
1610
|
|
|
1611
|
+
/**
|
|
1612
|
+
* The `ctx` type for an environment, for typing shared test helpers without
|
|
1613
|
+
* `any`. Instantiate with the environment from `defineEnvironment`:
|
|
1614
|
+
*
|
|
1615
|
+
* ```ts
|
|
1616
|
+
* const env = defineEnvironment({ ... });
|
|
1617
|
+
* export type AppCtx = Ctx<typeof env>;
|
|
1618
|
+
*
|
|
1619
|
+
* // A helper reaching into ctx.svc / ctx.fakes stays fully typed:
|
|
1620
|
+
* async function runJob(ctx: AppCtx) {
|
|
1621
|
+
* await ctx.svc.db.client`SELECT 1`;
|
|
1622
|
+
* }
|
|
1623
|
+
* ```
|
|
1624
|
+
*
|
|
1625
|
+
* This is the same `ctx` an `env.test(...)` callback receives. The parent
|
|
1626
|
+
* return type is left as `unknown` (helpers rarely touch `ctx.parent` — read
|
|
1627
|
+
* it in the test body and pass the value in). Prefer this over `ctx: any`:
|
|
1628
|
+
* an `any`-typed ctx also defeats the `expect(...)` overloads, silently
|
|
1629
|
+
* resolving `expect(value)` to the `expect(locator)` overload so value
|
|
1630
|
+
* matchers like `.toBe(...)` disappear.
|
|
1631
|
+
*/
|
|
1632
|
+
export type Ctx<E> =
|
|
1633
|
+
E extends DefinedEnvironment<infer S, infer F>
|
|
1634
|
+
? TestContext<unknown, S, F>
|
|
1635
|
+
: never;
|
|
1636
|
+
|
|
1597
1637
|
/**
|
|
1598
1638
|
* Define an environment and get back a builder you can hang tests off.
|
|
1599
1639
|
* The builder's `.test(...)` returns test cases typed against the
|
|
@@ -1820,7 +1860,49 @@ export interface Expectation extends Matchers {
|
|
|
1820
1860
|
not: Matchers;
|
|
1821
1861
|
}
|
|
1822
1862
|
|
|
1823
|
-
|
|
1863
|
+
/**
|
|
1864
|
+
* Auto-retrying web-first assertions for a {@link Locator} — Playwright's
|
|
1865
|
+
* `expect(locator)` matchers. Each polls the element until it passes or a
|
|
1866
|
+
* deadline elapses (default 5 s, `{ timeout }` overrides) and records an
|
|
1867
|
+
* assertion event just like a value `expect`. `await` them — they are async.
|
|
1868
|
+
*/
|
|
1869
|
+
export interface LocatorMatchers {
|
|
1870
|
+
/** The element is present and visible. */
|
|
1871
|
+
toBeVisible(opts?: { timeout?: number }): Promise<void>;
|
|
1872
|
+
/** The element is absent or hidden. */
|
|
1873
|
+
toBeHidden(opts?: { timeout?: number }): Promise<void>;
|
|
1874
|
+
/** The element's (trimmed) text equals `expected` (or matches a RegExp). */
|
|
1875
|
+
toHaveText(expected: string | RegExp, opts?: { timeout?: number }): Promise<void>;
|
|
1876
|
+
/** The element's text contains `expected`. */
|
|
1877
|
+
toContainText(expected: string, opts?: { timeout?: number }): Promise<void>;
|
|
1878
|
+
/** The input's value equals `expected` (or matches a RegExp). */
|
|
1879
|
+
toHaveValue(expected: string | RegExp, opts?: { timeout?: number }): Promise<void>;
|
|
1880
|
+
/** The locator resolves to exactly `expected` elements. */
|
|
1881
|
+
toHaveCount(expected: number, opts?: { timeout?: number }): Promise<void>;
|
|
1882
|
+
toBeEnabled(opts?: { timeout?: number }): Promise<void>;
|
|
1883
|
+
toBeDisabled(opts?: { timeout?: number }): Promise<void>;
|
|
1884
|
+
toBeChecked(opts?: { timeout?: number }): Promise<void>;
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
export interface LocatorAssertion extends LocatorMatchers {
|
|
1888
|
+
/** Negate every matcher (retries until the negated condition holds). */
|
|
1889
|
+
not: LocatorMatchers;
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
// `expect(locator)` returns the async web-first matchers; `expect(value)` the
|
|
1893
|
+
// synchronous value matchers. The Locator overload is listed first so a
|
|
1894
|
+
// locator (an object with no `unwrap`, hence not `Provenanced`) resolves to it.
|
|
1895
|
+
export function expect(actual: Locator, message?: string): LocatorAssertion;
|
|
1896
|
+
export function expect(actual: Provenanced, message?: string): Expectation;
|
|
1897
|
+
export function expect(
|
|
1898
|
+
actual: Provenanced | Locator,
|
|
1899
|
+
message?: string,
|
|
1900
|
+
): Expectation | LocatorAssertion {
|
|
1901
|
+
if (isLocator(actual)) return buildLocatorAssertion(actual, message);
|
|
1902
|
+
return expectValue(actual, message);
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
function expectValue(actual: Provenanced, message?: string): Expectation {
|
|
1824
1906
|
// The first parameter is typed to the {@link Provenanced} family so a raw
|
|
1825
1907
|
// value (`expect(res.status === 200)`, `expect(2 + 2)`) is a *compile error* —
|
|
1826
1908
|
// every assertion that reaches the timeline this way carries a provenance link
|
|
@@ -1843,6 +1925,192 @@ export function expect(actual: Provenanced, message?: string): Expectation {
|
|
|
1843
1925
|
return buildMatchers(adoptNullishTag(actual), false, message);
|
|
1844
1926
|
}
|
|
1845
1927
|
|
|
1928
|
+
// ── expect(locator): auto-retrying web-first matchers ─────────────────────
|
|
1929
|
+
|
|
1930
|
+
/** Poll interval for locator matchers. */
|
|
1931
|
+
const LOCATOR_POLL_MS = 50;
|
|
1932
|
+
|
|
1933
|
+
function buildLocatorAssertion(loc: Locator, message?: string): LocatorAssertion {
|
|
1934
|
+
return Object.assign(buildLocatorMatchers(loc, false, message), {
|
|
1935
|
+
not: buildLocatorMatchers(loc, true, message),
|
|
1936
|
+
});
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
function buildLocatorMatchers(
|
|
1940
|
+
loc: Locator,
|
|
1941
|
+
negated: boolean,
|
|
1942
|
+
message?: string,
|
|
1943
|
+
): LocatorMatchers {
|
|
1944
|
+
const probe = getLocatorProbe(loc);
|
|
1945
|
+
|
|
1946
|
+
// Poll `check` (a silent, non-recorded read) until the desired condition
|
|
1947
|
+
// holds or the deadline elapses, then emit ONE settled browser step (the
|
|
1948
|
+
// locator label + replay seek point) and record ONE assertion nested under
|
|
1949
|
+
// it via `sourceSeq` — so a web-first `expect(locator)` assertion carries
|
|
1950
|
+
// the same provenance a value assertion does (`expect(await loc.isVisible())`
|
|
1951
|
+
// renders identically). Without the anchor step these assertions floated as
|
|
1952
|
+
// disconnected top-level "value ✓" rows with no element and no replay seek.
|
|
1953
|
+
// `check` returns whether the base condition is satisfied plus the observed
|
|
1954
|
+
// value for the timeline.
|
|
1955
|
+
const run = async (
|
|
1956
|
+
matcher: string,
|
|
1957
|
+
timeout: number | undefined,
|
|
1958
|
+
check: () => Promise<{ satisfied: boolean; actual: unknown }>,
|
|
1959
|
+
describe: (actual: unknown) => string,
|
|
1960
|
+
expected?: unknown,
|
|
1961
|
+
): Promise<void> => {
|
|
1962
|
+
const started = Date.now();
|
|
1963
|
+
const deadline = started + (timeout ?? DEFAULT_ACTION_TIMEOUT_MS);
|
|
1964
|
+
let actual: unknown;
|
|
1965
|
+
for (;;) {
|
|
1966
|
+
let satisfied: boolean;
|
|
1967
|
+
try {
|
|
1968
|
+
const r = await check();
|
|
1969
|
+
satisfied = r.satisfied;
|
|
1970
|
+
actual = r.actual;
|
|
1971
|
+
} catch (err) {
|
|
1972
|
+
if (Date.now() < deadline) {
|
|
1973
|
+
await new Promise((r) => setTimeout(r, LOCATOR_POLL_MS));
|
|
1974
|
+
continue;
|
|
1975
|
+
}
|
|
1976
|
+
satisfied = false;
|
|
1977
|
+
actual = `<error: ${(err as Error)?.message ?? String(err)}>`;
|
|
1978
|
+
}
|
|
1979
|
+
const passed = satisfied !== negated;
|
|
1980
|
+
if (passed) {
|
|
1981
|
+
const sourceSeq = await probe.settle(matcher, Date.now() - started);
|
|
1982
|
+
recordAssertion({
|
|
1983
|
+
matcher,
|
|
1984
|
+
negated,
|
|
1985
|
+
passed: true,
|
|
1986
|
+
actual: safeSerialize(actual),
|
|
1987
|
+
expected: expected === undefined ? undefined : safeSerialize(expected),
|
|
1988
|
+
message,
|
|
1989
|
+
sourceSeq,
|
|
1990
|
+
});
|
|
1991
|
+
return;
|
|
1992
|
+
}
|
|
1993
|
+
if (Date.now() >= deadline) {
|
|
1994
|
+
const msg = describe(actual);
|
|
1995
|
+
const sourceSeq = await probe.settle(matcher, Date.now() - started, msg);
|
|
1996
|
+
recordAssertion({
|
|
1997
|
+
matcher,
|
|
1998
|
+
negated,
|
|
1999
|
+
passed: false,
|
|
2000
|
+
actual: safeSerialize(actual),
|
|
2001
|
+
expected: expected === undefined ? undefined : safeSerialize(expected),
|
|
2002
|
+
error: msg,
|
|
2003
|
+
message,
|
|
2004
|
+
sourceSeq,
|
|
2005
|
+
});
|
|
2006
|
+
throw new ExpectationError(msg);
|
|
2007
|
+
}
|
|
2008
|
+
await new Promise((r) => setTimeout(r, LOCATOR_POLL_MS));
|
|
2009
|
+
}
|
|
2010
|
+
};
|
|
2011
|
+
|
|
2012
|
+
const not = negated ? " not" : "";
|
|
2013
|
+
const matchesText = (v: string, expected: string | RegExp): boolean =>
|
|
2014
|
+
expected instanceof RegExp ? expected.test(v) : v === expected;
|
|
2015
|
+
|
|
2016
|
+
return {
|
|
2017
|
+
toBeVisible: (opts) =>
|
|
2018
|
+
run(
|
|
2019
|
+
"toBeVisible",
|
|
2020
|
+
opts?.timeout,
|
|
2021
|
+
async () => {
|
|
2022
|
+
const v = await probe.isVisible();
|
|
2023
|
+
return { satisfied: v, actual: v };
|
|
2024
|
+
},
|
|
2025
|
+
() => `expected ${probe.label}${not} to be visible`,
|
|
2026
|
+
),
|
|
2027
|
+
toBeHidden: (opts) =>
|
|
2028
|
+
run(
|
|
2029
|
+
"toBeHidden",
|
|
2030
|
+
opts?.timeout,
|
|
2031
|
+
async () => {
|
|
2032
|
+
const v = await probe.isVisible();
|
|
2033
|
+
return { satisfied: !v, actual: v };
|
|
2034
|
+
},
|
|
2035
|
+
() => `expected ${probe.label}${not} to be hidden`,
|
|
2036
|
+
),
|
|
2037
|
+
toHaveText: (expected, opts) =>
|
|
2038
|
+
run(
|
|
2039
|
+
"toHaveText",
|
|
2040
|
+
opts?.timeout,
|
|
2041
|
+
async () => {
|
|
2042
|
+
const t = (await probe.textContent(opts?.timeout)) ?? "";
|
|
2043
|
+
return { satisfied: matchesText(t.trim(), expected), actual: t };
|
|
2044
|
+
},
|
|
2045
|
+
(a) => `expected ${probe.label}${not} to have text ${fmt(expected)}, got ${fmt(a)}`,
|
|
2046
|
+
expected instanceof RegExp ? String(expected) : expected,
|
|
2047
|
+
),
|
|
2048
|
+
toContainText: (expected, opts) =>
|
|
2049
|
+
run(
|
|
2050
|
+
"toContainText",
|
|
2051
|
+
opts?.timeout,
|
|
2052
|
+
async () => {
|
|
2053
|
+
const t = (await probe.textContent(opts?.timeout)) ?? "";
|
|
2054
|
+
return { satisfied: t.includes(expected), actual: t };
|
|
2055
|
+
},
|
|
2056
|
+
(a) => `expected ${probe.label}${not} to contain text ${fmt(expected)}, got ${fmt(a)}`,
|
|
2057
|
+
expected,
|
|
2058
|
+
),
|
|
2059
|
+
toHaveValue: (expected, opts) =>
|
|
2060
|
+
run(
|
|
2061
|
+
"toHaveValue",
|
|
2062
|
+
opts?.timeout,
|
|
2063
|
+
async () => {
|
|
2064
|
+
const v = await probe.inputValue(opts?.timeout);
|
|
2065
|
+
return { satisfied: matchesText(v, expected), actual: v };
|
|
2066
|
+
},
|
|
2067
|
+
(a) => `expected ${probe.label}${not} to have value ${fmt(expected)}, got ${fmt(a)}`,
|
|
2068
|
+
expected instanceof RegExp ? String(expected) : expected,
|
|
2069
|
+
),
|
|
2070
|
+
toHaveCount: (expected, opts) =>
|
|
2071
|
+
run(
|
|
2072
|
+
"toHaveCount",
|
|
2073
|
+
opts?.timeout,
|
|
2074
|
+
async () => {
|
|
2075
|
+
const c = await probe.count();
|
|
2076
|
+
return { satisfied: c === expected, actual: c };
|
|
2077
|
+
},
|
|
2078
|
+
(a) => `expected ${probe.label}${not} to have count ${expected}, got ${fmt(a)}`,
|
|
2079
|
+
expected,
|
|
2080
|
+
),
|
|
2081
|
+
toBeEnabled: (opts) =>
|
|
2082
|
+
run(
|
|
2083
|
+
"toBeEnabled",
|
|
2084
|
+
opts?.timeout,
|
|
2085
|
+
async () => {
|
|
2086
|
+
const v = await probe.isEnabled(opts?.timeout);
|
|
2087
|
+
return { satisfied: v, actual: v };
|
|
2088
|
+
},
|
|
2089
|
+
() => `expected ${probe.label}${not} to be enabled`,
|
|
2090
|
+
),
|
|
2091
|
+
toBeDisabled: (opts) =>
|
|
2092
|
+
run(
|
|
2093
|
+
"toBeDisabled",
|
|
2094
|
+
opts?.timeout,
|
|
2095
|
+
async () => {
|
|
2096
|
+
const v = await probe.isEnabled(opts?.timeout);
|
|
2097
|
+
return { satisfied: !v, actual: v };
|
|
2098
|
+
},
|
|
2099
|
+
() => `expected ${probe.label}${not} to be disabled`,
|
|
2100
|
+
),
|
|
2101
|
+
toBeChecked: (opts) =>
|
|
2102
|
+
run(
|
|
2103
|
+
"toBeChecked",
|
|
2104
|
+
opts?.timeout,
|
|
2105
|
+
async () => {
|
|
2106
|
+
const v = await probe.isChecked(opts?.timeout);
|
|
2107
|
+
return { satisfied: v, actual: v };
|
|
2108
|
+
},
|
|
2109
|
+
() => `expected ${probe.label}${not} to be checked`,
|
|
2110
|
+
),
|
|
2111
|
+
};
|
|
2112
|
+
}
|
|
2113
|
+
|
|
1846
2114
|
/**
|
|
1847
2115
|
* Assert on a value with **no provenance** — a computed number, a raw
|
|
1848
2116
|
* WebSocket frame, anything that didn't flow from a recorded op. `message` is
|