@specific.dev/spectest 0.66.0 → 0.67.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
@@ -100,6 +100,7 @@ import {
100
100
  import { encodeRegistry } from "./harness/names-registry.js";
101
101
  import {
102
102
  InterceptRegistry,
103
+ parseTarget,
103
104
  runChain,
104
105
  type Interceptor,
105
106
  } from "./harness/intercept.js";
@@ -174,6 +175,7 @@ import {
174
175
  startRecording,
175
176
  stopRecording,
176
177
  truncateUtf8,
178
+ type StepBlock,
177
179
  type TestEvent,
178
180
  type OmittedBody,
179
181
  } from "./recorder.js";
@@ -2796,16 +2798,22 @@ function ingressClaimsHostname(hostname: string): boolean {
2796
2798
  return false;
2797
2799
  }
2798
2800
 
2799
- /** Record one request an interceptor saw, nested under its `intercept` step. */
2801
+ /** Record one request an interceptor saw, nested under its `intercept` step.
2802
+ *
2803
+ * A forced 503 is the step doing exactly what its description says, so it is
2804
+ * **not** marked failed — reddening the outcome the test asked for trains the
2805
+ * reader to ignore the colour. Only a handler that threw is marked, because
2806
+ * that 500 is a bug in the middleware rather than the outage it stands for. */
2800
2807
  function recordInterceptedRequest(
2801
2808
  it: Interceptor,
2802
- rec: { method: string; path: string; status: number; answeredBy: string },
2809
+ rec: { method: string; path: string; status: number; answeredBy: string; threw?: boolean },
2803
2810
  durationMs: number,
2804
2811
  ): void {
2805
2812
  const parentSeq = INTERCEPT_STEP_SEQ.get(it.id);
2806
2813
  if (parentSeq === undefined || !isRecording()) return;
2807
- const by =
2808
- rec.answeredBy === "handler"
2814
+ const by = rec.threw
2815
+ ? "the interceptor threw — 500 from spectest, not from your handler"
2816
+ : rec.answeredBy === "handler"
2809
2817
  ? "answered by the interceptor"
2810
2818
  : rec.answeredBy === "modified"
2811
2819
  ? "upstream answer replaced by the interceptor"
@@ -2814,22 +2822,42 @@ function recordInterceptedRequest(
2814
2822
  kind: "intercept-request",
2815
2823
  parentSeq,
2816
2824
  title: `${rec.method} ${rec.path} → ${rec.status}`,
2817
- status: rec.status >= 500 && rec.answeredBy !== "upstream" ? "failed" : "passed",
2825
+ status: rec.threw ? "failed" : "passed",
2826
+ // Method, path and status are already the title; the one thing the row
2827
+ // cannot say for itself is who produced that status.
2818
2828
  blocks: [
2819
- {
2820
- type: "kv",
2821
- rows: [
2822
- { label: "Host", value: it.hostname },
2823
- { label: "Request", value: `${rec.method} ${rec.path}` },
2824
- { label: "Status", value: String(rec.status) },
2825
- { label: "Answered", value: by },
2826
- ],
2827
- },
2829
+ { type: "kv", rows: [{ label: "Answered", value: by, error: rec.threw === true }] },
2828
2830
  ],
2829
2831
  durationMs,
2830
2832
  });
2831
2833
  }
2832
2834
 
