@reservamos/browser-analytics 1.1.7-alpha.0 → 1.1.7-alpha.1

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,160 @@
1
+ import mixpanel from 'mixpanel-browser';
2
+ import { version as mixpanelVersion } from 'mixpanel-browser/package.json';
3
+ import {
4
+ afterAll,
5
+ afterEach,
6
+ beforeAll,
7
+ describe,
8
+ expect,
9
+ it,
10
+ vi,
11
+ } from 'vitest';
12
+ import { version } from '../../package.json';
13
+ import mixpanelService from './mixpanel';
14
+
15
+ interface TransportData {
16
+ event?: string;
17
+ properties?: Record<string, unknown>;
18
+ }
19
+
20
+ // Intercept transport only: identify, track, register, and property merging stay real.
21
+ const transport = mixpanel as unknown as {
22
+ _track_or_batch(options: { data: TransportData }): TransportData;
23
+ stop_batch_senders(): void;
24
+ };
25
+
26
+ describe('Mixpanel SDK version metadata', () => {
27
+ beforeAll(async () => {
28
+ localStorage.clear();
29
+ await mixpanelService.init('test-sdk-version-token');
30
+ transport.stop_batch_senders();
31
+ mixpanel.set_config({ disable_persistence: true });
32
+ });
33
+
34
+ afterEach(() => {
35
+ vi.restoreAllMocks();
36
+ vi.clearAllTimers();
37
+ vi.useRealTimers();
38
+ });
39
+
40
+ afterAll(() => {
41
+ transport.stop_batch_senders();
42
+ localStorage.clear();
43
+ });
44
+
45
+ it('registers SDK metadata and propagates it to the native $identify event', () => {
46
+ expect(mixpanel.get_property('Event Source')).toBe('sdk');
47
+ expect(mixpanel.get_property('Reservamos Version')).toBe(version);
48
+
49
+ const send = vi
50
+ .spyOn(transport, '_track_or_batch')
51
+ .mockImplementation(({ data }) => data);
52
+
53
+ mixpanelService.identify('sdk-version-user', {});
54
+ const events = send.mock.calls
55
+ .map(([options]) => options.data)
56
+ .filter((data) => data.event);
57
+
58
+ expect(events).toHaveLength(1);
59
+ expect(events[0]).toEqual({
60
+ event: '$identify',
61
+ properties: expect.objectContaining({
62
+ 'Event Source': 'sdk',
63
+ 'Reservamos Version': version,
64
+ '$lib_version': mixpanelVersion,
65
+ }),
66
+ });
67
+
68
+ send.mockClear();
69
+ mixpanelService.identify('sdk-version-user', {});
70
+ expect(send.mock.calls.some(([options]) => options.data.event)).toBe(false);
71
+ });
72
+
73
+ it('keeps the SDK version authoritative for SDK-tracked events', () => {
74
+ const send = vi
75
+ .spyOn(transport, '_track_or_batch')
76
+ .mockImplementation(({ data }) => data);
77
+
78
+ mixpanelService.track('Test', { 'Reservamos Version': 'caller-version' });
79
+
80
+ expect(send.mock.calls[0][0].data).toEqual({
81
+ event: 'Test',
82
+ properties: expect.objectContaining({
83
+ 'Event Source': 'sdk',
84
+ 'Reservamos Version': version,
85
+ }),
86
+ });
87
+ });
88
+
89
+ it('still sets people properties when the SDK distinct ID cache is blocked', () => {
90
+ vi.spyOn(transport, '_track_or_batch').mockImplementation(
91
+ ({ data }) => data,
92
+ );
93
+ vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
94
+ throw new DOMException('Blocked', 'SecurityError');
95
+ });
96
+ const people = vi
97
+ .spyOn(mixpanel.people, 'set')
98
+ .mockImplementation(() => undefined);
99
+ mixpanelService.identify('cache-blocked-user', {});
100
+ expect(people).toHaveBeenCalledWith({});
101
+ });
102
+
103
+ it.each([
104
+ 1,
105
+ 0,
106
+ { status: 1, error: null },
107
+ { status: 0, error: 'private-id' },
108
+ -1,
109
+ ])(
110
+ 'observes the people callback %j with controlled diagnostics',
111
+ (response) => {
112
+ vi.useFakeTimers();
113
+ vi.spyOn(mixpanel, 'identify').mockImplementation(() => undefined);
114
+ vi.spyOn(transport, '_track_or_batch').mockImplementation(
115
+ ({ data }) => data,
116
+ );
117
+ let callback: ((response: unknown) => void) | undefined;
118
+ vi.spyOn(mixpanel.people, 'set').mockImplementation((...args) => {
119
+ callback = args[1] as (response: unknown) => void;
120
+ });
121
+ const report = vi.fn();
122
+ const schedule = vi.spyOn(globalThis, 'setTimeout');
123
+ const cancel = vi.spyOn(globalThis, 'clearTimeout');
124
+ mixpanelService.identify('callback-user', {}, report);
125
+ expect(schedule).toHaveBeenCalledWith(expect.any(Function), 5000);
126
+ const timeout = schedule.mock.results.at(-1)?.value;
127
+ callback?.(response);
128
+ expect(cancel).toHaveBeenCalledWith(timeout);
129
+ const status = typeof response === 'number' ? response : response.status;
130
+ if (status === 1) expect(report).not.toHaveBeenCalled();
131
+ else
132
+ expect(report).toHaveBeenCalledWith(
133
+ 'people',
134
+ status === 0 ? 'failed' : 'unconfirmed',
135
+ undefined,
136
+ );
137
+ vi.advanceTimersByTime(5000);
138
+ expect(report).toHaveBeenCalledTimes(status === 1 ? 0 : 1);
139
+ },
140
+ );
141
+
142
+ it('reports missing people callback as unobserved and ignores a late result', () => {
143
+ vi.useFakeTimers();
144
+ vi.spyOn(mixpanel, 'identify').mockImplementation(() => undefined);
145
+ vi.spyOn(transport, '_track_or_batch').mockImplementation(
146
+ ({ data }) => data,
147
+ );
148
+ let callback: ((response: unknown) => void) | undefined;
149
+ vi.spyOn(mixpanel.people, 'set').mockImplementation((...args) => {
150
+ callback = args[1] as (response: unknown) => void;
151
+ });
152
+ const report = vi.fn();
153
+ mixpanelService.identify('callback-user', {}, report);
154
+ vi.advanceTimersByTime(5000);
155
+ callback?.(0);
156
+ expect(report).toHaveBeenCalledOnce();
157
+ expect(report).toHaveBeenCalledWith('people', 'unobserved', undefined);
158
+ expect(vi.getTimerCount()).toBe(0);
159
+ });
160
+ });
@@ -1,4 +1,6 @@
1
+ import type { IdentifyReporter } from '@/util/identifyAttempt';
1
2
  import mixpanel from 'mixpanel-browser';
