Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions src/app/components/session-tab/session-tab.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,14 @@
* limitations under the License.
*/

import {Component, EventEmitter, Input, OnInit, Output, Inject} from '@angular/core';
import {NgClass} from '@angular/common';
import {ChangeDetectorRef, Component, EventEmitter, Inject, inject, Input, OnInit, Output} from '@angular/core';
import {MatDialog} from '@angular/material/dialog';
import {Subject} from 'rxjs';
import {switchMap} from 'rxjs/operators';

import {Session} from '../../core/models/Session';
import {SessionService, SESSION_SERVICE} from '../../core/services/session.service';
import { NgClass } from '@angular/common';
import {SESSION_SERVICE, SessionService} from '../../core/services/session.service';

@Component({
selector: 'app-session-tab',
Expand All @@ -40,6 +41,7 @@ export class SessionTabComponent implements OnInit {
sessionList: any[] = [];

private refreshSessionsSubject = new Subject<void>();
private readonly changeDetectorRef = inject(ChangeDetectorRef);

constructor(
@Inject(SESSION_SERVICE) private sessionService: SessionService,
Expand All @@ -58,6 +60,7 @@ export class SessionTabComponent implements OnInit {
Number(b.lastUpdateTime) - Number(a.lastUpdateTime),
);
this.sessionList = res;
this.changeDetectorRef.detectChanges();
});
}

Expand Down
115 changes: 112 additions & 3 deletions src/app/components/trace-tab/trace-tab.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,27 +14,136 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {HarnessLoader} from '@angular/cdk/testing';
import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {MatExpansionPanelHarness} from '@angular/material/expansion/testing';
import {NoopAnimationsModule} from '@angular/platform-browser/animations';

import {Span} from '../../core/models/Trace';
import {TRACE_SERVICE} from '../../core/services/trace.service';

import {MockTraceService} from './../../core/services/testing/mock-trace.service';
import {TraceTabComponent} from './trace-tab.component';

const MOCK_TRACE_DATA: Span[] = [
{
name: 'agent.act',
start_time: 1733084700000000000,
end_time: 1733084760000000000,
span_id: 'span-1',
trace_id: 'trace-1',
attributes: {
'event_id': 1,
'gcp.vertex.agent.invocation_id': '21332-322222',
'gcp.vertex.agent.llm_request':
'{"contents":[{"role":"user","parts":[{"text":"Hello"}]},{"role":"agent","parts":[{"text":"Hi. What can I help you with?"}]},{"role":"user","parts":[{"text":"I need help with my project."}]}]}',
},
},
{
name: 'tool.invoke',
start_time: 1733084705000000000,
end_time: 1733084755000000000,
span_id: 'span-2',
parent_span_id: 'span-1',
trace_id: 'trace-1',
attributes: {
'tool_name': 'project_helper',
},
},
];

describe('TraceTabComponent', () => {
let component: TraceTabComponent;
let fixture: ComponentFixture<TraceTabComponent>;
let loader: HarnessLoader;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TraceTabComponent]
})
await TestBed
.configureTestingModule({
imports: [TraceTabComponent, NoopAnimationsModule],
providers: [
{provide: TRACE_SERVICE, useClass: MockTraceService},
],
})
.compileComponents();

fixture = TestBed.createComponent(TraceTabComponent);
component = fixture.componentInstance;
loader = TestbedHarnessEnvironment.loader(fixture);
fixture.detectChanges();
});

it('should create', () => {
expect(component).toBeTruthy();
});

it('should display no invocations if traceData is empty', async () => {
const expansionPanels = await loader.getAllHarnesses(
MatExpansionPanelHarness,
);
expect(expansionPanels.length).toBe(0);
});