2835
+ /** The handler's own source, which is what the interceptor actually does.
2836
+ *
2837
+ * Free and impossible to let rot: Bun runs the project's TypeScript
2838
+ * directly, so `toString()` is the text the author wrote. It is the step's
2839
+ * body, under the description — a mechanism the reader can check against
2840
+ * the intent the description claims. A handler long enough to be a wall of
2841
+ * code folds into a disclosure instead of pushing the requests off screen.
2842
+ */
2843
+ function handlerBlocks(handler: InterceptHandler): StepBlock[] {
2844
+ let src: string;
2845
+ try {
2846
+ src = String(handler);
2847
+ } catch {
2848
+ return [];
2849
+ }
2850
+ const code: StepBlock = { type: "code", lang: "ts", label: "Handler", code: src };
2851
+ return src.split("\n").length > HANDLER_FOLD_LINES
2852
+ ? [{ type: "details", summary: "Handler", blocks: [code] }]
2853
+ : [code];
2854
+ }
2855
+
2856
+ /** Past this many lines the handler source is folded away. Chosen so the
2857
+ * shapes this feature is for — a one-line forced status, a short
2858
+ * fail-twice-then-pass — always show, and only a real program hides. */
2859
+ const HANDLER_FOLD_LINES = 15;
2860
+
2833
2861
  /**
2834
2862
  * Put middleware in front of a hostname the ingress serves — the
2835
2863
  * implementation behind `ctx.intercept`.
@@ -2838,19 +2866,25 @@ function recordInterceptedRequest(
2838
2866
  * the daemon (DNS does not point here), so the interceptor could only be
2839
2867
  * silent — and silence is the failure mode this whole layer is designed
2840
2868
  * against. The message names the two ways to get a route.
2869
+ *
2870
+ * The `description` is required because it is the step's whole title in the
2871
+ * timeline and in the CLI's failure detail: it is what tells a reader why
2872
+ * the UI under test went to its error state. The handler source says how;
2873
+ * only the author can say what it means.
2841
2874
  */
