@specific.dev/spectest 0.104.0 → 0.105.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/README.md CHANGED
@@ -66,6 +66,26 @@ The handshake returns a rejection `Response` or callbacks (`open`, `message`,
66
66
  in order per connection. WS and trusted WSS support text/binary messages;
67
67
  observe traffic through the fake's helpers.
68
68
 
69
+ ## Chat reactions (0.105.0)
70
+
71
+ A `chat` annotation's message can carry the tapbacks a messaging app puts on
72
+ a bubble. Each reaction is `{ emoji, from?, new? }`: `from` is `self` (the end
73
+ user) or `other` (the default), and `new` marks one that arrived in this step,
74
+ exactly like a message's `new`. A removed reaction simply stops being listed.
75
+
76
+ ```ts
77
+ return annotate(turns, "chat", {
78
+ messages: turns.map((t) => ({
79
+ side: t.fromCustomer ? "self" : "other",
80
+ text: t.body,
81
+ reactions: t.acked ? [{ emoji: "✅", new: t.ackedNow }] : [],
82
+ })),
83
+ });
84
+ ```
85
+
86
+ The dashboard draws the chips on the bubble's lower corner, opposite the
87
+ sender, and slides a new one in after the bubbles that arrived with it.
88
+
69
89
  ## HTTP assertions
70
90
 
71
91
  `ctx.fetch()` returns a wrapped response. Status fields, header lookups, and
@@ -113,6 +113,30 @@ export interface ChatMessageAnnotation {
113
113
  * the fake that marks them.
114
114
  */
115
115
  new?: boolean;
116
+ /**
117
+ * Reactions on this message — the tapbacks a messaging app lets either
118
+ * party put on a bubble (👀, ✅). Drawn as small chips on the bubble's
119
+ * corner. A reaction that was removed simply stops being listed.
120
+ */
121
+ reactions?: readonly ChatReactionAnnotation[];
122
+ }
123
+ /** One reaction on a {@link ChatMessageAnnotation}. */
124
+ export interface ChatReactionAnnotation {
125
+ /** The reaction itself — one emoji, usually. */
126
+ emoji: string;
127
+ /**
128
+ * Who reacted: `self` is the end user, `other` whoever they are talking
129
+ * to. Defaults to `other`, which is also what any other value draws as —
130
+ * a reaction on the wrong side can be seen and fixed, a dropped one
131
+ * cannot.
132
+ */
133
+ from?: "self" | "other";
134
+ /**
135
+ * This reaction arrived in *this* call — a message's `new`, and the
136
+ * fake's to set for the same reason: only it knows what the app had
137
+ * already seen. It slides in after the messages that arrived with it.
138
+ */
139
+ new?: boolean;
116
140
  }
117
141
  /**
118
142
  * Say what a fake helper's return value *is*, so the timeline can draw it
package/dist/annotate.js CHANGED
@@ -173,8 +173,33 @@ function chatMessageOf(src) {
173
173
  msg.text = truncateUtf8(body).value;
174
174
  if (src.new === true)
175
175
  msg.new = true;
176
+ const reactions = reactionsOf(src.reactions);
177
+ if (reactions.length > 0)
178
+ msg.reactions = reactions;
176
179
  return msg;
177
180
  }
181
+ /** Reactions get the tolerance `side` gets: an entry that isn't quite right
182
+ * is drawn rather than dropped — a `from` that is missing or misspelled is
183
+ * the other party's — and only one with nothing to draw (no string
184
+ * `emoji`, or an empty one) is skipped. */
185
+ function reactionsOf(v) {
186
+ if (!Array.isArray(v))
187
+ return [];
188
+ const out = [];
189
+ for (const entry of v) {
190
+ const src = fields(entry);
191
+ if (typeof src.emoji !== "string" || src.emoji === "")
192
+ continue;
193
+ const reaction = {
194
+ emoji: truncateUtf8(src.emoji).value,
195
+ from: text(src.from) === "self" ? "self" : "other",
196
+ };
197
+ if (src.new === true)
198
+ reaction.new = true;
199
+ out.push(reaction);
200
+ }
201
+ return out;
202
+ }
178
203
  function messageOf(src) {
179
204
  const msg = {};
180
205
  const from = text(src.from);
package/dist/index.d.ts CHANGED
@@ -5,7 +5,7 @@ export type { Carrier, Wrapped, WrappedObject, WrappedArray, WrappedHeaders, Wra
5
5
  import type { Provenanced, SpectestFetch, Wrapped } from "./inspect.js";
6
6
  export { field } from "./inspect.js";
7
7
  export { annotate } from "./annotate.js";
8
- export type { Annotated, AnnotationKind, AnnotationOptions, ChatAnnotation, ChatMessageAnnotation, EmailAnnotation, } from "./annotate.js";
8
+ export type { Annotated, AnnotationKind, AnnotationOptions, ChatAnnotation, ChatMessageAnnotation, ChatReactionAnnotation, EmailAnnotation, } from "./annotate.js";
9
9
  import type { Annotated } from "./annotate.js";
10
10
  export { SQL, type SqlClient, type SqlOptions } from "./sql.js";
11
11
  export { RedisClient, type RedisClientLike, type RedisOptions } from "./redis.js";
@@ -15,7 +15,7 @@
15
15
  // only ever replaced when we have something better to say.
16
16
  //
17
17
  // `classifyLocatorFailure` also says WHICH failure it was, because only one of
18
- // them — a locator that matched nothing — can be explained further by asking
18
+ // them — a locator that did not resolve — can be explained further by asking
19
19
  // the page what it holds (see locator-hints.ts).
20
20
  //
21
21
  // The call log is read from the error's own `log` array (playwright attaches
@@ -55,8 +55,8 @@ function logFromMessage(message) {
55
55
  function cleanLogLine(line) {
56
56
  return line.trim().replace(/^-\s*/, "").replace(/^\d+\s*×\s*/, "");
57
57
  }
58
- /** Did the locator ever match anything? Playwright logs "locator resolved to
59
- * <html>" the moment it does, and nothing of the sort when it never did. */
58
+ /** Did Playwright report resolution? A missing entry can also mean its
59
+ * renderer or transport stalled before it could resolve the locator. */
60
60
  function resolved(log) {
61
61
  return log.some((l) => l.startsWith("locator resolved to"));
62
62
  }
@@ -125,8 +125,11 @@ export function classifyLocatorFailure(label, err) {
125
125
  if (!detail)
126
126
  return undefined;
127
127
  const waited = `(waited ${formatWaited(detail.timeoutMs)})`;
128
+ // A renderer or transport stall can time out before resolution even when
129
+ // the element exists (confirmed by a Specific failure replay). Absence of
130
+ // a resolved log entry is not proof that the DOM contains no match.
128
131
  if (!resolved(detail.log)) {
129
- return { kind: "no-match", message: `No element matches ${label} ${waited}` };
132
+ return { kind: "no-match", message: `Timed out resolving ${label} ${waited}` };
130
133
  }
131
134
  // It matched, so the failure is about the element's state.
132
135
  const state = (message) => ({ kind: "state", message });
@@ -198,5 +201,11 @@ export async function rewriteLocatorErrorWithHints(err, label, hints) {
198
201
  /* A diagnostic must never replace the failure it explains. */
199
202
  }
200
203
  }
201
- return setErrorMessage(err, message);
204
+ // Preserve the original diagnostic when a friendly rewrite is misleading.
205
+ // Error.cause is non-enumerable, so it does not expand recorder payloads.
206
+ const e = err;
207
+ if (e.cause === undefined) {
208
+ Object.defineProperty(e, "cause", { value: new Error(e.message), configurable: true });
209
+ }
210
+ return setErrorMessage(e, message);
202
211
  }
@@ -646,6 +646,23 @@ export interface ChatBlockMessage {
646
646
  /** Arrived in *this* step: drawn with an entrance animation. Only the
647
647
  * producer knows this, so only the producer sets it. */
648
648
  new?: boolean;
649
+ /** Reactions on this message — the tapbacks a messaging app lets either
650
+ * party put on a bubble (👀, ✅). Drawn as chips on the bubble's lower
651
+ * corner, on the side opposite the sender. Absent and empty read the
652
+ * same: nothing to draw. */
653
+ reactions?: ChatBlockReaction[];
654
+ }
655
+ /** One reaction on a {@link ChatBlockMessage}. */
656
+ export interface ChatBlockReaction {
657
+ /** The reaction itself — an emoji, or any short string. */
658
+ emoji: string;
659
+ /** Who reacted. `self` is the end user, `other` the party they are
660
+ * talking to; absent reads as `other`. */
661
+ from?: "self" | "other";
662
+ /** Arrived in *this* step, like a message's `new`: drawn with the same
663
+ * entrance animation, after the bubbles that arrived with it. Only the
664
+ * producer knows this, so only the producer sets it. */
665
+ new?: boolean;
649
666
  }
650
667
  /**
651
668
  * A step that describes itself in presentation terms — a title plus an
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.104.0",
3
+ "version": "0.105.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -0,0 +1,79 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { annotate, readAnnotation } from "./annotate.js";
4
+
5
+ /** Open a chat annotation: the messages its one block lowered to, and the
6
+ * value the test would receive. */
7
+ function chat(boxed: unknown) {
8
+ const opened = readAnnotation(boxed);
9
+ if (!opened || opened.annotation.kind !== "chat") throw new Error("not a chat annotation");
10
+ const block = opened.annotation.blocks[0];
11
+ if (!block || block.type !== "chat") throw new Error("not a chat block");
12
+ return { messages: block.messages, value: opened.value };
13
+ }
14
+
15
+ describe("annotate chat", () => {
16
+ test("copies a message's reactions through and leaves the value alone", () => {
17
+ const turns = [
18
+ {
19
+ side: "self",
20
+ text: "where is my order?",
21
+ reactions: [{ emoji: "👀" }, { emoji: "✅", from: "self", new: true }],
22
+ },
23
+ { side: "other", text: "It ships today.", new: true },
24
+ ];
25
+ const { messages, value } = chat(annotate(turns, "chat"));
26
+ expect(value).toBe(turns);
27
+ expect(messages[0]!.reactions).toEqual([
28
+ // No `from` is the other party's, exactly as no `side` is.
29
+ { emoji: "👀", from: "other" },
30
+ { emoji: "✅", from: "self", new: true },
31
+ ]);
32
+ // A message without reactions carries no empty list.
33
+ expect(messages[1]!.reactions).toBeUndefined();
34
+ expect(messages[1]).toEqual({ side: "other", text: "It ships today.", new: true });
35
+ });
36
+
37
+ // The tolerance `side` has: a reaction that isn't quite right is drawn —
38
+ // on the other party's side — rather than dropped. Only one with nothing
39
+ // to draw is skipped.
40
+ test("draws a reaction it does not quite understand and skips one it cannot", () => {
41
+ const { messages } = chat(
42
+ annotate(
43
+ [
44
+ {
45
+ side: "self",
46
+ text: "hi",
47
+ reactions: [
48
+ { emoji: "❤️", from: "them", new: "yes" },
49
+ { emoji: 42 },
50
+ { emoji: "" },
51
+ "👍",
52
+ { emoji: "🎉", from: "self" },
53
+ ],
54
+ },
55
+ ],
56
+ "chat",
57
+ ),
58
+ );
59
+ expect(messages[0]!.reactions).toEqual([
60
+ { emoji: "❤️", from: "other" },
61
+ { emoji: "🎉", from: "self" },
62
+ ]);
63
+ });
64
+
65
+ test("a reactions field that is not a list is no reactions", () => {
66
+ const { messages } = chat(annotate([{ side: "self", text: "hi", reactions: "👍" }], "chat"));
67
+ expect(messages[0]!.reactions).toBeUndefined();
68
+ });
69
+
70
+ test("reactions ride along when the messages are passed as options", () => {
71
+ const { messages } = chat(
72
+ annotate({ id: 7 }, "chat", {
73
+ title: "+46 70 123 45 67",
74
+ messages: [{ side: "other", text: "Done?", reactions: [{ emoji: "✅", from: "self", new: true }] }],
75
+ }),
76
+ );
77
+ expect(messages[0]!.reactions).toEqual([{ emoji: "✅", from: "self", new: true }]);
78
+ });
79
+ });
package/src/annotate.ts CHANGED
@@ -34,6 +34,7 @@ import { deepUnwrap } from "./inspect.js";
34
34
  import {
35
35
  truncateUtf8,
36
36
  type ChatBlockMessage,
37
+ type ChatBlockReaction,
37
38
  type EmailEventMessage,
38
39
  type EmailEventSummary,
39
40
  type StepBlock,
@@ -171,6 +172,31 @@ export interface ChatMessageAnnotation {
171
172
  * the fake that marks them.
172
173
  */
173
174
  new?: boolean;
175
+ /**
176
+ * Reactions on this message — the tapbacks a messaging app lets either
177
+ * party put on a bubble (👀, ✅). Drawn as small chips on the bubble's
178
+ * corner. A reaction that was removed simply stops being listed.
179
+ */
180
+ reactions?: readonly ChatReactionAnnotation[];
181
+ }
182
+
183
+ /** One reaction on a {@link ChatMessageAnnotation}. */
184
+ export interface ChatReactionAnnotation {
185
+ /** The reaction itself — one emoji, usually. */
186
+ emoji: string;
187
+ /**
188
+ * Who reacted: `self` is the end user, `other` whoever they are talking
189
+ * to. Defaults to `other`, which is also what any other value draws as —
190
+ * a reaction on the wrong side can be seen and fixed, a dropped one
191
+ * cannot.
192
+ */
193
+ from?: "self" | "other";
194
+ /**
195
+ * This reaction arrived in *this* call — a message's `new`, and the
196
+ * fake's to set for the same reason: only it knows what the app had
197
+ * already seen. It slides in after the messages that arrived with it.
198
+ */
199
+ new?: boolean;
174
200
  }
175
201
 
176
202
  /** Listing rows carried on the event. The returned value is never capped. */
@@ -330,9 +356,31 @@ function chatMessageOf(src: Record<string, unknown>): ChatBlockMessage {
330
356
  const body = text(src.text);
331
357
  if (body) msg.text = truncateUtf8(body).value;
332
358
  if (src.new === true) msg.new = true;
359
+ const reactions = reactionsOf(src.reactions);
360
+ if (reactions.length > 0) msg.reactions = reactions;
333
361
  return msg;
334
362
  }
335
363
 
364
+ /** Reactions get the tolerance `side` gets: an entry that isn't quite right
365
+ * is drawn rather than dropped — a `from` that is missing or misspelled is
366
+ * the other party's — and only one with nothing to draw (no string
367
+ * `emoji`, or an empty one) is skipped. */
368
+ function reactionsOf(v: unknown): ChatBlockReaction[] {
369
+ if (!Array.isArray(v)) return [];
370
+ const out: ChatBlockReaction[] = [];
371
+ for (const entry of v) {
372
+ const src = fields(entry);
373
+ if (typeof src.emoji !== "string" || src.emoji === "") continue;
374
+ const reaction: ChatBlockReaction = {
375
+ emoji: truncateUtf8(src.emoji).value,
376
+ from: text(src.from) === "self" ? "self" : "other",
377
+ };
378
+ if (src.new === true) reaction.new = true;
379
+ out.push(reaction);
380
+ }
381
+ return out;
382
+ }
383
+
336
384
  function messageOf(src: Record<string, unknown>): EmailEventMessage {
337
385
  const msg: EmailEventMessage = {};
338
386
  const from = text(src.from);
package/src/index.ts CHANGED
@@ -63,6 +63,7 @@ export type {
63
63
  AnnotationOptions,
64
64
  ChatAnnotation,
65
65
  ChatMessageAnnotation,
66
+ ChatReactionAnnotation,
66
67
  EmailAnnotation,
67
68
  } from "./annotate.js";
68
69
  import type { Annotated } from "./annotate.js";
@@ -21,12 +21,12 @@ function timeoutError(message: string, log: string[]): Error {
21
21
  }
22
22
 
23
23
  describe("locatorFailureMessage", () => {
24
- test("names the element that was never found", () => {
24
+ test("names the locator that did not resolve", () => {
25
25
  const err = timeoutError("click: Timeout 5000ms exceeded.", [
26
26
  " - waiting for getByTestId('add')",
27
27
  ]);
28
28
  expect(locatorFailureMessage('testid "add"', err)).toBe(
29
- 'No element matches testid "add" (waited 5s)',
29
+ 'Timed out resolving testid "add" (waited 5s)',
30
30
  );
31
31
  });
32
32
 
@@ -84,7 +84,7 @@ describe("locatorFailureMessage", () => {
84
84
  ]);
85
85
  delete (err as { log?: unknown }).log;
86
86
  expect(locatorFailureMessage('testid "add"', err)).toBe(
87
- 'No element matches testid "add" (waited 5s)',
87
+ 'Timed out resolving testid "add" (waited 5s)',
88
88
  );
89
89
  });
90
90
 
@@ -145,7 +145,7 @@ describe("rewriteLocatorErrorWithHints", () => {
145
145
  "\nClose matches on the page:\n - testid \"add-todo\"",
146
146
  )) as Error;
147
147
  expect(err.message).toBe(
148
- 'No element matches testid "add" (waited 5s)\n' +
148
+ 'Timed out resolving testid "add" (waited 5s)\n' +
149
149
  'Close matches on the page:\n - testid "add-todo"',
150
150
  );
151
151
  // The stack's copy of the message is rewritten too, or the CLI would
@@ -173,7 +173,7 @@ describe("rewriteLocatorErrorWithHints", () => {
173
173
  const err = (await rewriteLocatorErrorWithHints(missing(), 'testid "add"', async () => {
174
174
  throw new Error("page closed");
175
175
  })) as Error;
176
- expect(err.message).toBe('No element matches testid "add" (waited 5s)');
176
+ expect(err.message).toBe('Timed out resolving testid "add" (waited 5s)');
177
177
  });
178
178
  });
179
179
 
@@ -189,8 +189,8 @@ describe("rewriteLocatorErrorWithHints (stack)", () => {
189
189
  )) as Error;
190
190
 
191
191
  expect(out).toBe(err); // same error: the author's own frames survive
192
- expect(out.message).toBe('No element matches testid "add" (waited 5s)');
193
- expect(out.stack).toStartWith('No element matches testid "add" (waited 5s)');
192
+ expect(out.message).toBe('Timed out resolving testid "add" (waited 5s)');
193
+ expect(out.stack).toStartWith('Timed out resolving testid "add" (waited 5s)');
194
194
  expect(out.stack).not.toContain("Timeout 5000ms exceeded");
195
195
  expect(out.stack).toContain("todo.ts:12:34");
196
196
  });
@@ -211,3 +211,11 @@ describe("formatWaited", () => {
211
211
  expect(formatWaited(30_000)).toBe("30s");
212
212
  });
213
213
  });
214
+
215
+ test("retains the original Playwright diagnostic for a stalled renderer", async () => {
216
+ const err = timeoutError("click: Timeout 10000ms exceeded.", [" - waiting for locator('#confirm')"]);
217
+ const original = err.message;
218
+ await rewriteLocatorErrorWithHints(err, 'css "#confirm"', async () => "");
219
+ expect(err.message).toBe('Timed out resolving css "#confirm" (waited 10s)');
220
+ expect((err.cause as Error).message).toBe(original);
221
+ });
@@ -15,7 +15,7 @@
15
15
  // only ever replaced when we have something better to say.
16
16
  //
17
17
  // `classifyLocatorFailure` also says WHICH failure it was, because only one of
18
- // them — a locator that matched nothing — can be explained further by asking
18
+ // them — a locator that did not resolve — can be explained further by asking
19
19
  // the page what it holds (see locator-hints.ts).
20
20
  //
21
21
  // The call log is read from the error's own `log` array (playwright attaches
@@ -67,8 +67,8 @@ function cleanLogLine(line: string): string {
67
67
  return line.trim().replace(/^-\s*/, "").replace(/^\d+\s*×\s*/, "");
68
68
  }
69
69
 
70
- /** Did the locator ever match anything? Playwright logs "locator resolved to
71
- * <html>" the moment it does, and nothing of the sort when it never did. */
70
+ /** Did Playwright report resolution? A missing entry can also mean its
71
+ * renderer or transport stalled before it could resolve the locator. */
72
72
  function resolved(log: string[]): boolean {
73
73
  return log.some((l) => l.startsWith("locator resolved to"));
74
74
  }
@@ -147,8 +147,11 @@ export function classifyLocatorFailure(
147
147
  const detail = parseTimeout(err);
148
148
  if (!detail) return undefined;
149
149
  const waited = `(waited ${formatWaited(detail.timeoutMs)})`;
150
+ // A renderer or transport stall can time out before resolution even when
151
+ // the element exists (confirmed by a Specific failure replay). Absence of
152
+ // a resolved log entry is not proof that the DOM contains no match.
150
153
  if (!resolved(detail.log)) {
151
- return { kind: "no-match", message: `No element matches ${label} ${waited}` };
154
+ return { kind: "no-match", message: `Timed out resolving ${label} ${waited}` };
152
155
  }
153
156
 
154
157
  // It matched, so the failure is about the element's state.
@@ -228,5 +231,11 @@ export async function rewriteLocatorErrorWithHints(
228
231
  /* A diagnostic must never replace the failure it explains. */
229
232
  }
230
233
  }
231
- return setErrorMessage(err as Error, message);
234
+ // Preserve the original diagnostic when a friendly rewrite is misleading.
235
+ // Error.cause is non-enumerable, so it does not expand recorder payloads.
236
+ const e = err as Error;
237
+ if (e.cause === undefined) {
238
+ Object.defineProperty(e, "cause", { value: new Error(e.message), configurable: true });
239
+ }
240
+ return setErrorMessage(e, message);
232
241
  }
package/src/recorder.ts CHANGED
@@ -897,6 +897,24 @@ export interface ChatBlockMessage {
897
897
  /** Arrived in *this* step: drawn with an entrance animation. Only the
898
898
  * producer knows this, so only the producer sets it. */
899
899
  new?: boolean;
900
+ /** Reactions on this message — the tapbacks a messaging app lets either
901
+ * party put on a bubble (👀, ✅). Drawn as chips on the bubble's lower
902
+ * corner, on the side opposite the sender. Absent and empty read the
903
+ * same: nothing to draw. */
904
+ reactions?: ChatBlockReaction[];
905
+ }
906
+
907
+ /** One reaction on a {@link ChatBlockMessage}. */
908
+ export interface ChatBlockReaction {
909
+ /** The reaction itself — an emoji, or any short string. */
910
+ emoji: string;
911
+ /** Who reacted. `self` is the end user, `other` the party they are
912
+ * talking to; absent reads as `other`. */
913
+ from?: "self" | "other";
914
+ /** Arrived in *this* step, like a message's `new`: drawn with the same
915
+ * entrance animation, after the bubbles that arrived with it. Only the
916
+ * producer knows this, so only the producer sets it. */
917
+ new?: boolean;
900
918
  }
901
919
 
902
920
  /**