@specific.dev/spectest 0.104.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 +41 -0
- package/dist/annotate.d.ts +45 -0
- package/dist/annotate.js +47 -1
- package/dist/index.d.ts +1 -1
- package/dist/locator-errors.js +14 -5
- package/dist/recorder.d.ts +26 -0
- package/package.json +1 -1
- package/src/annotate.test.ts +137 -0
- package/src/annotate.ts +92 -1
- package/src/index.ts +1 -0
- package/src/locator-errors.test.ts +15 -7
- package/src/locator-errors.ts +14 -5
- package/src/recorder.ts +29 -1
package/README.md
CHANGED
|
@@ -66,6 +66,47 @@ 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
|
+
|
|
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
|
+
|
|
69
110
|
## HTTP assertions
|
|
70
111
|
|
|
71
112
|
`ctx.fetch()` returns a wrapped response. Status fields, header lookups, and
|
package/dist/annotate.d.ts
CHANGED
|
@@ -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 {
|
|
@@ -113,6 +121,43 @@ export interface ChatMessageAnnotation {
|
|
|
113
121
|
* the fake that marks them.
|
|
114
122
|
*/
|
|
115
123
|
new?: boolean;
|
|
124
|
+
/**
|
|
125
|
+
* Reactions on this message — the tapbacks a messaging app lets either
|
|
126
|
+
* party put on a bubble (👀, ✅). Drawn as small chips on the bubble's
|
|
127
|
+
* corner. A reaction that was removed simply stops being listed.
|
|
128
|
+
*/
|
|
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
|
+
/** One reaction on a {@link ChatMessageAnnotation}. */
|
|
145
|
+
export interface ChatReactionAnnotation {
|
|
146
|
+
/** The reaction itself — one emoji, usually. */
|
|
147
|
+
emoji: string;
|
|
148
|
+
/**
|
|
149
|
+
* Who reacted: `self` is the end user, `other` whoever they are talking
|
|
150
|
+
* to. Defaults to `other`, which is also what any other value draws as —
|
|
151
|
+
* a reaction on the wrong side can be seen and fixed, a dropped one
|
|
152
|
+
* cannot.
|
|
153
|
+
*/
|
|
154
|
+
from?: "self" | "other";
|
|
155
|
+
/**
|
|
156
|
+
* This reaction arrived in *this* call — a message's `new`, and the
|
|
157
|
+
* fake's to set for the same reason: only it knows what the app had
|
|
158
|
+
* already seen. It slides in after the messages that arrived with it.
|
|
159
|
+
*/
|
|
160
|
+
new?: boolean;
|
|
116
161
|
}
|
|
117
162
|
/**
|
|
118
163
|
* Say what a fake helper's return value *is*, so the timeline can draw it
|
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: [
|
|
162
|
+
blocks: [
|
|
163
|
+
{ type: "chat", messages, ...(title ? { label: title } : {}), ...(typing ? { typing } : {}) },
|
|
164
|
+
],
|
|
161
165
|
};
|
|
162
166
|
}
|
|
163
167
|
function chatMessageOf(src) {
|
|
@@ -173,8 +177,50 @@ function chatMessageOf(src) {
|
|
|
173
177
|
msg.text = truncateUtf8(body).value;
|
|
174
178
|
if (src.new === true)
|
|
175
179
|
msg.new = true;
|
|
180
|
+
const reactions = reactionsOf(src.reactions);
|
|
181
|
+
if (reactions.length > 0)
|
|
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
|
+
}
|
|
176
194
|
return msg;
|
|
177
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
|
+
}
|
|
202
|
+
/** Reactions get the tolerance `side` gets: an entry that isn't quite right
|
|
203
|
+
* is drawn rather than dropped — a `from` that is missing or misspelled is
|
|
204
|
+
* the other party's — and only one with nothing to draw (no string
|
|
205
|
+
* `emoji`, or an empty one) is skipped. */
|
|
206
|
+
function reactionsOf(v) {
|
|
207
|
+
if (!Array.isArray(v))
|
|
208
|
+
return [];
|
|
209
|
+
const out = [];
|
|
210
|
+
for (const entry of v) {
|
|
211
|
+
const src = fields(entry);
|
|
212
|
+
if (typeof src.emoji !== "string" || src.emoji === "")
|
|
213
|
+
continue;
|
|
214
|
+
const reaction = {
|
|
215
|
+
emoji: truncateUtf8(src.emoji).value,
|
|
216
|
+
from: text(src.from) === "self" ? "self" : "other",
|
|
217
|
+
};
|
|
218
|
+
if (src.new === true)
|
|
219
|
+
reaction.new = true;
|
|
220
|
+
out.push(reaction);
|
|
221
|
+
}
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
178
224
|
function messageOf(src) {
|
|
179
225
|
const msg = {};
|
|
180
226
|
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";
|
package/dist/locator-errors.js
CHANGED
|
@@ -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
|
|
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
|
|
59
|
-
*
|
|
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: `
|
|
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
|
-
|
|
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
|
}
|
package/dist/recorder.d.ts
CHANGED
|
@@ -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;
|
|
@@ -646,6 +647,31 @@ export interface ChatBlockMessage {
|
|
|
646
647
|
/** Arrived in *this* step: drawn with an entrance animation. Only the
|
|
647
648
|
* producer knows this, so only the producer sets it. */
|
|
648
649
|
new?: boolean;
|
|
650
|
+
/** Reactions on this message — the tapbacks a messaging app lets either
|
|
651
|
+
* party put on a bubble (👀, ✅). Drawn as chips on the bubble's lower
|
|
652
|
+
* corner, on the side opposite the sender. Absent and empty read the
|
|
653
|
+
* same: nothing to draw. */
|
|
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
|
+
}
|
|
664
|
+
/** One reaction on a {@link ChatBlockMessage}. */
|
|
665
|
+
export interface ChatBlockReaction {
|
|
666
|
+
/** The reaction itself — an emoji, or any short string. */
|
|
667
|
+
emoji: string;
|
|
668
|
+
/** Who reacted. `self` is the end user, `other` the party they are
|
|
669
|
+
* talking to; absent reads as `other`. */
|
|
670
|
+
from?: "self" | "other";
|
|
671
|
+
/** Arrived in *this* step, like a message's `new`: drawn with the same
|
|
672
|
+
* entrance animation, after the bubbles that arrived with it. Only the
|
|
673
|
+
* producer knows this, so only the producer sets it. */
|
|
674
|
+
new?: boolean;
|
|
649
675
|
}
|
|
650
676
|
/**
|
|
651
677
|
* A step that describes itself in presentation terms — a title plus an
|
package/package.json
CHANGED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { annotate, readAnnotation, type ChatMessageAnnotation } 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, typing: block.typing, block, 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
|
+
});
|
|
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
|
@@ -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,
|
|
@@ -152,6 +153,14 @@ export interface ChatAnnotation {
|
|
|
152
153
|
* reads fine without it.
|
|
153
154
|
*/
|
|
154
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";
|
|
155
164
|
}
|
|
156
165
|
|
|
157
166
|
/** One turn of a {@link ChatAnnotation}. */
|
|
@@ -171,6 +180,44 @@ export interface ChatMessageAnnotation {
|
|
|
171
180
|
* the fake that marks them.
|
|
172
181
|
*/
|
|
173
182
|
new?: boolean;
|
|
183
|
+
/**
|
|
184
|
+
* Reactions on this message — the tapbacks a messaging app lets either
|
|
185
|
+
* party put on a bubble (👀, ✅). Drawn as small chips on the bubble's
|
|
186
|
+
* corner. A reaction that was removed simply stops being listed.
|
|
187
|
+
*/
|
|
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
|
+
|
|
204
|
+
/** One reaction on a {@link ChatMessageAnnotation}. */
|
|
205
|
+
export interface ChatReactionAnnotation {
|
|
206
|
+
/** The reaction itself — one emoji, usually. */
|
|
207
|
+
emoji: string;
|
|
208
|
+
/**
|
|
209
|
+
* Who reacted: `self` is the end user, `other` whoever they are talking
|
|
210
|
+
* to. Defaults to `other`, which is also what any other value draws as —
|
|
211
|
+
* a reaction on the wrong side can be seen and fixed, a dropped one
|
|
212
|
+
* cannot.
|
|
213
|
+
*/
|
|
214
|
+
from?: "self" | "other";
|
|
215
|
+
/**
|
|
216
|
+
* This reaction arrived in *this* call — a message's `new`, and the
|
|
217
|
+
* fake's to set for the same reason: only it knows what the app had
|
|
218
|
+
* already seen. It slides in after the messages that arrived with it.
|
|
219
|
+
*/
|
|
220
|
+
new?: boolean;
|
|
174
221
|
}
|
|
175
222
|
|
|
176
223
|
/** Listing rows carried on the event. The returned value is never capped. */
|
|
@@ -300,12 +347,14 @@ function lowerChat(
|
|
|
300
347
|
const source: unknown = deepUnwrap(options ?? value);
|
|
301
348
|
let list: unknown;
|
|
302
349
|
let title: string | undefined;
|
|
350
|
+
let typing: "self" | "other" | undefined;
|
|
303
351
|
if (Array.isArray(source)) {
|
|
304
352
|
list = source;
|
|
305
353
|
} else {
|
|
306
354
|
const bag = fields(source);
|
|
307
355
|
list = bag.messages;
|
|
308
356
|
title = text(bag.title) || undefined;
|
|
357
|
+
typing = typingOf(bag.typing);
|
|
309
358
|
}
|
|
310
359
|
// An empty conversation is a real state (nothing said yet) and renders as
|
|
311
360
|
// one; a value with no `messages` at all is the author's mistake.
|
|
@@ -317,7 +366,9 @@ function lowerChat(
|
|
|
317
366
|
return {
|
|
318
367
|
kind: "chat",
|
|
319
368
|
title,
|
|
320
|
-
blocks: [
|
|
369
|
+
blocks: [
|
|
370
|
+
{ type: "chat", messages, ...(title ? { label: title } : {}), ...(typing ? { typing } : {}) },
|
|
371
|
+
],
|
|
321
372
|
};
|
|
322
373
|
}
|
|
323
374
|
|
|
@@ -330,9 +381,49 @@ function chatMessageOf(src: Record<string, unknown>): ChatBlockMessage {
|
|
|
330
381
|
const body = text(src.text);
|
|
331
382
|
if (body) msg.text = truncateUtf8(body).value;
|
|
332
383
|
if (src.new === true) msg.new = true;
|
|
384
|
+
const reactions = reactionsOf(src.reactions);
|
|
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
|
+
}
|
|
333
397
|
return msg;
|
|
334
398
|
}
|
|
335
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
|
+
|
|
407
|
+
/** Reactions get the tolerance `side` gets: an entry that isn't quite right
|
|
408
|
+
* is drawn rather than dropped — a `from` that is missing or misspelled is
|
|
409
|
+
* the other party's — and only one with nothing to draw (no string
|
|
410
|
+
* `emoji`, or an empty one) is skipped. */
|
|
411
|
+
function reactionsOf(v: unknown): ChatBlockReaction[] {
|
|
412
|
+
if (!Array.isArray(v)) return [];
|
|
413
|
+
const out: ChatBlockReaction[] = [];
|
|
414
|
+
for (const entry of v) {
|
|
415
|
+
const src = fields(entry);
|
|
416
|
+
if (typeof src.emoji !== "string" || src.emoji === "") continue;
|
|
417
|
+
const reaction: ChatBlockReaction = {
|
|
418
|
+
emoji: truncateUtf8(src.emoji).value,
|
|
419
|
+
from: text(src.from) === "self" ? "self" : "other",
|
|
420
|
+
};
|
|
421
|
+
if (src.new === true) reaction.new = true;
|
|
422
|
+
out.push(reaction);
|
|
423
|
+
}
|
|
424
|
+
return out;
|
|
425
|
+
}
|
|
426
|
+
|
|
336
427
|
function messageOf(src: Record<string, unknown>): EmailEventMessage {
|
|
337
428
|
const msg: EmailEventMessage = {};
|
|
338
429
|
const from = text(src.from);
|
package/src/index.ts
CHANGED
|
@@ -21,12 +21,12 @@ function timeoutError(message: string, log: string[]): Error {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
describe("locatorFailureMessage", () => {
|
|
24
|
-
test("names the
|
|
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
|
-
'
|
|
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
|
-
'
|
|
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
|
-
'
|
|
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('
|
|
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('
|
|
193
|
-
expect(out.stack).toStartWith('
|
|
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
|
+
});
|
package/src/locator-errors.ts
CHANGED
|
@@ -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
|
|
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
|
|
71
|
-
*
|
|
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: `
|
|
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
|
-
|
|
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
|
@@ -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
|
-
|
|
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[] };
|
|
@@ -897,6 +899,32 @@ export interface ChatBlockMessage {
|
|
|
897
899
|
/** Arrived in *this* step: drawn with an entrance animation. Only the
|
|
898
900
|
* producer knows this, so only the producer sets it. */
|
|
899
901
|
new?: boolean;
|
|
902
|
+
/** Reactions on this message — the tapbacks a messaging app lets either
|
|
903
|
+
* party put on a bubble (👀, ✅). Drawn as chips on the bubble's lower
|
|
904
|
+
* corner, on the side opposite the sender. Absent and empty read the
|
|
905
|
+
* same: nothing to draw. */
|
|
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
|
+
}
|
|
916
|
+
|
|
917
|
+
/** One reaction on a {@link ChatBlockMessage}. */
|
|
918
|
+
export interface ChatBlockReaction {
|
|
919
|
+
/** The reaction itself — an emoji, or any short string. */
|
|
920
|
+
emoji: string;
|
|
921
|
+
/** Who reacted. `self` is the end user, `other` the party they are
|
|
922
|
+
* talking to; absent reads as `other`. */
|
|
923
|
+
from?: "self" | "other";
|
|
924
|
+
/** Arrived in *this* step, like a message's `new`: drawn with the same
|
|
925
|
+
* entrance animation, after the bubbles that arrived with it. Only the
|
|
926
|
+
* producer knows this, so only the producer sets it. */
|
|
927
|
+
new?: boolean;
|
|
900
928
|
}
|
|
901
929
|
|
|
902
930
|
/**
|