3
+ import { observeIdentifyResult } from '@/util/identifyAttempt';
2
4
  import { version } from '../../package.json';
3
5
 
4
6
  declare global {
@@ -10,10 +12,11 @@ declare global {
10
12
  const MIXPANEL_DISTINCT_ID_CACHE_KEY = 'mp_distinct_id';
11
13
 
12
14
  /**
13
- * Super property sent on every Mixpanel event. Fixed SDK marker; not configurable via the public API.
15
+ * SDK metadata sent on every Mixpanel event, including native identification events.
14
16
  */
15
- const EVENT_SOURCE_SUPER_PROPERTY = {
17
+ const SDK_SUPER_PROPERTIES = {
16
18
  'Event Source': 'sdk',
19
+ 'Reservamos Version': version,
17
20
  } as const;
18
21
 
19
22
  /**
@@ -33,7 +36,7 @@ function init(
33
36
  debug,
34
37
  loaded: () => {
35
38
  window.mixpanel = mixpanel;
36
- mixpanel.register({ ...EVENT_SOURCE_SUPER_PROPERTY });
39
+ mixpanel.register({ ...SDK_SUPER_PROPERTIES });
37
40
  resolve();
38
41
  },
39
42
  ...(proxyUrl && { api_host: proxyUrl }),
@@ -48,10 +51,32 @@ function init(
48
51
  * @returns {void}
49
52
  * @throws {Error} If Mixpanel is not initialized (handled internally).
50
53
  */
51
- function identify(userId: string, properties: Record<string, unknown>): void {
54
+ function identify(
55
+ userId: string,
56
+ properties: Record<string, unknown>,
57
+ report?: IdentifyReporter,
58
+ ): void {
52
59
  mixpanel.identify(userId);
53
- updateCachedDistinctId(userId);
54
- mixpanel.people.set(properties);
60
+ try {
61
+ updateCachedDistinctId(userId);
62
+ } catch {
63
+ report?.('cache', 'unavailable');
64
+ }
65
+ if (!report) {
66
+ mixpanel.people.set(properties);
67
+ return;
68
+ }
69
+ const observer = observeIdentifyResult('people', report);
70
+ try {
71
+ mixpanel.people.set(properties, (response) => {
72
+ const status = typeof response === 'number' ? response : response?.status;
73
+ observer.complete(
74
+ status === 1 ? undefined : status === 0 ? 'failed' : 'unconfirmed',
75
+ );
76
+ });
77
+ } catch {
78
+ observer.complete('failed');
79
+ }
55
80
  }
56
81
 
57
82
  /**
@@ -100,6 +125,7 @@ function track(eventName: string, properties: Record<string, unknown>): void {
100
125
  if (!isReady()) {
101
126
  return;
102
127
  }
128
+ // Event properties override superproperties; keep the SDK version authoritative.
103
129
  mixpanel.track(eventName, { ...properties, 'Reservamos Version': version });
104
130
  }
105
131
 
@@ -0,0 +1,156 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import botDetectionRelayService from '@/services/botDetectionRelay';
3
+ import fingerprintService from '@/services/fingerprint';
4
+ import mixpanelService from '@/services/mixpanel';
5
+ import { trackEvent } from './track';
6
+
7
+ vi.mock('@/services/botDetectionRelay', () => ({
8
+ default: {
9
+ isEnabledFor: vi.fn(),
10
+ relay: vi.fn().mockResolvedValue(undefined),
11
+ },
12
+ }));
13
+
14
+ vi.mock('@/services/fingerprint', () => ({
15
+ default: {
16
+ getFingerprint: vi.fn(),
17
+ identifyVisitor: vi.fn(),
18
+ isFingerprintReady: vi.fn(),
19
+ },
20
+ }));
21
+
22
+ vi.mock('@/services/geolocation', () => ({
23
+ default: {
24
+ getCoordinates: vi.fn().mockReturnValue(null),
25
+ getGeolocationSource: vi.fn().mockReturnValue(null),
26
+ },
27
+ }));
28
+
29
+ vi.mock('@/services/mixpanel', () => ({
30
+ default: {
31
+ isReady: vi.fn().mockReturnValue(true),
32
+ getMixpanelDistinctId: vi.fn().mockReturnValue('distinct-123'),
33
+ track: vi.fn(),
34
+ },
35
+ }));
36
+
37
+ vi.mock('@/services/validator', () => ({
38
+ default: {
39
+ parseEventProps: vi.fn(),
40
+ },
41
+ }));
42
+
43
+ describe('trackEvent Bot Detection relay', () => {
44
+ beforeEach(() => {
45
+ vi.clearAllMocks();
46
+ vi.mocked(mixpanelService.isReady).mockReturnValue(true);
47
+ vi.mocked(mixpanelService.getMixpanelDistinctId).mockReturnValue(
48
+ 'distinct-123',
49
+ );
50
+ vi.mocked(fingerprintService.isFingerprintReady).mockReturnValue(true);
51
+ });
52
+
53
+ it('uses a fresh identification and relays a configured event', async () => {
54
+ vi.mocked(botDetectionRelayService.isEnabledFor).mockReturnValue(true);
55
+ vi.mocked(fingerprintService.identifyVisitor).mockResolvedValue({
56
+ visitorId: 'visitor-123',
57
+ requestId: 'request-123',
58
+ visitorFound: true,
59
+ confidenceScore: 0.99,
60
+ cacheHit: false,
61
+ });
62
+
63
+ await trackEvent('Payment Attempt', { 'Payment Type': 'credit_card' });
64
+
65
+ expect(fingerprintService.identifyVisitor).toHaveBeenCalledWith(true);
66
+ expect(fingerprintService.getFingerprint).not.toHaveBeenCalled();
67
+ expect(mixpanelService.track).toHaveBeenCalledWith('Payment Attempt', {
68
+ 'User Fingerprint': 'visitor-123',
69
+ 'Payment Type': 'credit_card',
70
+ });
71
+ expect(botDetectionRelayService.relay).toHaveBeenCalledWith({
72
+ requestId: 'request-123',
73
+ visitorId: 'visitor-123',
74
+ distinctId: 'distinct-123',
75
+ eventName: 'Payment Attempt',
76
+ });
77
+ });
78
+
79
+ it('reports a failed fresh identification without tracking or relaying the configured event', async () => {
80
+ vi.mocked(botDetectionRelayService.isEnabledFor).mockReturnValue(true);
81
+ vi.mocked(fingerprintService.identifyVisitor).mockRejectedValue(
82
+ new Error('Fingerprint identification failed'),
83
+ );
84
+ const eventProperties = { 'Payment Type': 'credit_card' };
85
+
86
+ await trackEvent('Payment Attempt', eventProperties);
87
+
88
+ expect(fingerprintService.identifyVisitor).toHaveBeenCalledWith(true);
89
+ expect(fingerprintService.getFingerprint).not.toHaveBeenCalled();
90
+ expect(botDetectionRelayService.relay).not.toHaveBeenCalled();
91
+ expect(mixpanelService.track).toHaveBeenCalledTimes(1);
92
+ expect(mixpanelService.track).toHaveBeenCalledWith('Track Event Error', {
93
+ 'Failed Event Name': 'Payment Attempt',
94
+ 'Error Message': 'Fingerprint identification failed',
95
+ 'Validation Errors': [],
96
+ 'Event Payload': eventProperties,
97
+ });
98
+ });
99
+
100
+ it('preserves existing fingerprint behavior for an unconfigured event', async () => {
101
+ vi.mocked(botDetectionRelayService.isEnabledFor).mockReturnValue(false);
102
+ vi.mocked(fingerprintService.getFingerprint).mockResolvedValue(
103
+ 'cached-visitor',
104
+ );
105
+
106
+ await trackEvent('Payment Attempt', { 'Payment Type': 'credit_card' });
107
+
108
+ expect(fingerprintService.getFingerprint).toHaveBeenCalledWith(true);
109
+ expect(fingerprintService.identifyVisitor).not.toHaveBeenCalled();
110
+ expect(mixpanelService.track).toHaveBeenCalledWith('Payment Attempt', {
111
+ 'User Fingerprint': 'cached-visitor',
112
+ 'Payment Type': 'credit_card',
113
+ });
114
+ expect(botDetectionRelayService.relay).not.toHaveBeenCalled();
115
+ });
116
+
117
+ it('uses a fresh identification and relays Search', async () => {
118
+ vi.mocked(botDetectionRelayService.isEnabledFor).mockReturnValue(true);
119
+ vi.mocked(fingerprintService.identifyVisitor).mockResolvedValue({
120
+ visitorId: 'visitor-123',
121
+ requestId: 'search-request-123',
122
+ visitorFound: true,
123
+ confidenceScore: 0.99,
124
+ cacheHit: false,
125
+ });
126
+
127
+ await trackEvent('Search', {});
128
+
129
+ expect(fingerprintService.identifyVisitor).toHaveBeenCalledWith(true);
130
+ expect(fingerprintService.getFingerprint).not.toHaveBeenCalled();
131
+ expect(mixpanelService.track).toHaveBeenCalledWith('Search', {
132
+ 'User Fingerprint': 'visitor-123',
133
+ });
134
+ expect(botDetectionRelayService.relay).toHaveBeenCalledWith({
135
+ requestId: 'search-request-123',
136
+ visitorId: 'visitor-123',
137
+ distinctId: 'distinct-123',
138
+ eventName: 'Search',
139
+ });
140
+ });
141
+
142
+ it('tracks Search without a relay when Fingerprint is not ready', async () => {
143
+ vi.mocked(botDetectionRelayService.isEnabledFor).mockReturnValue(true);
144
+ vi.mocked(fingerprintService.isFingerprintReady).mockReturnValue(false);
145
+ vi.mocked(fingerprintService.getFingerprint).mockResolvedValue('');
146
+
147
+ await trackEvent('Search', {});
148
+
149
+ expect(fingerprintService.identifyVisitor).not.toHaveBeenCalled();
150
+ expect(fingerprintService.getFingerprint).toHaveBeenCalledWith(false);
151
+ expect(mixpanelService.track).toHaveBeenCalledWith('Search', {
152
+ 'User Fingerprint': '',
153
+ });
154
+ expect(botDetectionRelayService.relay).not.toHaveBeenCalled();
155
+ });
156
+ });
package/src/track.ts CHANGED
@@ -3,6 +3,7 @@ import type {
3
3
  EventData,
4
4
  EventMetadata,
5
5
  } from './types/eventData';
6
+ import botDetectionRelayService from '@/services/botDetectionRelay';
6
7
  import fingerprintService from '@/services/fingerprint';
7
8
  import geolocationService from '@/services/geolocation';
8
9
  import mixpanelService from '@/services/mixpanel';
@@ -117,12 +118,21 @@ export async function trackEvent(
117
118
 
118
119
  try {
119
120
  validator.parseEventProps(eventName, eventProperties);
120
- const cacheOnly = !FP_TRIGGER_EVENTS.includes(eventName);
121
-
122
121
  // Call the Mixpanel service to store the distinct ID in the cache
123
- mixpanelService.getMixpanelDistinctId();
124
-
125
- const fingerprint = await fingerprintService.getFingerprint(cacheOnly);
122
+ const distinctId = mixpanelService.getMixpanelDistinctId();
123
+ const botDetectionEnabled =
124
+ botDetectionRelayService.isEnabledFor(eventName);
125
+ let fingerprint: string;
126
+ let fingerprintRequestId: string | undefined;
127
+
128
+ if (botDetectionEnabled && fingerprintService.isFingerprintReady()) {
129
+ const identification = await fingerprintService.identifyVisitor(true);
130
+ fingerprint = identification.visitorId;
131
+ fingerprintRequestId = identification.requestId;
132
+ } else {
133
+ const cacheOnly = !FP_TRIGGER_EVENTS.includes(eventName);
134
+ fingerprint = await fingerprintService.getFingerprint(cacheOnly);
135
+ }
126
136
  const defaultCoordinates: Record<string, string | number> = {};
127
137
  const coordinates = geolocationService.getCoordinates();
128
138
  const geolocationSource = geolocationService.getGeolocationSource();
@@ -148,6 +158,15 @@ export async function trackEvent(
148
158
  };
149
159
 
150
160
  mixpanelService.track(eventName, properties);
161
+
162
+ if (fingerprintRequestId) {
163
+ void botDetectionRelayService.relay({
164
+ requestId: fingerprintRequestId,
165
+ visitorId: fingerprint,
166
+ ...(distinctId && { distinctId }),
167
+ eventName,
168
+ });
169
+ }
151
170
  } catch (error) {
152
171
  console.error(`Error tracking event '${eventName}':`, error);
153
172
  trackEventError(eventName, error, eventProperties);
@@ -0,0 +1,16 @@
1
+ interface FingerprintIdentification {
2
+ visitorId: string;
3
+ requestId: string;
4
+ visitorFound?: boolean;
5
+ confidenceScore: number;
6
+ cacheHit: boolean;
7
+ }
8
+
9
+ interface BotDetectionRelayPayload {
10
+ requestId: string;
11
+ visitorId: string;
12
+ distinctId?: string;
13
+ eventName: string;
14
+ }
15
+
16
+ export type { BotDetectionRelayPayload, FingerprintIdentification };
@@ -0,0 +1,139 @@
1
+ import mixpanelService from '@/services/mixpanel';
2
+ import { trackEventError } from '@/track';
3
+
4
+ type IdentifyStage =
5
+ | 'readiness'
6
+ | 'validation'
7
+ | 'profile'
8
+ | 'cache'
9
+ | 'mixpanel'
10
+ | 'people'
11
+ | 'fingerprint'
12
+ | 'datalake';
13
+ type IdentifyReason =
14
+ | 'failed'
15
+ | 'timeout'
16
+ | 'unobserved'
17
+ | 'unconfirmed'
18
+ | 'unavailable'
19
+ | 'invalid_input'
20
+ | 'http_error';
21
+
22
+ export type IdentifyReporter = (
23
+ stage: IdentifyStage,
24
+ reason: IdentifyReason,
25
+ httpStatus?: number,
26
+ ) => void;
27
+ export interface IdentifyStageContext {
28
+ isActive: () => boolean;
29
+ report: IdentifyReporter;
30
+ }
31
+
32
+ /** Reports only SDK-controlled diagnostics; original errors and inputs never enter telemetry. */
33
+ export function createIdentifyReporter(): IdentifyReporter {
34
+ const reported = new Set<string>();
35
+ return (stage, reason, httpStatus) => {
36
+ const key = `${stage}:${reason}`;
37
+ if (reported.has(key)) return;
38
+ reported.add(key);
39
+ const payload = {
40
+ stage,
41
+ reason,
42
+ ...(httpStatus !== undefined ? { httpStatus } : {}),
43
+ };
44
+ try {
45
+ if (mixpanelService.isReady()) {
46
+ trackEventError(
47
+ 'Identify',
48
+ new Error(`Identify ${stage}: ${reason}`),
49
+ payload,
50
+ );
51
+ } else {
52
+ console.error('Identify diagnostic', payload);
53
+ }
54
+ } catch {
55
+ console.error('Identify diagnostic', payload);
56
+ }
57
+ };
58
+ }
59
+
60
+ /** Waits only for this identify attempt, without changing the shared tracking queue. */
61
+ export function waitForIdentifyReady(
62
+ report: IdentifyReporter,
63
+ ): Promise<boolean> {
64
+ if (mixpanelService.isReady()) return Promise.resolve(true);
65
+ return new Promise((resolve) => {
66
+ let active = true;
67
+ const finish = (ready: boolean) => {
68
+ if (!active) return;
69
+ active = false;
70
+ clearTimeout(timer);
71
+ window.removeEventListener('Tracker Ready', onReady);
72
+ if (!ready) report('readiness', 'timeout');
73
+ resolve(ready);
74
+ };
75
+ const onReady = () => {
76
+ if (mixpanelService.isReady()) finish(true);
77
+ };
78
+ const timer = setTimeout(() => finish(false), 5000);
79
+ window.addEventListener('Tracker Ready', onReady);
80
+ onReady();
81
+ });
82
+ }
83
+
84
+ /** Closes optional work at its deadline and consumes late fulfillment or rejection. */
85
+ export function waitForIdentifyStage<T>(
86
+ stage: 'profile' | 'fingerprint',
87
+ timeout: number,
88
+ run: (context: IdentifyStageContext) => Promise<T>,
89
+ report: IdentifyReporter,
90
+ ): Promise<T | undefined> {
91
+ return new Promise((resolve) => {
92
+ let active = true;
93
+ const finish = (value?: T) => {
94
+ if (!active) return;
95
+ active = false;
96
+ clearTimeout(timer);
97
+ resolve(value);
98
+ };
99
+ const timer = setTimeout(() => {
100
+ report(stage, 'timeout');
101
+ finish();
102
+ }, timeout);
103
+ const context: IdentifyStageContext = {
104
+ isActive: () => active,
105
+ report: (...args) => {
106
+ if (active) report(...args);
107
+ },
108
+ };
109
+ void Promise.resolve()
110
+ .then(() => run(context))
111
+ .then(
112
+ (value) => finish(value),
113
+ () => {
114
+ if (active) report(stage, 'failed');
115
+ finish();
116
+ },
117
+ );
118
+ });
119
+ }
120
+
121
+ /** Observes remote outcomes separately from the public identify promise. */
122
+ export function observeIdentifyResult(
123
+ stage: 'people' | 'datalake',
124
+ report: IdentifyReporter,
125
+ onTimeout?: () => void,
126
+ ) {
127
+ let active = true;
128
+ const complete = (reason?: IdentifyReason, httpStatus?: number): void => {
129
+ if (!active) return;
130
+ active = false;
131
+ clearTimeout(timer);
132
+ if (reason) report(stage, reason, httpStatus);
133
+ };
134
+ const timer = setTimeout(() => {
135
+ complete(stage === 'people' ? 'unobserved' : 'timeout');
136
+ onTimeout?.();
137
+ }, 5000);
138
+ return { complete };
139
+ }