@meetelise/chat 1.55.3 → 1.56.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/dist/src/MyPubnub.d.ts +38 -3
- package/dist/src/WebComponent/me-chat.d.ts +33 -0
- package/dist/src/WebComponent/pubnub-chat.d.ts +3 -0
- package/dist/src/WebComponent/pubnub-navigation.d.ts +17 -0
- package/dist/src/webchatNavigation.d.ts +38 -0
- package/package.json +1 -1
- package/public/dist/index.js +81 -43
- package/src/MyPubnub.ts +87 -5
- package/src/WebComponent/me-chat.ts +158 -26
- package/src/WebComponent/pubnub-chat.ts +36 -10
- package/src/WebComponent/pubnub-navigation.ts +73 -0
- package/src/main/MEChat.ts +9 -1
- package/src/orgLevelNavigation.test.ts +625 -0
- package/src/webchatNavigation.test.ts +99 -0
- package/src/webchatNavigation.ts +108 -0
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
import { expect } from "@esm-bundle/chai";
|
|
2
|
+
import { restore, stub, useFakeXMLHttpRequest } from "sinon/pkg/sinon-esm";
|
|
3
|
+
import "../public/dist/index";
|
|
4
|
+
import type MyPubnub from "./MyPubnub";
|
|
5
|
+
import type { SimpleChatMessage } from "./MyPubnub";
|
|
6
|
+
import type { PubnubChat } from "./WebComponent/pubnub-chat";
|
|
7
|
+
import type { PubnubNavigation } from "./WebComponent/pubnub-navigation";
|
|
8
|
+
import type { BuildingWebchatView } from "./fetchBuildingWebchatView";
|
|
9
|
+
|
|
10
|
+
declare const afterEach: (callback: () => void) => void;
|
|
11
|
+
declare const beforeEach: (callback: () => void) => void;
|
|
12
|
+
declare const it: (name: string, callback: () => void | Promise<void>) => void;
|
|
13
|
+
|
|
14
|
+
// Org-level webchat navigation, end to end through the built widget. Every
|
|
15
|
+
// network request is answered by a fake: nothing reaches EliseAI or PubNub.
|
|
16
|
+
|
|
17
|
+
type FakeRequest = XMLHttpRequest & {
|
|
18
|
+
method: string;
|
|
19
|
+
url: string;
|
|
20
|
+
requestBody?: string;
|
|
21
|
+
respond: (
|
|
22
|
+
status: number,
|
|
23
|
+
headers?: Record<string, string>,
|
|
24
|
+
body?: string
|
|
25
|
+
) => void;
|
|
26
|
+
onSend?: () => void;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type MeChatInternals = HTMLElement & {
|
|
30
|
+
updateComplete: Promise<boolean>;
|
|
31
|
+
isLoading: boolean;
|
|
32
|
+
displayPubnubChat: boolean;
|
|
33
|
+
myPubnub: MyPubnub | null;
|
|
34
|
+
buildingSlug: string;
|
|
35
|
+
buildingWebchatView: BuildingWebchatView | null;
|
|
36
|
+
enabledChatWidgets: Record<string, boolean>;
|
|
37
|
+
phoneNumberForSource: { number: string } | null;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const REOPEN_KEY = "com.elise.webchat.reopenAfterNavigation";
|
|
41
|
+
|
|
42
|
+
const view = (overrides: Partial<BuildingWebchatView>) => ({
|
|
43
|
+
id: 445038,
|
|
44
|
+
orgId: 53,
|
|
45
|
+
name: "Test Building",
|
|
46
|
+
userId: 7,
|
|
47
|
+
active: 1,
|
|
48
|
+
userFirstName: "Elise",
|
|
49
|
+
welcomeMessage: "Welcome, I'm Elise!",
|
|
50
|
+
launchedDateTime: "2024-01-01T00:00:00Z",
|
|
51
|
+
webchatScopeConfigured: true,
|
|
52
|
+
displayStyle: "pills",
|
|
53
|
+
shouldShowChatDesktop: true,
|
|
54
|
+
shouldShowChatMobile: true,
|
|
55
|
+
phoneNumber: null,
|
|
56
|
+
layoutOptions: [],
|
|
57
|
+
tourTypeOptions: [],
|
|
58
|
+
...overrides,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/** Every contact option on, as a building's webchat config can set them. */
|
|
62
|
+
const allContactOptions = {
|
|
63
|
+
shouldShowPhoneDesktop: true,
|
|
64
|
+
shouldShowPhoneMobile: true,
|
|
65
|
+
shouldShowEmailDesktop: true,
|
|
66
|
+
shouldShowEmailMobile: true,
|
|
67
|
+
shouldShowTextDesktop: true,
|
|
68
|
+
shouldShowTextMobile: true,
|
|
69
|
+
textWithUsPhoneNumber: "(555) 555-0100",
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const GENERAL_BUILDING = "test-org-general";
|
|
73
|
+
|
|
74
|
+
interface FakeBackendOptions {
|
|
75
|
+
/** The org-level /webchat/init answer: today's 400, or the rollout's 200. */
|
|
76
|
+
orgInit?: "400" | { building_slug?: string };
|
|
77
|
+
/** Overrides for test-building's config. */
|
|
78
|
+
building?: Partial<BuildingWebchatView>;
|
|
79
|
+
/** The triage general building's config, or a status to fail with. */
|
|
80
|
+
generalBuilding?: Partial<BuildingWebchatView> | number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const fakeBackend = (options: FakeBackendOptions = {}): FakeRequest[] => {
|
|
84
|
+
const requests: FakeRequest[] = [];
|
|
85
|
+
const fakeXhr = useFakeXMLHttpRequest();
|
|
86
|
+
fakeXhr.onCreate = (request: FakeRequest) => {
|
|
87
|
+
requests.push(request);
|
|
88
|
+
request.onSend = () =>
|
|
89
|
+
setTimeout(() => {
|
|
90
|
+
const json = (status: number, body: unknown) =>
|
|
91
|
+
request.respond(
|
|
92
|
+
status,
|
|
93
|
+
{ "Content-Type": "application/json" },
|
|
94
|
+
JSON.stringify(body)
|
|
95
|
+
);
|
|
96
|
+
const { url } = request;
|
|
97
|
+
if (url.includes("/organization/test-org/building/test-building/v2")) {
|
|
98
|
+
return json(200, view(options.building ?? {}));
|
|
99
|
+
}
|
|
100
|
+
if (
|
|
101
|
+
url.includes(`/organization/test-org/building/${GENERAL_BUILDING}/v2`)
|
|
102
|
+
) {
|
|
103
|
+
const general = options.generalBuilding ?? {};
|
|
104
|
+
if (typeof general === "number") return json(general, {});
|
|
105
|
+
return json(
|
|
106
|
+
200,
|
|
107
|
+
view({ id: 900, userId: 9, name: "Test Org General", ...general })
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
if (url.includes("/organization/test-org/webchat/v2")) {
|
|
111
|
+
return json(200, view({ id: 0, userId: null, name: "Test Org" }));
|
|
112
|
+
}
|
|
113
|
+
if (url.endsWith("/webchat/init")) {
|
|
114
|
+
const body = JSON.parse(request.requestBody ?? "{}");
|
|
115
|
+
const session = {
|
|
116
|
+
session_token: "session",
|
|
117
|
+
lead_id: "lead_test",
|
|
118
|
+
channel: "webchat_lead_test",
|
|
119
|
+
expires_in_seconds: 3600,
|
|
120
|
+
};
|
|
121
|
+
if (body.central_convo) {
|
|
122
|
+
const orgInit = options.orgInit ?? "400";
|
|
123
|
+
// An org outside the org-level webchat rollout.
|
|
124
|
+
if (orgInit === "400") return json(400, { detail: "Invalid" });
|
|
125
|
+
// An org in it: the session lives in its triage general building.
|
|
126
|
+
return json(200, { ...session, ...orgInit });
|
|
127
|
+
}
|
|
128
|
+
return json(200, session);
|
|
129
|
+
}
|
|
130
|
+
if (url.includes("chat-ui-feature-flag")) return json(200, false);
|
|
131
|
+
return json(200, null);
|
|
132
|
+
});
|
|
133
|
+
};
|
|
134
|
+
return requests;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const startWidget = async (
|
|
138
|
+
attributes: Record<string, string>
|
|
139
|
+
): Promise<MeChatInternals> => {
|
|
140
|
+
const meChat = document.createElement("me-chat") as MeChatInternals;
|
|
141
|
+
Object.entries(attributes).forEach(([name, value]) =>
|
|
142
|
+
meChat.setAttribute(name, value)
|
|
143
|
+
);
|
|
144
|
+
document.body.appendChild(meChat);
|
|
145
|
+
for (let i = 0; i < 200 && meChat.isLoading; i++) {
|
|
146
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
147
|
+
}
|
|
148
|
+
expect(meChat.isLoading, "widget finished loading").to.equal(false);
|
|
149
|
+
await meChat.updateComplete;
|
|
150
|
+
return meChat;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const perBuilding = { orgslug: "test-org", buildingslug: "test-building" };
|
|
154
|
+
const orgMode = { orgslug: "test-org", centralconvo: "" };
|
|
155
|
+
|
|
156
|
+
const initBodies = (requests: FakeRequest[]) =>
|
|
157
|
+
requests
|
|
158
|
+
.filter((r) => r.url.endsWith("/webchat/init"))
|
|
159
|
+
.map((r) => JSON.parse(r.requestBody ?? "{}"));
|
|
160
|
+
|
|
161
|
+
beforeEach(() => {
|
|
162
|
+
localStorage.clear();
|
|
163
|
+
sessionStorage.clear();
|
|
164
|
+
// MEChat.start() sets this up for the launcher before adding the widget.
|
|
165
|
+
(window as unknown as { eliseAi: unknown }).eliseAi = {};
|
|
166
|
+
// Analytics and logging use fetch; keep them off the network too.
|
|
167
|
+
stub(window, "fetch").resolves(new Response("{}"));
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
afterEach(() => {
|
|
171
|
+
document
|
|
172
|
+
.querySelectorAll("me-chat, pubnub-chat")
|
|
173
|
+
.forEach((el) => el.remove());
|
|
174
|
+
restore();
|
|
175
|
+
localStorage.clear();
|
|
176
|
+
sessionStorage.clear();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// --- Keeping the chat open across a Take me there click ---
|
|
180
|
+
|
|
181
|
+
it("keeps a per-building chat closed on load, as today, when there was no Take me there click", async () => {
|
|
182
|
+
const requests = fakeBackend();
|
|
183
|
+
const meChat = await startWidget(perBuilding);
|
|
184
|
+
|
|
185
|
+
expect(meChat.displayPubnubChat).to.equal(false);
|
|
186
|
+
expect(initBodies(requests)).to.deep.equal([
|
|
187
|
+
{ building_slug: "test-building", org_slug: "test-org" },
|
|
188
|
+
]);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("reopens the chat on the page load after a Take me there click, and only that one", async () => {
|
|
192
|
+
fakeBackend();
|
|
193
|
+
sessionStorage.setItem(REOPEN_KEY, "true");
|
|
194
|
+
|
|
195
|
+
const meChat = await startWidget(perBuilding);
|
|
196
|
+
|
|
197
|
+
expect(meChat.displayPubnubChat).to.equal(true);
|
|
198
|
+
expect(sessionStorage.getItem(REOPEN_KEY)).to.equal(null);
|
|
199
|
+
const chat = meChat.shadowRoot?.querySelector("pubnub-chat") as PubnubChat;
|
|
200
|
+
await chat.updateComplete;
|
|
201
|
+
expect(chat.shadowRoot?.querySelector("#pubnub-chat-container")).to.exist;
|
|
202
|
+
// Closing state is untouched: the chat isn't marked open for later loads.
|
|
203
|
+
expect(localStorage.getItem("isChatOpen")).to.equal(null);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// --- Org mode (centralConvo with no building) ---
|
|
207
|
+
|
|
208
|
+
const BUILDING_ONLY_ENDPOINTS = [
|
|
209
|
+
/\/webchat\/\/|\/webchat\/undefined\//, // any /webchat/{buildingSlug}/... with no slug
|
|
210
|
+
/\/lead-sources/,
|
|
211
|
+
/\/phone-number/,
|
|
212
|
+
/\/tour\/availabilities/,
|
|
213
|
+
/\/cdn-version/,
|
|
214
|
+
/\/rentgrata/,
|
|
215
|
+
/\/organization\/test-org\/building\//,
|
|
216
|
+
];
|
|
217
|
+
|
|
218
|
+
it("loads org mode without calling building-only endpoints or crashing", async () => {
|
|
219
|
+
const requests = fakeBackend();
|
|
220
|
+
const errors: unknown[] = [];
|
|
221
|
+
const onRejection = (event: PromiseRejectionEvent) =>
|
|
222
|
+
errors.push(event.reason);
|
|
223
|
+
window.addEventListener("unhandledrejection", onRejection);
|
|
224
|
+
|
|
225
|
+
const meChat = await startWidget(orgMode);
|
|
226
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
227
|
+
window.removeEventListener("unhandledrejection", onRejection);
|
|
228
|
+
|
|
229
|
+
const urls = requests.map((r) => r.url);
|
|
230
|
+
for (const pattern of BUILDING_ONLY_ENDPOINTS) {
|
|
231
|
+
expect(
|
|
232
|
+
urls.filter((url) => pattern.test(url)),
|
|
233
|
+
String(pattern)
|
|
234
|
+
).to.deep.equal([]);
|
|
235
|
+
}
|
|
236
|
+
expect(errors).to.deep.equal([]);
|
|
237
|
+
// The session request says it's org-level; the backend decides the rest.
|
|
238
|
+
expect(initBodies(requests)).to.deep.equal([
|
|
239
|
+
{ building_slug: "", org_slug: "test-org", central_convo: true },
|
|
240
|
+
]);
|
|
241
|
+
expect(meChat.myPubnub?.centralConvo).to.equal(true);
|
|
242
|
+
expect(meChat.myPubnub?.declaredBuildingId).to.equal(null);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("draws the org-mode chat window instead of an empty one", async () => {
|
|
246
|
+
fakeBackend();
|
|
247
|
+
sessionStorage.setItem(REOPEN_KEY, "true");
|
|
248
|
+
const meChat = await startWidget(orgMode);
|
|
249
|
+
|
|
250
|
+
const chat = meChat.shadowRoot?.querySelector("pubnub-chat") as PubnubChat;
|
|
251
|
+
await chat.updateComplete;
|
|
252
|
+
expect(chat.shadowRoot?.querySelector("#header-text")?.textContent).to.equal(
|
|
253
|
+
"Test Org"
|
|
254
|
+
);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("sends the declared building's id in org mode when the snippet names a building", async () => {
|
|
258
|
+
const requests = fakeBackend();
|
|
259
|
+
const meChat = await startWidget({
|
|
260
|
+
...orgMode,
|
|
261
|
+
declaredbuildingslug: "test-building",
|
|
262
|
+
});
|
|
263
|
+
for (let i = 0; i < 50 && !meChat.myPubnub?.declaredBuildingId; i++) {
|
|
264
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
expect(meChat.myPubnub?.declaredBuildingId).to.equal(445038);
|
|
268
|
+
// Config and the conversation stay org-anchored.
|
|
269
|
+
expect(initBodies(requests)[0]).to.include({
|
|
270
|
+
building_slug: "",
|
|
271
|
+
central_convo: true,
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// --- Messages: page context out, navigation in ---
|
|
276
|
+
|
|
277
|
+
const myPubnubFor = async (
|
|
278
|
+
attributes: Record<string, string>,
|
|
279
|
+
requests = fakeBackend()
|
|
280
|
+
): Promise<MyPubnub> => {
|
|
281
|
+
const meChat = await startWidget(attributes);
|
|
282
|
+
if (!meChat.myPubnub) throw new Error("Expected a MyPubnub instance");
|
|
283
|
+
requests.length = 0;
|
|
284
|
+
return meChat.myPubnub;
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const publishedMessage = async (
|
|
288
|
+
myPubnub: MyPubnub
|
|
289
|
+
): Promise<Record<string, unknown>> => {
|
|
290
|
+
const publish = stub().resolves({});
|
|
291
|
+
myPubnub.pubnub = {
|
|
292
|
+
publish,
|
|
293
|
+
unsubscribe: stub(),
|
|
294
|
+
removeListener: stub(),
|
|
295
|
+
} as unknown as MyPubnub["pubnub"];
|
|
296
|
+
myPubnub.channel = "webchat_lead_test";
|
|
297
|
+
await myPubnub.sendMessage("Do you have 2 bedrooms?");
|
|
298
|
+
expect(publish.calledOnce).to.equal(true);
|
|
299
|
+
return publish.firstCall.args[0].message;
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
it("sends the visitor's page and the snippet's building id with each message", async () => {
|
|
303
|
+
document.title = "Chicago Apartments";
|
|
304
|
+
const myPubnub = await myPubnubFor(perBuilding);
|
|
305
|
+
|
|
306
|
+
const message = await publishedMessage(myPubnub);
|
|
307
|
+
|
|
308
|
+
expect(message).to.include({
|
|
309
|
+
text: "Do you have 2 bedrooms?",
|
|
310
|
+
customType: "lead_message",
|
|
311
|
+
buildingId: 445038,
|
|
312
|
+
buildingSlug: "test-building",
|
|
313
|
+
pageUrl: window.location.origin + window.location.pathname,
|
|
314
|
+
pageTitle: "Chicago Apartments",
|
|
315
|
+
declaredBuildingId: 445038,
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("omits declaredBuildingId in org mode with no building", async () => {
|
|
320
|
+
const myPubnub = await myPubnubFor(orgMode);
|
|
321
|
+
const message = await publishedMessage(myPubnub);
|
|
322
|
+
expect(message).to.have.property(
|
|
323
|
+
"pageUrl",
|
|
324
|
+
window.location.origin + window.location.pathname
|
|
325
|
+
);
|
|
326
|
+
expect(message).not.to.have.property("declaredBuildingId");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("never falls back to the v1 token endpoint in org mode", async () => {
|
|
330
|
+
const requests = fakeBackend();
|
|
331
|
+
const myPubnub = await myPubnubFor(orgMode, requests);
|
|
332
|
+
expect(await myPubnub.fetchToken("lead_test", "webchat_lead_test")).to.equal(
|
|
333
|
+
null
|
|
334
|
+
);
|
|
335
|
+
expect(requests).to.deep.equal([]);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const raw = (timetoken: number, message: Record<string, unknown>) => ({
|
|
339
|
+
channel: "webchat_lead_test",
|
|
340
|
+
message,
|
|
341
|
+
publisher: "eliseai",
|
|
342
|
+
subscription: "webchat_lead_test",
|
|
343
|
+
timetoken,
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
const translate = (
|
|
347
|
+
myPubnub: MyPubnub,
|
|
348
|
+
messages: unknown[]
|
|
349
|
+
): SimpleChatMessage[] =>
|
|
350
|
+
myPubnub.translatePubnubMessagesIntoSimpleChatMessages(
|
|
351
|
+
messages as Parameters<
|
|
352
|
+
MyPubnub["translatePubnubMessagesIntoSimpleChatMessages"]
|
|
353
|
+
>[0]
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
const replyWithNavigation = [
|
|
357
|
+
raw(1, {
|
|
358
|
+
text: "Here is Chicago.",
|
|
359
|
+
customType: "ai_message",
|
|
360
|
+
messageType: "text",
|
|
361
|
+
stream_id: "s1",
|
|
362
|
+
order: 0,
|
|
363
|
+
}),
|
|
364
|
+
raw(2, {
|
|
365
|
+
text: "",
|
|
366
|
+
customType: "ai_message",
|
|
367
|
+
messageType: "text",
|
|
368
|
+
stream_id: "s1",
|
|
369
|
+
order: 1,
|
|
370
|
+
is_done: true,
|
|
371
|
+
}),
|
|
372
|
+
raw(3, {
|
|
373
|
+
sender: "building_1",
|
|
374
|
+
customType: "ai_message",
|
|
375
|
+
messageType: "navigation",
|
|
376
|
+
navigation: { url: "https://example.com/chicago", label: "Chicago" },
|
|
377
|
+
}),
|
|
378
|
+
];
|
|
379
|
+
|
|
380
|
+
it("turns a navigation message into a button after the reply's text", async () => {
|
|
381
|
+
const myPubnub = await myPubnubFor(perBuilding);
|
|
382
|
+
const messages = translate(myPubnub, replyWithNavigation);
|
|
383
|
+
|
|
384
|
+
expect(messages.map((m) => m.type)).to.deep.equal(["text", "navigation"]);
|
|
385
|
+
expect(messages[1]).to.deep.include({
|
|
386
|
+
timestamp: 3,
|
|
387
|
+
isLeadMessage: false,
|
|
388
|
+
navigation: { url: "https://example.com/chicago", label: "Chicago" },
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it("drops unusable navigations and text-less unknown messages, and keeps unknown ones with text", async () => {
|
|
393
|
+
const myPubnub = await myPubnubFor(perBuilding);
|
|
394
|
+
const messages = translate(myPubnub, [
|
|
395
|
+
raw(1, {
|
|
396
|
+
customType: "ai_message",
|
|
397
|
+
messageType: "navigation",
|
|
398
|
+
navigation: { url: "javascript:alert(1)", label: "Chicago" },
|
|
399
|
+
}),
|
|
400
|
+
raw(2, { customType: "ai_message", messageType: "navigation" }),
|
|
401
|
+
raw(3, { customType: "ai_message", messageType: "carousel", cards: [] }),
|
|
402
|
+
raw(4, { text: "Hello", customType: "ai_message", messageType: "future" }),
|
|
403
|
+
]);
|
|
404
|
+
|
|
405
|
+
expect(messages).to.have.length(1);
|
|
406
|
+
expect(messages[0]).to.deep.include({ message: "Hello", type: "text" });
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// --- Rendering ---
|
|
410
|
+
|
|
411
|
+
const renderChat = async (
|
|
412
|
+
messages: SimpleChatMessage[]
|
|
413
|
+
): Promise<PubnubChat> => {
|
|
414
|
+
const chat = document.createElement("pubnub-chat") as PubnubChat;
|
|
415
|
+
chat.buildingSlug = "test-building";
|
|
416
|
+
chat.orgSlug = "test-org";
|
|
417
|
+
chat.building = view({}) as unknown as BuildingWebchatView;
|
|
418
|
+
document.body.appendChild(chat);
|
|
419
|
+
await chat.updateComplete;
|
|
420
|
+
chat.messages = messages;
|
|
421
|
+
await chat.updateComplete;
|
|
422
|
+
return chat;
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
it("renders Take me to {label}, which marks the chat to reopen and navigates the same tab", async () => {
|
|
426
|
+
const myPubnub = await myPubnubFor(perBuilding);
|
|
427
|
+
// As restored from history: the same translation as live messages.
|
|
428
|
+
const chat = await renderChat(translate(myPubnub, replyWithNavigation));
|
|
429
|
+
|
|
430
|
+
const navigation = chat.shadowRoot?.querySelector(
|
|
431
|
+
"pubnub-navigation"
|
|
432
|
+
) as PubnubNavigation;
|
|
433
|
+
expect(navigation).to.exist;
|
|
434
|
+
await navigation.updateComplete;
|
|
435
|
+
const navigate = stub();
|
|
436
|
+
navigation.navigate = navigate;
|
|
437
|
+
await navigation.updateComplete;
|
|
438
|
+
|
|
439
|
+
const button = navigation.shadowRoot?.querySelector("button");
|
|
440
|
+
expect(button?.textContent?.trim()).to.equal("Take me to Chicago");
|
|
441
|
+
expect(
|
|
442
|
+
navigation.closest("li")?.getAttribute("aria-label") ??
|
|
443
|
+
chat.shadowRoot
|
|
444
|
+
?.querySelector("li:has(pubnub-navigation)")
|
|
445
|
+
?.getAttribute("aria-label")
|
|
446
|
+
).to.equal("Elise: Take me to Chicago");
|
|
447
|
+
expect(chat.shadowRoot?.querySelector("pubnub-message")).to.exist;
|
|
448
|
+
|
|
449
|
+
button?.click();
|
|
450
|
+
expect(
|
|
451
|
+
navigate.calledOnceWithExactly("https://example.com/chicago")
|
|
452
|
+
).to.equal(true);
|
|
453
|
+
expect(sessionStorage.getItem(REOPEN_KEY)).to.equal("true");
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
// --- Org mode: running as the org's triage general building ---
|
|
457
|
+
|
|
458
|
+
const inRollout = { orgInit: { building_slug: GENERAL_BUILDING } };
|
|
459
|
+
|
|
460
|
+
it("adopts the building /webchat/init returns and runs as it, keeping the org-level conversation", async () => {
|
|
461
|
+
const requests = fakeBackend(inRollout);
|
|
462
|
+
const meChat = await startWidget(orgMode);
|
|
463
|
+
|
|
464
|
+
expect(meChat.buildingSlug).to.equal(GENERAL_BUILDING);
|
|
465
|
+
expect(meChat.buildingWebchatView?.id).to.equal(900);
|
|
466
|
+
// The session is asked for at the org, once.
|
|
467
|
+
expect(initBodies(requests)).to.deep.equal([
|
|
468
|
+
{ building_slug: "", org_slug: "test-org", central_convo: true },
|
|
469
|
+
]);
|
|
470
|
+
// Per-building endpoints now use the adopted building.
|
|
471
|
+
const urls = requests.map((r) => r.url);
|
|
472
|
+
expect(urls.some((url) => url.includes(`/${GENERAL_BUILDING}/lead-sources`)))
|
|
473
|
+
.to.be.true;
|
|
474
|
+
// The conversation is saved org-level, under the lead /webchat/init gave.
|
|
475
|
+
expect(
|
|
476
|
+
JSON.parse(localStorage.getItem("com.elise.webchat.slug=") ?? "{}")
|
|
477
|
+
).to.include({ leadId: "lead_test" });
|
|
478
|
+
expect(localStorage.getItem(`com.elise.webchat.slug=${GENERAL_BUILDING}`)).to
|
|
479
|
+
.be.null;
|
|
480
|
+
|
|
481
|
+
// Messages carry the adopted building, as a per-building widget's do.
|
|
482
|
+
const myPubnub = meChat.myPubnub as MyPubnub;
|
|
483
|
+
const message = await publishedMessage(myPubnub);
|
|
484
|
+
expect(message).to.include({
|
|
485
|
+
buildingId: 900,
|
|
486
|
+
buildingSlug: GENERAL_BUILDING,
|
|
487
|
+
userId: 9,
|
|
488
|
+
pageUrl: window.location.origin + window.location.pathname,
|
|
489
|
+
});
|
|
490
|
+
expect(myPubnub.centralConvo).to.equal(true);
|
|
491
|
+
// The first message saves the conversation org-level too.
|
|
492
|
+
expect(myPubnub.chatStorageSlug).to.equal("");
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it("stays in org mode when the adopted building's config can't be loaded", async () => {
|
|
496
|
+
fakeBackend({ ...inRollout, generalBuilding: 404 });
|
|
497
|
+
const meChat = await startWidget(orgMode);
|
|
498
|
+
|
|
499
|
+
expect(meChat.buildingSlug).to.equal("");
|
|
500
|
+
expect(meChat.buildingWebchatView?.name).to.equal("Test Org");
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it("stays in org mode when /webchat/init returns no building", async () => {
|
|
504
|
+
fakeBackend({ orgInit: {} });
|
|
505
|
+
const meChat = await startWidget(orgMode);
|
|
506
|
+
|
|
507
|
+
expect(meChat.buildingSlug).to.equal("");
|
|
508
|
+
expect(meChat.myPubnub?.centralConvo).to.equal(true);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
const contactOptions = (meChat: MeChatInternals) => {
|
|
512
|
+
const w = meChat.enabledChatWidgets;
|
|
513
|
+
return {
|
|
514
|
+
call: [w.callDesktop, w.callMobile],
|
|
515
|
+
email: [w.emailDesktop, w.emailMobile],
|
|
516
|
+
text: [w.textDesktop, w.textMobile],
|
|
517
|
+
};
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
it("hides Email and Text Us in org mode, and offers Call Us with the adopted building's number", async () => {
|
|
521
|
+
fakeBackend({
|
|
522
|
+
...inRollout,
|
|
523
|
+
generalBuilding: { ...allContactOptions, phoneNumber: "5555550123" },
|
|
524
|
+
});
|
|
525
|
+
const meChat = await startWidget(orgMode);
|
|
526
|
+
|
|
527
|
+
expect(contactOptions(meChat)).to.deep.equal({
|
|
528
|
+
call: [true, true],
|
|
529
|
+
email: [false, false],
|
|
530
|
+
text: [false, false],
|
|
531
|
+
});
|
|
532
|
+
expect(meChat.phoneNumberForSource?.number).to.equal("(555) 555-0123");
|
|
533
|
+
const launcher = meChat.shadowRoot?.querySelector("meetelise-launcher");
|
|
534
|
+
expect(launcher?.getAttribute("phoneNumber")).to.equal("(555) 555-0123");
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it("hides Call Us in org mode when the adopted building has no number", async () => {
|
|
538
|
+
fakeBackend({
|
|
539
|
+
...inRollout,
|
|
540
|
+
generalBuilding: { ...allContactOptions, phoneNumber: null },
|
|
541
|
+
});
|
|
542
|
+
const meChat = await startWidget(orgMode);
|
|
543
|
+
|
|
544
|
+
expect(contactOptions(meChat)).to.deep.equal({
|
|
545
|
+
call: [false, false],
|
|
546
|
+
email: [false, false],
|
|
547
|
+
text: [false, false],
|
|
548
|
+
});
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it("hides Email and Text Us in org mode before any building is adopted too", async () => {
|
|
552
|
+
fakeBackend();
|
|
553
|
+
const meChat = await startWidget(orgMode);
|
|
554
|
+
|
|
555
|
+
expect(contactOptions(meChat).email).to.deep.equal([false, false]);
|
|
556
|
+
expect(contactOptions(meChat).text).to.deep.equal([false, false]);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it("leaves a per-building widget's Call, Email and Text Us as its config sets them", async () => {
|
|
560
|
+
fakeBackend({
|
|
561
|
+
building: { ...allContactOptions, phoneNumber: "5555550199" },
|
|
562
|
+
});
|
|
563
|
+
const meChat = await startWidget(perBuilding);
|
|
564
|
+
|
|
565
|
+
expect(meChat.buildingSlug).to.equal("test-building");
|
|
566
|
+
expect(contactOptions(meChat)).to.deep.equal({
|
|
567
|
+
call: [true, true],
|
|
568
|
+
email: [true, true],
|
|
569
|
+
text: [true, true],
|
|
570
|
+
});
|
|
571
|
+
expect(meChat.phoneNumberForSource?.number).to.equal("(555) 555-0199");
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
/** Tour links on the host page before the widget starts, which is when it rewrites them. */
|
|
575
|
+
const addTourLinks = (buildingSlug: string): HTMLAnchorElement[] =>
|
|
576
|
+
[
|
|
577
|
+
`https://app.meetelise.com/test-org/${buildingSlug}/tour`,
|
|
578
|
+
"/apartments/chicago/schedule-a-tour",
|
|
579
|
+
].map((href) => {
|
|
580
|
+
const link = document.createElement("a");
|
|
581
|
+
link.setAttribute("href", href);
|
|
582
|
+
document.body.appendChild(link);
|
|
583
|
+
return link;
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
/** Whether the widget takes over a click on the link instead of letting it navigate. */
|
|
587
|
+
const widgetTakesOverClick = (link: HTMLAnchorElement): boolean => {
|
|
588
|
+
let seenByPage: boolean | null = null;
|
|
589
|
+
// Runs after the link's own listeners; then stops the test page navigating.
|
|
590
|
+
const record = (event: Event) => {
|
|
591
|
+
seenByPage = event.defaultPrevented;
|
|
592
|
+
event.preventDefault();
|
|
593
|
+
};
|
|
594
|
+
window.addEventListener("click", record);
|
|
595
|
+
const notCancelled = link.dispatchEvent(
|
|
596
|
+
new MouseEvent("click", { bubbles: true, cancelable: true })
|
|
597
|
+
);
|
|
598
|
+
window.removeEventListener("click", record);
|
|
599
|
+
// The widget's scheduler stops the click propagating, so the page never sees it.
|
|
600
|
+
return seenByPage ?? !notCancelled;
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
const tourScheduler = { shouldShowSstDesktop: true, shouldShowSstMobile: true };
|
|
604
|
+
|
|
605
|
+
it("leaves the org site's tour links alone in org mode", async () => {
|
|
606
|
+
fakeBackend({ ...inRollout, generalBuilding: tourScheduler });
|
|
607
|
+
const links = addTourLinks(GENERAL_BUILDING);
|
|
608
|
+
const meChat = await startWidget(orgMode);
|
|
609
|
+
|
|
610
|
+
expect(meChat.buildingSlug).to.equal(GENERAL_BUILDING);
|
|
611
|
+
expect(meChat.enabledChatWidgets.sstDesktop).to.equal(true);
|
|
612
|
+
links.forEach((link) => {
|
|
613
|
+
expect(widgetTakesOverClick(link), link.href).to.equal(false);
|
|
614
|
+
link.remove();
|
|
615
|
+
});
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
it("still opens a per-building widget's tour scheduler from its tour link", async () => {
|
|
619
|
+
fakeBackend({ building: tourScheduler });
|
|
620
|
+
const [buildingTourLink, ...others] = addTourLinks("test-building");
|
|
621
|
+
await startWidget(perBuilding);
|
|
622
|
+
|
|
623
|
+
expect(widgetTakesOverClick(buildingTourLink)).to.equal(true);
|
|
624
|
+
[buildingTourLink, ...others].forEach((link) => link.remove());
|
|
625
|
+
});
|