2842
2875
  function registerInterceptor(
2843
- hostname: string,
2844
- pathOrHandler: string | InterceptHandler,
2845
- maybeHandler?: InterceptHandler,
2876
+ target: string,
2877
+ description: string,
2878
+ handler: InterceptHandler,
2846
2879
  ): Interception {
2847
- const path = typeof pathOrHandler === "string" ? pathOrHandler : undefined;
2848
- const handler = typeof pathOrHandler === "function" ? pathOrHandler : maybeHandler;
2849
- if (typeof hostname !== "string" || hostname.length === 0) {
2850
- throw new Error("ctx.intercept: a hostname is required");
2880
+ const { hostname: host, path } = parseTarget(target);
2881
+ if (typeof description !== "string" || description.trim() === "") {
2882
+ throw new Error(
2883
+ `ctx.intercept(${JSON.stringify(target)}): a description is required — what the ` +
2884
+ `environment now does, as the timeline reads it, e.g. "the sync endpoint returns 503".`,
2885
+ );
2851
2886
  }
2852
- const host = hostname.toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
2853
- if (!handler) {
2887
+ if (typeof handler !== "function") {
2854
2888
  throw new Error("ctx.intercept: a handler (req, next) => Response is required");
2855
2889
  }
2856
2890
  if (!ingressClaimsHostname(host)) {
@@ -2863,20 +2897,42 @@ function registerInterceptor(
2863
2897
  }
2864
2898
  const resv = reserveEvent();
2865
2899
  const it = INTERCEPTORS.register(host, path, handler);
2900
+ const where = `${host}${it.path === "/" ? "" : it.path}`;
2866
2901
  const seq = recordStep(
2867
2902
  {
2868
2903
  kind: "intercept",
2869
- title: `intercept ${host}${it.path === "/" ? "" : it.path}`,
2904
+ // The description alone. The pill already says INTERCEPT, and the
2905
+ // target is in the panel — a row that repeats both spends its width
2906
+ // on what the reader can already see and none of it on the one thing
2907
+ // only the author knows.
2908
+ title: description.trim(),
2870
2909
  blocks: [
2910
+ ...handlerBlocks(handler),
2871
2911
  {
2872
- type: "kv",
2873
- rows: [
2874
- { label: "Host", value: host },
2875
- { label: "Path", value: it.path === "/" ? "/ (every path)" : `${it.path} and below` },
2912
+ // The target IS the summary line, so the closed disclosure still
2913
+ // says where the interceptor sits — a generic "Target" label
2914
+ // would spend the one visible line saying nothing.
2915
+ type: "details",
2916
+ summary: where,
2917
+ blocks: [
2918
+ {
2919
+ type: "kv",
2920
+ rows: [
2921
+ {
2922
+ label: "Matches",
2923
+ value: it.path === "/" ? "every path on this host" : `${it.path} and below`,
2924
+ },
2925
+ {
2926
+ label: "Until",
2927
+ value: it.scope === undefined ? "remove()" : "the end of this test",
2928
+ },
2929
+ ],
2930
+ },
2876
2931
  ],
2877
2932
  },
2878
2933
  ],
2879
- durationMs: 0,
2934
+ // No duration: this is a marker for the moment the interceptor went
2935
+ // up, and "(0 ms)" reads as a step that did nothing.
2880
2936
  },
2881
2937
  resv,
2882
2938
  );
@@ -3,6 +3,7 @@ import {
3
3
  InterceptRegistry,
4
4
  hostnameMatches,
5
5
  normalizeMount,
6
+ parseTarget,
6
7
  pathMounts,
7
8
  runChain,
8
9
  type Interceptor,
@@ -39,6 +40,41 @@ describe("normalizeMount", () => {
39
40
  });
40
41
  });
41
42
 
43
+ describe("parseTarget", () => {
44
+ test("a bare hostname mounts at the root", () => {
45
+ expect(parseTarget("app.test")).toEqual({ hostname: "app.test", path: "/" });
46
+ });
47
+ test("a path rides the target", () => {
48
+ expect(parseTarget("app.test/api/sync")).toEqual({
49
+ hostname: "app.test",
50
+ path: "/api/sync",
51
+ });
52
+ expect(parseTarget("app.test/api/")).toEqual({ hostname: "app.test", path: "/api" });
53
+ expect(parseTarget("app.test/")).toEqual({ hostname: "app.test", path: "/" });
54
+ });
55
+ test("a scheme and case are tolerated", () => {
56
+ expect(parseTarget("https://App.Test/API")).toEqual({
57
+ hostname: "app.test",
58
+ path: "/api",
59
+ });
60
+ });
61
+ test("a wildcard host keeps its pattern", () => {
62
+ expect(parseTarget("*.example.com/v1")).toEqual({
63
+ hostname: "*.example.com",
64
+ path: "/v1",
65
+ });
66
+ });
67
+ test("rejects a target that is only a path, or none at all", () => {
68
+ expect(() => parseTarget("/api")).toThrow(/names no hostname/);
69
+ expect(() => parseTarget("")).toThrow(/target hostname is required/);
70
+ expect(() => parseTarget(" ")).toThrow(/target hostname is required/);
71
+ });
72
+ test("rejects a query or fragment", () => {
73
+ expect(() => parseTarget("app.test/api?x=1")).toThrow(/mount prefix/);
74
+ expect(() => parseTarget("app.test?x=1")).toThrow(/query or fragment/);
75
+ });
76
+ });
77
+
42
78
  describe("hostnameMatches", () => {
43
79
  test("exact and wildcard", () => {
44
80
  expect(hostnameMatches("api.test", "api.test")).toBe(true);
@@ -56,6 +56,12 @@ export interface InterceptedRequest {
56
56
  * upstream via `next()` untouched (`upstream`), or the upstream's answer
57
57
  * replaced by the interceptor after `next()` (`modified`). */
58
58
  answeredBy: "handler" | "upstream" | "modified";
59
+ /** The handler threw (or returned something that is not a `Response`), so
60
+ * the 500 below is a bug in the test's own middleware rather than the
61
+ * outage it was asked to produce. The distinction cannot be recovered
62
+ * from the status: a deliberate 500 and a crashed handler are the same
63
+ * number, and only this tells the timeline which one to mark as wrong. */
64
+ threw?: boolean;
59
65
  }
60
66
 
61
67
  export interface Interceptor {
@@ -71,6 +77,39 @@ export interface Interceptor {
71
77
  requests: InterceptedRequest[];
72
78
  }
73
79
 
80
+ /**
81
+ * Split an intercept target — `app.test`, `app.test/api/sync`,
82
+ * `*.example.com/v1` — into the hostname and the mount path.
83
+ *
84
+ * The path rides the target rather than a second argument because that is
85
+ * what it is: part of what the interceptor claims, not a separate knob. It
86
+ * also keeps the signature down to one string, so the description that
87
+ * follows can never be mistaken for a path.
88
+ *
89
+ * A scheme is tolerated (`https://app.test/api`) since that is how the same
90
+ * address is written everywhere else.
91
+ */
92
+ export function parseTarget(target: string): { hostname: string; path: string } {
93
+ if (typeof target !== "string" || target.trim() === "") {
94
+ throw new Error("intercept: a target hostname is required, e.g. \"app.test\" or \"app.test/api\"");
95
+ }
96
+ const bare = target.trim().toLowerCase().replace(/^[a-z][a-z0-9+.-]*:\/\//, "");
97
+ const slash = bare.indexOf("/");
98
+ const hostname = slash === -1 ? bare : bare.slice(0, slash);
99
+ const rest = slash === -1 ? undefined : bare.slice(slash);
100
+ if (hostname === "") {
101
+ throw new Error(
102
+ `intercept: target ${JSON.stringify(target)} names no hostname — write it as "app.test/api", not "/api"`,
103
+ );
104
+ }
105
+ if (hostname.includes("?") || hostname.includes("#")) {
106
+ throw new Error(
107
+ `intercept: target ${JSON.stringify(target)} cannot carry a query or fragment`,
108
+ );
109
+ }
110
+ return { hostname, path: normalizeMount(rest) };
111
+ }
112
+
74
113
  /** Mount semantics (`app.use(path, fn)`): `/api` matches `/api`, `/api/`, `/api/x`, and
75
114
  * never `/apix`. `/` matches everything. Query strings do not take part. */
76
115
  export function pathMounts(mount: string, pathname: string): boolean {
@@ -222,6 +261,7 @@ export async function runChain(
222
261
  );
223
262
  record.answeredBy = "handler";
224
263
  record.status = 500;
264
+ record.threw = true;
225
265
  it.calls++;
226
266
  it.requests.push(record);
227
267
  observe?.(it, record, Date.now() - started);
package/src/index.ts CHANGED
@@ -116,7 +116,20 @@ import {
116
116
  getBrowserProbe,
117
117
  DEFAULT_ACTION_TIMEOUT_MS,
118
118
  } from "./locator.js";
119
- import { formatWaited, locatorFailureMessage } from "./locator-errors.js";
119
+ import {
120
+ classifyLocatorFailure,
121
+ formatWaited,
122
+ locatorFailureMessage,
123
+ } from "./locator-errors.js";
124
+ import {
125
+ containsText,
126
+ containsTextArray,
127
+ escapeInvisible,
128
+ matchesText,
129
+ matchesTextArray,
130
+ textDifferenceNote,
131
+ type TextMatchOptions,
132
+ } from "./text-match.js";
120
133
 
121
134
  export type { UrlPattern } from "./url-match.js";
122
135
  import type { UrlPattern } from "./url-match.js";
@@ -182,7 +195,8 @@ export type { InterceptHandler, InterceptNext, InterceptedRequest };
182
195
  */
183
196
  export interface Interception {
184
197
  hostname: string;
185
- /** The normalised mount path (`"/"` when none was given). */
198
+ /** The normalised mount path parsed off the target (`"/"` when the
199
+ * target was a bare hostname). */
186
200
  path: string;
187
201
  /** How many requests the interceptor has seen. Provenance-wrapped, so an
188
202
  * `expect(outage.calls)` nests under the intercept step; `.unwrap()` for
@@ -545,12 +559,23 @@ export interface SpectestContext<
545
559
  * make your **own** backend misbehave for one test: force a 500 from an
546
560
  * API route, add latency, fail twice then pass, or just count calls.
547
561
  *
562
+ * `target` is the hostname, optionally with a mount path:
563
+ * `"app.test"` claims every request to that host, `"app.test/api/sync"`
564
+ * claims that path and everything below it (like `app.use(path, fn)`).
565
+ *
566
+ * `description` is what the environment now **does**, in the present
567
+ * tense — it is the step's whole title in the timeline and in the CLI's
568
+ * failure detail, so it is what tells a reader why the page under test
569
+ * went to its error state. Write the effect, not the act of intercepting
570
+ * (the step is already labelled INTERCEPT) and not the test's goal:
571
+ * `"the sync endpoint returns 503"`, not `"intercept sync"`, `"mock the
572
+ * API"` or `"test error handling"`. The handler's own source is shown
573
+ * under it, so the description carries the intent, never the mechanism.
574
+ *
548
575
  * Hono/Koa-style middleware: `(req, next)` where `next()` returns the real upstream's
549
576
  * response (a proxied service, or a fake). Return a `Response` to answer
550
577
  * yourself, `next()` to pass through, or change what `next()` returned.
551
- * An optional mount `path` limits it to that path and everything below
552
- * (`"/functions/v1/sync"`), like `app.use(path, fn)`. Interceptors run in
553
- * registration order.
578
+ * Interceptors run in registration order.
554
579
  *
555
580
  * Only traffic that reaches the daemon can be intercepted: the browser,
556
581
  * `ctx.fetch`, and any container that calls the hostname — so the service
@@ -564,16 +589,18 @@ export interface SpectestContext<
564
589
  * `remove()`. Every request it sees is recorded under the intercept step.
565
590
  *
566
591
  * ```ts
567
- * const outage = ctx.intercept("api.test", "/functions/v1/sync", () =>
568
- * new Response("boom", { status: 500 }));
592
+ * const outage = ctx.intercept(
593
+ * "api.test/functions/v1/sync",
594
+ * "the sync function returns 500",
595
+ * () => new Response("boom", { status: 500 }),
596
+ * );
569
597
  * await page.getByRole("button", { name: "Sync" }).click();
570
598
  * await expect(page.getByText("Retry")).toBeVisible();
571
599
  * expect(outage.calls).toBe(1);
572
600
  * outage.remove(); // the retry now reaches the real function
573
601
  * ```
574
602
  */
575
- intercept(hostname: string, handler: InterceptHandler): Interception;
576
- intercept(hostname: string, path: string, handler: InterceptHandler): Interception;
603
+ intercept(target: string, description: string, handler: InterceptHandler): Interception;
577
604
  /**
578
605
  * Mint a leaf certificate from the in-VM root CA and return the PEMs.
579
606
  *
@@ -2657,6 +2684,15 @@ export interface Expectation extends Matchers {
2657
2684
  not: Matchers;
2658
2685
  }
2659
2686
 
2687
+ /** Options for the text matchers — playwright's set, same names, same
2688
+ * meanings. */
2689
+ export interface TextMatcherOptions extends TextMatchOptions {
2690
+ timeout?: number;
2691
+ /** Read `innerText` (what the page renders — hidden elements dropped,
2692
+ * `text-transform` applied) instead of `textContent`. */
2693
+ useInnerText?: boolean;
2694
+ }
2695
+
2660
2696
  /**
2661
2697
  * Auto-retrying web-first assertions for a {@link Locator} — Playwright's
2662
2698
  * `expect(locator)` matchers. Each polls the element until it passes or a
@@ -2668,10 +2704,33 @@ export interface LocatorMatchers {
2668
2704
  toBeVisible(opts?: { timeout?: number }): Promise<void>;
2669
2705
  /** The element is absent or hidden. */
2670
2706
  toBeHidden(opts?: { timeout?: number }): Promise<void>;
2671
- /** The element's (trimmed) text equals `expected` (or matches a RegExp). */
2672
- toHaveText(expected: string | RegExp, opts?: { timeout?: number }): Promise<void>;
2673
- /** The element's text contains `expected`. */
2674
- toContainText(expected: string, opts?: { timeout?: number }): Promise<void>;
2707
+ /**
2708
+ * The element's text equals `expected`, or matches it when it is a RegExp.
2709
+ *
2710
+ * **Whitespace is normalized on both sides**, exactly as playwright does
2711
+ * it: the text is trimmed and every run of whitespace becomes one space.
2712
+ * So a typed space matches the no-break space (U+00A0) that
2713
+ * `Intl.NumberFormat` puts between thousands, and a value wrapped across
2714
+ * two lines in the markup matches the one-line string you wrote.
2715
+ *
2716
+ * Pass an **array** to assert over every element the locator matches, in
2717
+ * order; the counts must then agree.
2718
+ */
2719
+ toHaveText(
2720
+ expected: string | RegExp | Array<string | RegExp>,
2721
+ opts?: TextMatcherOptions,
2722
+ ): Promise<void>;
2723
+ /**
2724
+ * The element's text contains `expected` — a substring, or a RegExp tested
2725
+ * against the text. Whitespace is normalized as in {@link toHaveText}.
2726
+ *
2727
+ * An **array** asserts that the matched elements contain these texts in
2728
+ * order; unlike `toHaveText` extra elements between them are allowed.
2729
+ */
2730
+ toContainText(
2731
+ expected: string | RegExp | Array<string | RegExp>,
2732
+ opts?: TextMatcherOptions,
2733
+ ): Promise<void>;
2675
2734
  /** The input's value equals `expected` (or matches a RegExp). */
2676
2735
  toHaveValue(expected: string | RegExp, opts?: { timeout?: number }): Promise<void>;
2677
2736
  /** The locator resolves to exactly `expected` elements. */
@@ -2805,12 +2864,32 @@ function buildLocatorMatchers(
2805
2864
  // matches a hidden element fail identically — so the count is read once,
2806
2865
  // at failure time only. `toHaveCount` is left alone: "expected count 2,
2807
2866
  // got 0" already says it, and better.
2867
+ //
2868
+ // Either way the message ends with the near-miss block — what the page
2869
+ // holds that is close to what was asked for (see locator-hints.ts) — so
2870
+ // the author does not have to run again to find out.
2871
+ const nearMiss = async (): Promise<string> => {
2872
+ try {
2873
+ return await probe.nearMiss();
2874
+ } catch {
2875
+ return "";
2876
+ }
2877
+ };
2808
2878
  const elementFailure = async (err: unknown): Promise<string | undefined> => {
2809
- if (err !== undefined) return locatorFailureMessage(probe.label, err);
2879
+ if (err !== undefined) {
2880
+ const failure = classifyLocatorFailure(probe.label, err);
2881
+ if (!failure) return undefined;
2882
+ return failure.kind === "no-match"
2883
+ ? failure.message + (await nearMiss())
2884
+ : failure.message;
2885
+ }
2810
2886
  if (expectsGone || matcher === "toHaveCount") return undefined;
2811
2887
  try {
2812
2888
  if ((await probe.count()) === 0) {
2813
- return `No element matches ${probe.label} (waited ${formatWaited(budget)})`;
2889
+ return (
2890
+ `No element matches ${probe.label} (waited ${formatWaited(budget)})` +
2891
+ (await nearMiss())
2892
+ );
2814
2893
  }
2815
2894
  } catch {
2816
2895
  /* The page is gone or the chain is invalid — the matcher's own
@@ -2882,8 +2961,29 @@ function buildLocatorMatchers(
2882
2961
  };
2883
2962
 
2884
2963
  const not = negated ? " not" : "";
2885
- const matchesText = (v: string, expected: string | RegExp): boolean =>
2886
- expected instanceof RegExp ? expected.test(v) : v === expected;
2964
+ // The element's text, read the way the caller asked for it. `useInnerText`
2965
+ // is playwright's option and means the same here: what the page renders,
2966
+ // rather than every character in the subtree.
2967
+ const readText = async (opts?: TextMatcherOptions): Promise<string> =>
2968
+ opts?.useInnerText
2969
+ ? await probe.innerText(opts.timeout)
2970
+ : ((await probe.textContent(opts?.timeout)) ?? "");
2971
+ const readTexts = async (opts?: TextMatcherOptions): Promise<string[]> =>
2972
+ opts?.useInnerText ? await probe.allInnerTexts() : await probe.allTextContents();
2973
+ const isArrayExpectation = (
2974
+ v: string | RegExp | Array<string | RegExp>,
2975
+ ): v is Array<string | RegExp> => Array.isArray(v);
2976
+ /** Expected values render as themselves; a RegExp renders as its source. */
2977
+ const fmtExpected = (v: string | RegExp | Array<string | RegExp>): string =>
2978
+ v instanceof RegExp
2979
+ ? String(v)
2980
+ : Array.isArray(v)
2981
+ ? `[${v.map(fmtExpected).join(", ")}]`
2982
+ : fmt(v);
2983
+ /** What goes on the assertion event: a RegExp cannot be serialized. */
2984
+ const expectedValue = (
2985
+ v: string | RegExp | Array<string | RegExp>,
2986
+ ): unknown => (v instanceof RegExp ? String(v) : Array.isArray(v) ? v.map(expectedValue) : v);
2887
2987
 
2888
2988
  return {
2889
2989
  toBeVisible: (opts) =>
@@ -2911,33 +3011,53 @@ function buildLocatorMatchers(
2911
3011
  "toHaveText",
2912
3012
  opts?.timeout,
2913
3013
  async () => {
2914
- const t = (await probe.textContent(opts?.timeout)) ?? "";
2915
- return { satisfied: matchesText(t.trim(), expected), actual: t };
3014
+ if (isArrayExpectation(expected)) {
3015
+ const texts = await readTexts(opts);
3016
+ return { satisfied: matchesTextArray(texts, expected, opts), actual: texts };
3017
+ }
3018
+ const t = await readText(opts);
3019
+ return { satisfied: matchesText(t, expected, opts), actual: t };
2916
3020
  },
2917
- (a) => `expected ${probe.label}${not} to have text ${fmt(expected)}, got ${fmt(a)}`,
2918
- expected instanceof RegExp ? String(expected) : expected,
3021
+ (a) =>
3022
+ `expected ${probe.label}${not} to have text ${fmtExpected(expected)}, got ${fmt(a)}` +
3023
+ textDifferenceNote(a, expected, "equal"),
3024
+ expectedValue(expected),
2919
3025
  ),
2920
3026
  toContainText: (expected, opts) =>
2921
3027
  run(
2922
3028
  "toContainText",
2923
3029
  opts?.timeout,
2924
3030
  async () => {
2925
- const t = (await probe.textContent(opts?.timeout)) ?? "";
2926
- return { satisfied: t.includes(expected), actual: t };
3031
+ if (isArrayExpectation(expected)) {
3032
+ const texts = await readTexts(opts);
3033
+ return { satisfied: containsTextArray(texts, expected, opts), actual: texts };
3034
+ }
3035
+ const t = await readText(opts);
3036
+ return { satisfied: containsText(t, expected, opts), actual: t };
2927
3037
  },
2928
- (a) => `expected ${probe.label}${not} to contain text ${fmt(expected)}, got ${fmt(a)}`,
2929
- expected,
3038
+ (a) =>
3039
+ `expected ${probe.label}${not} to contain text ${fmtExpected(expected)}, got ${fmt(a)}` +
3040
+ textDifferenceNote(a, expected, "contains"),
3041
+ expectedValue(expected),
2930
3042
  ),
3043
+ // Deliberately NOT whitespace-normalized: an input's value is data the
3044
+ // user typed or the app set, not rendered text, and playwright compares
3045
+ // it exactly for the same reason. When that exactness is what failed,
3046
+ // `textDifferenceNote` says so instead of leaving two identical-looking
3047
+ // strings on screen.
2931
3048
  toHaveValue: (expected, opts) =>
2932
3049
  run(
2933
3050
  "toHaveValue",
2934
3051
  opts?.timeout,
2935
3052
  async () => {
2936
3053
  const v = await probe.inputValue(opts?.timeout);
2937
- return { satisfied: matchesText(v, expected), actual: v };
3054
+ const satisfied = expected instanceof RegExp ? expected.test(v) : v === expected;
3055
+ return { satisfied, actual: v };
2938
3056
  },
2939
- (a) => `expected ${probe.label}${not} to have value ${fmt(expected)}, got ${fmt(a)}`,
2940
- expected instanceof RegExp ? String(expected) : expected,
3057
+ (a) =>
3058
+ `expected ${probe.label}${not} to have value ${fmtExpected(expected)}, got ${fmt(a)}` +
3059
+ textDifferenceNote(a, expected, "equal"),
3060
+ expectedValue(expected),
2941
3061
  ),
2942
3062
  toHaveCount: (expected, opts) =>
2943
3063
  run(
@@ -3162,7 +3282,8 @@ function buildCore(
3162
3282
  run(
3163
3283
  "toBe",
3164
3284
  Object.is(actual, exp),
3165
- `expected ${fmt(actual)}${negated ? " not" : ""} to be ${fmt(exp)}`,
3285
+ `expected ${fmt(actual)}${negated ? " not" : ""} to be ${fmt(exp)}` +
3286
+ textDifferenceNote(actual, exp, "equal"),
3166
3287
  exp,
3167
3288
  );
3168
3289
  },
@@ -3177,7 +3298,8 @@ function buildCore(
3177
3298
  run(
3178
3299
  "toEqual",
3179
3300
  equal,
3180
- `expected ${fmt(actual)}${negated ? " not" : ""} to deep-equal ${fmt(exp)}`,
3301
+ `expected ${fmt(actual)}${negated ? " not" : ""} to deep-equal ${fmt(exp)}` +
3302
+ textDifferenceNote(actual, exp, "equal"),
3181
3303
  exp,
3182
3304
  );
3183
3305
  },
@@ -3245,7 +3367,8 @@ function buildCore(
3245
3367
  run(
3246
3368
  "toContain",
3247
3369
  contained,
3248
- `expected ${fmt(actual)}${negated ? " not" : ""} to contain ${fmt(exp)}`,
3370
+ `expected ${fmt(actual)}${negated ? " not" : ""} to contain ${fmt(exp)}` +
3371
+ textDifferenceNote(actual, exp, "contains"),
3249
3372
  exp,
3250
3373
  );
3251
3374
  },
@@ -3295,7 +3418,11 @@ function buildCore(
3295
3418
  }
3296
3419
 
3297
3420
  function fmt(v: unknown): string {
3298
- if (typeof v === "string") return JSON.stringify(v);
3421
+ // A string is escaped down to its invisible characters, so two values that
3422
+ // print the same on screen do not print the same in a failure. Without it a
3423
+ // no-break space and a space are the same three characters wide, and the
3424
+ // message reads "expected "15 000 kr", got "15 000 kr"".
3425
+ if (typeof v === "string") return escapeInvisible(JSON.stringify(v));
3299
3426
  if (typeof v === "bigint") return `${v}n`;
3300
3427
  if (v === undefined) return "undefined";
3301
3428
  try {