@specific.dev/spectest 0.20.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/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";
@@ -121,14 +135,14 @@ export interface ServiceConfig {
121
135
  * postgres's `docker-entrypoint.sh` initialization. Mutually
122
136
  * exclusive with {@link command}.
123
137
  */
124
- args?: string[];
138
+ args?: readonly string[];
125
139
  env?: Record<string, string>;
126
140
  /**
127
141
  * Ports the container listens on. Advisory only — surfaced in
128
142
  * `spectest list` output. Peer services reach each other by `<service>:<port>`
129
143
  * without any port declaration.
130
144
  */
131
- ports?: number[];
145
+ ports?: readonly number[];
132
146
  /**
133
147
  * Extra DNS names this service answers to inside the environment.
134
148
  * Each entry must be a fully-qualified, multi-label hostname (e.g.
@@ -144,7 +158,7 @@ export interface ServiceConfig {
144
158
  * answers to `<name>.internal` in addition to its bare `<name>`, and
145
159
  * user-supplied hostnames may not end in `.internal`.
146
160
  */
147
- hostnames?: string[];
161
+ hostnames?: readonly string[];
148
162
  /**
149
163
  * Expose this service over HTTPS via a TLS-terminating reverse proxy
150
164
  * hosted in the spectest-daemon. Each entry maps a fully-qualified
@@ -161,9 +175,9 @@ export interface ServiceConfig {
161
175
  * multi-label, lowercase, no `.internal` suffix, no collision with
162
176
  * services, other service TLS hostnames, or fakes.
163
177
  */
164
- tls?: ServiceTls[];
178
+ tls?: readonly ServiceTls[];
165
179
  /** Bind-mounted volumes for state that survives snapshot/fork. */
166
- volumes?: VolumeMount[];
180
+ volumes?: readonly VolumeMount[];
167
181
  /**
168
182
  * Files seeded into the container's filesystem **before it starts**.
169
183
  * Each entry's `content` is written to a VM-host staging path and
@@ -173,9 +187,9 @@ export interface ServiceConfig {
173
187
  * `/etc/rancher/k3s/registries.yaml`, which must exist before
174
188
  * `k3s server` starts.
175
189
  */
176
- files?: FileMount[];
190
+ files?: readonly FileMount[];
177
191
  /** Other services (keys in the services map) that must be ready first. */
178
- dependsOn?: string[];
192
+ dependsOn?: readonly string[];
179
193
  readyCheck?: ReadyCheck;
180
194
  /** Container workdir override. */
181
195
  workdir?: string;
@@ -191,7 +205,7 @@ export interface ServiceConfig {
191
205
  * non-persistent. Snapshots/forks preserve tmpfs contents along with
192
206
  * the rest of process memory.
193
207
  */
194
- tmpfs?: string[];
208
+ tmpfs?: readonly string[];
195
209
  /**
196
210
  * Cgroup namespace mode — `"host"` or `"private"`. Omit for docker's
197
211
  * default. k3s needs `"host"` so its embedded containerd can manage
@@ -685,7 +699,7 @@ export type ServiceImage =
685
699
  */
686
700
  content: string;
687
701
  /** Extra glob patterns to exclude from the build context. */
688
- exclude?: string[];
702
+ exclude?: readonly string[];
689
703
  };
690
704
 
691
705
  export interface VolumeMount {
@@ -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 +
@@ -1279,7 +1293,7 @@ export interface FakeDefinition<
1279
1293
  * lowercase, no `.internal` suffix, no collisions with services or
1280
1294
  * other fakes.
1281
1295
  */
1282
- hostnames: string[];
1296
+ hostnames: readonly string[];
1283
1297
  /** TCP port the fake listens on. Default `80`. */
1284
1298
  port?: number;
1285
1299
  /**
@@ -1324,7 +1338,7 @@ export interface FakeDefinition<
1324
1338
  * files, the config hash, or a cassette. Not part of the authoring
1325
1339
  * surface; `JSON.stringify` ignores it (fakes never serialize to config).
1326
1340
  */
1327
- secretRefs?: string[];
1341
+ secretRefs?: readonly string[];
1328
1342
  }
1329
1343
 
1330
1344
  /**
@@ -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