@specific.dev/spectest 0.105.0 → 0.106.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,27 @@ 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
+
89
110
  ## HTTP assertions
90
111
 
91
112
  `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,19 @@ 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;
122
143
  }
123
144
  /** One reaction on a {@link ChatMessageAnnotation}. */
124
145
  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,25 @@ 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
+ }
179
194
  return msg;
180
195
  }
196
+ /** The block's `typing`: `self` or `other`, else nobody. The thread's
197
+ * present tense is a decoration, not a turn, so a value that isn't one of
198
+ * the two draws nothing rather than guessing at who. */
199
+ function typingOf(v) {
200
+ return v === "self" || v === "other" ? v : undefined;
201
+ }
181
202
  /** Reactions get the tolerance `side` gets: an entry that isn't quite right
182
203
  * is drawn rather than dropped — a `from` that is missing or misspelled is
183
204
  * 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,14 @@ 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;
654
663
  }
655
664
  /** One reaction on a {@link ChatBlockMessage}. */
656
665
  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.106.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,6 @@
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
4
 
5
5
  /** Open a chat annotation: the messages its one block lowered to, and the
6
6
  * value the test would receive. */
@@ -9,7 +9,7 @@ function chat(boxed: unknown) {
9
9
  if (!opened || opened.annotation.kind !== "chat") throw new Error("not a chat annotation");
10
10
  const block = opened.annotation.blocks[0];
11
11
  if (!block || block.type !== "chat") throw new Error("not a chat block");
12
- return { messages: block.messages, value: opened.value };
12
+ return { messages: block.messages, typing: block.typing, block, value: opened.value };
13
13
  }
14
14
 
15
15
  describe("annotate chat", () => {
@@ -77,3 +77,61 @@ describe("annotate chat", () => {
77
77
  expect(messages[0]!.reactions).toEqual([{ emoji: "✅", from: "self", new: true }]);
78
78
  });
79
79
  });
80
+
81
+ describe("annotate chat status and typing", () => {
82
+ test("captions a message with its delivery status and leaves the rest alone", () => {
83
+ const { messages } = chat(
84
+ annotate(
85
+ [
86
+ { side: "self", text: "where is my order?", status: "read", statusAt: "23:14" },
87
+ { side: "self", text: "hello?", status: "delivered" },
88
+ { side: "other", text: "It ships today.", new: true },
89
+ ],
90
+ "chat",
91
+ ),
92
+ );
93
+ expect(messages[0]).toEqual({ side: "self", text: "where is my order?", status: "read", statusAt: "23:14" });
94
+ // A status without a time carries no `statusAt`; a message without a
95
+ // status carries neither key.
96
+ expect(messages[1]).toEqual({ side: "self", text: "hello?", status: "delivered" });
97
+ expect(messages[2]).toEqual({ side: "other", text: "It ships today.", new: true });
98
+ });
99
+
100
+ // A caption, unlike a turn, can be left off without the transcript
101
+ // lying: a status that isn't one of the two is dropped, and so is a time
102
+ // that isn't text, is empty, or has no status to qualify.
103
+ test("drops a status it does not know and a time it cannot draw", () => {
104
+ const { messages } = chat(
105
+ annotate(
106
+ [
107
+ { side: "self", text: "a", status: "seen", statusAt: "23:14" },
108
+ { side: "self", text: "b", status: "read", statusAt: 2314 },
109
+ { side: "self", text: "c", statusAt: "23:14" },
110
+ { side: "self", text: "d", status: "read", statusAt: "" },
111
+ ],
112
+ "chat",
113
+ ),
114
+ );
115
+ expect(messages[0]).toEqual({ side: "self", text: "a" });
116
+ expect(messages[1]).toEqual({ side: "self", text: "b", status: "read" });
117
+ expect(messages[2]).toEqual({ side: "self", text: "c" });
118
+ expect(messages[3]).toEqual({ side: "self", text: "d", status: "read" });
119
+ });
120
+
121
+ test("a typing indicator rides on the block, and only for a side it knows", () => {
122
+ const messages: ChatMessageAnnotation[] = [{ side: "other", text: "Let me check." }];
123
+ expect(chat(annotate({ id: 7 }, "chat", { messages, typing: "other" })).typing).toBe("other");
124
+ expect(chat(annotate({ id: 7 }, "chat", { messages, typing: "self" })).typing).toBe("self");
125
+ // Nobody typing is no key at all: absent, misspelled, or a bare flag.
126
+ // (Read off the value, which skips the options' typing.)
127
+ expect(chat(annotate({ id: 7 }, "chat", { messages })).block).toEqual({
128
+ type: "chat",
129
+ messages: [{ side: "other", text: "Let me check." }],
130
+ });
131
+ for (const typing of ["agent", true, null, ""]) {
132
+ expect(chat(annotate({ messages, typing }, "chat")).typing).toBeUndefined();
133
+ }
134
+ // The messages-alone form has no block options to carry it.
135
+ expect(chat(annotate(messages, "chat")).typing).toBeUndefined();
136
+ });
137
+ });
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,19 @@ 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;
181
202
  }
182
203
 
183
204
  /** One reaction on a {@link ChatMessageAnnotation}. */
@@ -326,12 +347,14 @@ function lowerChat(
326
347
  const source: unknown = deepUnwrap(options ?? value);
327
348
  let list: unknown;
328
349
  let title: string | undefined;
350
+ let typing: "self" | "other" | undefined;
329
351
  if (Array.isArray(source)) {
330
352
  list = source;
331
353
  } else {
332
354
  const bag = fields(source);
333
355
  list = bag.messages;
334
356
  title = text(bag.title) || undefined;
357
+ typing = typingOf(bag.typing);
335
358
  }
336
359
  // An empty conversation is a real state (nothing said yet) and renders as
337
360
  // one; a value with no `messages` at all is the author's mistake.
@@ -343,7 +366,9 @@ function lowerChat(
343
366
  return {
344
367
  kind: "chat",
345
368
  title,
346
- blocks: [{ type: "chat", messages, ...(title ? { label: title } : {}) }],
369
+ blocks: [
370
+ { type: "chat", messages, ...(title ? { label: title } : {}), ...(typing ? { typing } : {}) },
371
+ ],
347
372
  };
348
373
  }
349
374
 
@@ -358,9 +383,27 @@ function chatMessageOf(src: Record<string, unknown>): ChatBlockMessage {
358
383
  if (src.new === true) msg.new = true;
359
384
  const reactions = reactionsOf(src.reactions);
360
385
  if (reactions.length > 0) msg.reactions = reactions;
386
+ // A status caption, unlike a turn, can be left off without the
387
+ // transcript lying, so one that isn't `delivered` or `read` is dropped
388
+ // rather than guessed at. Its time rides with it: free text, truncated
389
+ // like a body, and meaningless without a status to qualify.
390
+ const status = src.status;
391
+ if (status === "delivered" || status === "read") {
392
+ msg.status = status;
393
+ if (typeof src.statusAt === "string" && src.statusAt !== "") {
394
+ msg.statusAt = truncateUtf8(src.statusAt).value;
395
+ }
396
+ }
361
397
  return msg;
362
398
  }
363
399
 
400
+ /** The block's `typing`: `self` or `other`, else nobody. The thread's
401
+ * present tense is a decoration, not a turn, so a value that isn't one of
402
+ * the two draws nothing rather than guessing at who. */
403
+ function typingOf(v: unknown): "self" | "other" | undefined {
404
+ return v === "self" || v === "other" ? v : undefined;
405
+ }
406
+
364
407
  /** Reactions get the tolerance `side` gets: an entry that isn't quite right
365
408
  * is drawn rather than dropped — a `from` that is missing or misspelled is
366
409
  * 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,14 @@ 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;
905
915
  }
906
916
 
907
917
  /** One reaction on a {@link ChatBlockMessage}. */