Skip to content

Commit 792a2af

Browse files
committed
feat: add user activity tracking for embedded apps
1 parent 67ab095 commit 792a2af

18 files changed

+1682
-0
lines changed
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/** The user activity message type used for communication between shell and iframes */
2+
export const USER_ACTIVITY_MESSAGE_TYPE = 'user_activity';
3+
4+
/**
5+
* Types of user activity events that can be tracked
6+
*/
7+
export type UserActivityEventType =
8+
| 'click'
9+
| 'keydown'
10+
| 'scroll'
11+
| 'touchstart'
12+
| 'focus'
13+
| 'visibilitychange'
14+
| 'iframeinteraction';
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import type {
2+
UserActivityMessageV1_0,
3+
} from './user-activity-versions';
4+
5+
/** The versions of user activity messages */
6+
export type UserActivityMessage = UserActivityMessageV1_0;
7+
export * from './user-activity-versions';
8+
export { USER_ACTIVITY_MESSAGE_TYPE, type UserActivityEventType } from './base';
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type {
2+
VersionedMessage,
3+
} from '@amadeus-it-group/microfrontends';
4+
import type {
5+
USER_ACTIVITY_MESSAGE_TYPE,
6+
UserActivityEventType,
7+
} from './base';
8+
9+
/**
10+
* User activity message version 1.0
11+
* Sent from embedded modules to the shell to indicate user interaction
12+
*/
13+
export interface UserActivityMessageV1_0 extends VersionedMessage {
14+
/** @inheritdoc */
15+
type: typeof USER_ACTIVITY_MESSAGE_TYPE;
16+
/** @inheritdoc */
17+
version: '1.0';
18+
/** The type of activity event that occurred */
19+
eventType: UserActivityEventType;
20+
/** Timestamp when the activity occurred */
21+
timestamp: number;
22+
}

