@specific.dev/spectest 0.106.0 → 0.107.1

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
@@ -107,6 +107,32 @@ return annotate(turns, "chat", {
107
107
  });
108
108
  ```
109
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 reply to the bubble right above it draws
118
+ only the connector, since the message it answers is already on screen. A
119
+ run of consecutive replies to one message quotes it once, above the first
120
+ of them, and the rest draw only the connector so they read as one thread;
121
+ a reply to a different message, or an ordinary message, ends the run. A
122
+ `replyTo` that names no earlier message of the same call draws nothing
123
+ extra; the bubble is an ordinary message.
124
+
125
+ ```ts
126
+ return annotate(turns, "chat", {
127
+ messages: turns.map((t) => ({
128
+ id: t.id,
129
+ side: t.fromCustomer ? "self" : "other",
130
+ text: t.body,
131
+ replyTo: t.inReplyTo,
132
+ })),
133
+ });
134
+ ```
135
+
110
136
  ## HTTP assertions
111
137
 
112
138
  `ctx.fetch()` returns a wrapped response. Status fields, header lookups, and
@@ -140,6 +140,21 @@ export interface ChatMessageAnnotation {
140
140
  * qualify and is dropped.
141
141
  */
142
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;
143
158
  }
144
159
  /** One reaction on a {@link ChatMessageAnnotation}. */
145
160
  export interface ChatReactionAnnotation {
package/dist/annotate.js CHANGED
@@ -191,6 +191,16 @@ function chatMessageOf(src) {
191
191
  msg.statusAt = truncateUtf8(src.statusAt).value;
192
192
  }
193
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
+ }
194
204
  return msg;
195
205
  }
196
206
  /** The block's `typing`: `self` or `other`, else nobody. The thread's
@@ -89,8 +89,8 @@ function requestView(request) {
89
89
  export function replaySignature(request, key) {
90
90
  if (key)
91
91
  return hashKey(key(requestView(request)));
92
- // Existing cassettes retain the original byte hash even when their stored
93
- // text was redacted. Older/hand-written cassettes may only have the body.
94
- const body = request.bodyHash ?? `sha256:${createHash("sha256").update(bodyBytes(request)).digest("hex")}`;
95
- return hashKey({ method: request.method.toUpperCase(), path: request.path, query: request.query, body });
92
+ // Preserve the original replay default: bodies and headers may change
93
+ // between recording and replay. Requests to the same endpoint consume
94
+ // responses in cassette order; body-sensitive identity is opt-in via key.
95
+ return hashKey({ method: request.method.toUpperCase(), path: request.path, query: request.query });
96
96
  }
@@ -71,8 +71,10 @@ export interface ReplayFakeOptions<S = undefined, H extends Record<string, unkno
71
71
  * resolved via an external DNS server — see the module header). Your
72
72
  * app points at this real host directly; no separate stand-in. */
73
73
  host: string;
74
- /** Complete request identity. Default: method, path, normalized query,
75
- * and exact body bytes. The same synchronous, deterministic callback runs
74
+ /** Complete request identity. Default: method, path, and normalized query;
75
+ * bodies and headers are ignored, and matching responses replay in cassette
76
+ * order. Include body content in a custom key to distinguish concurrent
77
+ * calls to the same endpoint. The same synchronous, deterministic callback runs
76
78
  * on recorded and incoming requests. Return JSON values and/or Uint8Array
77
79
  * bytes; objects compare independently of property order. No implicit
78
80
  * matching dimensions or fallback apply when a key is supplied. */
@@ -660,6 +660,15 @@ export interface ChatBlockMessage {
660
660
  * text drawn after the status caption ("Read 23:14"). Rides with
661
661
  * `status`; alone it has nothing to qualify. */
662
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;
663
672
  }
664
673
  /** One reaction on a {@link ChatBlockMessage}. */
665
674
  export interface ChatBlockReaction {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.106.0",
3
+ "version": "0.107.1",
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
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. */
@@ -135,3 +136,69 @@ describe("annotate chat status and typing", () => {
135
136
  expect(chat(annotate(messages, "chat")).typing).toBeUndefined();
136
137
  });
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
@@ -199,6 +199,21 @@ export interface ChatMessageAnnotation {
199
199
  * qualify and is dropped.
200
200
  */
201
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;
202
217
  }
203
218
 
204
219
  /** One reaction on a {@link ChatMessageAnnotation}. */
@@ -394,6 +409,15 @@ function chatMessageOf(src: Record<string, unknown>): ChatBlockMessage {
394
409
  msg.statusAt = truncateUtf8(src.statusAt).value;
395
410
  }
396
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
+ }
397
421
  return msg;
398
422
  }
399
423
 
@@ -120,8 +120,8 @@ function requestView(request: CassetteRequest): ReplayRequest {
120
120
  /** Internal only; the SDK exports the request/key types, not this function. */
121
121
  export function replaySignature(request: CassetteRequest, key?: ReplayKey): string {
122
122
  if (key) return hashKey(key(requestView(request)));
123
- // Existing cassettes retain the original byte hash even when their stored
124
- // text was redacted. Older/hand-written cassettes may only have the body.
125
- const body = request.bodyHash ?? `sha256:${createHash("sha256").update(bodyBytes(request)).digest("hex")}`;
126
- return hashKey({ method: request.method.toUpperCase(), path: request.path, query: request.query, body });
123
+ // Preserve the original replay default: bodies and headers may change
124
+ // between recording and replay. Requests to the same endpoint consume
125
+ // responses in cassette order; body-sensitive identity is opt-in via key.
126
+ return hashKey({ method: request.method.toUpperCase(), path: request.path, query: request.query });
127
127
  }
@@ -25,6 +25,7 @@ const request = (task = "one", path = "/messages") => new Request(`https://${hos
25
25
  function mixedFake() {
26
26
  return replayFake({
27
27
  name: "mixed", host, mode: "replay",
28
+ key: request => ({ method: request.method, path: request.path, task: request.json<{ task: string }>().task }),
28
29
  state: () => ({ useRecordings: false, reply: "scripted", seen: [] as string[] }),
29
30
  helpers: ({ state }) => ({
30
31
  useRecordings(enabled = true) { state.useRecordings = enabled; },
@@ -5,7 +5,7 @@ import { join } from "node:path";
5
5
 
6
6
  // Isolate module mocks from other tests. Drive the actual record handler,
7
7
  // replacing only DNS/HTTPS transport; no credentials or external egress.
8
- test("recorded headers, redaction, and exact body bytes survive record → replay", async () => {
8
+ test("recorded bodies and redaction survive; replay uses default order or an explicit header key", async () => {
9
9
  const moduleUrl = (file: string) => JSON.stringify(new URL(file, import.meta.url).href);
10
10
  const script = `
11
11
  import { strict as assert } from "node:assert";
@@ -37,7 +37,7 @@ test("recorded headers, redaction, and exact body bytes survive record → repla
37
37
  res.statusCode = 200;
38
38
  res.headers = { "content-type": "text/plain" };
39
39
  callback(res);
40
- res.emit("data", Buffer.from("response"));
40
+ res.emit("data", Buffer.from(options.headers["x-task"] ? "response-" + options.headers["x-task"] : "response"));
41
41
  res.emit("end");
42
42
  });
43
43
  return req;
@@ -86,12 +86,12 @@ test("recorded headers, redaction, and exact body bytes survive record → repla
86
86
  const replay = replayFake({ ...options, mode: "replay", key });
87
87
  const replayState = replay.state();
88
88
  replay.helpers({ name: "test", state: replayState, ctx: {} }).replay(cassette);
89
- for (const i of [2, 0, 1]) {
89
+ for (const [position, i] of [2, 0, 1].entries()) {
90
90
  const res = await replay.handler(new Request("https://api.example.test/messages", {
91
91
  method: "POST", body: bodies[i], headers: { "X-Task": "task-" + i },
92
92
  }), replayState, {});
93
93
  assert.equal(res.status, 200);
94
- assert.equal(await res.text(), "response");
94
+ assert.equal(await res.text(), "response-task-" + (key ? i : position));
95
95
  }
96
96
  }
97
97
  assert.equal(upstreamHeaders.length, 3); // Replay never forwards.
@@ -6,6 +6,9 @@ import { replayFake, type Cassette, type CassetteInteraction, type ReplayFakeOpt
6
6
  const context = {} as FakeContext;
7
7
  const host = "api.example.test";
8
8
  const sha = (body: string | Uint8Array) => `sha256:${createHash("sha256").update(body).digest("hex")}`;
9
+ const exactBodyKey: ReplayKey = request => ({
10
+ method: request.method, path: request.path, query: request.query, body: request.bytes(),
11
+ });
9
12
 
10
13
  function interaction(body: string | Uint8Array, answer: string, path = "/messages"): CassetteInteraction {
11
14
  return {
@@ -38,8 +41,8 @@ async function harness(options: Partial<ReplayFakeOptions> = {}) {
38
41
  }
39
42
 
40
43
  describe("replay request identity", () => {
41
- test("default matches 654 concurrent requests by body, independently of arrival order", async () => {
42
- const h = await harness();
44
+ test("explicit body key matches 654 concurrent requests independently of arrival order", async () => {
45
+ const h = await harness({ key: exactBodyKey });
43
46
  const calls = Array.from({ length: 654 }, (_, id) => interaction(JSON.stringify({ id }), `answer-${id}`));
44
47
  h.helpers.replay(cassette(calls));
45
48
  // A permutation visits every identity once in a different order.
@@ -53,7 +56,7 @@ describe("replay request identity", () => {
53
56
  expect(h.state.cursor.size).toBe(654);
54
57
  });
55
58
 
56
- test("default uses method/path/normalized query/exact bytes, including old cassettes without hashes", async () => {
59
+ test("default uses method/path/normalized query and ignores changed bodies and headers", async () => {
57
60
  const h = await harness();
58
61
  const it = interaction('{"a":1}', "ok");
59
62
  delete it.request.bodyHash;
@@ -61,15 +64,36 @@ describe("replay request identity", () => {
61
64
  it.request.query = { b: ["1", "2"], a: ["x"] };
62
65
  h.helpers.replay(cassette([it]));
63
66
  for (const [body, path, init] of [
64
- ['{ "a":1}', "/messages?a=x&b=2&b=1", {}],
65
67
  ['{"a":1}', "/other?a=x&b=2&b=1", {}],
66
68
  ['{"a":1}', "/messages?a=x&b=2&b=1", { method: "PUT" }],
67
69
  ['{"a":1}', "/messages?a=y&b=2&b=1", {}],
68
70
  ] as const) expect((await h.send(body, path, init)).status).toBe(599);
69
- expect(await (await h.send('{"a":1}', "/messages?b=2&a=x&b=1")).text()).toBe("ok");
71
+ expect(await (await h.send('{"a":2,"generatedId":"new"}', "/messages?b=2&a=x&b=1", {
72
+ headers: { "content-type": "text/plain", "x-request-id": "new" },
73
+ })).text()).toBe("ok");
74
+ });
75
+
76
+ test.each([true, false])("default replays version 1 cassettes in order despite body drift (stored hashes: %p)", async storedHashes => {
77
+ const h = await harness();
78
+ const c = cassette([
79
+ interaction('{"projectId":"old","turn":1}', "first"),
80
+ interaction('{"projectId":"old","turn":2}', "second"),
81
+ ]);
82
+ for (const it of c.interactions) {
83
+ if (storedHashes) it.request.body = "<REDACTED>";
84
+ else { delete it.request.bodyHash; delete it.request.bodyEncoding; }
85
+ }
86
+ h.helpers.replay(JSON.parse(JSON.stringify(c)));
87
+ expect(await (await h.send('{"projectId":"new","turn":1}')).text()).toBe("first");
88
+ expect(await (await h.send('{"projectId":"new","turn":2}')).text()).toBe("second");
89
+ const exhausted = await h.send("another body");
90
+ expect(exhausted.status).toBe(599);
91
+ expect(await exhausted.text()).toContain("exhausted key");
92
+ h.helpers.replay(c);
93
+ expect(await (await h.send("yet another body")).text()).toBe("first");
70
94
  });
71
95
 
72
- test("the existing committed HTTP cassette still replays with the new default", async () => {
96
+ test("the existing committed HTTP cassette still replays with the default", async () => {
73
97
  const h = await harness();
74
98
  const existing = await Bun.file(new URL("../../../examples/replay-fake/spectest/tests/recordings/httpbin.json", import.meta.url)).json() as Cassette;
75
99
  h.helpers.replay(existing);
@@ -134,7 +158,7 @@ describe("replay request identity", () => {
134
158
  });
135
159
 
136
160
  test("empty and BOM-prefixed bodies preserve their bytes", async () => {
137
- for (const key of [undefined, (r: ReplayRequest) => r.bytes()]) {
161
+ for (const key of [exactBodyKey, (r: ReplayRequest) => r.bytes()]) {
138
162
  const h = await harness({ key });
139
163
  h.helpers.replay(cassette([interaction("", "empty"), interaction("\ufeffhello", "bom")]));
140
164
  expect(await (await h.send("")).text()).toBe("empty");
@@ -144,7 +168,7 @@ describe("replay request identity", () => {
144
168
  });
145
169
 
146
170
  test("same-key queues consume once, fail on exhaustion, and reset on reload", async () => {
147
- const h = await harness();
171
+ const h = await harness({ key: exactBodyKey });
148
172
  const c = cassette([interaction("same", "first"), interaction("other", "other"), interaction("same", "second")]);
149
173
  h.helpers.replay(c);
150
174
  expect(await Promise.all([h.send("same"), h.send("same")].map(async p => (await p).text()))).toEqual(["first", "second"]);
@@ -161,8 +161,10 @@ export interface ReplayFakeOptions<
161
161
  * resolved via an external DNS server — see the module header). Your
162
162
  * app points at this real host directly; no separate stand-in. */
163
163
  host: string;
164
- /** Complete request identity. Default: method, path, normalized query,
165
- * and exact body bytes. The same synchronous, deterministic callback runs
164
+ /** Complete request identity. Default: method, path, and normalized query;
165
+ * bodies and headers are ignored, and matching responses replay in cassette
166
+ * order. Include body content in a custom key to distinguish concurrent
167
+ * calls to the same endpoint. The same synchronous, deterministic callback runs
166
168
  * on recorded and incoming requests. Return JSON values and/or Uint8Array
167
169
  * bytes; objects compare independently of property order. No implicit
168
170
  * matching dimensions or fallback apply when a key is supplied. */
package/src/recorder.ts CHANGED
@@ -912,6 +912,15 @@ export interface ChatBlockMessage {
912
912
  * text drawn after the status caption ("Read 23:14"). Rides with
913
913
  * `status`; alone it has nothing to qualify. */
914
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;
915
924
  }
916
925
 
917
926
  /** One reaction on a {@link ChatBlockMessage}. */