coveo.analytics 2.30.27 → 2.30.36

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.
@@ -0,0 +1,113 @@
1
+ import {truncateUrl} from './utils';
2
+
3
+ describe('utils', () => {
4
+ describe('truncateUrl', () => {
5
+ // Note: to obtain a UTF-8 encoded escape sequence, run encodeUriComponent('<value>') in your browser console or NodeJS REPL
6
+ const URL_PLAIN = 'http://coveo.com/this/is/a/really/long/url/that/will/be/truncated?at=some&arbitrary=point';
7
+
8
+ it.each([[8], [16], [32], [64], [128]])(
9
+ `truncateUrl('${URL_PLAIN}', %d) truncates to exactly that length`,
10
+ (limit) => {
11
+ expect(truncateUrl(URL_PLAIN, limit)).toBe(URL_PLAIN.substring(0, limit));
12
+ }
13
+ );
14
+
15
+ /** Decoded: `'http://test/ ¿OKツ😅#fine'` */
16
+ const URL_WITH_ESCAPES = 'http://test/%20%C2%BFOK%E3%83%84%F0%9F%98%85#fine';
17
+ // Number of bytes in code-point: <1>< 2 > < 3 >< 4 >
18
+
19
+ it.each([[7], [22], [45], [46], [47], [48], [100]])(
20
+ `truncateUrl('${URL_WITH_ESCAPES}', %d) truncates to the exact limit outside of codepoints`,
21
+ (limit) => {
22
+ expect(truncateUrl(URL_WITH_ESCAPES, limit)).toBe(URL_WITH_ESCAPES.substring(0, limit));
23
+ }
24
+ );
25
+
26
+ it.each([
27
+ [12, 12],
28
+ [13, 12],
29
+ [14, 12],
30
+ [15, 15],
31
+ ])(
32
+ `truncateUrl('${URL_WITH_ESCAPES}', %d) does not break up single-byte codepoints`,
33
+ (limit, expectedLength) => {
34
+ expect(truncateUrl(URL_WITH_ESCAPES, limit)).toBe(URL_WITH_ESCAPES.substring(0, expectedLength));
35
+ }
36
+ );
37
+
38
+ it.each([
39
+ [15, 15],
40
+ [16, 15],
41
+ [17, 15],
42
+ [18, 15],
43
+ [19, 15],
44
+ [20, 15],
45
+ [21, 21],
46
+ ])(`truncateUrl('${URL_WITH_ESCAPES}', %d) does not break up two-byte codepoints`, (limit, expectedLength) => {
47
+ expect(truncateUrl(URL_WITH_ESCAPES, limit)).toBe(URL_WITH_ESCAPES.substring(0, expectedLength));
48
+ });
49
+
50
+ it.each([
51
+ [23, 23],
52
+ [24, 23],
53
+ [25, 23],
54
+ [26, 23],
55
+ [27, 23],
56
+ [28, 23],
57
+ [29, 23],
58
+ [30, 23],
59
+ [31, 23],
60
+ [32, 32],
61
+ ])(
62
+ `truncateUrl('${URL_WITH_ESCAPES}', %d) does not break up three-byte codepoints`,
63
+ (limit, expectedLength) => {
64
+ expect(truncateUrl(URL_WITH_ESCAPES, limit)).toBe(URL_WITH_ESCAPES.substring(0, expectedLength));
65
+ }
66
+ );
67
+
68
+ it.each([
69
+ [32, 32],
70
+ [33, 32],
71
+ [34, 32],
72
+ [35, 32],
73
+ [36, 32],
74
+ [37, 32],
75
+ [38, 32],
76
+ [39, 32],
77
+ [40, 32],
78
+ [41, 32],
79
+ [42, 32],
80
+ [43, 32],
81
+ [44, 44],
82
+ ])(`truncateUrl('${URL_WITH_ESCAPES}', %d) does not break up four-byte codepoints`, (limit, expectedLength) => {
83
+ expect(truncateUrl(URL_WITH_ESCAPES, limit)).toBe(URL_WITH_ESCAPES.substring(0, expectedLength));
84
+ });
85
+
86
+ const URL_WITH_INVALID_ESCAPES = 'http://test/%this%is%so%invalid';
87
+
88
+ it.each([
89
+ [12, 12],
90
+ [13, 12],
91
+ [14, 12],
92
+ [15, 15],
93
+ [16, 16],
94
+ [17, 17],
95
+ [18, 17],
96
+ [19, 17],
97
+ [20, 20],
98
+ [21, 20],
99
+ [22, 20],
100
+ [23, 23],
101
+ [24, 23],
102
+ [25, 23],
103
+ [26, 26],
104
+ ])(
105
+ `truncateUrl('${URL_WITH_INVALID_ESCAPES}', %d) only checks for percent with invalid escapes`,
106
+ (limit, expectedLength) => {
107
+ expect(truncateUrl(URL_WITH_INVALID_ESCAPES, limit)).toBe(
108
+ URL_WITH_INVALID_ESCAPES.substring(0, expectedLength)
109
+ );
110
+ }
111
+ );
112
+ });
113
+ });
@@ -15,3 +15,68 @@ export function isObject(o: any): boolean {
15
15
  export function coerceToNumber(input: any): any {
16
16
  return typeof input === 'string' && input != '' && !Number.isNaN(+input) ? +input : input;
17
17
  }
18
+
19
+ /**
20
+ * Can be used to both check if the first bit is set (for any utf-8 multibyte part),
21
+ * and to detect "following bytes" (which start with `10xx_xxxx`)
22
+ */
23
+ const UTF8_HIGH_BIT = 0b1000_0000;
24
+ /** Header for a 2-byte code point: `110x_xxxx`. Can also be used as bit-mask to check for "following bytes". */
25
+ const UTF8_HEADER_2 = 0b1100_0000;
26
+ /** Header for a 3-byte code point: `1110_xxxx`. Can also be used as bit-mask to check for "header 2". */
27
+ const UTF8_HEADER_3 = 0b1110_0000;
28
+ /** Header for a 4-byte code point: `1111_0xxx`. Can also be used as bit-mask to check for "header 3". */
29
+ const UTF8_HEADER_4 = 0b1111_0000;
30
+
31
+ function utf8ByteCountFromFirstByte(firstByte: number): number {
32
+ if ((firstByte & 0b1111_1000) === UTF8_HEADER_4) {
33
+ return 4;
34
+ }
35
+ if ((firstByte & UTF8_HEADER_4) === UTF8_HEADER_3) {
36
+ return 3;
37
+ }
38
+ if ((firstByte & UTF8_HEADER_3) === UTF8_HEADER_2) {
39
+ return 2;
40
+ }
41
+ return 1;
42
+ }
43
+
44
+ /**
45
+ * Truncate a URL to an arbitrary length, taking care to not break inside a percent escape or UTF-8 multibyte sequence.
46
+ *
47
+ * @param input The input to truncate to the specified limit.
48
+ * @param limit The limit to apply; if negative, no truncation is applied.
49
+ * @returns The URL, possibly truncated to a length near limit (at most 11 characters less than limit).
50
+ */
51
+ export function truncateUrl(input: string, limit: number): string {
52
+ if (limit < 0 || input.length <= limit) {
53
+ return input;
54
+ }
55
+ // A valid escape sequence is a percent followed by 2 hexadecimal characters; check if we split one up.
56
+ let end = input.indexOf('%', limit - 2);
57
+ if (end < 0 || end > limit) {
58
+ end = limit;
59
+ } else {
60
+ limit = end;
61
+ }
62
+ // Check that truncating at end won't break up an UTF-8 multibyte sequence half-way,
63
+ // by peeking backwards to find the first byte of an UTF-8 sequence (if present).
64
+ while (end > 2 && input.charAt(end - 3) == '%') {
65
+ const peekByte = Number.parseInt(input.substring(end - 2, end), 16);
66
+ // Note: if parsing fails, NaN gets coerced to 0 by the bitwise and.
67
+ if ((peekByte & UTF8_HIGH_BIT) != UTF8_HIGH_BIT) {
68
+ break;
69
+ }
70
+ end -= 3;
71
+ // Check if we reached the first byte by checking it is not a "follow byte": 10xx_xxxx.
72
+ if ((peekByte & UTF8_HEADER_2) != UTF8_HIGH_BIT) {
73
+ // If the full code point is there, keep it.
74
+ if (limit - end >= utf8ByteCountFromFirstByte(peekByte) * 3) {
75
+ end = limit;
76
+ }
77
+ // Otherwise, end is already set at the correct point to truncate at (the start of the multibyte sequence).
78
+ break;
79
+ }
80
+ }
81
+ return input.substring(0, end);
82
+ }
@@ -653,6 +653,14 @@ describe('InsightClient', () => {
653
653
  expectMatchCustomEventPayload(InsightEvents.createArticle, exampleCreateArticleMetadata);
654
654
  });
655
655
 
656
+ it('should send proper payload for #logTriggerNotify', async () => {
657
+ const exampleTriggerNotifyMetadata = {
658
+ notifications: ['foo', 'bar'],
659
+ };
660
+ await client.logTriggerNotify(exampleTriggerNotifyMetadata);
661
+ expectMatchCustomEventPayload(SearchPageEvents.triggerNotify, exampleTriggerNotifyMetadata);
662
+ });
663
+
656
664
  it('should send proper payload for #generatedAnswerFeedbackSubmitV2', async () => {
657
665
  const exampleGeneratedAnswerMetadata = {
658
666
  generativeQuestionAnsweringId: '123',
@@ -1488,6 +1496,18 @@ describe('InsightClient', () => {
1488
1496
  await client.logCreateArticle(exampleCreateArticleMetadata, baseCaseMetadata);
1489
1497
  expectMatchCustomEventPayload(InsightEvents.createArticle, expectedMetadata);
1490
1498
  });
1499
+
1500
+ it('should send proper payload for #logTriggerNotify', async () => {
1501
+ const exampleTriggerNotifyMetadata = {
1502
+ notifications: ['foo', 'bar'],
1503
+ };
1504
+ const expectedMetadata = {
1505
+ ...exampleTriggerNotifyMetadata,
1506
+ ...expectedBaseCaseMetadata,
1507
+ };
1508
+ await client.logTriggerNotify(exampleTriggerNotifyMetadata, baseCaseMetadata);
1509
+ expectMatchCustomEventPayload(SearchPageEvents.triggerNotify, expectedMetadata);
1510
+ });
1491
1511
  });
1492
1512
 
1493
1513
  it('should enable analytics tracking by default', () => {
@@ -19,6 +19,7 @@ import {
19
19
  SmartSnippetFeedbackReason,
20
20
  SmartSnippetLinkMeta,
21
21
  SmartSnippetSuggestionMeta,
22
+ TriggerNotifyMetadata,
22
23
  } from '../searchPage/searchPageEvents';
23
24
  import {
24
25
  ExpandToFullUIMetadata,
@@ -687,6 +688,13 @@ export class CoveoInsightClient {
687
688
  );
688
689
  }
689
690
 
691
+ public logTriggerNotify(triggerNotifyMetadata: TriggerNotifyMetadata, metadata?: CaseMetadata) {
692
+ return this.logCustomEvent(
693
+ SearchPageEvents.triggerNotify,
694
+ metadata ? {...generateMetadataToSend(metadata, false), ...triggerNotifyMetadata} : triggerNotifyMetadata
695
+ );
696
+ }
697
+
690
698
  private async getBaseCustomEventRequest(metadata?: Record<string, any>) {
691
699
  return {
692
700
  ...(await this.getBaseEventRequest(metadata)),
@@ -539,41 +539,48 @@ export interface SmartSnippetDocumentIdentifier {
539
539
 
540
540
  export type PartialDocumentInformation = Omit<DocumentInformation, 'actionCause' | 'searchQueryUid'>;
541
541
 
542
- export interface GeneratedAnswerBaseMeta {
542
+ interface AnswerGeneratedWithAnswerAPI {
543
+ answerAPIStreamId: string;
544
+ generativeQuestionAnsweringId?: never;
545
+ }
546
+
547
+ interface AnswerGeneratedWithSearchAPI {
548
+ answerAPIStreamId?: never;
543
549
  generativeQuestionAnsweringId: string;
544
550
  }
545
551
 
546
- export interface GeneratedAnswerStreamEndMeta extends GeneratedAnswerBaseMeta {
552
+ export type GeneratedAnswerBaseMeta = AnswerGeneratedWithAnswerAPI | AnswerGeneratedWithSearchAPI;
553
+
554
+ export type GeneratedAnswerStreamEndMeta = GeneratedAnswerBaseMeta & {
547
555
  answerGenerated: boolean;
548
556
  answerTextIsEmpty?: boolean;
549
- }
557
+ };
550
558
 
551
- export interface GeneratedAnswerCitationMeta {
552
- generativeQuestionAnsweringId: string;
559
+ export type GeneratedAnswerCitationMeta = GeneratedAnswerBaseMeta & {
553
560
  permanentId: string;
554
561
  citationId: string;
555
- }
562
+ };
556
563
 
557
564
  export type GeneratedAnswerFeedbackReason = 'irrelevant' | 'notAccurate' | 'outOfDate' | 'harmful' | 'other';
558
565
 
559
566
  export type GeneratedAnswerRephraseFormat = 'step' | 'bullet' | 'concise' | 'default';
560
567
 
561
- export interface GeneratedAnswerSourceHoverMeta extends GeneratedAnswerCitationMeta {
568
+ export type GeneratedAnswerSourceHoverMeta = GeneratedAnswerCitationMeta & {
562
569
  citationHoverTimeMs: number;
563
- }
570
+ };
564
571
 
565
- export interface GeneratedAnswerRephraseMeta extends GeneratedAnswerBaseMeta {
572
+ export type GeneratedAnswerRephraseMeta = GeneratedAnswerBaseMeta & {
566
573
  rephraseFormat: GeneratedAnswerRephraseFormat;
567
- }
574
+ };
568
575
 
569
- export interface GeneratedAnswerFeedbackMeta extends GeneratedAnswerBaseMeta {
576
+ export type GeneratedAnswerFeedbackMeta = GeneratedAnswerBaseMeta & {
570
577
  reason: GeneratedAnswerFeedbackReason;
571
578
  details?: string;
572
- }
579
+ };
573
580
 
574
581
  export type GeneratedAnswerFeedbackReasonOption = 'yes' | 'unknown' | 'no';
575
582
 
576
- export interface GeneratedAnswerFeedbackMetaV2 extends GeneratedAnswerBaseMeta {
583
+ export type GeneratedAnswerFeedbackMetaV2 = GeneratedAnswerBaseMeta & {
577
584
  helpful: boolean;
578
585
  readable: GeneratedAnswerFeedbackReasonOption;
579
586
  documented: GeneratedAnswerFeedbackReasonOption;
@@ -581,4 +588,4 @@ export interface GeneratedAnswerFeedbackMetaV2 extends GeneratedAnswerBaseMeta {
581
588
  hallucinationFree: GeneratedAnswerFeedbackReasonOption;
582
589
  details?: string;
583
590
  documentUrl?: string;
584
- }
591
+ };