@specific.dev/spectest 0.105.0 → 0.107.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
@@ -86,6 +86,48 @@ return annotate(turns, "chat", {
86
86
  The dashboard draws the chips on the bubble's lower corner, opposite the
87
87
  sender, and slides a new one in after the bubbles that arrived with it.
88
88
 
89
+ ## Read receipts and typing (0.106.0)
90
+
91
+ A `chat` message can carry its delivery `status` (`delivered` or `read`)
92
+ and an optional `statusAt` — free text such as `"23:14"` — drawn as the
93
+ caption a messaging app shows under the bubble, on the bubble's own side.
94
+ The annotation itself takes `typing: "self" | "other"`, drawn as the
95
+ three-dot bubble at the end of the thread on that side. Both are the fake's
96
+ to set, like `new`; typing is transient, so a step either shows it or not.
97
+
98
+ ```ts
99
+ return annotate(turns, "chat", {
100
+ typing: state.agentTyping ? "other" : undefined,
101
+ messages: turns.map((t) => ({
102
+ side: t.fromCustomer ? "self" : "other",
103
+ text: t.body,
104
+ status: t.readAt ? "read" : "delivered",
105
+ statusAt: t.readAt,
106
+ })),
107
+ });
108
+ ```
109
+
110
+ ## Inline replies (0.107.0)
111
+
112
+ A `chat` message can carry an `id` — any non-empty string the fake chooses,
113
+ the provider's message handle usually; it is never drawn — and a reply names
114
+ an earlier message's `id` as `replyTo`. The dashboard draws the reply the
115
+ way a messaging app threads one: an excerpt of the earlier message in a
116
+ muted mini-bubble directly above the reply, on the reply's side, with a
117
+ short connector between them. A `replyTo` that names no earlier message of
118
+ the same call draws nothing extra; the bubble is an ordinary message.
119
+
120
+ ```ts
121
+ return annotate(turns, "chat", {
122
+ messages: turns.map((t) => ({
123
+ id: t.id,
124
+ side: t.fromCustomer ? "self" : "other",
125
+ text: t.body,
126
+ replyTo: t.inReplyTo,
127
+ })),
128
+ });
129
+ ```
130
+
89
131
  ## HTTP assertions
90
132
 
91
133
  `ctx.fetch()` returns a wrapped response. Status fields, header lookups, and
@@ -95,6 +95,14 @@ export interface ChatAnnotation {
95
95
  * reads fine without it.
96
96
  */
97
97
  title?: string;
98
+ /**
99
+ * Who is writing right now — `self` the end user, `other` whoever they
100
+ * are talking to — drawn as the three-dot bubble at the end of the
101
+ * thread on that side. It is the thread's present tense: a call either
102
+ * shows it or not, so there is no `new` to mark. Any other value is
103
+ * nobody typing.
104
+ */
105
+ typing?: "self" | "other";
98
106
  }
99
107
  /** One turn of a {@link ChatAnnotation}. */
100
108
  export interface ChatMessageAnnotation {
@@ -119,6 +127,34 @@ export interface ChatMessageAnnotation {
119
127
  * corner. A reaction that was removed simply stops being listed.
120
128
  */
121
129
  reactions?: readonly ChatReactionAnnotation[];
130
+ /**
131
+ * Delivery status, as the provider reported it: drawn as the small
132
+ * caption under the bubble ("Delivered", "Read"), on the bubble's own
133
+ * side. Any other value is left off — a caption, unlike a turn, can be
134
+ * dropped without the transcript lying.
135
+ */
136
+ status?: "delivered" | "read";
137
+ /**
138
+ * When, as the app would show it ("23:14"): free text drawn after the
139
+ * status ("Read 23:14"). Rides with `status`; alone it has nothing to
140
+ * qualify and is dropped.
141
+ */
142
+ statusAt?: string;
143
+ /**
144
+ * The author's handle for this message — the provider's message id,
145
+ * usually — so a later message can reply to it by name. Any non-empty
146
+ * string; the dashboard never draws it.
147
+ */
148
+ id?: string;
149
+ /**
150
+ * The `id` of an earlier message in this transcript that this one
151
+ * replies to, drawn the way a messaging app threads a reply: an excerpt
152
+ * of that message quoted above this bubble, on this bubble's side. One
153
+ * that names no earlier message of the same call — an unknown id, a
154
+ * later message, the message itself — draws nothing extra, and the
155
+ * bubble reads as an ordinary message.
156
+ */
157
+ replyTo?: string;
122
158
  }
123
159
  /** One reaction on a {@link ChatMessageAnnotation}. */
124
160
  export interface ChatReactionAnnotation {
package/dist/annotate.js CHANGED
@@ -138,6 +138,7 @@ function lowerChat(options, value) {
138
138
  const source = deepUnwrap(options ?? value);
139
139
  let list;
140
140
  let title;
141
+ let typing;
141
142
  if (Array.isArray(source)) {
142
143
  list = source;
143
144
  }
@@ -145,6 +146,7 @@ function lowerChat(options, value) {
145
146
  const bag = fields(source);
146
147
  list = bag.messages;
147
148
  title = text(bag.title) || undefined;
149
+ typing = typingOf(bag.typing);
148
150
  }
149
151
  // An empty conversation is a real state (nothing said yet) and renders as
150
152
  // one; a value with no `messages` at all is the author's mistake.
@@ -157,7 +159,9 @@ function lowerChat(options, value) {
157
159
  return {
158
160
  kind: "chat",
159
161
  title,
160
- blocks: [{ type: "chat", messages, ...(title ? { label: title } : {}) }],
162
+ blocks: [
163
+ { type: "chat", messages, ...(title ? { label: title } : {}), ...(typing ? { typing } : {}) },
164
+ ],
161
165
  };
162
166
  }
163
167
  function chatMessageOf(src) {
@@ -176,8 +180,35 @@ function chatMessageOf(src) {
176
180
  const reactions = reactionsOf(src.reactions);
177
181
  if (reactions.length > 0)
178
182
  msg.reactions = reactions;
183
+ // A status caption, unlike a turn, can be left off without the
184
+ // transcript lying, so one that isn't `delivered` or `read` is dropped
185
+ // rather than guessed at. Its time rides with it: free text, truncated
186
+ // like a body, and meaningless without a status to qualify.
187
+ const status = src.status;
188
+ if (status === "delivered" || status === "read") {
189
+ msg.status = status;
190
+ if (typeof src.statusAt === "string" && src.statusAt !== "") {
191
+ msg.statusAt = truncateUtf8(src.statusAt).value;
192
+ }
193
+ }
194
+ // A reply threads to an earlier message by the handle its author gave
195
+ // it, so both ride through as given — a non-empty string, cut like a
196
+ // body — and anything else is dropped. Whether `replyTo` names an
197
+ // earlier message of the transcript is the dashboard's to decide: it
198
+ // draws the quote when it can and an ordinary bubble when it cannot.
199
+ if (typeof src.id === "string" && src.id !== "")
200
+ msg.id = truncateUtf8(src.id).value;
201
+ if (typeof src.replyTo === "string" && src.replyTo !== "") {
202
+ msg.replyTo = truncateUtf8(src.replyTo).value;
203
+ }
179
204
  return msg;
180
205
  }
206
+ /** The block's `typing`: `self` or `other`, else nobody. The thread's
207
+ * present tense is a decoration, not a turn, so a value that isn't one of
208
+ * the two draws nothing rather than guessing at who. */
209
+ function typingOf(v) {
210
+ return v === "self" || v === "other" ? v : undefined;
211
+ }
181
212
  /** Reactions get the tolerance `side` gets: an entry that isn't quite right
182
213
  * is drawn rather than dropped — a `from` that is missing or misspelled is
183
214
  * the other party's — and only one with nothing to draw (no string
@@ -631,6 +631,7 @@ export type StepBlock = {
631
631
  type: "chat";
632
632
  messages: ChatBlockMessage[];
633
633
  label?: string;
634
+ typing?: "self" | "other";
634
635
  } | {
635
636
  type: "details";
636
637
  summary: string;
@@ -651,6 +652,23 @@ export interface ChatBlockMessage {
651
652
  * corner, on the side opposite the sender. Absent and empty read the
652
653
  * same: nothing to draw. */
653
654
  reactions?: ChatBlockReaction[];
655
+ /** Delivery status — the caption a messaging app shows under a bubble
656
+ * ("Delivered", "Read"), on the bubble's own side. Absent draws
657
+ * nothing. */
658
+ status?: "delivered" | "read";
659
+ /** When the status happened, as the app would show it ("23:14"): free
660
+ * text drawn after the status caption ("Read 23:14"). Rides with
661
+ * `status`; alone it has nothing to qualify. */
662
+ statusAt?: string;
663
+ /** The author's handle for this message — the provider's message id,
664
+ * usually — which a later message's `replyTo` names. Never drawn.
665
+ * Absent when the author gave none. */
666
+ id?: string;
667
+ /** The `id` of an earlier message in the same block this one replies
668
+ * to, drawn as a quoted excerpt of that message above the bubble, the
669
+ * way a messaging app threads a reply. One that names no earlier
670
+ * message draws nothing extra. */
671
+ replyTo?: string;
654
672
  }
655
673
  /** One reaction on a {@link ChatBlockMessage}. */
656
674
  export interface ChatBlockReaction {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.105.0",
3
+ "version": "0.107.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
- import { annotate, readAnnotation } from "./annotate.js";
3
+ import { annotate, readAnnotation, type ChatMessageAnnotation } from "./annotate.js";
4
+ import { truncateUtf8 } from "./recorder.js";
4
5
 
5
6
  /** Open a chat annotation: the messages its one block lowered to, and the
6
7
  * value the test would receive. */
@@ -9,7 +10,7 @@ function chat(boxed: unknown) {
9
10
  if (!opened || opened.annotation.kind !== "chat") throw new Error("not a chat annotation");
10
11
  const block = opened.annotation.blocks[0];
11
12
  if (!block || block.type !== "chat") throw new Error("not a chat block");
12
- return { messages: block.messages, value: opened.value };
13
+ return { messages: block.messages, typing: block.typing, block, value: opened.value };
13
14
  }
14
15
 
15
16
  describe("annotate chat", () => {
@@ -77,3 +78,127 @@ describe("annotate chat", () => {
77
78
  expect(messages[0]!.reactions).toEqual([{ emoji: "✅", from: "self", new: true }]);
78
79
  });
79
80
  });
81
+
82
+ describe("annotate chat status and typing", () => {
83
+ test("captions a message with its delivery status and leaves the rest alone", () => {
84
+ const { messages } = chat(
85
+ annotate(
86
+ [
87
+ { side: "self", text: "where is my order?", status: "read", statusAt: "23:14" },
88
+ { side: "self", text: "hello?", status: "delivered" },
89
+ { side: "other", text: "It ships today.", new: true },
90
+ ],
91
+ "chat",
92
+ ),
93
+ );
94
+ expect(messages[0]).toEqual({ side: "self", text: "where is my order?", status: "read", statusAt: "23:14" });
95
+ // A status without a time carries no `statusAt`; a message without a
96
+ // status carries neither key.
97
+ expect(messages[1]).toEqual({ side: "self", text: "hello?", status: "delivered" });
98
+ expect(messages[2]).toEqual({ side: "other", text: "It ships today.", new: true });
99
+ });
100
+
101
+ // A caption, unlike a turn, can be left off without the transcript
102
+ // lying: a status that isn't one of the two is dropped, and so is a time
103
+ // that isn't text, is empty, or has no status to qualify.
104
+ test("drops a status it does not know and a time it cannot draw", () => {
105
+ const { messages } = chat(
106
+ annotate(
107
+ [
108
+ { side: "self", text: "a", status: "seen", statusAt: "23:14" },
109
+ { side: "self", text: "b", status: "read", statusAt: 2314 },
110
+ { side: "self", text: "c", statusAt: "23:14" },
111
+ { side: "self", text: "d", status: "read", statusAt: "" },
112
+ ],
113
+ "chat",
114
+ ),
115
+ );
116
+ expect(messages[0]).toEqual({ side: "self", text: "a" });
117
+ expect(messages[1]).toEqual({ side: "self", text: "b", status: "read" });
118
+ expect(messages[2]).toEqual({ side: "self", text: "c" });
119
+ expect(messages[3]).toEqual({ side: "self", text: "d", status: "read" });
120
+ });
121
+
122
+ test("a typing indicator rides on the block, and only for a side it knows", () => {
123
+ const messages: ChatMessageAnnotation[] = [{ side: "other", text: "Let me check." }];
124
+ expect(chat(annotate({ id: 7 }, "chat", { messages, typing: "other" })).typing).toBe("other");
125
+ expect(chat(annotate({ id: 7 }, "chat", { messages, typing: "self" })).typing).toBe("self");
126
+ // Nobody typing is no key at all: absent, misspelled, or a bare flag.
127
+ // (Read off the value, which skips the options' typing.)
128
+ expect(chat(annotate({ id: 7 }, "chat", { messages })).block).toEqual({
129
+ type: "chat",
130
+ messages: [{ side: "other", text: "Let me check." }],
131
+ });
132
+ for (const typing of ["agent", true, null, ""]) {
133
+ expect(chat(annotate({ messages, typing }, "chat")).typing).toBeUndefined();
134
+ }
135
+ // The messages-alone form has no block options to carry it.
136
+ expect(chat(annotate(messages, "chat")).typing).toBeUndefined();
137
+ });
138
+ });
139
+
140
+ describe("annotate chat inline replies", () => {
141
+ test("threads a reply to the earlier message it names and leaves the rest alone", () => {
142
+ const { messages } = chat(
143
+ annotate(
144
+ [
145
+ { id: "m1", side: "other", text: "Let me check." },
146
+ { id: "m2", side: "self", text: "where is my order?", replyTo: "m1", new: true },
147
+ { side: "other", text: "It ships today." },
148
+ ],
149
+ "chat",
150
+ ),
151
+ );
152
+ expect(messages[0]).toEqual({ id: "m1", side: "other", text: "Let me check." });
153
+ expect(messages[1]).toEqual({ id: "m2", side: "self", text: "where is my order?", replyTo: "m1", new: true });
154
+ // A message with neither carries neither key.
155
+ expect(messages[2]).toEqual({ side: "other", text: "It ships today." });
156
+ });
157
+
158
+ // Both are the author's handles, carried as given: the dashboard sees
159
+ // the whole transcript and decides whether a `replyTo` names an earlier
160
+ // message, drawing an ordinary bubble when it does not. Only a value
161
+ // that is not a non-empty string is dropped.
162
+ test("drops an id or a replyTo that is not a non-empty string and carries the rest as given", () => {
163
+ const { messages } = chat(
164
+ annotate(
165
+ [
166
+ { id: 42, side: "self", text: "a", replyTo: 7 },
167
+ { id: "", side: "self", text: "b", replyTo: "" },
168
+ { id: null, side: "self", text: "c", replyTo: true },
169
+ { id: "m4", side: "self", text: "d", replyTo: "nowhere" },
170
+ ],
171
+ "chat",
172
+ ),
173
+ );
174
+ expect(messages[0]).toEqual({ side: "self", text: "a" });
175
+ expect(messages[1]).toEqual({ side: "self", text: "b" });
176
+ expect(messages[2]).toEqual({ side: "self", text: "c" });
177
+ expect(messages[3]).toEqual({ id: "m4", side: "self", text: "d", replyTo: "nowhere" });
178
+ });
179
+
180
+ test("an id is cut like a body, and the replyTo that names it the same way", () => {
181
+ // Past the recorder's snippet cap, so the cut is visible.
182
+ const long = "m".repeat(300_000);
183
+ const { messages } = chat(
184
+ annotate(
185
+ [
186
+ { id: long, side: "other", text: "Let me check." },
187
+ { side: "self", text: "ok", replyTo: long },
188
+ ],
189
+ "chat",
190
+ ),
191
+ );
192
+ expect(messages[0]!.id).toBe(truncateUtf8(long).value);
193
+ expect(messages[0]!.id!.length).toBeLessThan(long.length);
194
+ expect(messages[1]!.replyTo).toBe(messages[0]!.id);
195
+ });
196
+
197
+ test("replies ride along when the messages are passed as options", () => {
198
+ const messages: ChatMessageAnnotation[] = [
199
+ { id: "m1", side: "other", text: "Done?" },
200
+ { id: "m2", side: "self", text: "Yes.", replyTo: "m1" },
201
+ ];
202
+ expect(chat(annotate({ id: 7 }, "chat", { title: "+46 70 123 45 67", messages })).messages).toEqual(messages);
203
+ });
204
+ });
package/src/annotate.ts CHANGED
@@ -153,6 +153,14 @@ export interface ChatAnnotation {
153
153
  * reads fine without it.
154
154
  */
155
155
  title?: string;
156
+ /**
157
+ * Who is writing right now — `self` the end user, `other` whoever they
158
+ * are talking to — drawn as the three-dot bubble at the end of the
159
+ * thread on that side. It is the thread's present tense: a call either
160
+ * shows it or not, so there is no `new` to mark. Any other value is
161
+ * nobody typing.
162
+ */
163
+ typing?: "self" | "other";
156
164
  }
157
165
 
158
166
  /** One turn of a {@link ChatAnnotation}. */
@@ -178,6 +186,34 @@ export interface ChatMessageAnnotation {
178
186
  * corner. A reaction that was removed simply stops being listed.
179
187
  */
180
188
  reactions?: readonly ChatReactionAnnotation[];
189
+ /**
190
+ * Delivery status, as the provider reported it: drawn as the small
191
+ * caption under the bubble ("Delivered", "Read"), on the bubble's own
192
+ * side. Any other value is left off — a caption, unlike a turn, can be
193
+ * dropped without the transcript lying.
194
+ */
195
+ status?: "delivered" | "read";
196
+ /**
197
+ * When, as the app would show it ("23:14"): free text drawn after the
198
+ * status ("Read 23:14"). Rides with `status`; alone it has nothing to
199
+ * qualify and is dropped.
200
+ */
201
+ statusAt?: string;
202
+ /**
203
+ * The author's handle for this message — the provider's message id,
204
+ * usually — so a later message can reply to it by name. Any non-empty
205
+ * string; the dashboard never draws it.
206
+ */
207
+ id?: string;
208
+ /**
209
+ * The `id` of an earlier message in this transcript that this one
210
+ * replies to, drawn the way a messaging app threads a reply: an excerpt
211
+ * of that message quoted above this bubble, on this bubble's side. One
212
+ * that names no earlier message of the same call — an unknown id, a
213
+ * later message, the message itself — draws nothing extra, and the
214
+ * bubble reads as an ordinary message.
215
+ */
216
+ replyTo?: string;
181
217
  }
182
218
 
183
219
  /** One reaction on a {@link ChatMessageAnnotation}. */
@@ -326,12 +362,14 @@ function lowerChat(
326
362
  const source: unknown = deepUnwrap(options ?? value);
327
363
  let list: unknown;
328
364
  let title: string | undefined;
365
+ let typing: "self" | "other" | undefined;
329
366
  if (Array.isArray(source)) {
330
367
  list = source;
331
368
  } else {
332
369
  const bag = fields(source);
333
370
  list = bag.messages;
334
371
  title = text(bag.title) || undefined;
372
+ typing = typingOf(bag.typing);
335
373
  }
336
374
  // An empty conversation is a real state (nothing said yet) and renders as
337
375
  // one; a value with no `messages` at all is the author's mistake.
@@ -343,7 +381,9 @@ function lowerChat(
343
381
  return {
344
382
  kind: "chat",
345
383
  title,
346
- blocks: [{ type: "chat", messages, ...(title ? { label: title } : {}) }],
384
+ blocks: [
385
+ { type: "chat", messages, ...(title ? { label: title } : {}), ...(typing ? { typing } : {}) },
386
+ ],
347
387
  };
348
388
  }
349
389
 
@@ -358,9 +398,36 @@ function chatMessageOf(src: Record<string, unknown>): ChatBlockMessage {
358
398
  if (src.new === true) msg.new = true;
359
399
  const reactions = reactionsOf(src.reactions);
360
400
  if (reactions.length > 0) msg.reactions = reactions;
401
+ // A status caption, unlike a turn, can be left off without the
402
+ // transcript lying, so one that isn't `delivered` or `read` is dropped
403
+ // rather than guessed at. Its time rides with it: free text, truncated
404
+ // like a body, and meaningless without a status to qualify.
405
+ const status = src.status;
406
+ if (status === "delivered" || status === "read") {
407
+ msg.status = status;
408
+ if (typeof src.statusAt === "string" && src.statusAt !== "") {
409
+ msg.statusAt = truncateUtf8(src.statusAt).value;
410
+ }
411
+ }
412
+ // A reply threads to an earlier message by the handle its author gave
413
+ // it, so both ride through as given — a non-empty string, cut like a
414
+ // body — and anything else is dropped. Whether `replyTo` names an
415
+ // earlier message of the transcript is the dashboard's to decide: it
416
+ // draws the quote when it can and an ordinary bubble when it cannot.
417
+ if (typeof src.id === "string" && src.id !== "") msg.id = truncateUtf8(src.id).value;
418
+ if (typeof src.replyTo === "string" && src.replyTo !== "") {
419
+ msg.replyTo = truncateUtf8(src.replyTo).value;
420
+ }
361
421
  return msg;
362
422
  }
363
423
 
424
+ /** The block's `typing`: `self` or `other`, else nobody. The thread's
425
+ * present tense is a decoration, not a turn, so a value that isn't one of
426
+ * the two draws nothing rather than guessing at who. */
427
+ function typingOf(v: unknown): "self" | "other" | undefined {
428
+ return v === "self" || v === "other" ? v : undefined;
429
+ }
430
+
364
431
  /** Reactions get the tolerance `side` gets: an entry that isn't quite right
365
432
  * is drawn rather than dropped — a `from` that is missing or misspelled is
366
433
  * the other party's — and only one with nothing to draw (no string
package/src/recorder.ts CHANGED
@@ -882,7 +882,9 @@ export type StepBlock =
882
882
  | { type: "kv"; rows: { label: string; value?: string; error?: boolean }[]; label?: string }
883
883
  | { type: "table"; columns: string[]; rows: unknown[][] }
884
884
  | { type: "htmlSandbox"; html: string; label?: string }
885
- | { type: "chat"; messages: ChatBlockMessage[]; label?: string }
885
+ // `typing` is the thread's present tense: whoever is writing right now
886
+ // gets the three-dot bubble at the end of the thread, on their side.
887
+ | { type: "chat"; messages: ChatBlockMessage[]; label?: string; typing?: "self" | "other" }
886
888
  // A disclosure. For what a step has but a reader does not want on every
887
889
  // step — a connection's protocol version, session id, redacted token.
888
890
  | { type: "details"; summary: string; blocks: StepBlock[] };
@@ -902,6 +904,23 @@ export interface ChatBlockMessage {
902
904
  * corner, on the side opposite the sender. Absent and empty read the
903
905
  * same: nothing to draw. */
904
906
  reactions?: ChatBlockReaction[];
907
+ /** Delivery status — the caption a messaging app shows under a bubble
908
+ * ("Delivered", "Read"), on the bubble's own side. Absent draws
909
+ * nothing. */
910
+ status?: "delivered" | "read";
911
+ /** When the status happened, as the app would show it ("23:14"): free
912
+ * text drawn after the status caption ("Read 23:14"). Rides with
913
+ * `status`; alone it has nothing to qualify. */
914
+ statusAt?: string;
915
+ /** The author's handle for this message — the provider's message id,
916
+ * usually — which a later message's `replyTo` names. Never drawn.
917
+ * Absent when the author gave none. */
918
+ id?: string;
919
+ /** The `id` of an earlier message in the same block this one replies
920
+ * to, drawn as a quoted excerpt of that message above the bubble, the
921
+ * way a messaging app threads a reply. One that names no earlier
922
+ * message draws nothing extra. */
923
+ replyTo?: string;
905
924
  }
906
925
 
907
926
  /** One reaction on a {@link ChatBlockMessage}. */