This is an automated email from the ASF dual-hosted git repository.
MartijnVisser pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/flink.git
The following commit(s) were added to refs/heads/master by this push:
new 383996fa423 [FLINK-40600][runtime-web] Add missing web dashboard specs
and tighten thin ones
383996fa423 is described below
commit 383996fa423f1315475ec36dd60987002dad6b2c
Author: Purushottam Sinha <[email protected]>
AuthorDate: Tue Sep 8 23:57:28 2026 +0530
[FLINK-40600][runtime-web] Add missing web dashboard specs and tighten thin
ones
FLINK-40117 added Vitest specs for most web dashboard views, but
pages/application/overview and pages/application/exceptions still had
none, and several existing specs only checked that mocked data was
loaded rather than what the component derived from it.
Adds the two missing specs, and tightens the timeline, job overview,
and cluster overview specs to assert their computed values, expands
TaskManagerService coverage beyond loadManagers/loadManager, and
replaces the HumanizeDatePipe format-'B' test's reliance on an
Angular-internal throw with one driven by an unregistered locale.
Generated-by: Claude Code (claude-sonnet-5)
---
.../src/app/components/humanize-date.pipe.spec.ts | 8 +-
.../application-exceptions.component.spec.ts | 104 +++++++++++++++
.../application-overview.component.spec.ts | 98 ++++++++++++++
.../job/overview/job-overview.component.spec.ts | 84 +++++++++++-
.../job/timeline/job-timeline.component.spec.ts | 26 +++-
.../app/pages/overview/overview.component.spec.ts | 42 +++++-
.../src/app/services/task-manager.service.spec.ts | 148 ++++++++++++++++++++-
7 files changed, 492 insertions(+), 18 deletions(-)
diff --git
a/flink-runtime-web/web-dashboard/src/app/components/humanize-date.pipe.spec.ts
b/flink-runtime-web/web-dashboard/src/app/components/humanize-date.pipe.spec.ts
index 01082214e8c..c2f471e02cc 100644
---
a/flink-runtime-web/web-dashboard/src/app/components/humanize-date.pipe.spec.ts
+++
b/flink-runtime-web/web-dashboard/src/app/components/humanize-date.pipe.spec.ts
@@ -43,9 +43,9 @@ describe('HumanizeDatePipe', () => {
expect(pipe.transform(0, undefined, 'UTC')).toBe('Jan 1, 1970');
});
- it('swallows formatting errors and returns undefined when the format needs
unloaded extra locale data', () => {
- // 'B' (flexible day period, e.g. "in the morning") requires extra Angular
locale data
- // that plain "en-US" doesn't register by default, so formatDate throws
internally.
- expect(pipe.transform(0, 'B', 'UTC')).toBeUndefined();
+ it('swallows formatting errors and returns undefined', () => {
+ // An unregistered locale always throws (a stable part of Angular's i18n
contract), unlike
+ // format 'B' needing "extra" locale data, which is a version-dependent
implementation detail.
+ expect(pipe.transform(0, 'yyyy-MM-dd', 'UTC',
'not-a-registered-locale')).toBeUndefined();
});
});
diff --git
a/flink-runtime-web/web-dashboard/src/app/pages/application/exceptions/application-exceptions.component.spec.ts
b/flink-runtime-web/web-dashboard/src/app/pages/application/exceptions/application-exceptions.component.spec.ts
new file mode 100644
index 00000000000..bd74781ecb5
--- /dev/null
+++
b/flink-runtime-web/web-dashboard/src/app/pages/application/exceptions/application-exceptions.component.spec.ts
@@ -0,0 +1,104 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 { ComponentFixture, TestBed } from '@angular/core/testing';
+import { of } from 'rxjs';
+
+import { ApplicationDetail, ApplicationExceptions } from
'@flink-runtime-web/interfaces';
+import { ApplicationService } from '@flink-runtime-web/services';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { ApplicationExceptionsComponent } from
'./application-exceptions.component';
+import { ApplicationLocalService } from '../application-local.service';
+
+const mockApplicationDetail = { id: 'app-1' } as ApplicationDetail;
+
+describe('ApplicationExceptionsComponent', () => {
+ let fixture: ComponentFixture<ApplicationExceptionsComponent>;
+ const loadExceptions = vi.fn();
+
+ beforeEach(async () => {
+ loadExceptions.mockReset();
+ await TestBed.configureTestingModule({
+ imports: [ApplicationExceptionsComponent],
+ providers: [
+ { provide: ApplicationService, useValue: { loadExceptions } },
+ { provide: ApplicationLocalService, useValue: {
applicationDetailChanges: () => of(mockApplicationDetail) } }
+ ]
+ }).compileComponents();
+ fixture = TestBed.createComponent(ApplicationExceptionsComponent);
+ });
+
+ it('formats the most recent exception with its timestamp and related job',
() => {
+ const mockExceptions: ApplicationExceptions = {
+ exceptionHistory: {
+ entries: [
+ {
+ exceptionName: 'java.lang.RuntimeException',
+ stacktrace: 'java.lang.RuntimeException: boom\n\tat
com.example.Foo.bar(Foo.java:42)',
+ timestamp: 1_781_000_000_000,
+ jobId: 'job-1'
+ },
+ {
+ exceptionName: 'java.lang.IllegalStateException',
+ stacktrace: 'java.lang.IllegalStateException: stale\n\tat
com.example.Bar.baz(Bar.java:7)',
+ timestamp: 1_780_000_000_000,
+ jobId: 'job-0'
+ }
+ ]
+ }
+ };
+ loadExceptions.mockReturnValue(of(mockExceptions));
+
+ fixture.detectChanges();
+
+ expect(loadExceptions).toHaveBeenCalledWith('app-1');
+ const rootException = fixture.componentInstance.rootException;
+ // The first line is the formatted timestamp; assert its shape rather than
a value, since
+ // formatDate is called here without a time zone so the rendered value is
environment-dependent.
+ expect(rootException.split('\n')[0]).toMatch(/^\d{4}-\d{2}-\d{2}
\d{2}:\d{2}:\d{2}$/);
+ // Only the most recent (first) entry is rendered, not the older one below
it.
+ expect(rootException).toContain('Related Job: job-1');
+ expect(rootException).toContain('java.lang.RuntimeException: boom');
+ expect(rootException).not.toContain('job-0');
+ expect(rootException).not.toContain('IllegalStateException');
+ expect(fixture.componentInstance.isLoading).toBe(false);
+ });
+
+ it('omits the related job line when the exception has no job id', () => {
+ loadExceptions.mockReturnValue(
+ of({
+ exceptionHistory: {
+ entries: [{ exceptionName: 'java.lang.RuntimeException', stacktrace:
'boom', timestamp: 0 }]
+ }
+ } as ApplicationExceptions)
+ );
+
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.rootException).not.toContain('Related
Job');
+ });
+
+ it('falls back to "No Root Exception" when the history is empty', () => {
+ loadExceptions.mockReturnValue(of({ exceptionHistory: { entries: [] } } as
ApplicationExceptions));
+
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.rootException).toBe('No Root Exception');
+ });
+});
diff --git
a/flink-runtime-web/web-dashboard/src/app/pages/application/overview/application-overview.component.spec.ts
b/flink-runtime-web/web-dashboard/src/app/pages/application/overview/application-overview.component.spec.ts
new file mode 100644
index 00000000000..be37aecd23c
--- /dev/null
+++
b/flink-runtime-web/web-dashboard/src/app/pages/application/overview/application-overview.component.spec.ts
@@ -0,0 +1,98 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 { ComponentFixture, TestBed } from '@angular/core/testing';
+import { Router } from '@angular/router';
+import { of } from 'rxjs';
+
+import { ApplicationDetail, JobsItem } from '@flink-runtime-web/interfaces';
+import { JobService, StatusService } from '@flink-runtime-web/services';
+import { NzMessageService } from 'ng-zorro-antd/message';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { ApplicationOverviewComponent } from
'./application-overview.component';
+import { ApplicationLocalService } from '../application-local.service';
+
+const runningJob = {
+ jid: 'job-1',
+ name: 'Streaming ETL',
+ state: 'RUNNING',
+ 'start-time': 200,
+ 'end-time': -1,
+ duration: 50,
+ completed: false
+} as JobsItem;
+
+const finishedJob = {
+ jid: 'job-2',
+ name: 'Batch Report',
+ state: 'FINISHED',
+ 'start-time': 50,
+ 'end-time': 150,
+ duration: 100,
+ completed: true
+} as JobsItem;
+
+const mockApplicationDetail = {
+ id: 'app-1',
+ jobs: [runningJob, finishedJob]
+} as ApplicationDetail;
+
+describe('ApplicationOverviewComponent', () => {
+ let fixture: ComponentFixture<ApplicationOverviewComponent>;
+ let element: HTMLElement;
+ const navigate = vi.fn().mockResolvedValue(true);
+
+ beforeEach(async () => {
+ navigate.mockClear();
+ // JobListComponent (rendered twice by this component) depends on
StatusService, JobService
+ // and NzMessageService; since jobData$ is passed in directly, they're
never actually called.
+ await TestBed.configureTestingModule({
+ imports: [ApplicationOverviewComponent],
+ providers: [
+ { provide: ApplicationLocalService, useValue: {
applicationDetailChanges: () => of(mockApplicationDetail) } },
+ { provide: Router, useValue: { navigate } },
+ { provide: StatusService, useValue: {} },
+ { provide: JobService, useValue: {} },
+ { provide: NzMessageService, useValue: {} }
+ ]
+ }).compileComponents();
+ fixture = TestBed.createComponent(ApplicationOverviewComponent);
+ element = fixture.nativeElement as HTMLElement;
+ });
+
+ it('splits the application jobs into running and completed lists', () => {
+ fixture.detectChanges();
+
+ const [runningList, completedList] =
Array.from(element.querySelectorAll('flink-job-list'));
+ expect(runningList.textContent).toContain('Running Job List');
+ expect(runningList.textContent).toContain('Streaming ETL');
+ expect(runningList.textContent).not.toContain('Batch Report');
+ expect(completedList.textContent).toContain('Completed Job List');
+ expect(completedList.textContent).toContain('Batch Report');
+ expect(completedList.textContent).not.toContain('Streaming ETL');
+ });
+
+ it('navigates to the selected job', () => {
+ fixture.detectChanges();
+
+ fixture.componentInstance.navigateToJob(['job', 'running', 'job-1']);
+
+ expect(navigate).toHaveBeenCalledWith(['job', 'running', 'job-1']);
+ });
+});
diff --git
a/flink-runtime-web/web-dashboard/src/app/pages/job/overview/job-overview.component.spec.ts
b/flink-runtime-web/web-dashboard/src/app/pages/job/overview/job-overview.component.spec.ts
index a8ea1ccc2ed..6a237542a3d 100644
---
a/flink-runtime-web/web-dashboard/src/app/pages/job/overview/job-overview.component.spec.ts
+++
b/flink-runtime-web/web-dashboard/src/app/pages/job/overview/job-overview.component.spec.ts
@@ -17,12 +17,12 @@
*/
import { NgIf } from '@angular/common';
-import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
+import { ChangeDetectorRef, CUSTOM_ELEMENTS_SCHEMA, ElementRef } from
'@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router } from '@angular/router';
import { EMPTY, of } from 'rxjs';
-import { NodesItemCorrect } from '@flink-runtime-web/interfaces';
+import { JobDetailCorrect, NodesItemCorrect, NodesItemLink } from
'@flink-runtime-web/interfaces';
import { JobService, MetricsService } from '@flink-runtime-web/services';
import { NzAlertModule } from 'ng-zorro-antd/alert';
import { NzNotificationService } from 'ng-zorro-antd/notification';
@@ -98,3 +98,83 @@ describe('JobOverviewComponent', () => {
);
});
});
+
+describe('JobOverviewComponent with a resolved plan', () => {
+ // The Dagre graph relies on SVG layout APIs jsdom does not implement, so
the component is
+ // constructed directly (bypassing TestBed/change detection) and given a
fake dagreComponent,
+ // rather than trying to render the real child through the view.
+ const mockPlan: JobDetailCorrect['plan'] = {
+ jid: 'job-1',
+ name: 'Test Job',
+ type: 'STREAMING',
+ nodes: [{ id: 'vertex-a' } as NodesItemCorrect],
+ links: [] as NodesItemLink[],
+ streamNodes: [
+ { id: 'node-a', job_vertex_id: 'vertex-a' } as NodesItemCorrect,
+ { id: 'node-b' } as NodesItemCorrect
+ ],
+ streamLinks: [{ id: 'link-1', source: 'node-a', target: 'node-b' } as
NodesItemLink]
+ };
+
+ function createComponent(): {
+ component: JobOverviewComponent;
+ fakeDagre: { showPendingOperators: boolean; flush: ReturnType<typeof
vi.fn>; updateNode: ReturnType<typeof vi.fn> };
+ } {
+ const fakeDagre = {
+ showPendingOperators: false,
+ flush: vi.fn().mockResolvedValue(undefined),
+ updateNode: vi.fn()
+ };
+ const component = new JobOverviewComponent(
+ {} as unknown as Router,
+ activatedRoute as unknown as ActivatedRoute,
+ {} as ElementRef,
+ {
+ loadMetricsWithAllAggregates: vi.fn().mockReturnValue(of({})),
+ loadWatermarks: vi.fn().mockReturnValue(of({ lowWatermark: NaN }))
+ } as unknown as MetricsService,
+ {
+ jobDetailChanges: () => of({ jid: 'job-1', plan: mockPlan } as
JobDetailCorrect),
+ selectedVertexChanges: () => EMPTY
+ } as unknown as JobLocalService,
+ {} as unknown as JobService,
+ {} as unknown as NzNotificationService,
+ { markForCheck: vi.fn() } as unknown as ChangeDetectorRef
+ );
+ (component as unknown as { dagreComponent: typeof fakeDagre
}).dagreComponent = fakeDagre;
+ return { component, fakeDagre };
+ }
+
+ it('derives pending nodes and links from the streaming graph when a plan
arrives', () => {
+ const { component } = createComponent();
+ component.ngOnInit();
+
+ expect(component.nodes).toEqual(mockPlan.nodes);
+ expect(component.pendingNodes).toEqual([{ id: 'node-b' }]);
+ // The pending link's endpoints are remapped through the streaming-graph
node ids onto
+ // their job-vertex ids, so 'node-a' becomes 'vertex-a' while the
still-pending 'node-b'
+ // (no job vertex yet) is left as-is.
+ expect(component.pendingLinks).toEqual([
+ { id: 'vertex-a-node-b', source: 'vertex-a', target: 'node-b', pending:
true }
+ ]);
+ });
+
+ it('flushes the dagre graph with the resolved nodes and links', () => {
+ const { component, fakeDagre } = createComponent();
+ component.ngOnInit();
+
+ expect(fakeDagre.flush).toHaveBeenCalledWith(mockPlan.nodes,
mockPlan.links, true);
+ });
+
+ it('flushes the pending nodes and links alongside the resolved ones when
pending operators are shown', () => {
+ const { component, fakeDagre } = createComponent();
+ fakeDagre.showPendingOperators = true;
+ component.ngOnInit();
+
+ expect(fakeDagre.flush).toHaveBeenCalledWith(
+ [...mockPlan.nodes, { id: 'node-b' }],
+ [{ id: 'vertex-a-node-b', source: 'vertex-a', target: 'node-b', pending:
true }],
+ true
+ );
+ });
+});
diff --git
a/flink-runtime-web/web-dashboard/src/app/pages/job/timeline/job-timeline.component.spec.ts
b/flink-runtime-web/web-dashboard/src/app/pages/job/timeline/job-timeline.component.spec.ts
index 70eb7985e4f..84d0b0c1b3b 100644
---
a/flink-runtime-web/web-dashboard/src/app/pages/job/timeline/job-timeline.component.spec.ts
+++
b/flink-runtime-web/web-dashboard/src/app/pages/job/timeline/job-timeline.component.spec.ts
@@ -28,7 +28,13 @@ import { JobLocalService } from '../job-local.service';
const mockJobDetail = {
jid: 'job-1',
- vertices: [{ id: 'v1', name: 'Source: generator', 'start-time': 1000,
'end-time': 2000, duration: 1000 }]
+ vertices: [
+ // No end-time recorded yet: the range end must be derived from start-time
+ duration.
+ { id: 'v2', name: 'Sink: writer', 'start-time': 3000, 'end-time': -1,
duration: 500 },
+ { id: 'v1', name: 'Source: generator', 'start-time': 1000, 'end-time':
2000, duration: 1000 },
+ // Not started yet: must be filtered out of the timeline entirely.
+ { id: 'v3', name: 'Not started yet', 'start-time': -1, 'end-time': -1,
duration: 0 }
+ ]
};
const mockSubTaskTimes = {
@@ -39,7 +45,7 @@ const mockSubTaskTimes = {
{
subtask: 0,
endpoint: 'host-a',
- duration: 500,
+ duration: 1000,
timestamps: {
CREATED: 1000,
RUNNING: 1200,
@@ -88,25 +94,31 @@ describe('JobTimelineComponent', () => {
});
});
- it('maps the vertices into timeline ranges and renders the main chart', ()
=> {
+ it('maps the vertices into timeline ranges, filtering unstarted ones and
sorting by start time', () => {
fixture.detectChanges();
expect(component.jobDetail).toEqual(mockJobDetail);
- expect(component.listOfVertex).toHaveLength(1);
+ expect(component.listOfVertex.map(v => v.id)).toEqual(['v1', 'v2']);
expect(component.listOfVertex[0].range).toEqual([1000, 2000]);
+ expect(component.listOfVertex[1].range).toEqual([3000, 3500]);
expect(fakeMain.data).toHaveBeenCalledWith(component.listOfVertex);
expect(fakeMain.render).toHaveBeenCalled();
});
- it('builds the subtask timeline from subtask time stamps', () => {
+ it('derives each subtask timeline range from its ordered status timestamps',
() => {
fixture.detectChanges();
component.updateSubTaskChart('v1');
expect(loadSubTaskTimes).toHaveBeenCalledWith('job-1', 'v1');
expect(component.isShowSubTaskTimeLine).toBe(true);
- expect(component.listOfSubTaskTimeLine.length).toBeGreaterThan(0);
- expect(component.listOfSubTaskTimeLine.every(item => item.name === '0 -
host-a')).toBe(true);
+ expect(component.listOfSubTaskTimeLine).toEqual([
+ { name: '0 - host-a', status: 'CREATED', range: [1000, 1200] },
+ { name: '0 - host-a', status: 'RUNNING', range: [1200, 1700] },
+ // The final status runs until the subtask's overall finish (first start
+ duration).
+ { name: '0 - host-a', status: 'FINISHED', range: [1700, 2000] }
+ ]);
+ expect(fakeSub.data).toHaveBeenCalledWith(component.listOfSubTaskTimeLine);
expect(fakeSub.render).toHaveBeenCalled();
});
});
diff --git
a/flink-runtime-web/web-dashboard/src/app/pages/overview/overview.component.spec.ts
b/flink-runtime-web/web-dashboard/src/app/pages/overview/overview.component.spec.ts
index 74966d45867..e90ca679af9 100644
---
a/flink-runtime-web/web-dashboard/src/app/pages/overview/overview.component.spec.ts
+++
b/flink-runtime-web/web-dashboard/src/app/pages/overview/overview.component.spec.ts
@@ -18,7 +18,7 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
-import { of } from 'rxjs';
+import { firstValueFrom, of } from 'rxjs';
import { ApplicationService, OverviewService, StatusService } from
'@flink-runtime-web/services';
import { beforeEach, describe, expect, it, vi } from 'vitest';
@@ -57,6 +57,26 @@ const mockApplications = [
duration: 150,
completed: true,
jobs: { ...emptyJobStatus, FINISHED: 1 }
+ },
+ {
+ id: 'app-3',
+ name: 'another-running-app',
+ status: 'RUNNING',
+ 'start-time': 120,
+ 'end-time': -1,
+ duration: 20,
+ completed: false,
+ jobs: { ...emptyJobStatus, RUNNING: 1 }
+ },
+ {
+ id: 'app-4',
+ name: 'cancelled-app',
+ status: 'CANCELED',
+ 'start-time': 30,
+ 'end-time': 90,
+ duration: 60,
+ completed: true,
+ jobs: { ...emptyJobStatus, CANCELED: 1 }
}
];
@@ -108,6 +128,26 @@ describe('OverviewComponent', () => {
expect(text).toContain('Completed Application List');
});
+ it('derives the cluster statistics from the applications and overview data',
async () => {
+ fixture.detectChanges();
+
+ const stats = await
firstValueFrom(fixture.componentInstance.statisticData$);
+
+ expect(stats).toEqual({
+ 'applications-running': 2,
+ 'applications-finished': 1,
+ 'applications-cancelled': 1,
+ 'applications-failed': 0,
+ taskmanagers: 3,
+ 'taskmanagers-blocked': 0,
+ 'slots-total': 12,
+ 'slots-available': 5,
+ 'slots-free-and-blocked': 0,
+ 'flink-version': '2.4-SNAPSHOT',
+ 'flink-commit': 'abcdef0'
+ });
+ });
+
it('navigates when an application is selected', () => {
fixture.detectChanges();
diff --git
a/flink-runtime-web/web-dashboard/src/app/services/task-manager.service.spec.ts
b/flink-runtime-web/web-dashboard/src/app/services/task-manager.service.spec.ts
index e95d49fb082..9597bc5c07f 100644
---
a/flink-runtime-web/web-dashboard/src/app/services/task-manager.service.spec.ts
+++
b/flink-runtime-web/web-dashboard/src/app/services/task-manager.service.spec.ts
@@ -19,7 +19,8 @@
import { HttpClient, HttpContext } from '@angular/common/http';
import { firstValueFrom, of, throwError } from 'rxjs';
-import { TaskManagerDetail, TaskManagersItem } from
'@flink-runtime-web/interfaces';
+import { TaskManagerDetail, TaskManagerLogItem, TaskManagersItem } from
'@flink-runtime-web/interfaces';
+import { ProfilingDetail } from '@flink-runtime-web/interfaces/job-profiler';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ConfigService } from './config.service';
@@ -27,12 +28,14 @@ import { EXPECTED_NOT_FOUND } from './http-context';
import { TaskManagerService } from './task-manager.service';
describe('TaskManagerService', () => {
- let httpClient: { get: ReturnType<typeof vi.fn> };
+ let httpClient: { get: ReturnType<typeof vi.fn>; post: ReturnType<typeof
vi.fn> };
+ let configService: ConfigService;
let service: TaskManagerService;
beforeEach(() => {
- httpClient = { get: vi.fn() };
- service = new TaskManagerService(httpClient as unknown as HttpClient, new
ConfigService());
+ httpClient = { get: vi.fn(), post: vi.fn() };
+ configService = new ConfigService();
+ service = new TaskManagerService(httpClient as unknown as HttpClient,
configService);
});
describe('loadManagers', () => {
@@ -88,4 +91,141 @@ describe('TaskManagerService', () => {
expect(options.context.get(EXPECTED_NOT_FOUND)).toBe(true);
});
});
+
+ describe('loadLogList', () => {
+ it('extracts the logs array from the response', async () => {
+ const log = { name: 'jobmanager.log', size: 100, mtime: 1 } as
TaskManagerLogItem;
+ httpClient.get.mockReturnValue(of({ logs: [log] }));
+
+ const result = await firstValueFrom(service.loadLogList('tm-1'));
+
+
expect(httpClient.get).toHaveBeenCalledWith(`${configService.BASE_URL}/taskmanagers/tm-1/logs`);
+ expect(result).toEqual([log]);
+ });
+ });
+
+ describe('loadLog', () => {
+ it('pairs the raw log text with the request url', async () => {
+ httpClient.get.mockReturnValue(of('log contents'));
+
+ const result = await firstValueFrom(service.loadLog('tm-1',
'jobmanager.log'));
+
+ expect(result.data).toBe('log contents');
+
expect(result.url).toBe(`${configService.BASE_URL}/taskmanagers/tm-1/logs/jobmanager.log`);
+ });
+ });
+
+ describe('loadThreadDump', () => {
+ it('joins the stringified thread infos into a single dump', async () => {
+ httpClient.get.mockReturnValue(
+ of({ threadInfos: [{ stringifiedThreadInfo: 'thread-A\n' }, {
stringifiedThreadInfo: 'thread-B\n' }] })
+ );
+
+ const result = await firstValueFrom(service.loadThreadDump('tm-1'));
+
+
expect(httpClient.get).toHaveBeenCalledWith(`${configService.BASE_URL}/taskmanagers/tm-1/thread-dump`);
+ expect(result).toBe('thread-A\nthread-B\n');
+ });
+
+ it('appends the mode query parameter when provided', async () => {
+ httpClient.get.mockReturnValue(of({ threadInfos: [] }));
+
+ await firstValueFrom(service.loadThreadDump('tm-1', 'full'));
+
+
expect(httpClient.get).toHaveBeenCalledWith(`${configService.BASE_URL}/taskmanagers/tm-1/thread-dump?mode=full`);
+ });
+ });
+
+ describe('loadLogs', () => {
+ it('returns the raw log text for the given TaskManager', async () => {
+ httpClient.get.mockReturnValue(of('log contents'));
+
+ const result = await firstValueFrom(service.loadLogs('tm-1'));
+
+
expect(httpClient.get).toHaveBeenCalledWith(`${configService.BASE_URL}/taskmanagers/tm-1/log`,
expect.anything());
+ expect(result).toBe('log contents');
+ });
+ });
+
+ describe('loadStdout', () => {
+ it('returns the raw stdout text for the given TaskManager', async () => {
+ httpClient.get.mockReturnValue(of('stdout contents'));
+
+ const result = await firstValueFrom(service.loadStdout('tm-1'));
+
+ expect(httpClient.get).toHaveBeenCalledWith(
+ `${configService.BASE_URL}/taskmanagers/tm-1/stdout`,
+ expect.anything()
+ );
+ expect(result).toBe('stdout contents');
+ });
+ });
+
+ describe('loadMetrics', () => {
+ it('parses the metric values into a numeric map keyed by id and joins the
requested names', async () => {
+ httpClient.get.mockReturnValue(
+ of([
+ { id: 'Status.JVM.CPU.Load', value: '0.42' },
+ { id: 'Status.JVM.Memory.Heap.Used', value: '128' }
+ ])
+ );
+
+ const result = await firstValueFrom(
+ service.loadMetrics('tm-1', ['Status.JVM.CPU.Load',
'Status.JVM.Memory.Heap.Used'])
+ );
+
+
expect(httpClient.get).toHaveBeenCalledWith(`${configService.BASE_URL}/taskmanagers/tm-1/metrics`,
{
+ params: { get: 'Status.JVM.CPU.Load,Status.JVM.Memory.Heap.Used' }
+ });
+ expect(result).toEqual({ 'Status.JVM.CPU.Load': 0.42,
'Status.JVM.Memory.Heap.Used': 128 });
+ });
+ });
+
+ describe('loadHistoryServerTaskManagerLogUrl', () => {
+ it('extracts the log url from the response', async () => {
+ httpClient.get.mockReturnValue(of({ url: 'http://history/log' }));
+
+ const result = await
firstValueFrom(service.loadHistoryServerTaskManagerLogUrl('job-1', 'tm-1'));
+
+ expect(result).toBe('http://history/log');
+ });
+ });
+
+ describe('loadProfilingList', () => {
+ it('passes through the profiling list on success', async () => {
+ const profilingList = { profiling: [] };
+ httpClient.get.mockReturnValue(of(profilingList));
+
+ const result = await firstValueFrom(service.loadProfilingList('tm-1'));
+
+
expect(httpClient.get).toHaveBeenCalledWith(`${configService.BASE_URL}/taskmanagers/tm-1/profiler`);
+ expect(result).toBe(profilingList);
+ });
+ });
+
+ describe('createProfilingInstance', () => {
+ it('posts the requested profiling mode and duration', async () => {
+ const profilingDetail = { status: 'RUNNING' } as ProfilingDetail;
+ httpClient.post.mockReturnValue(of(profilingDetail));
+
+ const result = await
firstValueFrom(service.createProfilingInstance('tm-1', 'ITIMER', 30));
+
+
expect(httpClient.post).toHaveBeenCalledWith(`${configService.BASE_URL}/taskmanagers/tm-1/profiler`,
{
+ mode: 'ITIMER',
+ duration: 30
+ });
+ expect(result).toBe(profilingDetail);
+ });
+ });
+
+ describe('loadProfilingResult', () => {
+ it('pairs the raw profiling text with the request url', async () => {
+ httpClient.get.mockReturnValue(of('profile contents'));
+
+ const result = await firstValueFrom(service.loadProfilingResult('tm-1',
'profile.jfr'));
+
+ expect(result.data).toBe('profile contents');
+
expect(result.url).toBe(`${configService.BASE_URL}/taskmanagers/tm-1/profiler/profile.jfr`);
+ });
+ });
});