@askdialog/dialog-sdk 2.9.1 → 2.11.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 +44 -10
- package/dist/Dialog.d.ts +21 -6
- package/dist/Dialog.d.ts.map +1 -1
- package/dist/Dialog.js +72 -12
- package/dist/__tests__/dialogCurrentProduct.spec.d.ts +2 -0
- package/dist/__tests__/dialogCurrentProduct.spec.d.ts.map +1 -0
- package/dist/__tests__/dialogCurrentProduct.spec.js +134 -0
- package/dist/__tests__/dialogSearchAnalytics.spec.js +1 -0
- package/dist/__tests__/publicTypes.spec.js +2 -1
- package/dist/__tests__/searchController.spec.js +126 -35
- package/dist/__tests__/searchControllerAttribution.spec.js +30 -26
- package/dist/__tests__/searchImpressions.spec.js +3 -20
- package/dist/__tests__/searchService.spec.js +65 -60
- package/dist/config/config.development.js +1 -1
- package/dist/config/config.production.js +1 -1
- package/dist/config/index.js +1 -1
- package/dist/searchController.d.ts +1 -1
- package/dist/searchController.d.ts.map +1 -1
- package/dist/searchController.js +34 -24
- package/dist/services/search.d.ts +3 -2
- package/dist/services/search.d.ts.map +1 -1
- package/dist/services/search.js +4 -9
- package/dist/types/constructor.d.ts +15 -0
- package/dist/types/constructor.d.ts.map +1 -1
- package/dist/types/search.d.ts +24 -22
- package/dist/types/search.d.ts.map +1 -1
- package/dist/types/search.js +7 -4
- package/dist/types/searchAnalytics.d.ts +2 -0
- package/dist/types/searchAnalytics.d.ts.map +1 -1
- package/dist/types/searchController.d.ts +13 -5
- package/dist/types/searchController.d.ts.map +1 -1
- package/dist/utils/searchControllerAnalytics.d.ts +4 -4
- package/dist/utils/searchControllerAnalytics.d.ts.map +1 -1
- package/dist/utils/searchControllerAnalytics.js +14 -16
- package/dist/utils/searchImpressions.d.ts.map +1 -1
- package/dist/utils/searchImpressions.js +1 -6
- package/dist/utils/searchSections.d.ts +5 -0
- package/dist/utils/searchSections.d.ts.map +1 -0
- package/dist/utils/searchSections.js +21 -0
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* eslint max-lines: ["error",
|
|
1
|
+
/* eslint max-lines: ["error", 460] */
|
|
2
2
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
3
|
import { createSearchController } from "../searchController";
|
|
4
4
|
import { SearchStatus } from "../types/searchController";
|
|
@@ -13,18 +13,20 @@ vi.mock("../utils/searchImpressions", () => ({
|
|
|
13
13
|
}));
|
|
14
14
|
const DEBOUNCE_MS = 250;
|
|
15
15
|
const response = (overrides = {}) => ({
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
results: [
|
|
17
|
+
{
|
|
18
|
+
index: "products_fr",
|
|
19
|
+
hits: [{ objectID: "p1" }, { objectID: "p2" }],
|
|
20
|
+
nbHits: 2,
|
|
21
|
+
page: 0,
|
|
22
|
+
nbPages: 3,
|
|
23
|
+
hitsPerPage: 12,
|
|
24
|
+
processingTimeMS: 5,
|
|
25
|
+
query: "shoes",
|
|
26
|
+
queryID: "qid-1",
|
|
27
|
+
...overrides,
|
|
28
|
+
},
|
|
20
29
|
],
|
|
21
|
-
nbHits: 2,
|
|
22
|
-
page: 0,
|
|
23
|
-
nbPages: 3,
|
|
24
|
-
hitsPerPage: 12,
|
|
25
|
-
processingTimeMs: 5,
|
|
26
|
-
query: "shoes",
|
|
27
|
-
...overrides,
|
|
28
30
|
});
|
|
29
31
|
const deferred = () => {
|
|
30
32
|
let resolve;
|
|
@@ -52,6 +54,7 @@ const createController = () => {
|
|
|
52
54
|
trackViewSearchResults,
|
|
53
55
|
trackSelectSearchResult,
|
|
54
56
|
},
|
|
57
|
+
locale: "fr",
|
|
55
58
|
});
|
|
56
59
|
controller.subscribe((state) => states.push(state));
|
|
57
60
|
return controller;
|
|
@@ -84,33 +87,31 @@ describe("createSearchController", () => {
|
|
|
84
87
|
vi.advanceTimersByTime(DEBOUNCE_MS);
|
|
85
88
|
await settle();
|
|
86
89
|
expect(search).toHaveBeenCalledTimes(1);
|
|
87
|
-
expect(search.mock.calls[0][0]).toMatchObject({
|
|
90
|
+
expect(search.mock.calls[0][0].requests[0]).toMatchObject({
|
|
91
|
+
query: "shoes",
|
|
92
|
+
page: 0,
|
|
93
|
+
});
|
|
88
94
|
expect(controller.getState().status).toBe(SearchStatus.SUCCESS);
|
|
89
95
|
});
|
|
90
|
-
it("
|
|
91
|
-
const
|
|
96
|
+
it("sends one products entry named after the bare-language locale", async () => {
|
|
97
|
+
const controller = createSearchController({
|
|
92
98
|
search,
|
|
93
99
|
analytics: {
|
|
94
100
|
surface: "search_page",
|
|
95
101
|
trackViewSearchResults,
|
|
96
102
|
trackSelectSearchResult,
|
|
97
103
|
},
|
|
98
|
-
locale: "fr",
|
|
99
|
-
countryCode: "CA",
|
|
104
|
+
locale: "fr-FR",
|
|
100
105
|
});
|
|
101
106
|
search.mockResolvedValue(response({ query: "shoes" }));
|
|
102
|
-
|
|
107
|
+
controller.submit("shoes");
|
|
103
108
|
await settle();
|
|
104
|
-
expect(search.mock.calls[0][0]).
|
|
105
|
-
|
|
106
|
-
|
|
109
|
+
expect(search.mock.calls[0][0]).toEqual({
|
|
110
|
+
requests: [
|
|
111
|
+
{ indexName: "products_fr", query: "shoes", page: 0, hitsPerPage: 12 },
|
|
112
|
+
],
|
|
107
113
|
});
|
|
108
|
-
|
|
109
|
-
const bareController = createController();
|
|
110
|
-
bareController.submit("shoes");
|
|
111
|
-
await settle();
|
|
112
|
-
expect(search.mock.calls[1][0]).not.toHaveProperty("locale");
|
|
113
|
-
expect(search.mock.calls[1][0]).not.toHaveProperty("countryCode");
|
|
114
|
+
controller.dispose();
|
|
114
115
|
});
|
|
115
116
|
it("submits immediately without waiting for the debounce", async () => {
|
|
116
117
|
const controller = createController();
|
|
@@ -119,7 +120,9 @@ describe("createSearchController", () => {
|
|
|
119
120
|
controller.submit("shoes");
|
|
120
121
|
await settle();
|
|
121
122
|
expect(search).toHaveBeenCalledTimes(1);
|
|
122
|
-
expect(search.mock.calls[0][0]).toMatchObject({
|
|
123
|
+
expect(search.mock.calls[0][0].requests[0]).toMatchObject({
|
|
124
|
+
query: "shoes",
|
|
125
|
+
});
|
|
123
126
|
expect(controller.getState().status).toBe(SearchStatus.SUCCESS);
|
|
124
127
|
});
|
|
125
128
|
it("aborts the previous in-flight request when a new one starts", async () => {
|
|
@@ -133,7 +136,7 @@ describe("createSearchController", () => {
|
|
|
133
136
|
const firstSignal = search.mock.calls[0][1]?.signal;
|
|
134
137
|
controller.submit("boots");
|
|
135
138
|
expect(firstSignal?.aborted).toBe(true);
|
|
136
|
-
second.resolve(response({ query: "boots",
|
|
139
|
+
second.resolve(response({ query: "boots", queryID: "qid-2" }));
|
|
137
140
|
await settle();
|
|
138
141
|
expect(controller.getState().response?.query).toBe("boots");
|
|
139
142
|
});
|
|
@@ -146,10 +149,10 @@ describe("createSearchController", () => {
|
|
|
146
149
|
.mockReturnValueOnce(fresh.promise);
|
|
147
150
|
controller.submit("shoes");
|
|
148
151
|
controller.submit("boots");
|
|
149
|
-
fresh.resolve(response({ query: "boots",
|
|
152
|
+
fresh.resolve(response({ query: "boots", queryID: "qid-2" }));
|
|
150
153
|
await settle();
|
|
151
154
|
// The stale transport ignored the abort and answers after the fresh one.
|
|
152
|
-
stale.resolve(response({ query: "shoes",
|
|
155
|
+
stale.resolve(response({ query: "shoes", queryID: "qid-1" }));
|
|
153
156
|
await settle();
|
|
154
157
|
expect(controller.getState().status).toBe(SearchStatus.SUCCESS);
|
|
155
158
|
expect(controller.getState().response?.query).toBe("boots");
|
|
@@ -159,7 +162,7 @@ describe("createSearchController", () => {
|
|
|
159
162
|
const first = deferred();
|
|
160
163
|
search
|
|
161
164
|
.mockReturnValueOnce(first.promise)
|
|
162
|
-
.mockResolvedValueOnce(response({ query: "boots",
|
|
165
|
+
.mockResolvedValueOnce(response({ query: "boots", queryID: "qid-2" }));
|
|
163
166
|
controller.submit("shoes");
|
|
164
167
|
controller.submit("boots");
|
|
165
168
|
first.reject(new DOMException("The operation was aborted.", "AbortError"));
|
|
@@ -186,7 +189,10 @@ describe("createSearchController", () => {
|
|
|
186
189
|
controller.retry();
|
|
187
190
|
await settle();
|
|
188
191
|
expect(search).toHaveBeenCalledTimes(3);
|
|
189
|
-
expect(search.mock.calls[2][0]).toMatchObject({
|
|
192
|
+
expect(search.mock.calls[2][0].requests[0]).toMatchObject({
|
|
193
|
+
query: "shoes",
|
|
194
|
+
page: 2,
|
|
195
|
+
});
|
|
190
196
|
expect(controller.getState().status).toBe(SearchStatus.SUCCESS);
|
|
191
197
|
});
|
|
192
198
|
it("ignores retry outside the error state", async () => {
|
|
@@ -225,7 +231,10 @@ describe("createSearchController", () => {
|
|
|
225
231
|
await settle();
|
|
226
232
|
controller.submit("boots");
|
|
227
233
|
await settle();
|
|
228
|
-
expect(search.mock.calls[2][0]).toMatchObject({
|
|
234
|
+
expect(search.mock.calls[2][0].requests[0]).toMatchObject({
|
|
235
|
+
query: "boots",
|
|
236
|
+
page: 0,
|
|
237
|
+
});
|
|
229
238
|
});
|
|
230
239
|
it("flushes a pending debounced query instead of paginating stale results", async () => {
|
|
231
240
|
const controller = createController();
|
|
@@ -236,7 +245,10 @@ describe("createSearchController", () => {
|
|
|
236
245
|
controller.setPage(1);
|
|
237
246
|
await settle();
|
|
238
247
|
expect(search).toHaveBeenCalledTimes(2);
|
|
239
|
-
expect(search.mock.calls[1][0]).toMatchObject({
|
|
248
|
+
expect(search.mock.calls[1][0].requests[0]).toMatchObject({
|
|
249
|
+
query: "boots",
|
|
250
|
+
page: 0,
|
|
251
|
+
});
|
|
240
252
|
});
|
|
241
253
|
it("ignores pagination without a committed query", () => {
|
|
242
254
|
const controller = createController();
|
|
@@ -276,4 +288,83 @@ describe("createSearchController", () => {
|
|
|
276
288
|
expect(search).toHaveBeenCalledTimes(1);
|
|
277
289
|
expect(controller.getState().status).toBe(SearchStatus.LOADING);
|
|
278
290
|
});
|
|
291
|
+
it("requests the sections on their first page with the products and exposes their entries", async () => {
|
|
292
|
+
const controller = createSearchController({
|
|
293
|
+
search,
|
|
294
|
+
analytics: {
|
|
295
|
+
surface: "search_page",
|
|
296
|
+
trackViewSearchResults,
|
|
297
|
+
trackSelectSearchResult,
|
|
298
|
+
},
|
|
299
|
+
locale: "fr",
|
|
300
|
+
sections: [{ index: "collections", hitsPerPage: 5 }],
|
|
301
|
+
});
|
|
302
|
+
const collections = {
|
|
303
|
+
...response().results[0],
|
|
304
|
+
index: "collections_fr",
|
|
305
|
+
hits: [{ objectID: "c1" }],
|
|
306
|
+
nbHits: 1,
|
|
307
|
+
};
|
|
308
|
+
search.mockResolvedValueOnce({
|
|
309
|
+
results: [...response().results, collections],
|
|
310
|
+
});
|
|
311
|
+
controller.submit("shoes");
|
|
312
|
+
await settle();
|
|
313
|
+
expect(search.mock.calls[0][0].requests).toEqual([
|
|
314
|
+
{ indexName: "products_fr", query: "shoes", page: 0, hitsPerPage: 12 },
|
|
315
|
+
{ indexName: "collections_fr", query: "shoes", page: 0, hitsPerPage: 5 },
|
|
316
|
+
]);
|
|
317
|
+
expect(controller.getState().response?.index).toBe("products_fr");
|
|
318
|
+
expect(controller.getState().sections).toEqual({ collections });
|
|
319
|
+
});
|
|
320
|
+
it("resolves a sections function per request and leaves a missing entry out", async () => {
|
|
321
|
+
let enabled = false;
|
|
322
|
+
const controller = createSearchController({
|
|
323
|
+
search,
|
|
324
|
+
analytics: {
|
|
325
|
+
surface: "search_page",
|
|
326
|
+
trackViewSearchResults,
|
|
327
|
+
trackSelectSearchResult,
|
|
328
|
+
},
|
|
329
|
+
locale: "fr",
|
|
330
|
+
sections: () => (enabled ? [{ index: "collections" }] : []),
|
|
331
|
+
});
|
|
332
|
+
search.mockResolvedValue(response());
|
|
333
|
+
controller.submit("shoes");
|
|
334
|
+
await settle();
|
|
335
|
+
enabled = true;
|
|
336
|
+
controller.submit("boots");
|
|
337
|
+
await settle();
|
|
338
|
+
expect(search.mock.calls[0][0].requests).toHaveLength(1);
|
|
339
|
+
expect(search.mock.calls[1][0].requests).toHaveLength(2);
|
|
340
|
+
expect(search.mock.calls[1][0].requests[1]).toMatchObject({
|
|
341
|
+
indexName: "collections_fr",
|
|
342
|
+
hitsPerPage: 12,
|
|
343
|
+
});
|
|
344
|
+
expect(controller.getState().sections).toEqual({});
|
|
345
|
+
});
|
|
346
|
+
it("enters the error state when the sections resolver throws", async () => {
|
|
347
|
+
const failure = new Error("entitlement lookup failed");
|
|
348
|
+
const controller = createSearchController({
|
|
349
|
+
search,
|
|
350
|
+
analytics: {
|
|
351
|
+
surface: "search_page",
|
|
352
|
+
trackViewSearchResults,
|
|
353
|
+
trackSelectSearchResult,
|
|
354
|
+
},
|
|
355
|
+
locale: "fr",
|
|
356
|
+
sections: () => {
|
|
357
|
+
throw failure;
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
controller.submit("shoes");
|
|
361
|
+
await settle();
|
|
362
|
+
expect(search).not.toHaveBeenCalled();
|
|
363
|
+
expect(controller.getState()).toMatchObject({
|
|
364
|
+
status: SearchStatus.ERROR,
|
|
365
|
+
error: failure,
|
|
366
|
+
response: undefined,
|
|
367
|
+
sections: undefined,
|
|
368
|
+
});
|
|
369
|
+
});
|
|
279
370
|
});
|
|
@@ -12,22 +12,23 @@ vi.mock("../utils/searchImpressions", () => ({
|
|
|
12
12
|
createSearchImpressionTracker: () => tracker,
|
|
13
13
|
}));
|
|
14
14
|
const response = (overrides = {}) => ({
|
|
15
|
-
|
|
16
|
-
hits: [
|
|
15
|
+
results: [
|
|
17
16
|
{
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
index: "products_fr",
|
|
18
|
+
hits: [
|
|
19
|
+
{ objectID: "p1", url: "https://shop.example/p1" },
|
|
20
|
+
{ objectID: "p2" },
|
|
21
|
+
],
|
|
22
|
+
nbHits: 30,
|
|
23
|
+
page: 0,
|
|
24
|
+
nbPages: 3,
|
|
25
|
+
hitsPerPage: 12,
|
|
26
|
+
processingTimeMS: 5,
|
|
27
|
+
query: "shoes",
|
|
28
|
+
queryID: "qid-1",
|
|
29
|
+
...overrides,
|
|
21
30
|
},
|
|
22
|
-
{ id: "h2", score: 0.9, product: { id: "p2" } },
|
|
23
31
|
],
|
|
24
|
-
nbHits: 30,
|
|
25
|
-
page: 0,
|
|
26
|
-
nbPages: 3,
|
|
27
|
-
hitsPerPage: 12,
|
|
28
|
-
processingTimeMs: 5,
|
|
29
|
-
query: "shoes",
|
|
30
|
-
...overrides,
|
|
31
32
|
});
|
|
32
33
|
const search = vi.fn();
|
|
33
34
|
const trackViewSearchResults = vi.fn();
|
|
@@ -41,6 +42,7 @@ const createController = () => createSearchController({
|
|
|
41
42
|
trackSelectSearchResult,
|
|
42
43
|
},
|
|
43
44
|
navigate,
|
|
45
|
+
locale: "fr",
|
|
44
46
|
});
|
|
45
47
|
beforeEach(() => {
|
|
46
48
|
vi.useFakeTimers();
|
|
@@ -54,27 +56,26 @@ const settle = async () => {
|
|
|
54
56
|
await Promise.resolve();
|
|
55
57
|
};
|
|
56
58
|
describe("search controller attribution", () => {
|
|
57
|
-
it("
|
|
59
|
+
it("never resends a query id: each request is its own query", async () => {
|
|
58
60
|
const controller = createController();
|
|
59
61
|
search.mockResolvedValue(response());
|
|
60
62
|
controller.submit("shoes");
|
|
61
63
|
await settle();
|
|
62
64
|
controller.setPage(1);
|
|
63
65
|
await settle();
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
queryId: "qid-1",
|
|
69
|
-
});
|
|
66
|
+
for (const [request] of search.mock.calls) {
|
|
67
|
+
expect(request.requests[0]).not.toHaveProperty("queryId");
|
|
68
|
+
expect(request.requests[0]).not.toHaveProperty("queryID");
|
|
69
|
+
}
|
|
70
70
|
});
|
|
71
|
-
it("declares the rendered
|
|
71
|
+
it("declares the rendered result's envelope to the impression tracker", async () => {
|
|
72
72
|
const controller = createController();
|
|
73
73
|
search.mockResolvedValue(response({ page: 1, query: "shoes" }));
|
|
74
74
|
controller.submit("shoes");
|
|
75
75
|
await settle();
|
|
76
76
|
expect(tracker.setContext).toHaveBeenCalledWith({
|
|
77
77
|
query_id: "qid-1",
|
|
78
|
+
index: "products_fr",
|
|
78
79
|
surface: "search_page",
|
|
79
80
|
search_type: "lexical",
|
|
80
81
|
page: 2,
|
|
@@ -82,12 +83,12 @@ describe("search controller attribution", () => {
|
|
|
82
83
|
query_length: 5,
|
|
83
84
|
});
|
|
84
85
|
});
|
|
85
|
-
it("
|
|
86
|
+
it("follows the response's queryID: every page is its own query", async () => {
|
|
86
87
|
const controller = createController();
|
|
87
88
|
search
|
|
88
89
|
.mockResolvedValueOnce(response())
|
|
89
|
-
.mockResolvedValueOnce(response({ page: 1,
|
|
90
|
-
.mockResolvedValueOnce(response({ query: "boots",
|
|
90
|
+
.mockResolvedValueOnce(response({ page: 1, queryID: "qid-2" }))
|
|
91
|
+
.mockResolvedValueOnce(response({ query: "boots", queryID: "qid-3" }));
|
|
91
92
|
controller.submit("shoes");
|
|
92
93
|
await settle();
|
|
93
94
|
controller.setPage(1);
|
|
@@ -95,7 +96,7 @@ describe("search controller attribution", () => {
|
|
|
95
96
|
controller.submit("boots");
|
|
96
97
|
await settle();
|
|
97
98
|
const queryIds = tracker.setContext.mock.calls.map(([envelope]) => envelope.query_id);
|
|
98
|
-
expect(queryIds).toEqual(["qid-1", "qid-
|
|
99
|
+
expect(queryIds).toEqual(["qid-1", "qid-2", "qid-3"]);
|
|
99
100
|
});
|
|
100
101
|
it("emits the zero-items view event for a rendered no-results state", async () => {
|
|
101
102
|
const controller = createController();
|
|
@@ -104,6 +105,7 @@ describe("search controller attribution", () => {
|
|
|
104
105
|
await settle();
|
|
105
106
|
expect(trackViewSearchResults).toHaveBeenCalledWith({
|
|
106
107
|
query_id: "qid-1",
|
|
108
|
+
index: "products_fr",
|
|
107
109
|
surface: "search_page",
|
|
108
110
|
search_type: "lexical",
|
|
109
111
|
page: 1,
|
|
@@ -149,6 +151,7 @@ describe("search controller attribution", () => {
|
|
|
149
151
|
});
|
|
150
152
|
expect(trackSelectSearchResult).toHaveBeenCalledWith({
|
|
151
153
|
query_id: "qid-1",
|
|
154
|
+
index: "products_fr",
|
|
152
155
|
surface: "search_page",
|
|
153
156
|
search_type: "lexical",
|
|
154
157
|
page: 1,
|
|
@@ -156,7 +159,7 @@ describe("search controller attribution", () => {
|
|
|
156
159
|
query_length: 5,
|
|
157
160
|
items: [{ product_id: "p1", position: 1 }],
|
|
158
161
|
});
|
|
159
|
-
expect(navigate).toHaveBeenCalledWith("https://shop.example/p1", landed.hits[0]);
|
|
162
|
+
expect(navigate).toHaveBeenCalledWith("https://shop.example/p1", landed.results[0].hits[0]);
|
|
160
163
|
expect(order).toEqual(["impression", "select", "navigate"]);
|
|
161
164
|
});
|
|
162
165
|
it("still records attribution when the hit has no URL to navigate to", async () => {
|
|
@@ -203,6 +206,7 @@ describe("search controller attribution", () => {
|
|
|
203
206
|
trackViewSearchResults,
|
|
204
207
|
trackSelectSearchResult,
|
|
205
208
|
},
|
|
209
|
+
locale: "fr",
|
|
206
210
|
});
|
|
207
211
|
search.mockResolvedValue(response());
|
|
208
212
|
controller.submit("shoes");
|
|
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
3
3
|
import { createSearchImpressionTracker } from "../utils/searchImpressions";
|
|
4
4
|
const envelope = {
|
|
5
5
|
query_id: "query-1",
|
|
6
|
+
index: "products_fr",
|
|
6
7
|
surface: "search_page",
|
|
7
8
|
search_type: "lexical",
|
|
8
9
|
page: 1,
|
|
@@ -133,7 +134,7 @@ describe("createSearchImpressionTracker", () => {
|
|
|
133
134
|
tracker.forceImpression({ product_id: "product-1", position: 1 });
|
|
134
135
|
expect(emit).toHaveBeenCalledTimes(1);
|
|
135
136
|
});
|
|
136
|
-
it("
|
|
137
|
+
it("restarts deduplication on every context change: each response mints a fresh query_id", () => {
|
|
137
138
|
const emit = vi.fn();
|
|
138
139
|
const tracker = createSearchImpressionTracker({ emit });
|
|
139
140
|
tracker.setContext(envelope);
|
|
@@ -141,29 +142,11 @@ describe("createSearchImpressionTracker", () => {
|
|
|
141
142
|
tracker.observe(card, { product_id: "product-1", position: 1 });
|
|
142
143
|
intersect(card, 0.6);
|
|
143
144
|
vi.advanceTimersByTime(2000);
|
|
144
|
-
tracker.setContext({ ...envelope, page: 2 });
|
|
145
|
+
tracker.setContext({ ...envelope, query_id: "query-2", page: 2 });
|
|
145
146
|
const cardOnPage2 = element();
|
|
146
147
|
tracker.observe(cardOnPage2, { product_id: "product-1", position: 1 });
|
|
147
148
|
intersect(cardOnPage2, 0.6);
|
|
148
149
|
vi.advanceTimersByTime(2000);
|
|
149
|
-
expect(emit).toHaveBeenCalledTimes(1);
|
|
150
|
-
});
|
|
151
|
-
it("drops a previous query's dedup keys when the query changes", () => {
|
|
152
|
-
const emit = vi.fn();
|
|
153
|
-
const tracker = createSearchImpressionTracker({ emit });
|
|
154
|
-
tracker.setContext(envelope);
|
|
155
|
-
const card = element();
|
|
156
|
-
tracker.observe(card, { product_id: "product-1", position: 1 });
|
|
157
|
-
intersect(card, 0.6);
|
|
158
|
-
vi.advanceTimersByTime(2000);
|
|
159
|
-
tracker.setContext({ ...envelope, query_id: "query-2" });
|
|
160
|
-
// Same query_id served again (e.g. a cached response): a fresh render is
|
|
161
|
-
// a new exposure, like the bfcache restore.
|
|
162
|
-
tracker.setContext(envelope);
|
|
163
|
-
const cardAgain = element();
|
|
164
|
-
tracker.observe(cardAgain, { product_id: "product-1", position: 1 });
|
|
165
|
-
intersect(cardAgain, 0.6);
|
|
166
|
-
vi.advanceTimersByTime(2000);
|
|
167
150
|
expect(emit).toHaveBeenCalledTimes(2);
|
|
168
151
|
});
|
|
169
152
|
it("flushes qualified impressions on pagehide", () => {
|
|
@@ -1,16 +1,31 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { DialogSearchError } from "../DialogSearchError";
|
|
3
|
-
import {
|
|
3
|
+
import { searchIndexName, searchLexical } from "../services/search";
|
|
4
4
|
const API_KEY = "pk_test_abcdef";
|
|
5
|
+
const request = {
|
|
6
|
+
requests: [
|
|
7
|
+
{
|
|
8
|
+
indexName: "products_fr",
|
|
9
|
+
query: "running shoes",
|
|
10
|
+
page: 2,
|
|
11
|
+
hitsPerPage: 50,
|
|
12
|
+
},
|
|
13
|
+
],
|
|
14
|
+
};
|
|
5
15
|
const emptyResponse = {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
16
|
+
results: [
|
|
17
|
+
{
|
|
18
|
+
index: "products_fr",
|
|
19
|
+
hits: [],
|
|
20
|
+
nbHits: 0,
|
|
21
|
+
page: 0,
|
|
22
|
+
nbPages: 0,
|
|
23
|
+
hitsPerPage: 20,
|
|
24
|
+
processingTimeMS: 3,
|
|
25
|
+
query: "running shoes",
|
|
26
|
+
queryID: "0198c3f2-0000-7000-8000-000000000000",
|
|
27
|
+
},
|
|
28
|
+
],
|
|
14
29
|
};
|
|
15
30
|
const jsonResponse = (body, status = 200) => new Response(JSON.stringify(body), {
|
|
16
31
|
status,
|
|
@@ -24,86 +39,76 @@ afterEach(() => {
|
|
|
24
39
|
vi.unstubAllGlobals();
|
|
25
40
|
vi.clearAllMocks();
|
|
26
41
|
});
|
|
27
|
-
describe("
|
|
28
|
-
it(
|
|
42
|
+
describe("searchIndexName", () => {
|
|
43
|
+
it.each([
|
|
44
|
+
["fr-FR", "products_fr"],
|
|
45
|
+
["pt-BR", "products_pt"],
|
|
46
|
+
["fr", "products_fr"],
|
|
47
|
+
["not a locale", "products_not a locale"],
|
|
48
|
+
])("reduces the locale %s to a bare-language index name", (locale, name) => {
|
|
49
|
+
expect(searchIndexName("products", locale)).toBe(name);
|
|
50
|
+
});
|
|
51
|
+
it("names every logical index", () => {
|
|
52
|
+
expect(searchIndexName("collections", "en-US")).toBe("collections_en");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
describe("searchLexical", () => {
|
|
56
|
+
it("POSTs the exact request to /public/search/lexical with only the api key header", async () => {
|
|
29
57
|
fetchMock.mockResolvedValue(jsonResponse(emptyResponse));
|
|
30
|
-
await
|
|
31
|
-
query: "running shoes",
|
|
32
|
-
page: 2,
|
|
33
|
-
hitsPerPage: 50,
|
|
34
|
-
queryId: "0198c3f2-0000-7000-8000-000000000000",
|
|
35
|
-
});
|
|
58
|
+
await searchLexical(API_KEY, request);
|
|
36
59
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
37
60
|
const [url, init] = fetchMock.mock.calls[0];
|
|
38
|
-
expect(url).toMatch(/\/public\/search$/);
|
|
61
|
+
expect(url).toMatch(/\/public\/search\/lexical$/);
|
|
39
62
|
expect(init.method).toBe("POST");
|
|
40
63
|
expect(init.headers).toEqual({
|
|
41
64
|
"Content-Type": "application/json",
|
|
42
65
|
"x-dialog-api-key": API_KEY,
|
|
43
66
|
});
|
|
44
|
-
expect(JSON.parse(init.body)).toEqual(
|
|
45
|
-
query: "running shoes",
|
|
46
|
-
page: 2,
|
|
47
|
-
hitsPerPage: 50,
|
|
48
|
-
queryId: "0198c3f2-0000-7000-8000-000000000000",
|
|
49
|
-
});
|
|
67
|
+
expect(JSON.parse(init.body)).toEqual(request);
|
|
50
68
|
});
|
|
51
|
-
it
|
|
52
|
-
["fr-FR", "fr"],
|
|
53
|
-
["pt-BR", "pt"],
|
|
54
|
-
["fr", "fr"],
|
|
55
|
-
["not a locale", "not a locale"],
|
|
56
|
-
])("sends the locale %s as its bare ISO 639-1 language %s", async (locale, expected) => {
|
|
69
|
+
it("keeps optional per-entry fields the caller did not provide off the wire", async () => {
|
|
57
70
|
fetchMock.mockResolvedValue(jsonResponse(emptyResponse));
|
|
58
|
-
await
|
|
59
|
-
|
|
60
|
-
expect(JSON.parse(init.body)).toEqual({
|
|
61
|
-
query: "running shoes",
|
|
62
|
-
locale: expected,
|
|
71
|
+
await searchLexical(API_KEY, {
|
|
72
|
+
requests: [{ indexName: "products_fr", query: "running shoes" }],
|
|
63
73
|
});
|
|
64
|
-
});
|
|
65
|
-
it("omits pagination fields and queryId the caller did not provide", async () => {
|
|
66
|
-
fetchMock.mockResolvedValue(jsonResponse(emptyResponse));
|
|
67
|
-
await searchProducts(API_KEY, { query: "running shoes" });
|
|
68
74
|
const [, init] = fetchMock.mock.calls[0];
|
|
69
75
|
expect(JSON.parse(init.body)).toEqual({
|
|
70
|
-
query: "running shoes",
|
|
76
|
+
requests: [{ indexName: "products_fr", query: "running shoes" }],
|
|
71
77
|
});
|
|
72
78
|
});
|
|
73
79
|
it("resolves the typed response, including empty results", async () => {
|
|
74
80
|
fetchMock.mockResolvedValue(jsonResponse(emptyResponse));
|
|
75
|
-
await expect(
|
|
81
|
+
await expect(searchLexical(API_KEY, request)).resolves.toEqual(emptyResponse);
|
|
76
82
|
});
|
|
77
83
|
it.each([
|
|
78
|
-
[
|
|
79
|
-
[
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
],
|
|
84
|
-
[429, "THROTTLED", "Too many requests"],
|
|
85
|
-
[503, "STOREFRONT_SEARCH_UNAVAILABLE", "Storefront search is unavailable"],
|
|
86
|
-
])("rejects a %i answer with a DialogSearchError carrying status, code and message", async (status, code, message) => {
|
|
87
|
-
fetchMock.mockResolvedValue(jsonResponse({ statusCode: status, error: code, message }, status));
|
|
88
|
-
const error = await searchProducts(API_KEY, {
|
|
89
|
-
query: "running shoes",
|
|
90
|
-
}).catch((caught) => caught);
|
|
84
|
+
[404, "Index products_xx does not exist"],
|
|
85
|
+
[400, "Unknown parameter: foo"],
|
|
86
|
+
])("rejects the Algolia-shaped %i error body with a DialogSearchError", async (status, message) => {
|
|
87
|
+
fetchMock.mockResolvedValue(jsonResponse({ message, status }, status));
|
|
88
|
+
const error = await searchLexical(API_KEY, request).catch((caught) => caught);
|
|
91
89
|
expect(error).toBeInstanceOf(DialogSearchError);
|
|
92
90
|
expect(error).toMatchObject({
|
|
93
91
|
name: "DialogSearchError",
|
|
94
92
|
status,
|
|
95
|
-
code,
|
|
93
|
+
code: undefined,
|
|
96
94
|
message,
|
|
97
95
|
});
|
|
98
96
|
});
|
|
97
|
+
it.each([
|
|
98
|
+
[401, "INVALID_WIDGET_API_KEY", "Invalid widget API key"],
|
|
99
|
+
[429, "THROTTLED", "Too many requests"],
|
|
100
|
+
])("still reads the code of a Nest-shaped %i guard error", async (status, code, message) => {
|
|
101
|
+
fetchMock.mockResolvedValue(jsonResponse({ statusCode: status, error: code, message }, status));
|
|
102
|
+
const error = await searchLexical(API_KEY, request).catch((caught) => caught);
|
|
103
|
+
expect(error).toBeInstanceOf(DialogSearchError);
|
|
104
|
+
expect(error).toMatchObject({ status, code, message });
|
|
105
|
+
});
|
|
99
106
|
it("falls back to statusText when the error body is not JSON", async () => {
|
|
100
107
|
fetchMock.mockResolvedValue(new Response("<html>Bad Gateway</html>", {
|
|
101
108
|
status: 502,
|
|
102
109
|
statusText: "Bad Gateway",
|
|
103
110
|
}));
|
|
104
|
-
const error = await
|
|
105
|
-
query: "running shoes",
|
|
106
|
-
}).catch((caught) => caught);
|
|
111
|
+
const error = await searchLexical(API_KEY, request).catch((caught) => caught);
|
|
107
112
|
expect(error).toBeInstanceOf(DialogSearchError);
|
|
108
113
|
expect(error).toMatchObject({
|
|
109
114
|
status: 502,
|
|
@@ -114,13 +119,13 @@ describe("searchProducts", () => {
|
|
|
114
119
|
it("propagates network failures untouched", async () => {
|
|
115
120
|
const networkError = new TypeError("Failed to fetch");
|
|
116
121
|
fetchMock.mockRejectedValue(networkError);
|
|
117
|
-
await expect(
|
|
122
|
+
await expect(searchLexical(API_KEY, request)).rejects.toBe(networkError);
|
|
118
123
|
});
|
|
119
124
|
it("forwards the AbortSignal and preserves the native AbortError", async () => {
|
|
120
125
|
const abortError = new DOMException("The operation was aborted.", "AbortError");
|
|
121
126
|
fetchMock.mockRejectedValue(abortError);
|
|
122
127
|
const controller = new AbortController();
|
|
123
|
-
await expect(
|
|
128
|
+
await expect(searchLexical(API_KEY, request, { signal: controller.signal })).rejects.toBe(abortError);
|
|
124
129
|
const [, init] = fetchMock.mock.calls[0];
|
|
125
130
|
expect(init.signal).toBe(controller.signal);
|
|
126
131
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export const config = {
|
|
2
2
|
baseApiUrl: "https://hr5buzenb1.execute-api.eu-west-1.amazonaws.com",
|
|
3
|
-
// Nest monolith (staging) — serves POST /public/search.
|
|
3
|
+
// Nest monolith (staging) — serves POST /public/search/lexical.
|
|
4
4
|
monolithApiUrl: "https://fvcphlqyle.execute-api.eu-west-1.amazonaws.com",
|
|
5
5
|
assistantUrl: "https://d2bycosa71tnxv.cloudfront.net/assets/index.js",
|
|
6
6
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export const config = {
|
|
2
2
|
baseApiUrl: "https://rtbzcxkmwj.execute-api.eu-west-1.amazonaws.com",
|
|
3
|
-
// Nest monolith (production) — serves POST /public/search. Custom domain,
|
|
3
|
+
// Nest monolith (production) — serves POST /public/search/lexical. Custom domain,
|
|
4
4
|
// same as the dashboard's VITE_MONOLITH_API_URL: the raw execute-api
|
|
5
5
|
// gateway URL answers 500 on every route and must not be used.
|
|
6
6
|
monolithApiUrl: "https://api.askdialog.ai",
|
package/dist/config/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export const config = {
|
|
2
2
|
baseApiUrl: "https://rtbzcxkmwj.execute-api.eu-west-1.amazonaws.com",
|
|
3
|
-
// Nest monolith (production) — serves POST /public/search. Custom domain,
|
|
3
|
+
// Nest monolith (production) — serves POST /public/search/lexical. Custom domain,
|
|
4
4
|
// same as the dashboard's VITE_MONOLITH_API_URL: the raw execute-api
|
|
5
5
|
// gateway URL answers 500 on every route and must not be used.
|
|
6
6
|
monolithApiUrl: "https://api.askdialog.ai",
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import { SearchController, SearchControllerOptions } from "./types/searchController";
|
|
2
|
-
export declare function createSearchController({ search, analytics, navigate, debounceMs, hitsPerPage, locale,
|
|
2
|
+
export declare function createSearchController({ search, analytics, navigate, debounceMs, hitsPerPage, locale, sections, }: SearchControllerOptions): SearchController;
|
|
3
3
|
//# sourceMappingURL=searchController.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"searchController.d.ts","sourceRoot":"","sources":["../src/searchController.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"searchController.d.ts","sourceRoot":"","sources":["../src/searchController.ts"],"names":[],"mappings":"AAOA,OAAO,EACL,gBAAgB,EAChB,uBAAuB,EAIxB,MAAM,0BAA0B,CAAC;AAqBlC,wBAAgB,sBAAsB,CAAC,EACrC,MAAM,EACN,SAAS,EACT,QAAQ,EACR,UAAgC,EAChC,WAAmC,EACnC,MAAM,EACN,QAAa,GACd,EAAE,uBAAuB,GAAG,gBAAgB,CA6L5C"}
|