packages/@ama-mfe/messages/src/public_api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export * from './messages/history/index';
22
export * from './messages/navigation/index';
33
export * from './messages/resize/index';
44
export * from './messages/theme/index';
5+
export * from './messages/user-activity/index';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './available-sender';
22
export * from './error-sender';
33
export * from './error/index';
4+
export * from './user-activity';
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import {
2+
USER_ACTIVITY_MESSAGE_TYPE,
3+
type UserActivityMessageV1_0,
4+
} from '@ama-mfe/messages';
5+
import {
6+
isUserActivityMessage,
7+
} from './user-activity';
8+
9+
describe('isUserActivityMessage', () => {
10+
it('should return true for valid UserActivityMessageV1_0', () => {
11+
const message: UserActivityMessageV1_0 = {
12+
type: USER_ACTIVITY_MESSAGE_TYPE,
13+
version: '1.0',
14+
eventType: 'click',
15+
timestamp: Date.now()
16+
};
17+
18+
expect(isUserActivityMessage(message)).toBe(true);
19+
});
20+
21+
it('should return true for all valid event types', () => {
22+
const eventTypes = ['click', 'keydown', 'scroll', 'touchstart', 'focus', 'visibilitychange', 'iframeinteraction'] as const;
23+
24+
eventTypes.forEach((eventType) => {
25+
const message: UserActivityMessageV1_0 = {
26+
type: USER_ACTIVITY_MESSAGE_TYPE,
27+
version: '1.0',
28+
eventType,
29+
timestamp: Date.now()
30+
};
31+
32+
expect(isUserActivityMessage(message)).toBe(true);
33+
});
34+
});
35+
36+
it('should return false for null', () => {
37+
expect(isUserActivityMessage(null)).toBe(false);
38+
});
39+
40+
it('should return false for undefined', () => {
41+
expect(isUserActivityMessage(undefined)).toBe(false);
42+
});
43+
44+
it('should return false for non-object values', () => {
45+
expect(isUserActivityMessage('string')).toBe(false);
46+
expect(isUserActivityMessage(123)).toBe(false);
47+
expect(isUserActivityMessage(true)).toBe(false);
48+
});
49+
50+
it('should return false for object without type property', () => {
51+
const message = {
52+
version: '1.0',
53+
eventType: 'click',
54+
timestamp: Date.now()
55+
};
56+
57+
expect(isUserActivityMessage(message)).toBe(false);
58+
});
59+
60+
it('should return false for object with wrong type', () => {
61+
const message = {
62+
type: 'wrong_type',
63+
version: '1.0',
64+
eventType: 'click',
65+
timestamp: Date.now()
66+
};
67+
68+
expect(isUserActivityMessage(message)).toBe(false);
69+
});
70+
71+
it('should return false for empty object', () => {
72+
expect(isUserActivityMessage({})).toBe(false);
73+
});
74+
75+
it('should return false for array', () => {
76+
expect(isUserActivityMessage([])).toBe(false);
77+
});
78+
79+
it('should return true even if other properties are missing (only checks type)', () => {
80+
// The type guard only checks for the type property
81+
const message = {
82+
type: USER_ACTIVITY_MESSAGE_TYPE
83+
};
84+
85+
expect(isUserActivityMessage(message)).toBe(true);
86+
});
87+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import {
2+
USER_ACTIVITY_MESSAGE_TYPE,
3+
type UserActivityMessage,
4+
} from '@ama-mfe/messages';
5+
import type {
6+
VersionedMessage,
7+
} from '@amadeus-it-group/microfrontends';
8+
9+
/**
10+
* Type guard to check if a message is a user activity message
11+
* @param message The message to check
12+
*/
13+
export function isUserActivityMessage(message: unknown): message is UserActivityMessage {
14+
return (
15+
typeof message === 'object'
16+
&& message !== null
17+
&& 'type' in message
18+
&& (message as VersionedMessage).type === USER_ACTIVITY_MESSAGE_TYPE
19+
);
20+
}

packages/@ama-mfe/ng-utils/src/public_api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ export * from './messages/index';
66
export * from './navigation/index';
77
export * from './resize/index';
88
export * from './theme/index';
9+
export * from './user-activity/index';
910
export * from './utils';
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import {
2+
USER_ACTIVITY_MESSAGE_TYPE,
3+
type UserActivityMessageV1_0,
4+
} from '@ama-mfe/messages';
5+
import type {
6+
RoutedMessage,
7+
} from '@amadeus-it-group/microfrontends';
8+
import {
9+
TestBed,
10+
} from '@angular/core/testing';
11+
import {
12+
ActivityConsumerService,
13+
} from './activity-consumer.service';
14+
import {
15+
ConsumerManagerService,
16+
} from '@ama-mfe/ng-utils';
17+
18+
describe('ActivityConsumerService', () => {
19+
let service: ActivityConsumerService;
20+
let consumerManagerServiceMock: jest.Mocked<ConsumerManagerService>;
21+
22+
beforeEach(() => {
23+
consumerManagerServiceMock = {
24+
register: jest.fn(),
25+
unregister: jest.fn()
26+
} as unknown as jest.Mocked<ConsumerManagerService>;
27+
28+
TestBed.configureTestingModule({
29+
providers: [
30+
ActivityConsumerService,
31+
{ provide: ConsumerManagerService, useValue: consumerManagerServiceMock }
32+
]
33+
});
34+
35+
service = TestBed.inject(ActivityConsumerService);
36+
});
37+
38+
afterEach(() => {
39+
jest.resetAllMocks();
40+
});
41+
42+
describe('initialization', () => {
43+
it('should be created', () => {
44+
expect(service).toBeDefined();
45+
expect(service instanceof ActivityConsumerService).toBe(true);
46+
});
47+
48+
it('should have the correct message type', () => {
49+
expect(service.type).toBe(USER_ACTIVITY_MESSAGE_TYPE);
50+
});
51+
52+
it('should have supported version 1.0', () => {
53+
expect(service.supportedVersions['1.0']).toBeDefined();
54+
expect(typeof service.supportedVersions['1.0']).toBe('function');
55+
});
56+
57+
it('should have undefined latestReceivedActivity initially', () => {
58+
expect(service.latestReceivedActivity()).toBeUndefined();
59+
});
60+
});
61+
62+
describe('start', () => {
63+
it('should register with consumer manager service', () => {
64+
service.start();
65+
66+
expect(consumerManagerServiceMock.register).toHaveBeenCalledWith(service);
67+
expect(consumerManagerServiceMock.register).toHaveBeenCalledTimes(1);
68+
});
69+
});
70+
71+
describe('stop', () => {
72+
it('should unregister from consumer manager service', () => {
73+
service.stop();
74+
75+
expect(consumerManagerServiceMock.unregister).toHaveBeenCalledWith(service);
76+
expect(consumerManagerServiceMock.unregister).toHaveBeenCalledTimes(1);
77+
});
78+
});
79+
80+
describe('supportedVersions["1.0"]', () => {
81+
it('should update latestReceivedActivity signal with message data', () => {
82+
const mockMessage: RoutedMessage<UserActivityMessageV1_0> = {
83+
from: 'test-channel-id',
84+
to: ['consumer'],
85+
payload: {
86+
type: USER_ACTIVITY_MESSAGE_TYPE,
87+
version: '1.0',
88+
eventType: 'click',
89+
timestamp: 1_234_567_890
90+
}
91+
};
92+
93+
service.supportedVersions['1.0'](mockMessage);
94+
95+
const activity = service.latestReceivedActivity();
96+
expect(activity).toBeDefined();
97+
expect(activity?.channelId).toBe('test-channel-id');
98+
expect(activity?.eventType).toBe('click');
99+
expect(activity?.timestamp).toBe(1_234_567_890);
100+
});
101+
102+
it('should use "unknown" as channelId when from is undefined', () => {
103+
const mockMessage: RoutedMessage<UserActivityMessageV1_0> = {
104+
from: undefined,
105+
to: ['consumer'],
106+
payload: {
107+
type: USER_ACTIVITY_MESSAGE_TYPE,
108+
version: '1.0',
109+
eventType: 'keydown',
110+
timestamp: 9_876_543_210
111+
}
112+
};
113+
114+
service.supportedVersions['1.0'](mockMessage);
115+
116+
const activity = service.latestReceivedActivity();
117+
expect(activity?.channelId).toBe('unknown');
118+
});
119+
120+
it('should update latestReceivedActivity on subsequent messages', () => {
121+
const firstMessage: RoutedMessage<UserActivityMessageV1_0> = {
122+
from: 'channel-1',
123+
to: ['consumer'],
124+
payload: {
125+
type: USER_ACTIVITY_MESSAGE_TYPE,
126+
version: '1.0',
127+
eventType: 'click',
128+
timestamp: 1000
129+
}
130+
};
131+
132+
const secondMessage: RoutedMessage<UserActivityMessageV1_0> = {
133+
from: 'channel-2',
134+
to: ['consumer'],
135+
payload: {
136+
type: USER_ACTIVITY_MESSAGE_TYPE,
137+
version: '1.0',
138+
eventType: 'scroll',
139+
timestamp: 2000
140+
}
141+
};
142+
143+
service.supportedVersions['1.0'](firstMessage);
144+
expect(service.latestReceivedActivity()?.channelId).toBe('channel-1');
145+
expect(service.latestReceivedActivity()?.eventType).toBe('click');
146+
147+
service.supportedVersions['1.0'](secondMessage);
148+
expect(service.latestReceivedActivity()?.channelId).toBe('channel-2');
149+
expect(service.latestReceivedActivity()?.eventType).toBe('scroll');
150+
expect(service.latestReceivedActivity()?.timestamp).toBe(2000);
151+
});
152+
153+
it('should handle all event types', () => {
154+
const eventTypes = ['click', 'keydown', 'scroll', 'touchstart', 'focus', 'visibilitychange'] as const;
155+
156+
eventTypes.forEach((eventType) => {
157+
const mockMessage: RoutedMessage<UserActivityMessageV1_0> = {
158+
from: 'test-channel',
159+
to: ['consumer'],
160+
payload: {
161+
type: USER_ACTIVITY_MESSAGE_TYPE,
162+
version: '1.0',
163+
eventType,
164+
timestamp: Date.now()
165+
}
166+
};
167+
168+
service.supportedVersions['1.0'](mockMessage);
169+
expect(service.latestReceivedActivity()?.eventType).toBe(eventType);
170+
});
171+
});
172+
});
173+
174+
describe('latestReceivedActivity signal', () => {
175+
it('should be read-only', () => {
176+
// The signal should be a readonly signal (no set method exposed)
177+
expect(service.latestReceivedActivity).toBeDefined();
178+
expect(typeof service.latestReceivedActivity).toBe('function');
179+
// Verify it's not a WritableSignal by checking it doesn't have set/update methods
180+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- testing signal type
181+
expect((service.latestReceivedActivity as any).set).toBeUndefined();
182+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- testing signal type
183+
expect((service.latestReceivedActivity as any).update).toBeUndefined();
184+
});
185+
});
186+
});

0 commit comments

Comments
 (0)