describe('with trace data', () => {
const MOCK_TRACE_DATA_WITH_MULTIPLE_TRACES: Span[] = [
...MOCK_TRACE_DATA,
{
name: 'agent.act-2',
start_time: 1733084700000000000,
end_time: 1733084760000000000,
span_id: 'span-10',
trace_id: 'trace-2',
attributes: {
'event_id': 10,
'gcp.vertex.agent.invocation_id': 'invoc-2',
'gcp.vertex.agent.llm_request':
'{"contents":[{"role":"user","parts":[{"text":"Another user message"}]}]}',
},
},
];

beforeEach(async () => {
fixture.componentRef.setInput(
'traceData',
MOCK_TRACE_DATA_WITH_MULTIPLE_TRACES,
);
fixture.detectChanges();
await fixture.whenStable();
});

it('should group traces by trace_id and display as expansion panels',
async () => {
const expansionPanels = await loader.getAllHarnesses(
MatExpansionPanelHarness,
);
expect(expansionPanels.length).toBe(2);
});

it('should display user message as panel title', async () => {
const expansionPanels = await loader.getAllHarnesses(
MatExpansionPanelHarness,
);
expect(await expansionPanels[0].getTitle())
.toBe(
'I need help with my project.',
);
expect(await expansionPanels[1].getTitle()).toBe('Another user message');
});

it('should pass correct data to trace-tree component', async () => {
spyOn(component, 'findInvocIdFromTraceId').and.callThrough();
const expansionPanels = await loader.getAllHarnesses(
MatExpansionPanelHarness,
);
await expansionPanels[0].expand();
fixture.detectChanges();

expect(component.findInvocIdFromTraceId).toHaveBeenCalledWith('trace-1');
const traceTree = fixture.nativeElement.querySelector('app-trace-tree');
expect(traceTree).toBeTruthy();
// Further inspection of trace-tree inputs would require a harness or
// mocking TraceTreeComponent
});
});
});
5 changes: 5 additions & 0 deletions src/app/components/trace-tab/trace-tab.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ export class TraceTabComponent implements OnInit, OnChanges {
const eventItem = group?.find(
item => item.attributes !== undefined &&
'gcp.vertex.agent.invocation_id' in item.attributes)

if (!eventItem) {
return '[no invocation id found]';
}

const requestJson =
JSON.parse(eventItem.attributes['gcp.vertex.agent.llm_request']) as LlmRequest
const userContent =
Expand Down
8 changes: 7 additions & 1 deletion src/app/core/models/AgentRunRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,13 @@ export interface AgentRunRequest {
appName: string;
userId: string;
sessionId: string;
newMessage: any;
newMessage: {
parts: Array<{
text?: string,
'function_response'?: {id?: string; name?: string; response?: any}
}>,
role: string,
};
functionCallEventId?: string;
streaming?: boolean;
stateDelta?: any;
Expand Down
2 changes: 1 addition & 1 deletion src/app/core/services/agent.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ const RUN_SSE_PAYLOAD = {
sessionId: SESSION_ID,
appName: TEST_APP_NAME,
userId: USER_ID,
newMessage: NEW_MESSAGE,
newMessage: {parts: [{text: NEW_MESSAGE}], role: 'user'},
};

describe('AgentService', () => {
Expand Down
171 changes: 171 additions & 0 deletions src/app/directives/resizable-drawer.directive.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/**
* @license
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import {Component} from '@angular/core';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {By} from '@angular/platform-browser';

import {ResizableDrawerDirective} from './resizable-drawer.directive';

// Directive constants
const SIDE_DRAWER_WIDTH_VAR = '--side-drawer-width';
const INITIAL_WIDTH = 570;
const MIN_WIDTH = 310;

// Test constants
const MOCKED_WINDOW_WIDTH = 2000;
const MAX_WIDTH = MOCKED_WINDOW_WIDTH / 2; // 1000

@Component({
template: `
<div appResizableDrawer>Drawer</div>
<div class="resize-handler"></div>
`,
standalone: true,
imports: [ResizableDrawerDirective],
})
class TestHostComponent {
}

describe('ResizableDrawerDirective', () => {
let fixture: ComponentFixture<TestHostComponent>;
let directiveElement: HTMLElement;
let resizeHandle: HTMLElement;
let body: HTMLElement;
let innerWidthSpy: jasmine.Spy;

/**
* Reads the drawer width from the CSS variable on the root element.
*/
function getDrawerWidth(): number {
const widthStr = getComputedStyle(document.documentElement)
.getPropertyValue(SIDE_DRAWER_WIDTH_VAR);
return parseFloat(widthStr);
}

/**
* Dispatches a mouse event with a given clientX position.
*/
function dispatchMouseEvent(
element: EventTarget, type: string, clientX: number) {
element.dispatchEvent(new MouseEvent(type, {
bubbles: true,
clientX,
}));
}

beforeEach(() => {
TestBed.configureTestingModule({
imports: [TestHostComponent],
});
innerWidthSpy = spyOnProperty(window, 'innerWidth', 'get');
innerWidthSpy.and.returnValue(MOCKED_WINDOW_WIDTH);
fixture = TestBed.createComponent(TestHostComponent);
directiveElement =
fixture.debugElement.query(By.directive(ResizableDrawerDirective))
.nativeElement;
resizeHandle =
fixture.debugElement.query(By.css('.resize-handler')).nativeElement;
body = document.body;
fixture.detectChanges(); // This calls ngAfterViewInit
window.dispatchEvent(new Event('resize'));
});

afterEach(() => {
document.documentElement.style.removeProperty(SIDE_DRAWER_WIDTH_VAR);
body.classList.remove('resizing');
});

it('should set initial width to 570px after view init', () => {
// Assert
expect(directiveElement.style.width).toBe('var(--side-drawer-width)');
expect(getDrawerWidth()).toBe(INITIAL_WIDTH);
});

it('should resize drawer on mouse drag', () => {
// Arrange
const startClientX = 600;
const moveClientX = 650; // Drag 50px to the right
const expectedWidth = INITIAL_WIDTH + (moveClientX - startClientX);

// Act
dispatchMouseEvent(resizeHandle, 'mousedown', startClientX);
dispatchMouseEvent(document, 'mousemove', moveClientX);
fixture.detectChanges();

// Assert
expect(getDrawerWidth()).toBe(expectedWidth);
expect(body.classList).toContain('resizing');

// Act: Release mouse
dispatchMouseEvent(document, 'mouseup', moveClientX);
fixture.detectChanges();

// Assert: Resizing class is removed
expect(body.classList).not.toContain('resizing');
});

it('should not resize below min width', () => {
// Arrange
const startClientX = 600;
const moveClientX = 100; // Attempt to drag far left (-500px)

// Act
dispatchMouseEvent(resizeHandle, 'mousedown', startClientX);
dispatchMouseEvent(document, 'mousemove', moveClientX);
fixture.detectChanges();

// Assert
expect(getDrawerWidth()).toBe(MIN_WIDTH);

// Cleanup
dispatchMouseEvent(document, 'mouseup', moveClientX);
});

it('should not resize above max width', () => {
// Arrange
const startClientX = 100;
const moveClientX = 9000; // Attempt to drag far right

// Act
dispatchMouseEvent(resizeHandle, 'mousedown', startClientX);
dispatchMouseEvent(document, 'mousemove', moveClientX);
fixture.detectChanges();

// Assert
expect(getDrawerWidth()).toBe(MAX_WIDTH);

// Cleanup
dispatchMouseEvent(document, 'mouseup', moveClientX);
});

it('should re-clamp width on window resize if current width exceeds new max width',
() => {
// Arrange
expect(getDrawerWidth()).toBe(INITIAL_WIDTH); // 570
const smallWindowWidth = 800;
const expectedClampedWidth = smallWindowWidth / 2; // 400

// Act
innerWidthSpy.and.returnValue(smallWindowWidth);
window.dispatchEvent(new Event('resize'));
fixture.detectChanges();

// Assert
expect(getDrawerWidth()).toBe(expectedClampedWidth);
});
});
Loading