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`);
+    });
+  });
 });

Reply via email to