@testgorilla/tgo-ai-interview-test 0.0.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.
- package/.eslintrc.json +46 -0
- package/README.md +91 -0
- package/jest.config.ts +29 -0
- package/ng-package.json +16 -0
- package/package.json +25 -0
- package/project.json +37 -0
- package/src/assets/i18n/en.json +19 -0
- package/src/index.ts +3 -0
- package/src/lib/components/ai-interview-test/ai-interview-test.component.html +42 -0
- package/src/lib/components/ai-interview-test/ai-interview-test.component.scss +167 -0
- package/src/lib/components/ai-interview-test/ai-interview-test.component.spec.ts +211 -0
- package/src/lib/components/ai-interview-test/ai-interview-test.component.ts +193 -0
- package/src/lib/components/index.ts +3 -0
- package/src/lib/components/interview-stream/interview-stream.component.html +9 -0
- package/src/lib/components/interview-stream/interview-stream.component.scss +5 -0
- package/src/lib/components/interview-stream/interview-stream.component.spec.ts +285 -0
- package/src/lib/components/interview-stream/interview-stream.component.ts +321 -0
- package/src/lib/components/interview-video/interview-video.component.html +8 -0
- package/src/lib/components/interview-video/interview-video.component.scss +7 -0
- package/src/lib/components/interview-video/interview-video.component.spec.ts +140 -0
- package/src/lib/components/interview-video/interview-video.component.ts +68 -0
- package/src/lib/models/index.ts +13 -0
- package/src/lib/models/question-component.ts +13 -0
- package/src/lib/models/translations.ts +3 -0
- package/src/test-setup.ts +28 -0
- package/tsconfig.json +17 -0
- package/tsconfig.lib.json +15 -0
- package/tsconfig.lib.prod.json +10 -0
- package/tsconfig.spec.json +13 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChangeDetectorRef,
|
|
3
|
+
Component,
|
|
4
|
+
EventEmitter,
|
|
5
|
+
inject,
|
|
6
|
+
Input,
|
|
7
|
+
OnDestroy,
|
|
8
|
+
OnInit,
|
|
9
|
+
Output,
|
|
10
|
+
signal,
|
|
11
|
+
} from '@angular/core';
|
|
12
|
+
import { CommonModule } from '@angular/common';
|
|
13
|
+
import DailyIframe, {
|
|
14
|
+
DailyCall,
|
|
15
|
+
DailyEventObjectParticipant,
|
|
16
|
+
DailyParticipant,
|
|
17
|
+
DailyEventObjectFatalError,
|
|
18
|
+
DailyEventObjectParticipants,
|
|
19
|
+
DailyEventObjectNoPayload,
|
|
20
|
+
DailyEventObjectParticipantLeft,
|
|
21
|
+
DailyEventObjectTrack,
|
|
22
|
+
DailyEventObjectAppMessage,
|
|
23
|
+
} from '@daily-co/daily-js';
|
|
24
|
+
import { SelectedMediaDevices } from '@testgorilla/tgo-test-shared';
|
|
25
|
+
import { InterviewVideoComponent } from '../interview-video/interview-video.component';
|
|
26
|
+
|
|
27
|
+
export type Participant = {
|
|
28
|
+
videoTrack?: MediaStreamTrack | undefined;
|
|
29
|
+
audioTrack?: MediaStreamTrack | undefined;
|
|
30
|
+
videoReady: boolean;
|
|
31
|
+
audioReady: boolean;
|
|
32
|
+
userName: string;
|
|
33
|
+
local: boolean;
|
|
34
|
+
sessionId: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const PLAYABLE_STATE = 'playable';
|
|
38
|
+
const LOADING_STATE = 'loading';
|
|
39
|
+
|
|
40
|
+
interface EventData {
|
|
41
|
+
event_type: string;
|
|
42
|
+
properties: {
|
|
43
|
+
name: string;
|
|
44
|
+
arguments: string;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
@Component({
|
|
49
|
+
selector: 'tgo-interview-stream',
|
|
50
|
+
templateUrl: './interview-stream.component.html',
|
|
51
|
+
styleUrls: ['./interview-stream.component.scss'],
|
|
52
|
+
standalone: true,
|
|
53
|
+
imports: [CommonModule, InterviewVideoComponent],
|
|
54
|
+
})
|
|
55
|
+
export class InterviewStreamComponent implements OnInit, OnDestroy {
|
|
56
|
+
@Input() conversationUrl: string | undefined;
|
|
57
|
+
@Input() selectedMediaDevices: SelectedMediaDevices | undefined;
|
|
58
|
+
@Input() translations: { [key: string]: string } = {};
|
|
59
|
+
@Output() streamStart: EventEmitter<null> = new EventEmitter();
|
|
60
|
+
@Output() streamEnd: EventEmitter<null> = new EventEmitter();
|
|
61
|
+
@Output() checkMediaPermissions: EventEmitter<null> = new EventEmitter();
|
|
62
|
+
|
|
63
|
+
callObject: DailyCall | undefined;
|
|
64
|
+
avatarParticipant: Participant | undefined;
|
|
65
|
+
candidateJoined = signal(false);
|
|
66
|
+
private cdr = inject(ChangeDetectorRef);
|
|
67
|
+
|
|
68
|
+
ngOnInit(): void {
|
|
69
|
+
void this.setupCall();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
ngOnDestroy(): void {
|
|
73
|
+
if (!this.callObject) return;
|
|
74
|
+
this.leaveCall();
|
|
75
|
+
this.callObject
|
|
76
|
+
.off('joined-meeting', this.candidateJoinMeeting)
|
|
77
|
+
.off('participant-joined', this.participantJoined)
|
|
78
|
+
.off('app-message', this.handleNewMessage)
|
|
79
|
+
.off('track-started', this.handleTrackStartedStopped)
|
|
80
|
+
.off('track-stopped', this.handleTrackStartedStopped)
|
|
81
|
+
.off('participant-left', this.handleParticipantLeft)
|
|
82
|
+
.off('left-meeting', this.handleLeftMeeting)
|
|
83
|
+
.off('error', this.handleError);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private async setupCall() {
|
|
87
|
+
this.callObject = DailyIframe.getCallInstance();
|
|
88
|
+
if (!this.callObject) {
|
|
89
|
+
this.callObject = DailyIframe.createCallObject();
|
|
90
|
+
}
|
|
91
|
+
if (this.selectedMediaDevices) {
|
|
92
|
+
await this.callObject.setInputDevicesAsync({
|
|
93
|
+
videoDeviceId: this.selectedMediaDevices.videoDeviceId,
|
|
94
|
+
audioDeviceId: this.selectedMediaDevices.audioDeviceId,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
this.callObject.startRecording({
|
|
98
|
+
type: 'cloud',
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
this.callObject
|
|
102
|
+
.on('joined-meeting', this.candidateJoinMeeting)
|
|
103
|
+
.on('participant-joined', this.participantJoined)
|
|
104
|
+
.on('app-message', this.handleNewMessage)
|
|
105
|
+
.on('track-started', this.handleTrackStartedStopped)
|
|
106
|
+
.on('track-stopped', this.handleTrackStartedStopped)
|
|
107
|
+
.on('participant-left', this.handleParticipantLeft)
|
|
108
|
+
.on('left-meeting', this.handleLeftMeeting)
|
|
109
|
+
.on('error', this.handleError);
|
|
110
|
+
|
|
111
|
+
await this.callObject.join({
|
|
112
|
+
userName: 'Candidate',
|
|
113
|
+
url: this.conversationUrl,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
formatParticipantObj(participant: DailyParticipant): Participant {
|
|
118
|
+
const { video, audio } = participant.tracks;
|
|
119
|
+
|
|
120
|
+
const videoTrack = video?.persistentTrack;
|
|
121
|
+
const audioTrack = audio?.persistentTrack;
|
|
122
|
+
return {
|
|
123
|
+
videoTrack: videoTrack,
|
|
124
|
+
audioTrack: audioTrack,
|
|
125
|
+
videoReady: !!(
|
|
126
|
+
videoTrack &&
|
|
127
|
+
(video.state === PLAYABLE_STATE || video.state === LOADING_STATE)
|
|
128
|
+
),
|
|
129
|
+
audioReady: !!(
|
|
130
|
+
audioTrack &&
|
|
131
|
+
(audio.state === PLAYABLE_STATE || audio.state === LOADING_STATE)
|
|
132
|
+
),
|
|
133
|
+
userName: participant.user_name,
|
|
134
|
+
local: participant.local,
|
|
135
|
+
sessionId: participant.session_id,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
updateTrack(participant: DailyParticipant, newTrackType: string): void {
|
|
140
|
+
if (
|
|
141
|
+
!this.avatarParticipant ||
|
|
142
|
+
this.avatarParticipant.sessionId !== participant.session_id
|
|
143
|
+
) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const existingParticipant = this.avatarParticipant as Participant;
|
|
147
|
+
const currentParticipantCopy = this.formatParticipantObj(participant);
|
|
148
|
+
|
|
149
|
+
if (newTrackType === 'video') {
|
|
150
|
+
if (
|
|
151
|
+
existingParticipant.videoReady !== currentParticipantCopy.videoReady
|
|
152
|
+
) {
|
|
153
|
+
existingParticipant.videoReady = currentParticipantCopy.videoReady;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (
|
|
157
|
+
currentParticipantCopy.videoReady &&
|
|
158
|
+
existingParticipant.videoTrack?.id !==
|
|
159
|
+
currentParticipantCopy.videoTrack?.id
|
|
160
|
+
) {
|
|
161
|
+
existingParticipant.videoTrack = currentParticipantCopy.videoTrack;
|
|
162
|
+
}
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (newTrackType === 'audio') {
|
|
167
|
+
if (
|
|
168
|
+
existingParticipant.audioReady !== currentParticipantCopy.audioReady
|
|
169
|
+
) {
|
|
170
|
+
existingParticipant.audioReady = currentParticipantCopy.audioReady;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (
|
|
174
|
+
currentParticipantCopy.audioReady &&
|
|
175
|
+
existingParticipant.audioTrack?.id !==
|
|
176
|
+
currentParticipantCopy.audioTrack?.id
|
|
177
|
+
) {
|
|
178
|
+
existingParticipant.audioTrack = currentParticipantCopy.audioTrack;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private candidateJoinMeeting = (
|
|
184
|
+
event: DailyEventObjectParticipants | undefined
|
|
185
|
+
): void => {
|
|
186
|
+
if (!event || !this.callObject) return;
|
|
187
|
+
this.candidateJoined.set(true);
|
|
188
|
+
this.streamStart.emit();
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
private participantJoined = (
|
|
192
|
+
event: DailyEventObjectParticipant | undefined
|
|
193
|
+
) => {
|
|
194
|
+
if (!event) return;
|
|
195
|
+
this.avatarParticipant = this.formatParticipantObj(event.participant);
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
private handleTrackStartedStopped = (
|
|
199
|
+
event: DailyEventObjectTrack | undefined
|
|
200
|
+
): void => {
|
|
201
|
+
if (!event || !event.participant || !this.candidateJoined()) return;
|
|
202
|
+
if (event.action === 'track-stopped') {
|
|
203
|
+
this.checkMediaPermissions.emit();
|
|
204
|
+
}
|
|
205
|
+
this.updateTrack(event.participant, event.type);
|
|
206
|
+
this.cdr.detectChanges();
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
private handleParticipantLeft = (
|
|
210
|
+
event: DailyEventObjectParticipantLeft | undefined
|
|
211
|
+
): void => {
|
|
212
|
+
if (!event) return;
|
|
213
|
+
this.leaveCall();
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
private handleError = (
|
|
217
|
+
event: DailyEventObjectFatalError | undefined
|
|
218
|
+
): void => {
|
|
219
|
+
if (!event) return;
|
|
220
|
+
console.error('Interview stream error', event);
|
|
221
|
+
this.leaveCall();
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
private handleLeftMeeting = (
|
|
225
|
+
event: DailyEventObjectNoPayload | undefined
|
|
226
|
+
): void => {
|
|
227
|
+
this.callObject?.stopRecording();
|
|
228
|
+
if (!event || !this.callObject) return;
|
|
229
|
+
this.candidateJoined.set(false);
|
|
230
|
+
this.callObject.destroy();
|
|
231
|
+
this.streamEnd.emit();
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
private leaveCall(): void {
|
|
235
|
+
if (!this.callObject) return;
|
|
236
|
+
this.callObject.leave();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private handleNewMessage = (
|
|
240
|
+
event: DailyEventObjectAppMessage<EventData> | undefined
|
|
241
|
+
): void => {
|
|
242
|
+
if (!event) return;
|
|
243
|
+
if (event.data.event_type === 'conversation.tool_call') {
|
|
244
|
+
this.handleToolCall(event.data.properties);
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
private getConversationId(): string | undefined {
|
|
249
|
+
return this.conversationUrl?.replace('https://tavus.daily.co/', '');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/*
|
|
253
|
+
This is a test implementation of tool calling.
|
|
254
|
+
These events will only be triggered if configured with a Tavus persona. The message content will be further refined by the IP Team.
|
|
255
|
+
https://docs.tavus.io/sections/event-schemas/conversation-toolcall
|
|
256
|
+
*/
|
|
257
|
+
private handleToolCall = (properties: {
|
|
258
|
+
name: string;
|
|
259
|
+
arguments: string;
|
|
260
|
+
}): void => {
|
|
261
|
+
switch (properties.name) {
|
|
262
|
+
case 'next_question':
|
|
263
|
+
try {
|
|
264
|
+
const args = JSON.parse(properties.arguments);
|
|
265
|
+
if (args?.questionsLeft > 0) {
|
|
266
|
+
this.sendMessage(this.getToolCallTranslation('NEXT_QUESTION'));
|
|
267
|
+
window.setTimeout(() => {
|
|
268
|
+
this.sendMessage('Read next question', 'respond');
|
|
269
|
+
}, 1000);
|
|
270
|
+
} else {
|
|
271
|
+
throw new Error('No more questions left');
|
|
272
|
+
}
|
|
273
|
+
} catch (err) {
|
|
274
|
+
console.error(
|
|
275
|
+
'Failed to parse arguments for next_question tool call',
|
|
276
|
+
err
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
this.sendMessage(this.getToolCallTranslation('ALL_FOR_TODAY'));
|
|
280
|
+
window.setTimeout(() => {
|
|
281
|
+
this.leaveCall();
|
|
282
|
+
}, 5000);
|
|
283
|
+
}
|
|
284
|
+
break;
|
|
285
|
+
case 'end_conversation':
|
|
286
|
+
window.setTimeout(() => {
|
|
287
|
+
this.leaveCall();
|
|
288
|
+
}, 5000);
|
|
289
|
+
break;
|
|
290
|
+
default:
|
|
291
|
+
console.warn('Unknown tool call code:', properties);
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
/*
|
|
297
|
+
Echo message is a message that avatar reads and candidate can hear
|
|
298
|
+
Respond message is a message that avatar reads and candidate cannot hear
|
|
299
|
+
*/
|
|
300
|
+
|
|
301
|
+
private sendMessage(text: string, type: 'echo' | 'respond' = 'echo'): void {
|
|
302
|
+
if (!this.callObject) return;
|
|
303
|
+
|
|
304
|
+
this.callObject.sendAppMessage({
|
|
305
|
+
message_type: 'conversation',
|
|
306
|
+
event_type: 'conversation.' + type,
|
|
307
|
+
conversation_id: this.getConversationId(),
|
|
308
|
+
properties: {
|
|
309
|
+
text,
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private getToolCallTranslation(key: string) {
|
|
315
|
+
const toolCallTranslations = this.translations['TOOL_CALL'] as unknown as {
|
|
316
|
+
[key: string]: string;
|
|
317
|
+
};
|
|
318
|
+
return toolCallTranslations[key];
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
|
2
|
+
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
3
|
+
import { InterviewVideoComponent } from './interview-video.component';
|
|
4
|
+
|
|
5
|
+
class MediaStreamTrackMock {
|
|
6
|
+
kind: string;
|
|
7
|
+
enabled: boolean;
|
|
8
|
+
id: string;
|
|
9
|
+
|
|
10
|
+
constructor(kind = 'video') {
|
|
11
|
+
this.kind = kind;
|
|
12
|
+
this.enabled = true;
|
|
13
|
+
this.id = `${kind}-track-${Math.random().toString(36).substring(2, 15)}`;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('InterviewVideoComponent', () => {
|
|
18
|
+
let component: InterviewVideoComponent;
|
|
19
|
+
let fixture: ComponentFixture<InterviewVideoComponent>;
|
|
20
|
+
beforeEach(async () => {
|
|
21
|
+
class MediaStreamMock {
|
|
22
|
+
tracks: MediaStreamTrack[] = [];
|
|
23
|
+
constructor(initialTracks: MediaStreamTrack[]) {
|
|
24
|
+
this.tracks = [...initialTracks];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
addTrack(track: MediaStreamTrack) {
|
|
28
|
+
this.tracks.push(track);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
removeTrack(track: MediaStreamTrack) {
|
|
32
|
+
const index = this.tracks.findIndex((t) => t.id === track.id);
|
|
33
|
+
if (index !== -1) {
|
|
34
|
+
this.tracks.splice(index, 1);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
getVideoTracks() {
|
|
38
|
+
return this.tracks.filter((t) => t.kind === 'video');
|
|
39
|
+
}
|
|
40
|
+
getAudioTracks() {
|
|
41
|
+
return this.tracks.filter((t) => t.kind === 'audio');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
Object.defineProperty(global, 'MediaStream', {
|
|
46
|
+
writable: true,
|
|
47
|
+
value: jest
|
|
48
|
+
.fn()
|
|
49
|
+
.mockImplementation((tracks) => new MediaStreamMock(tracks)),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
await TestBed.configureTestingModule({
|
|
53
|
+
imports: [InterviewVideoComponent],
|
|
54
|
+
schemas: [NO_ERRORS_SCHEMA],
|
|
55
|
+
}).compileComponents();
|
|
56
|
+
|
|
57
|
+
fixture = TestBed.createComponent(InterviewVideoComponent);
|
|
58
|
+
component = fixture.componentInstance;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
fixture.destroy();
|
|
63
|
+
jest.clearAllMocks();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('when component is initialized with videoTrack and audioTrack', () => {
|
|
67
|
+
const testVideoTrack = new MediaStreamTrackMock(
|
|
68
|
+
'video'
|
|
69
|
+
) as MediaStreamTrack;
|
|
70
|
+
const testAudioTrack = new MediaStreamTrackMock(
|
|
71
|
+
'audio'
|
|
72
|
+
) as MediaStreamTrack;
|
|
73
|
+
beforeEach(() => {
|
|
74
|
+
component.videoTrack = testVideoTrack;
|
|
75
|
+
component.audioTrack = testAudioTrack;
|
|
76
|
+
component.ngOnInit();
|
|
77
|
+
fixture.detectChanges();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should initialize videoStream and audioStream', () => {
|
|
81
|
+
expect(component.videoStream).toBeDefined();
|
|
82
|
+
expect(component.audioStream).toBeDefined();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should add video track to videoStream', () => {
|
|
86
|
+
expect(component.videoStream?.getVideoTracks().length).toBe(1);
|
|
87
|
+
expect(component.videoStream?.getVideoTracks()[0].id).toBe(
|
|
88
|
+
testVideoTrack.id
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should add audio track to audioStream', () => {
|
|
93
|
+
expect(component.audioStream?.getAudioTracks().length).toBe(1);
|
|
94
|
+
expect(component.audioStream?.getAudioTracks()[0].id).toBe(
|
|
95
|
+
testAudioTrack.id
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('when track changes', () => {
|
|
100
|
+
const changedVideoTrack = new MediaStreamTrackMock(
|
|
101
|
+
'video'
|
|
102
|
+
) as MediaStreamTrack;
|
|
103
|
+
const changedAudioTrack = new MediaStreamTrackMock(
|
|
104
|
+
'audio'
|
|
105
|
+
) as MediaStreamTrack;
|
|
106
|
+
beforeEach(() => {
|
|
107
|
+
component.ngOnChanges({
|
|
108
|
+
videoTrack: {
|
|
109
|
+
currentValue: changedVideoTrack,
|
|
110
|
+
previousValue: testVideoTrack,
|
|
111
|
+
isFirstChange: () => false,
|
|
112
|
+
firstChange: false,
|
|
113
|
+
},
|
|
114
|
+
audioTrack: {
|
|
115
|
+
currentValue: changedAudioTrack,
|
|
116
|
+
previousValue: testAudioTrack,
|
|
117
|
+
isFirstChange: () => false,
|
|
118
|
+
firstChange: false,
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
fixture.detectChanges();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('should update videoStream with new video track', () => {
|
|
125
|
+
expect(component.videoStream?.getVideoTracks().length).toBe(1);
|
|
126
|
+
expect(component.videoStream?.getVideoTracks()[0].id).toBe(
|
|
127
|
+
changedVideoTrack.id
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should update audioStream with new audio track', () => {
|
|
132
|
+
expect(component.audioStream?.getAudioTracks().length).toBe(1);
|
|
133
|
+
expect(component.audioStream?.getAudioTracks()[0].id).toBe(
|
|
134
|
+
changedAudioTrack.id
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Component, Input, OnInit, SimpleChanges, OnChanges } from '@angular/core';
|
|
2
|
+
import { CommonModule } from '@angular/common';
|
|
3
|
+
|
|
4
|
+
@Component({
|
|
5
|
+
selector: 'tgo-interview-video',
|
|
6
|
+
templateUrl: './interview-video.component.html',
|
|
7
|
+
styleUrls: ['./interview-video.component.scss'],
|
|
8
|
+
standalone: true,
|
|
9
|
+
imports: [CommonModule],
|
|
10
|
+
})
|
|
11
|
+
export class InterviewVideoComponent implements OnInit, OnChanges {
|
|
12
|
+
@Input() videoTrack: MediaStreamTrack | undefined;
|
|
13
|
+
@Input() audioTrack: MediaStreamTrack | undefined;
|
|
14
|
+
videoStream: MediaStream | undefined;
|
|
15
|
+
audioStream: MediaStream | undefined;
|
|
16
|
+
|
|
17
|
+
ngOnInit(): void {
|
|
18
|
+
if (this.videoTrack) {
|
|
19
|
+
this.addVideoStream(this.videoTrack);
|
|
20
|
+
}
|
|
21
|
+
if (this.audioTrack) {
|
|
22
|
+
this.addAudioStream(this.audioTrack);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
ngOnChanges(changes: SimpleChanges): void {
|
|
27
|
+
const { videoTrack, audioTrack } = changes;
|
|
28
|
+
|
|
29
|
+
if (videoTrack?.currentValue && !this.videoStream) {
|
|
30
|
+
this.addVideoStream(videoTrack.currentValue);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (audioTrack?.currentValue && !this.audioStream) {
|
|
34
|
+
this.addAudioStream(audioTrack.currentValue);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (videoTrack?.currentValue && this.videoStream) {
|
|
38
|
+
this.updateVideoTrack(videoTrack.previousValue, videoTrack.currentValue);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (audioTrack?.currentValue && this.audioStream) {
|
|
42
|
+
this.updateAudioTrack(audioTrack.previousValue, audioTrack.currentValue);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
addVideoStream(track: MediaStreamTrack) {
|
|
47
|
+
this.videoStream = new MediaStream([track]);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
addAudioStream(track: MediaStreamTrack) {
|
|
51
|
+
this.audioStream = new MediaStream([track]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
updateVideoTrack(oldTrack: MediaStreamTrack, track: MediaStreamTrack) {
|
|
55
|
+
if (oldTrack) {
|
|
56
|
+
this.videoStream?.removeTrack(oldTrack);
|
|
57
|
+
}
|
|
58
|
+
this.videoStream?.addTrack(track);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
updateAudioTrack(oldTrack: MediaStreamTrack, track: MediaStreamTrack) {
|
|
62
|
+
if (oldTrack) {
|
|
63
|
+
this.audioStream?.removeTrack(oldTrack);
|
|
64
|
+
}
|
|
65
|
+
this.audioStream?.addTrack(track);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Re-export shared models
|
|
2
|
+
export {
|
|
3
|
+
Question,
|
|
4
|
+
TestResultRead,
|
|
5
|
+
SelectedMediaDevices,
|
|
6
|
+
ISubmissionState,
|
|
7
|
+
ROOT_TRANSLATIONS_SCOPE,
|
|
8
|
+
} from '@testgorilla/tgo-test-shared';
|
|
9
|
+
|
|
10
|
+
// Export library-specific extensions
|
|
11
|
+
export * from './question-component';
|
|
12
|
+
export * from './translations';
|
|
13
|
+
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { IQuestionDataContract as BaseIQuestionDataContract } from '@testgorilla/tgo-test-shared';
|
|
2
|
+
|
|
3
|
+
// Extend the base interface to add conversationUrl for AI Interview
|
|
4
|
+
export interface IQuestionDataContract extends BaseIQuestionDataContract {
|
|
5
|
+
conversationUrl?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// Re-export shared interfaces for convenience
|
|
9
|
+
export {
|
|
10
|
+
SelectedMediaDevices,
|
|
11
|
+
ISubmissionState,
|
|
12
|
+
} from '@testgorilla/tgo-test-shared';
|
|
13
|
+
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import 'jest-preset-angular/setup-jest';
|
|
2
|
+
|
|
3
|
+
// Mock HTMLMediaElement.play() for jsdom
|
|
4
|
+
Object.defineProperty(HTMLMediaElement.prototype, 'play', {
|
|
5
|
+
writable: true,
|
|
6
|
+
value: jest.fn().mockResolvedValue(undefined),
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
// Mock @daily-co/daily-js
|
|
10
|
+
jest.mock('@daily-co/daily-js', () => {
|
|
11
|
+
return {
|
|
12
|
+
default: jest.fn().mockImplementation(() => ({
|
|
13
|
+
createCallObject: jest.fn().mockReturnValue({
|
|
14
|
+
join: jest.fn(),
|
|
15
|
+
leave: jest.fn(),
|
|
16
|
+
destroy: jest.fn(),
|
|
17
|
+
setInputDevicesAsync: jest.fn(),
|
|
18
|
+
startRecording: jest.fn(),
|
|
19
|
+
stopRecording: jest.fn(),
|
|
20
|
+
sendAppMessage: jest.fn(),
|
|
21
|
+
on: jest.fn(),
|
|
22
|
+
off: jest.fn(),
|
|
23
|
+
}),
|
|
24
|
+
getCallInstance: jest.fn(),
|
|
25
|
+
})),
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "./tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"outDir": "../../dist/out-tsc",
|
|
5
|
+
"declaration": true,
|
|
6
|
+
"declarationMap": true,
|
|
7
|
+
"inlineSources": true,
|
|
8
|
+
"types": [
|
|
9
|
+
"node",
|
|
10
|
+
]
|
|
11
|
+
},
|
|
12
|
+
"exclude": ["src/**/*.spec.ts", "src/test-setup.ts", "jest.config.ts", "src/**/*.test.ts"],
|
|
13
|
+
"include": ["src/**/*.ts"]
|
|
14
|
+
}
|
|
15
|
+
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "./tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"outDir": "../../dist/out-tsc",
|
|
5
|
+
"module": "commonjs",
|
|
6
|
+
"target": "es2016",
|
|
7
|
+
"types": ["jest", "node"]
|
|
8
|
+
},
|
|
9
|
+
"files": ["src/test-setup.ts"],
|
|
10
|
+
"include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"],
|
|
11
|
+
"exclude": ["src/lib/models/test.ts"]
|
|
12
|
+
}
|
|
13
|
+
|