@specific.dev/spectest 0.103.0 → 0.105.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -0
- package/dist/annotate.d.ts +24 -0
- package/dist/annotate.js +25 -0
- package/dist/browser.d.ts +8 -1
- package/dist/browser.js +23 -18
- package/dist/index.d.ts +1 -1
- package/dist/locator-errors.js +14 -5
- package/dist/recorder.d.ts +17 -0
- package/package.json +1 -1
- package/src/annotate.test.ts +79 -0
- package/src/annotate.ts +48 -0
- package/src/browser-timezone.test.ts +105 -0
- package/src/browser.ts +25 -15
- 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 +18 -0
package/README.md
CHANGED
|
@@ -66,6 +66,26 @@ The handshake returns a rejection `Response` or callbacks (`open`, `message`,
|
|
|
66
66
|
in order per connection. WS and trusted WSS support text/binary messages;
|
|
67
67
|
observe traffic through the fake's helpers.
|
|
68
68
|
|
|
69
|
+
## Chat reactions (0.105.0)
|
|
70
|
+
|
|
71
|
+
A `chat` annotation's message can carry the tapbacks a messaging app puts on
|
|
72
|
+
a bubble. Each reaction is `{ emoji, from?, new? }`: `from` is `self` (the end
|
|
73
|
+
user) or `other` (the default), and `new` marks one that arrived in this step,
|
|
74
|
+
exactly like a message's `new`. A removed reaction simply stops being listed.
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
return annotate(turns, "chat", {
|
|
78
|
+
messages: turns.map((t) => ({
|
|
79
|
+
side: t.fromCustomer ? "self" : "other",
|
|
80
|
+
text: t.body,
|
|
81
|
+
reactions: t.acked ? [{ emoji: "✅", new: t.ackedNow }] : [],
|
|
82
|
+
})),
|
|
83
|
+
});
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The dashboard draws the chips on the bubble's lower corner, opposite the
|
|
87
|
+
sender, and slides a new one in after the bubbles that arrived with it.
|
|
88
|
+
|
|
69
89
|
## HTTP assertions
|
|
70
90
|
|
|
71
91
|
`ctx.fetch()` returns a wrapped response. Status fields, header lookups, and
|
package/dist/annotate.d.ts
CHANGED
|
@@ -113,6 +113,30 @@ export interface ChatMessageAnnotation {
|
|
|
113
113
|
* the fake that marks them.
|
|
114
114
|
*/
|
|
115
115
|
new?: boolean;
|
|
116
|
+
/**
|
|
117
|
+
* Reactions on this message — the tapbacks a messaging app lets either
|
|
118
|
+
* party put on a bubble (👀, ✅). Drawn as small chips on the bubble's
|
|
119
|
+
* corner. A reaction that was removed simply stops being listed.
|
|
120
|
+
*/
|
|
121
|
+
reactions?: readonly ChatReactionAnnotation[];
|
|
122
|
+
}
|
|
123
|
+
/** One reaction on a {@link ChatMessageAnnotation}. */
|
|
124
|
+
export interface ChatReactionAnnotation {
|
|
125
|
+
/** The reaction itself — one emoji, usually. */
|
|
126
|
+
emoji: string;
|
|
127
|
+
/**
|
|
128
|
+
* Who reacted: `self` is the end user, `other` whoever they are talking
|
|
129
|
+
* to. Defaults to `other`, which is also what any other value draws as —
|
|
130
|
+
* a reaction on the wrong side can be seen and fixed, a dropped one
|
|
131
|
+
* cannot.
|
|
132
|
+
*/
|
|
133
|
+
from?: "self" | "other";
|
|
134
|
+
/**
|
|
135
|
+
* This reaction arrived in *this* call — a message's `new`, and the
|
|
136
|
+
* fake's to set for the same reason: only it knows what the app had
|
|
137
|
+
* already seen. It slides in after the messages that arrived with it.
|
|
138
|
+
*/
|
|
139
|
+
new?: boolean;
|
|
116
140
|
}
|
|
117
141
|
/**
|
|
118
142
|
* Say what a fake helper's return value *is*, so the timeline can draw it
|
package/dist/annotate.js
CHANGED
|
@@ -173,8 +173,33 @@ function chatMessageOf(src) {
|
|
|
173
173
|
msg.text = truncateUtf8(body).value;
|
|
174
174
|
if (src.new === true)
|
|
175
175
|
msg.new = true;
|
|
176
|
+
const reactions = reactionsOf(src.reactions);
|
|
177
|
+
if (reactions.length > 0)
|
|
178
|
+
msg.reactions = reactions;
|
|
176
179
|
return msg;
|
|
177
180
|
}
|
|
181
|
+
/** Reactions get the tolerance `side` gets: an entry that isn't quite right
|
|
182
|
+
* is drawn rather than dropped — a `from` that is missing or misspelled is
|
|
183
|
+
* the other party's — and only one with nothing to draw (no string
|
|
184
|
+
* `emoji`, or an empty one) is skipped. */
|
|
185
|
+
function reactionsOf(v) {
|
|
186
|
+
if (!Array.isArray(v))
|
|
187
|
+
return [];
|
|
188
|
+
const out = [];
|
|
189
|
+
for (const entry of v) {
|
|
190
|
+
const src = fields(entry);
|
|
191
|
+
if (typeof src.emoji !== "string" || src.emoji === "")
|
|
192
|
+
continue;
|
|
193
|
+
const reaction = {
|
|
194
|
+
emoji: truncateUtf8(src.emoji).value,
|
|
195
|
+
from: text(src.from) === "self" ? "self" : "other",
|
|
196
|
+
};
|
|
197
|
+
if (src.new === true)
|
|
198
|
+
reaction.new = true;
|
|
199
|
+
out.push(reaction);
|
|
200
|
+
}
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
178
203
|
function messageOf(src) {
|
|
179
204
|
const msg = {};
|
|
180
205
|
const from = text(src.from);
|
package/dist/browser.d.ts
CHANGED
|
@@ -22,6 +22,13 @@ export interface BrowserOptions {
|
|
|
22
22
|
frame?: "browser" | "mobile";
|
|
23
23
|
/** Initial URL to navigate to before the constructor returns. */
|
|
24
24
|
url?: string;
|
|
25
|
+
/** IANA timezone, e.g. "Europe/Stockholm". Defaults to the host timezone.
|
|
26
|
+
* Set on first creation; inherited sessions keep their original timezone. */
|
|
27
|
+
timezone?: string;
|
|
28
|
+
/** Locale, e.g. "sv-SE", for navigator.language, Accept-Language and
|
|
29
|
+
* date/number formatting. Omit to use the browser's default locale.
|
|
30
|
+
* Set on first creation; inherited sessions keep their original locale. */
|
|
31
|
+
locale?: string;
|
|
25
32
|
/** Feed a WAV fixture through Chromium's native microphone capture.
|
|
26
33
|
* Set on the session's first creation; inherited sessions keep their source.
|
|
27
34
|
* Fixture sessions use a dedicated Chromium process for audio isolation. */
|
|
@@ -296,7 +303,7 @@ export interface SafeAreaInsets {
|
|
|
296
303
|
* Pre-open `n` views into the pool (called by the daemon at the end of
|
|
297
304
|
* /bootstrap, before the warm-template snapshot is captured). Only
|
|
298
305
|
* default-viewport views are pooled — `openBrowser` with a custom
|
|
299
|
-
* width/height bypasses the pool.
|
|
306
|
+
* width/height, timezone, locale or microphone bypasses the pool.
|
|
300
307
|
*/
|
|
301
308
|
export declare function prewarmViewPool(n?: number): Promise<void>;
|
|
302
309
|
/**
|
package/dist/browser.js
CHANGED
|
@@ -160,20 +160,24 @@ function deviceContextOptions(d) {
|
|
|
160
160
|
}
|
|
161
161
|
/** Create the context one view lives in — device emulation when the view is
|
|
162
162
|
* phone-emulated, a plain viewport otherwise. */
|
|
163
|
-
async function newViewContext(width, height, device,
|
|
164
|
-
const fixture = microphone === undefined ? undefined : await prepareMicrophoneFixture(microphone);
|
|
163
|
+
async function newViewContext(width, height, device, opts = {}) {
|
|
164
|
+
const fixture = opts.microphone === undefined ? undefined : await prepareMicrophoneFixture(opts.microphone);
|
|
165
165
|
let browser;
|
|
166
166
|
try {
|
|
167
167
|
browser = fixture ? await chromium.launch({
|
|
168
168
|
executablePath: chromiumPath(), headless: true, args: [...CHROME_ARGV, fixture.arg],
|
|
169
169
|
}) : await ensurePlaywrightBrowser();
|
|
170
|
-
const context = await browser.newContext(
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
170
|
+
const context = await browser.newContext({
|
|
171
|
+
...(device
|
|
172
|
+
? deviceContextOptions(device)
|
|
173
|
+
: {
|
|
174
|
+
viewport: { width, height },
|
|
175
|
+
// Reduced-motion for replay fidelity — see deviceContextOptions.
|
|
176
|
+
reducedMotion: "reduce",
|
|
177
|
+
}),
|
|
178
|
+
timezoneId: opts.timezone,
|
|
179
|
+
locale: opts.locale,
|
|
180
|
+
});
|
|
177
181
|
context.setDefaultTimeout(DEFAULT_ACTION_TIMEOUT_MS);
|
|
178
182
|
context.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);
|
|
179
183
|
if (fixture) {
|
|
@@ -879,7 +883,7 @@ async function createView(width, height) {
|
|
|
879
883
|
* Pre-open `n` views into the pool (called by the daemon at the end of
|
|
880
884
|
* /bootstrap, before the warm-template snapshot is captured). Only
|
|
881
885
|
* default-viewport views are pooled — `openBrowser` with a custom
|
|
882
|
-
* width/height bypasses the pool.
|
|
886
|
+
* width/height, timezone, locale or microphone bypasses the pool.
|
|
883
887
|
*/
|
|
884
888
|
export async function prewarmViewPool(n = 1) {
|
|
885
889
|
for (let i = 0; i < n; i++) {
|
|
@@ -910,10 +914,11 @@ export async function openMobileBackend(opts = {}) {
|
|
|
910
914
|
const device = opts.frame === "mobile" ? LATEST_IPHONE : null;
|
|
911
915
|
const wantW = device ? device.viewport.width : opts.width ?? 1280;
|
|
912
916
|
const wantH = device ? device.viewport.height : opts.height ?? 720;
|
|
913
|
-
//
|
|
914
|
-
//
|
|
915
|
-
|
|
916
|
-
|
|
917
|
+
// The pool holds only default-desktop contexts. Context creation options
|
|
918
|
+
// such as timezone and locale require a fresh context.
|
|
919
|
+
const pooled = !device && opts.microphone === undefined && opts.timezone === undefined &&
|
|
920
|
+
opts.locale === undefined && wantW === 1280 && wantH === 720
|
|
921
|
+
? VIEW_POOL.pop() : undefined;
|
|
917
922
|
let holder;
|
|
918
923
|
if (pooled) {
|
|
919
924
|
holder = {
|
|
@@ -930,7 +935,7 @@ export async function openMobileBackend(opts = {}) {
|
|
|
930
935
|
};
|
|
931
936
|
}
|
|
932
937
|
else {
|
|
933
|
-
holder = await newHolder(wantW, wantH, device, opts
|
|
938
|
+
holder = await newHolder(wantW, wantH, device, opts);
|
|
934
939
|
}
|
|
935
940
|
const { backend } = buildBackend(holder, opts.recorder ?? null, {
|
|
936
941
|
persistent: false,
|
|
@@ -992,8 +997,8 @@ function checkSessionCap(kind, live, name) {
|
|
|
992
997
|
`Each named session is a live browser captured in every snapshot from here on — name them for the ` +
|
|
993
998
|
`roles under test (e.g. "buyer"/"seller") rather than per row of data, or close() the ones you're done with.`);
|
|
994
999
|
}
|
|
995
|
-
async function newHolder(width, height, device,
|
|
996
|
-
const context = await newViewContext(width, height, device,
|
|
1000
|
+
async function newHolder(width, height, device, opts = {}) {
|
|
1001
|
+
const context = await newViewContext(width, height, device, opts);
|
|
997
1002
|
let spawned;
|
|
998
1003
|
try {
|
|
999
1004
|
spawned = await spawnPage(context);
|
|
@@ -1073,7 +1078,7 @@ export async function acquirePersistentBrowser(name = "", opts = {}) {
|
|
|
1073
1078
|
}
|
|
1074
1079
|
else {
|
|
1075
1080
|
checkSessionCap("browser", SHARED_BROWSERS.size, name);
|
|
1076
|
-
holder = await newHolder(device ? device.viewport.width : opts.width ?? 1280, device ? device.viewport.height : opts.height ?? 720, device, opts
|
|
1081
|
+
holder = await newHolder(device ? device.viewport.width : opts.width ?? 1280, device ? device.viewport.height : opts.height ?? 720, device, opts);
|
|
1077
1082
|
SHARED_BROWSERS.set(name, holder);
|
|
1078
1083
|
}
|
|
1079
1084
|
const view = holder;
|
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
|
@@ -646,6 +646,23 @@ export interface ChatBlockMessage {
|
|
|
646
646
|
/** Arrived in *this* step: drawn with an entrance animation. Only the
|
|
647
647
|
* producer knows this, so only the producer sets it. */
|
|
648
648
|
new?: boolean;
|
|
649
|
+
/** Reactions on this message — the tapbacks a messaging app lets either
|
|
650
|
+
* party put on a bubble (👀, ✅). Drawn as chips on the bubble's lower
|
|
651
|
+
* corner, on the side opposite the sender. Absent and empty read the
|
|
652
|
+
* same: nothing to draw. */
|
|
653
|
+
reactions?: ChatBlockReaction[];
|
|
654
|
+
}
|
|
655
|
+
/** One reaction on a {@link ChatBlockMessage}. */
|
|
656
|
+
export interface ChatBlockReaction {
|
|
657
|
+
/** The reaction itself — an emoji, or any short string. */
|
|
658
|
+
emoji: string;
|
|
659
|
+
/** Who reacted. `self` is the end user, `other` the party they are
|
|
660
|
+
* talking to; absent reads as `other`. */
|
|
661
|
+
from?: "self" | "other";
|
|
662
|
+
/** Arrived in *this* step, like a message's `new`: drawn with the same
|
|
663
|
+
* entrance animation, after the bubbles that arrived with it. Only the
|
|
664
|
+
* producer knows this, so only the producer sets it. */
|
|
665
|
+
new?: boolean;
|
|
649
666
|
}
|
|
650
667
|
/**
|
|
651
668
|
* A step that describes itself in presentation terms — a title plus an
|
package/package.json
CHANGED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { annotate, readAnnotation } from "./annotate.js";
|
|
4
|
+
|
|
5
|
+
/** Open a chat annotation: the messages its one block lowered to, and the
|
|
6
|
+
* value the test would receive. */
|
|
7
|
+
function chat(boxed: unknown) {
|
|
8
|
+
const opened = readAnnotation(boxed);
|
|
9
|
+
if (!opened || opened.annotation.kind !== "chat") throw new Error("not a chat annotation");
|
|
10
|
+
const block = opened.annotation.blocks[0];
|
|
11
|
+
if (!block || block.type !== "chat") throw new Error("not a chat block");
|
|
12
|
+
return { messages: block.messages, value: opened.value };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("annotate chat", () => {
|
|
16
|
+
test("copies a message's reactions through and leaves the value alone", () => {
|
|
17
|
+
const turns = [
|
|
18
|
+
{
|
|
19
|
+
side: "self",
|
|
20
|
+
text: "where is my order?",
|
|
21
|
+
reactions: [{ emoji: "👀" }, { emoji: "✅", from: "self", new: true }],
|
|
22
|
+
},
|
|
23
|
+
{ side: "other", text: "It ships today.", new: true },
|
|
24
|
+
];
|
|
25
|
+
const { messages, value } = chat(annotate(turns, "chat"));
|
|
26
|
+
expect(value).toBe(turns);
|
|
27
|
+
expect(messages[0]!.reactions).toEqual([
|
|
28
|
+
// No `from` is the other party's, exactly as no `side` is.
|
|
29
|
+
{ emoji: "👀", from: "other" },
|
|
30
|
+
{ emoji: "✅", from: "self", new: true },
|
|
31
|
+
]);
|
|
32
|
+
// A message without reactions carries no empty list.
|
|
33
|
+
expect(messages[1]!.reactions).toBeUndefined();
|
|
34
|
+
expect(messages[1]).toEqual({ side: "other", text: "It ships today.", new: true });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// The tolerance `side` has: a reaction that isn't quite right is drawn —
|
|
38
|
+
// on the other party's side — rather than dropped. Only one with nothing
|
|
39
|
+
// to draw is skipped.
|
|
40
|
+
test("draws a reaction it does not quite understand and skips one it cannot", () => {
|
|
41
|
+
const { messages } = chat(
|
|
42
|
+
annotate(
|
|
43
|
+
[
|
|
44
|
+
{
|
|
45
|
+
side: "self",
|
|
46
|
+
text: "hi",
|
|
47
|
+
reactions: [
|
|
48
|
+
{ emoji: "❤️", from: "them", new: "yes" },
|
|
49
|
+
{ emoji: 42 },
|
|
50
|
+
{ emoji: "" },
|
|
51
|
+
"👍",
|
|
52
|
+
{ emoji: "🎉", from: "self" },
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
"chat",
|
|
57
|
+
),
|
|
58
|
+
);
|
|
59
|
+
expect(messages[0]!.reactions).toEqual([
|
|
60
|
+
{ emoji: "❤️", from: "other" },
|
|
61
|
+
{ emoji: "🎉", from: "self" },
|
|
62
|
+
]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("a reactions field that is not a list is no reactions", () => {
|
|
66
|
+
const { messages } = chat(annotate([{ side: "self", text: "hi", reactions: "👍" }], "chat"));
|
|
67
|
+
expect(messages[0]!.reactions).toBeUndefined();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("reactions ride along when the messages are passed as options", () => {
|
|
71
|
+
const { messages } = chat(
|
|
72
|
+
annotate({ id: 7 }, "chat", {
|
|
73
|
+
title: "+46 70 123 45 67",
|
|
74
|
+
messages: [{ side: "other", text: "Done?", reactions: [{ emoji: "✅", from: "self", new: true }] }],
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
expect(messages[0]!.reactions).toEqual([{ emoji: "✅", from: "self", new: true }]);
|
|
78
|
+
});
|
|
79
|
+
});
|
package/src/annotate.ts
CHANGED
|
@@ -34,6 +34,7 @@ import { deepUnwrap } from "./inspect.js";
|
|
|
34
34
|
import {
|
|
35
35
|
truncateUtf8,
|
|
36
36
|
type ChatBlockMessage,
|
|
37
|
+
type ChatBlockReaction,
|
|
37
38
|
type EmailEventMessage,
|
|
38
39
|
type EmailEventSummary,
|
|
39
40
|
type StepBlock,
|
|
@@ -171,6 +172,31 @@ export interface ChatMessageAnnotation {
|
|
|
171
172
|
* the fake that marks them.
|
|
172
173
|
*/
|
|
173
174
|
new?: boolean;
|
|
175
|
+
/**
|
|
176
|
+
* Reactions on this message — the tapbacks a messaging app lets either
|
|
177
|
+
* party put on a bubble (👀, ✅). Drawn as small chips on the bubble's
|
|
178
|
+
* corner. A reaction that was removed simply stops being listed.
|
|
179
|
+
*/
|
|
180
|
+
reactions?: readonly ChatReactionAnnotation[];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** One reaction on a {@link ChatMessageAnnotation}. */
|
|
184
|
+
export interface ChatReactionAnnotation {
|
|
185
|
+
/** The reaction itself — one emoji, usually. */
|
|
186
|
+
emoji: string;
|
|
187
|
+
/**
|
|
188
|
+
* Who reacted: `self` is the end user, `other` whoever they are talking
|
|
189
|
+
* to. Defaults to `other`, which is also what any other value draws as —
|
|
190
|
+
* a reaction on the wrong side can be seen and fixed, a dropped one
|
|
191
|
+
* cannot.
|
|
192
|
+
*/
|
|
193
|
+
from?: "self" | "other";
|
|
194
|
+
/**
|
|
195
|
+
* This reaction arrived in *this* call — a message's `new`, and the
|
|
196
|
+
* fake's to set for the same reason: only it knows what the app had
|
|
197
|
+
* already seen. It slides in after the messages that arrived with it.
|
|
198
|
+
*/
|
|
199
|
+
new?: boolean;
|
|
174
200
|
}
|
|
175
201
|
|
|
176
202
|
/** Listing rows carried on the event. The returned value is never capped. */
|
|
@@ -330,9 +356,31 @@ function chatMessageOf(src: Record<string, unknown>): ChatBlockMessage {
|
|
|
330
356
|
const body = text(src.text);
|
|
331
357
|
if (body) msg.text = truncateUtf8(body).value;
|
|
332
358
|
if (src.new === true) msg.new = true;
|
|
359
|
+
const reactions = reactionsOf(src.reactions);
|
|
360
|
+
if (reactions.length > 0) msg.reactions = reactions;
|
|
333
361
|
return msg;
|
|
334
362
|
}
|
|
335
363
|
|
|
364
|
+
/** Reactions get the tolerance `side` gets: an entry that isn't quite right
|
|
365
|
+
* is drawn rather than dropped — a `from` that is missing or misspelled is
|
|
366
|
+
* the other party's — and only one with nothing to draw (no string
|
|
367
|
+
* `emoji`, or an empty one) is skipped. */
|
|
368
|
+
function reactionsOf(v: unknown): ChatBlockReaction[] {
|
|
369
|
+
if (!Array.isArray(v)) return [];
|
|
370
|
+
const out: ChatBlockReaction[] = [];
|
|
371
|
+
for (const entry of v) {
|
|
372
|
+
const src = fields(entry);
|
|
373
|
+
if (typeof src.emoji !== "string" || src.emoji === "") continue;
|
|
374
|
+
const reaction: ChatBlockReaction = {
|
|
375
|
+
emoji: truncateUtf8(src.emoji).value,
|
|
376
|
+
from: text(src.from) === "self" ? "self" : "other",
|
|
377
|
+
};
|
|
378
|
+
if (src.new === true) reaction.new = true;
|
|
379
|
+
out.push(reaction);
|
|
380
|
+
}
|
|
381
|
+
return out;
|
|
382
|
+
}
|
|
383
|
+
|
|
336
384
|
function messageOf(src: Record<string, unknown>): EmailEventMessage {
|
|
337
385
|
const msg: EmailEventMessage = {};
|
|
338
386
|
const from = text(src.from);
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { readRaw } from "./inspect.js";
|
|
3
|
+
import {
|
|
4
|
+
acquirePersistentBrowser,
|
|
5
|
+
openBrowser,
|
|
6
|
+
prewarmViewPool,
|
|
7
|
+
type Browser,
|
|
8
|
+
} from "./browser.js";
|
|
9
|
+
|
|
10
|
+
interface Settings {
|
|
11
|
+
timezone: string;
|
|
12
|
+
locale: string;
|
|
13
|
+
language: string;
|
|
14
|
+
acceptLanguage: string;
|
|
15
|
+
winterOffset: number;
|
|
16
|
+
summerOffset: number;
|
|
17
|
+
number: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function settings(browser: Browser): Promise<Settings> {
|
|
21
|
+
return readRaw(await browser.evaluate<Settings>("read initial regional settings", "window.settings")) as unknown as Settings;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe.skipIf(!Bun.which("chromium") && !Bun.which("chromium-browser") && !Bun.which("google-chrome"))("browser timezone and locale", () => {
|
|
25
|
+
let server: ReturnType<typeof Bun.serve>;
|
|
26
|
+
beforeAll(() => {
|
|
27
|
+
server = Bun.serve({
|
|
28
|
+
port: 0,
|
|
29
|
+
hostname: "127.0.0.1",
|
|
30
|
+
fetch: request => new Response(`<script>
|
|
31
|
+
window.settings = {
|
|
32
|
+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
33
|
+
locale: Intl.DateTimeFormat().resolvedOptions().locale,
|
|
34
|
+
language: navigator.language,
|
|
35
|
+
acceptLanguage: ${JSON.stringify(request.headers.get("accept-language"))},
|
|
36
|
+
winterOffset: new Date("2026-01-15T12:00:00Z").getTimezoneOffset(),
|
|
37
|
+
summerOffset: new Date("2026-07-15T12:00:00Z").getTimezoneOffset(),
|
|
38
|
+
number: new Intl.NumberFormat().format(1234.5),
|
|
39
|
+
};
|
|
40
|
+
</script>`, { headers: { "content-type": "text/html" } }),
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
afterAll(() => server.stop(true));
|
|
44
|
+
|
|
45
|
+
test("applies both options before initial navigation and retains them after reload", async () => {
|
|
46
|
+
const browser = await openBrowser({ timezone: "Europe/Stockholm", locale: "sv-SE", url: server.url.href });
|
|
47
|
+
try {
|
|
48
|
+
const initial = await settings(browser);
|
|
49
|
+
expect(initial).toMatchObject({
|
|
50
|
+
timezone: "Europe/Stockholm", locale: "sv-SE", language: "sv-SE",
|
|
51
|
+
winterOffset: -60, summerOffset: -120, number: "1\u00a0234,5",
|
|
52
|
+
});
|
|
53
|
+
expect(initial.acceptLanguage.split(",")[0]).toBe("sv-SE");
|
|
54
|
+
await browser.reload();
|
|
55
|
+
expect(await settings(browser)).toEqual(initial);
|
|
56
|
+
} finally { await browser.close(); }
|
|
57
|
+
}, 20000);
|
|
58
|
+
|
|
59
|
+
test.each([
|
|
60
|
+
{ timezone: "Europe/Stockholm" },
|
|
61
|
+
{ locale: "sv-SE" },
|
|
62
|
+
])("bypasses the default view pool for %j without changing defaults", async options => {
|
|
63
|
+
const baseline = await openBrowser({ url: server.url.href });
|
|
64
|
+
let defaults: Settings;
|
|
65
|
+
try { defaults = await settings(baseline); } finally { await baseline.close(); }
|
|
66
|
+
await prewarmViewPool();
|
|
67
|
+
const browser = await openBrowser({ ...options, url: server.url.href });
|
|
68
|
+
try {
|
|
69
|
+
const actual = await settings(browser);
|
|
70
|
+
expect(actual.timezone).toBe(options.timezone ?? defaults.timezone);
|
|
71
|
+
expect(actual.language).toBe(options.locale ?? defaults.language);
|
|
72
|
+
} finally { await browser.close(); }
|
|
73
|
+
const pooled = await openBrowser({ url: server.url.href });
|
|
74
|
+
try { expect(await settings(pooled)).toEqual(defaults); } finally { await pooled.close(); }
|
|
75
|
+
}, 20000);
|
|
76
|
+
|
|
77
|
+
test("named sessions are isolated and reattachment keeps first-call settings", async () => {
|
|
78
|
+
const a = await acquirePersistentBrowser("regional-a", { timezone: "Europe/Stockholm", locale: "sv-SE", url: server.url.href });
|
|
79
|
+
try {
|
|
80
|
+
const b = await acquirePersistentBrowser("regional-b", { timezone: "America/New_York", locale: "en-US", url: server.url.href });
|
|
81
|
+
try {
|
|
82
|
+
expect(await settings(b.browser)).toMatchObject({ timezone: "America/New_York", language: "en-US", winterOffset: 300, summerOffset: 240 });
|
|
83
|
+
const original = await settings(a.browser);
|
|
84
|
+
expect(original).toMatchObject({ timezone: "Europe/Stockholm", language: "sv-SE" });
|
|
85
|
+
await a.detach();
|
|
86
|
+
const attached = await acquirePersistentBrowser("regional-a", { timezone: "UTC", locale: "en-US" });
|
|
87
|
+
expect(attached.attached).toBe(true);
|
|
88
|
+
await attached.browser.reload();
|
|
89
|
+
expect(await settings(attached.browser)).toEqual(original);
|
|
90
|
+
await attached.detach();
|
|
91
|
+
} finally { await b.browser.close(); }
|
|
92
|
+
} finally { await a.browser.close(); }
|
|
93
|
+
}, 20000);
|
|
94
|
+
|
|
95
|
+
test("mobile framing retains regional settings", async () => {
|
|
96
|
+
const browser = await openBrowser({ frame: "mobile", timezone: "Europe/Stockholm", locale: "sv-SE", url: server.url.href });
|
|
97
|
+
try {
|
|
98
|
+
expect(await settings(browser)).toMatchObject({ timezone: "Europe/Stockholm", language: "sv-SE", winterOffset: -60 });
|
|
99
|
+
} finally { await browser.close(); }
|
|
100
|
+
}, 20000);
|
|
101
|
+
|
|
102
|
+
test("rejects an invalid timezone", async () => {
|
|
103
|
+
await expect(openBrowser({ timezone: "Invalid/Timezone" })).rejects.toThrow(/timezone/i);
|
|
104
|
+
}, 20000);
|
|
105
|
+
});
|
package/src/browser.ts
CHANGED
|
@@ -93,6 +93,13 @@ export interface BrowserOptions {
|
|
|
93
93
|
frame?: "browser" | "mobile";
|
|
94
94
|
/** Initial URL to navigate to before the constructor returns. */
|
|
95
95
|
url?: string;
|
|
96
|
+
/** IANA timezone, e.g. "Europe/Stockholm". Defaults to the host timezone.
|
|
97
|
+
* Set on first creation; inherited sessions keep their original timezone. */
|
|
98
|
+
timezone?: string;
|
|
99
|
+
/** Locale, e.g. "sv-SE", for navigator.language, Accept-Language and
|
|
100
|
+
* date/number formatting. Omit to use the browser's default locale.
|
|
101
|
+
* Set on first creation; inherited sessions keep their original locale. */
|
|
102
|
+
locale?: string;
|
|
96
103
|
/** Feed a WAV fixture through Chromium's native microphone capture.
|
|
97
104
|
* Set on the session's first creation; inherited sessions keep their source.
|
|
98
105
|
* Fixture sessions use a dedicated Chromium process for audio isolation. */
|
|
@@ -510,23 +517,25 @@ async function newViewContext(
|
|
|
510
517
|
width: number,
|
|
511
518
|
height: number,
|
|
512
519
|
device: DevicePreset | null,
|
|
513
|
-
microphone
|
|
520
|
+
opts: Pick<BrowserOptions, "microphone" | "timezone" | "locale"> = {},
|
|
514
521
|
): Promise<BrowserContext> {
|
|
515
|
-
const fixture = microphone === undefined ? undefined : await prepareMicrophoneFixture(microphone);
|
|
522
|
+
const fixture = opts.microphone === undefined ? undefined : await prepareMicrophoneFixture(opts.microphone);
|
|
516
523
|
let browser: PlaywrightBrowser | undefined;
|
|
517
524
|
try {
|
|
518
525
|
browser = fixture ? await chromium.launch({
|
|
519
526
|
executablePath: chromiumPath(), headless: true, args: [...CHROME_ARGV, fixture.arg],
|
|
520
527
|
}) : await ensurePlaywrightBrowser();
|
|
521
|
-
const context = await browser.newContext(
|
|
522
|
-
device
|
|
528
|
+
const context = await browser.newContext({
|
|
529
|
+
...(device
|
|
523
530
|
? deviceContextOptions(device)
|
|
524
531
|
: {
|
|
525
532
|
viewport: { width, height },
|
|
526
533
|
// Reduced-motion for replay fidelity — see deviceContextOptions.
|
|
527
534
|
reducedMotion: "reduce",
|
|
528
|
-
},
|
|
529
|
-
|
|
535
|
+
}),
|
|
536
|
+
timezoneId: opts.timezone,
|
|
537
|
+
locale: opts.locale,
|
|
538
|
+
});
|
|
530
539
|
context.setDefaultTimeout(DEFAULT_ACTION_TIMEOUT_MS);
|
|
531
540
|
context.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);
|
|
532
541
|
if (fixture) {
|
|
@@ -1310,7 +1319,7 @@ async function createView(width: number, height: number): Promise<PooledView> {
|
|
|
1310
1319
|
* Pre-open `n` views into the pool (called by the daemon at the end of
|
|
1311
1320
|
* /bootstrap, before the warm-template snapshot is captured). Only
|
|
1312
1321
|
* default-viewport views are pooled — `openBrowser` with a custom
|
|
1313
|
-
* width/height bypasses the pool.
|
|
1322
|
+
* width/height, timezone, locale or microphone bypasses the pool.
|
|
1314
1323
|
*/
|
|
1315
1324
|
export async function prewarmViewPool(n = 1): Promise<void> {
|
|
1316
1325
|
for (let i = 0; i < n; i++) {
|
|
@@ -1345,11 +1354,12 @@ export async function openMobileBackend(
|
|
|
1345
1354
|
const device = opts.frame === "mobile" ? LATEST_IPHONE : null;
|
|
1346
1355
|
const wantW = device ? device.viewport.width : opts.width ?? 1280;
|
|
1347
1356
|
const wantH = device ? device.viewport.height : opts.height ?? 720;
|
|
1348
|
-
//
|
|
1349
|
-
//
|
|
1350
|
-
// emulated context anyway.
|
|
1357
|
+
// The pool holds only default-desktop contexts. Context creation options
|
|
1358
|
+
// such as timezone and locale require a fresh context.
|
|
1351
1359
|
const pooled =
|
|
1352
|
-
!device && opts.microphone === undefined &&
|
|
1360
|
+
!device && opts.microphone === undefined && opts.timezone === undefined &&
|
|
1361
|
+
opts.locale === undefined && wantW === 1280 && wantH === 720
|
|
1362
|
+
? VIEW_POOL.pop() : undefined;
|
|
1353
1363
|
let holder: ViewHolder;
|
|
1354
1364
|
if (pooled) {
|
|
1355
1365
|
holder = {
|
|
@@ -1365,7 +1375,7 @@ export async function openMobileBackend(
|
|
|
1365
1375
|
initScripts: [],
|
|
1366
1376
|
};
|
|
1367
1377
|
} else {
|
|
1368
|
-
holder = await newHolder(wantW, wantH, device, opts
|
|
1378
|
+
holder = await newHolder(wantW, wantH, device, opts);
|
|
1369
1379
|
}
|
|
1370
1380
|
|
|
1371
1381
|
const { backend } = buildBackend(holder, opts.recorder ?? null, {
|
|
@@ -1437,9 +1447,9 @@ async function newHolder(
|
|
|
1437
1447
|
width: number,
|
|
1438
1448
|
height: number,
|
|
1439
1449
|
device: DevicePreset | null,
|
|
1440
|
-
microphone
|
|
1450
|
+
opts: Pick<BrowserOptions, "microphone" | "timezone" | "locale"> = {},
|
|
1441
1451
|
): Promise<ViewHolder> {
|
|
1442
|
-
const context = await newViewContext(width, height, device,
|
|
1452
|
+
const context = await newViewContext(width, height, device, opts);
|
|
1443
1453
|
let spawned: SpawnedPage;
|
|
1444
1454
|
try { spawned = await spawnPage(context); } catch (err) {
|
|
1445
1455
|
await closeViewContext(context);
|
|
@@ -1539,7 +1549,7 @@ export async function acquirePersistentBrowser(
|
|
|
1539
1549
|
device ? device.viewport.width : opts.width ?? 1280,
|
|
1540
1550
|
device ? device.viewport.height : opts.height ?? 720,
|
|
1541
1551
|
device,
|
|
1542
|
-
opts
|
|
1552
|
+
opts,
|
|
1543
1553
|
);
|
|
1544
1554
|
SHARED_BROWSERS.set(name, holder);
|
|
1545
1555
|
}
|
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
|
@@ -897,6 +897,24 @@ export interface ChatBlockMessage {
|
|
|
897
897
|
/** Arrived in *this* step: drawn with an entrance animation. Only the
|
|
898
898
|
* producer knows this, so only the producer sets it. */
|
|
899
899
|
new?: boolean;
|
|
900
|
+
/** Reactions on this message — the tapbacks a messaging app lets either
|
|
901
|
+
* party put on a bubble (👀, ✅). Drawn as chips on the bubble's lower
|
|
902
|
+
* corner, on the side opposite the sender. Absent and empty read the
|
|
903
|
+
* same: nothing to draw. */
|
|
904
|
+
reactions?: ChatBlockReaction[];
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/** One reaction on a {@link ChatBlockMessage}. */
|
|
908
|
+
export interface ChatBlockReaction {
|
|
909
|
+
/** The reaction itself — an emoji, or any short string. */
|
|
910
|
+
emoji: string;
|
|
911
|
+
/** Who reacted. `self` is the end user, `other` the party they are
|
|
912
|
+
* talking to; absent reads as `other`. */
|
|
913
|
+
from?: "self" | "other";
|
|
914
|
+
/** Arrived in *this* step, like a message's `new`: drawn with the same
|
|
915
|
+
* entrance animation, after the bubbles that arrived with it. Only the
|
|
916
|
+
* producer knows this, so only the producer sets it. */
|
|
917
|
+
new?: boolean;
|
|
900
918
|
}
|
|
901
919
|
|
|
902
920
|
/**
|