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 7ebd1522f77 [FLINK-40117][runtime-web] Extend Vitest smoke-test 
coverage to more web-dashboard views
7ebd1522f77 is described below

commit 7ebd1522f77d24f78209085bea539d91d2c6a3c6
Author: Purushottam Sinha <[email protected]>
AuthorDate: Tue Jul 28 00:34:28 2026 +0530

    [FLINK-40117][runtime-web] Extend Vitest smoke-test coverage to more 
web-dashboard views
    
    FLINK-40035 introduced a Vitest setup with smoke tests for three views.
    Extend that coverage to 30 additional spec files: the remaining
    high-traffic pages plus the services, pipes, badges, guards, and the
    HTTP interceptor they depend on.
    
    - Job: overview, checkpoints, timeline, data skew, rescales, configuration
    - Task Manager: metrics, logs
    - Job Manager: metrics
    - Application (cluster) overview
    - Services: application, jar, metrics, task-manager
    - Pipes: humanize-bytes, humanize-chart-numeric, humanize-date,
      humanize-duration, humanize-watermark, parse-int
    - Badges: application, backpressure, checkpoint, duration, job
    - Guards: cluster-config, running-application, running-job
    - Components: navigation
    - app.interceptor
    
    Each spec mocks the view's services through TestBed and asserts that the
    component loads its data and populates its key bindings. Chart- and
    graph-backed views (timeline, job overview) drive the component logic
    directly and stub the rendering layer, which relies on browser APIs jsdom
    does not implement.
    
    Also add a no-op Web Worker stub to src/test-setup.ts: @antv/g2 creates a
    worker at module load for its label layout, and Worker is undefined in 
jsdom.
    
    Generated-by: Claude Code (claude-opus-4-8)
---
 .../web-dashboard/src/app/app.interceptor.spec.ts  | 222 +++++++++++++++++++++
 .../application-badge.component.spec.ts            |  36 ++++
 .../backpressure-badge.component.spec.ts           |  57 ++++++
 .../checkpoint-badge.component.spec.ts             |  41 ++++
 .../duration-badge.component.spec.ts               |  41 ++++
 .../src/app/components/humanize-bytes.pipe.spec.ts |  55 +++++
 .../components/humanize-chart-numeric.pipe.spec.ts |  50 +++++
 .../src/app/components/humanize-date.pipe.spec.ts  |  51 +++++
 .../app/components/humanize-duration.pipe.spec.ts  |  60 ++++++
 .../app/components/humanize-watermark.pipe.spec.ts |  67 +++++++
 .../job-badge/job-badge.component.spec.ts          |  36 ++++
 .../navigation/navigation.component.spec.ts        |  95 +++++++++
 .../src/app/components/parse-int.pipe.spec.ts      |  45 +++++
 .../running-application.guard.spec.ts              |  87 ++++++++
 .../metrics/job-manager-metrics.component.spec.ts  |  91 +++++++++
 .../checkpoints/job-checkpoints.component.spec.ts  |  94 +++++++++
 .../job-configuration.component.spec.ts            |  77 +++++++
 .../pages/job/dataskew/data-skew.component.spec.ts |  83 ++++++++
 .../completed-job/cluster-config.guard.spec.ts     |  59 ++++++
 .../modules/running-job/running-job.guard.spec.ts  |  84 ++++++++
 .../job/overview/job-overview.component.spec.ts    | 100 ++++++++++
 .../job/rescales/job-rescales.component.spec.ts    |  97 +++++++++
 .../job/timeline/job-timeline.component.spec.ts    | 112 +++++++++++
 .../app/pages/overview/overview.component.spec.ts  | 118 +++++++++++
 .../logs/task-manager-logs.component.spec.ts       |  69 +++++++
 .../metrics/task-manager-metrics.component.spec.ts | 117 +++++++++++
 .../src/app/services/application.service.spec.ts   | 155 ++++++++++++++
 .../src/app/services/jar.service.spec.ts           | 190 ++++++++++++++++++
 .../src/app/services/metrics.service.spec.ts       | 154 ++++++++++++++
 .../src/app/services/task-manager.service.spec.ts  |  82 ++++++++
 flink-runtime-web/web-dashboard/src/test-setup.ts  |  13 ++
 31 files changed, 2638 insertions(+)

diff --git a/flink-runtime-web/web-dashboard/src/app/app.interceptor.spec.ts 
b/flink-runtime-web/web-dashboard/src/app/app.interceptor.spec.ts
new file mode 100644
index 00000000000..2b41327a0b0
--- /dev/null
+++ b/flink-runtime-web/web-dashboard/src/app/app.interceptor.spec.ts
@@ -0,0 +1,222 @@
+/*
+ * 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 { HttpErrorResponse, HttpHandler, HttpHeaders, HttpRequest, 
HttpResponse } from '@angular/common/http';
+import { of, Subject, throwError } from 'rxjs';
+
+import { StatusService } from '@flink-runtime-web/services';
+import { NzNotificationService } from 'ng-zorro-antd/notification';
+import { type Mock, afterEach, beforeEach, describe, expect, it, vi } from 
'vitest';
+
+import { AppInterceptor } from './app.interceptor';
+
+describe('AppInterceptor', () => {
+  let interceptor: AppInterceptor;
+  let statusService: StatusService;
+  let notificationService: NzNotificationService;
+  let handle: Mock<HttpHandler['handle']>;
+  let handler: HttpHandler;
+  let originalLocation: Location;
+  let warningClose: Subject<boolean>;
+
+  beforeEach(() => {
+    statusService = {
+      listOfErrorMessage: [],
+      networkFailureCount: 0,
+      networkFailureThreshold: 5,
+      networkErrorNotificationId: null,
+      markAppForCheck: vi.fn()
+    } as unknown as StatusService;
+    warningClose = new Subject<boolean>();
+    notificationService = {
+      info: vi.fn(),
+      warning: vi.fn().mockReturnValue({ messageId: 'net-err-1', onClose: 
warningClose }),
+      remove: vi.fn()
+    } as unknown as NzNotificationService;
+    handle = vi.fn();
+    handler = { handle };
+    interceptor = new AppInterceptor(statusService, notificationService);
+
+    originalLocation = window.location;
+    Object.defineProperty(window, 'location', {
+      configurable: true,
+      value: { ...originalLocation, href: 'https://dashboard.example/jobs' }
+    });
+  });
+
+  afterEach(() => {
+    Object.defineProperty(window, 'location', { configurable: true, value: 
originalLocation });
+  });
+
+  it('clones the outgoing request to include credentials', () => {
+    handle.mockReturnValue(of(new HttpResponse({ status: 200 })));
+    const request = new HttpRequest('GET', '/overview');
+
+    interceptor.intercept(request, handler).subscribe();
+
+    expect(handle).toHaveBeenCalledWith(expect.objectContaining({ 
withCredentials: true }));
+  });
+
+  it('passes through a successful response unchanged', () => {
+    const response = new HttpResponse({ status: 200 });
+    handle.mockReturnValue(of(response));
+    const request = new HttpRequest('GET', '/overview');
+
+    let emitted: unknown;
+    interceptor.intercept(request, handler).subscribe(value => (emitted = 
value));
+
+    expect(emitted).toBe(response);
+  });
+
+  it('navigates to the Location header on a redirect response', () => {
+    const redirect = new HttpErrorResponse({
+      status: 307,
+      url: '/jobs/123',
+      headers: new HttpHeaders({ Location: 'https://dashboard.example/login' })
+    });
+    handle.mockReturnValue(throwError(() => redirect));
+
+    interceptor.intercept(new HttpRequest('GET', '/jobs/123'), 
handler).subscribe({ error: () => {} });
+
+    expect(window.location.href).toBe('https://dashboard.example/login');
+  });
+
+  it('does not navigate on a redirect status without a Location header', () => 
{
+    const redirect = new HttpErrorResponse({ status: 301, url: '/jobs/123' });
+    handle.mockReturnValue(throwError(() => redirect));
+
+    interceptor.intercept(new HttpRequest('GET', '/jobs/123'), 
handler).subscribe({ error: () => {} });
+
+    expect(window.location.href).toBe('https://dashboard.example/jobs');
+  });
+
+  it('re-throws the original error after handling it', () => {
+    const error = new HttpErrorResponse({ status: 500, url: '/jobs/123' });
+    handle.mockReturnValue(throwError(() => error));
+
+    let caught: unknown;
+    interceptor.intercept(new HttpRequest('GET', '/jobs/123'), 
handler).subscribe({ error: err => (caught = err) });
+
+    expect(caught).toBe(error);
+  });
+
+  it('surfaces a server error message via notification and the status service 
cache', () => {
+    const error = new HttpErrorResponse({
+      status: 500,
+      url: '/jobs/123/exceptions',
+      error: { errors: ['Something failed at line 10'] }
+    });
+    handle.mockReturnValue(throwError(() => error));
+
+    let caught: unknown;
+    interceptor
+      .intercept(new HttpRequest('GET', '/jobs/123/exceptions'), handler)
+      .subscribe({ error: err => (caught = err) });
+
+    expect(caught).toBe(error);
+    expect(statusService.listOfErrorMessage).toEqual(['Something failed at 
line 10']);
+    expect(notificationService.info).toHaveBeenCalledWith(
+      'Server Response Message:',
+      'Something failed\n at line 10',
+      expect.objectContaining({ nzDuration: 0 })
+    );
+    expect(statusService.markAppForCheck).toHaveBeenCalled();
+  });
+
+  it.each(['/jobs/123/checkpoints', '/jobs/123/checkpoints/config'])(
+    'suppresses the notification for the ignored URL %s',
+    url => {
+      const error = new HttpErrorResponse({ status: 500, url, error: { errors: 
['Some error'] } });
+      handle.mockReturnValue(throwError(() => error));
+
+      interceptor.intercept(new HttpRequest('GET', url), handler).subscribe({ 
error: () => {} });
+
+      expect(statusService.listOfErrorMessage).toEqual([]);
+      expect(notificationService.info).not.toHaveBeenCalled();
+    }
+  );
+
+  it.each(['File not found.', 'Resource not found.'])('suppresses the 
notification for the message "%s"', message => {
+    const error = new HttpErrorResponse({ status: 404, url: 
'/jobs/123/exceptions', error: { errors: [message] } });
+    handle.mockReturnValue(throwError(() => error));
+
+    interceptor.intercept(new HttpRequest('GET', '/jobs/123/exceptions'), 
handler).subscribe({ error: () => {} });
+
+    expect(statusService.listOfErrorMessage).toEqual([]);
+    expect(notificationService.info).not.toHaveBeenCalled();
+  });
+
+  it.each([0, 500, 503])(
+    'counts a bodyless status %i as a network failure without surfacing a 
warning below the threshold',
+    status => {
+      const error = new HttpErrorResponse({ status, url: '/overview' });
+      handle.mockReturnValue(throwError(() => error));
+
+      interceptor.intercept(new HttpRequest('GET', '/overview'), 
handler).subscribe({ error: () => {} });
+
+      expect(statusService.networkFailureCount).toBe(1);
+      expect(statusService.networkErrorNotificationId).toBeNull();
+      expect(notificationService.warning).not.toHaveBeenCalled();
+    }
+  );
+
+  it('surfaces the network-error warning once the failure threshold is 
reached, and only once', () => {
+    statusService.networkFailureCount = statusService.networkFailureThreshold 
- 1;
+    handle.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 
500, url: '/overview' })));
+
+    interceptor.intercept(new HttpRequest('GET', '/overview'), 
handler).subscribe({ error: () => {} });
+
+    
expect(statusService.networkFailureCount).toBe(statusService.networkFailureThreshold);
+    expect(notificationService.warning).toHaveBeenCalledWith(
+      'Network Error:',
+      'Connection lost or server error.',
+      expect.objectContaining({ nzDuration: 0 })
+    );
+    expect(statusService.networkErrorNotificationId).toBe('net-err-1');
+
+    // A further failure while a notification is already visible must not open 
a second one.
+    interceptor.intercept(new HttpRequest('GET', '/overview'), 
handler).subscribe({ error: () => {} });
+
+    
expect(statusService.networkFailureCount).toBe(statusService.networkFailureThreshold
 + 1);
+    expect(notificationService.warning).toHaveBeenCalledTimes(1);
+  });
+
+  it('clears the network-error state when the warning notification closes', () 
=> {
+    statusService.networkFailureCount = statusService.networkFailureThreshold 
- 1;
+    handle.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 
500, url: '/overview' })));
+
+    interceptor.intercept(new HttpRequest('GET', '/overview'), 
handler).subscribe({ error: () => {} });
+    expect(statusService.networkErrorNotificationId).toBe('net-err-1');
+
+    warningClose.next(true);
+
+    expect(statusService.networkErrorNotificationId).toBeNull();
+    expect(statusService.networkFailureCount).toBe(0);
+  });
+
+  it('resets the failure count and removes the notification on a successful 
response', () => {
+    statusService.networkFailureCount = 3;
+    statusService.networkErrorNotificationId = 'net-err-1';
+    handle.mockReturnValue(of(new HttpResponse({ status: 200 })));
+
+    interceptor.intercept(new HttpRequest('GET', '/overview'), 
handler).subscribe();
+
+    expect(statusService.networkFailureCount).toBe(0);
+    expect(notificationService.remove).toHaveBeenCalledWith('net-err-1');
+  });
+});
diff --git 
a/flink-runtime-web/web-dashboard/src/app/components/application-badge/application-badge.component.spec.ts
 
b/flink-runtime-web/web-dashboard/src/app/components/application-badge/application-badge.component.spec.ts
new file mode 100644
index 00000000000..f0d7d7bb35e
--- /dev/null
+++ 
b/flink-runtime-web/web-dashboard/src/app/components/application-badge/application-badge.component.spec.ts
@@ -0,0 +1,36 @@
+/*
+ * 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 { ConfigService } from '@flink-runtime-web/services';
+import { describe, expect, it } from 'vitest';
+
+import { ApplicationBadgeComponent } from './application-badge.component';
+
+describe('ApplicationBadgeComponent', () => {
+  const configService = new ConfigService();
+  const component = new ApplicationBadgeComponent(configService);
+
+  it('resolves the color configured for a known status', () => {
+    
expect(component.backgroundColor('RUNNING')).toBe(configService.COLOR_MAP.RUNNING);
+    
expect(component.backgroundColor('FAILED')).toBe(configService.COLOR_MAP.FAILED);
+  });
+
+  it('returns undefined for an unrecognized status', () => {
+    expect(component.backgroundColor('NOT_A_REAL_STATUS')).toBeUndefined();
+  });
+});
diff --git 
a/flink-runtime-web/web-dashboard/src/app/components/backpressure-badge/backpressure-badge.component.spec.ts
 
b/flink-runtime-web/web-dashboard/src/app/components/backpressure-badge/backpressure-badge.component.spec.ts
new file mode 100644
index 00000000000..87ed9f72700
--- /dev/null
+++ 
b/flink-runtime-web/web-dashboard/src/app/components/backpressure-badge/backpressure-badge.component.spec.ts
@@ -0,0 +1,57 @@
+/*
+ * 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 { describe, expect, it } from 'vitest';
+
+import { BackpressureBadgeComponent } from './backpressure-badge.component';
+
+describe('BackpressureBadgeComponent', () => {
+  it('maps each known backpressure state to its color', () => {
+    const component = new BackpressureBadgeComponent();
+
+    component.state = 'ok';
+    expect(component.backgroundColor).toBe('#52c41a');
+
+    component.state = 'low';
+    expect(component.backgroundColor).toBe('#faad14');
+
+    component.state = 'high';
+    expect(component.backgroundColor).toBe('#f5222d');
+
+    component.state = 'in-progress';
+    expect(component.backgroundColor).toBe('#f5222d');
+  });
+
+  it('is case-insensitive', () => {
+    const component = new BackpressureBadgeComponent();
+
+    component.state = 'OK';
+
+    expect(component.backgroundColor).toBe('#52c41a');
+  });
+
+  it('returns undefined for an unrecognized or missing state', () => {
+    const component = new BackpressureBadgeComponent();
+
+    component.state = 'unknown';
+    expect(component.backgroundColor).toBeUndefined();
+
+    component.state = undefined as unknown as string;
+    expect(component.backgroundColor).toBeUndefined();
+  });
+});
diff --git 
a/flink-runtime-web/web-dashboard/src/app/components/checkpoint-badge/checkpoint-badge.component.spec.ts
 
b/flink-runtime-web/web-dashboard/src/app/components/checkpoint-badge/checkpoint-badge.component.spec.ts
new file mode 100644
index 00000000000..c93c6e4317c
--- /dev/null
+++ 
b/flink-runtime-web/web-dashboard/src/app/components/checkpoint-badge/checkpoint-badge.component.spec.ts
@@ -0,0 +1,41 @@
+/*
+ * 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 { ConfigService } from '@flink-runtime-web/services';
+import { describe, expect, it } from 'vitest';
+
+import { CheckpointBadgeComponent } from './checkpoint-badge.component';
+
+describe('CheckpointBadgeComponent', () => {
+  const configService = new ConfigService();
+  const component = new CheckpointBadgeComponent(configService);
+
+  it('resolves the color configured for a known state', () => {
+    component.state = 'COMPLETED';
+    expect(component.backgroundColor).toBe(configService.COLOR_MAP.COMPLETED);
+
+    component.state = 'IN_PROGRESS';
+    
expect(component.backgroundColor).toBe(configService.COLOR_MAP.IN_PROGRESS);
+  });
+
+  it('returns undefined for an unrecognized state', () => {
+    component.state = 'NOT_A_REAL_STATE';
+
+    expect(component.backgroundColor).toBeUndefined();
+  });
+});
diff --git 
a/flink-runtime-web/web-dashboard/src/app/components/duration-badge/duration-badge.component.spec.ts
 
b/flink-runtime-web/web-dashboard/src/app/components/duration-badge/duration-badge.component.spec.ts
new file mode 100644
index 00000000000..12b309304e3
--- /dev/null
+++ 
b/flink-runtime-web/web-dashboard/src/app/components/duration-badge/duration-badge.component.spec.ts
@@ -0,0 +1,41 @@
+/*
+ * 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 { ConfigService } from '@flink-runtime-web/services';
+import { describe, expect, it } from 'vitest';
+
+import { DurationBadgeComponent } from './duration-badge.component';
+
+describe('DurationBadgeComponent', () => {
+  const configService = new ConfigService();
+  const component = new DurationBadgeComponent(configService);
+
+  it('resolves the color configured for a known state', () => {
+    component.state = 'FINISHED';
+    expect(component.backgroundColor).toBe(configService.COLOR_MAP.FINISHED);
+
+    component.state = 'RESTARTING';
+    expect(component.backgroundColor).toBe(configService.COLOR_MAP.RESTARTING);
+  });
+
+  it('returns undefined for an unrecognized state', () => {
+    component.state = 'NOT_A_REAL_STATE';
+
+    expect(component.backgroundColor).toBeUndefined();
+  });
+});
diff --git 
a/flink-runtime-web/web-dashboard/src/app/components/humanize-bytes.pipe.spec.ts
 
b/flink-runtime-web/web-dashboard/src/app/components/humanize-bytes.pipe.spec.ts
new file mode 100644
index 00000000000..bd75305c282
--- /dev/null
+++ 
b/flink-runtime-web/web-dashboard/src/app/components/humanize-bytes.pipe.spec.ts
@@ -0,0 +1,55 @@
+/*
+ * 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 { describe, expect, it } from 'vitest';
+
+import { HumanizeBytesPipe } from './humanize-bytes.pipe';
+
+describe('HumanizeBytesPipe', () => {
+  const pipe = new HumanizeBytesPipe();
+
+  it('returns a dash for nil, NaN, or negative values', () => {
+    expect(pipe.transform(null as unknown as number)).toBe('-');
+    expect(pipe.transform(undefined as unknown as number)).toBe('-');
+    expect(pipe.transform(NaN)).toBe('-');
+    expect(pipe.transform(-1)).toBe('-');
+  });
+
+  it('renders sub-1000 values as whole bytes', () => {
+    expect(pipe.transform(0)).toBe('0 B');
+    expect(pipe.transform(999)).toBe('999 B');
+  });
+
+  it('renders values below one KB unit with two decimal places', () => {
+    expect(pipe.transform(1000)).toBe('0.98 KB');
+    expect(pipe.transform(1023)).toBe('1.00 KB');
+  });
+
+  it('renders values within a tier with three significant digits', () => {
+    expect(pipe.transform(1536)).toBe('1.50 KB');
+    // Just below the KB->MB rollover boundary (1024 * 1000): still 
three-sig-fig KB, rounds up to 1.00e+3.
+    expect(pipe.transform(1023999)).toBe('1.00e+3 KB');
+    expect(pipe.transform(999 * 1024)).toBe('999 KB');
+  });
+
+  it('rolls over into the next unit once a tier is exceeded', () => {
+    // Exactly at the KB->MB rollover boundary (1024 * 1000): recurses into MB 
and lands back in the toFixed(2) branch.
+    expect(pipe.transform(1024 * 1000)).toBe('0.98 MB');
+    expect(pipe.transform(1024 * 1024)).toBe('1.00 MB');
+  });
+});
diff --git 
a/flink-runtime-web/web-dashboard/src/app/components/humanize-chart-numeric.pipe.spec.ts
 
b/flink-runtime-web/web-dashboard/src/app/components/humanize-chart-numeric.pipe.spec.ts
new file mode 100644
index 00000000000..63385081ecb
--- /dev/null
+++ 
b/flink-runtime-web/web-dashboard/src/app/components/humanize-chart-numeric.pipe.spec.ts
@@ -0,0 +1,50 @@
+/*
+ * 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 { describe, expect, it } from 'vitest';
+
+import { HumanizeChartNumericPipe } from './humanize-chart-numeric.pipe';
+
+describe('HumanizeChartNumericPipe', () => {
+  const pipe = new HumanizeChartNumericPipe();
+
+  it('returns a dash for a nil value regardless of metric id', () => {
+    expect(pipe.transform(null as unknown as number, 'numRecords')).toBe('-');
+  });
+
+  it('appends a per-second suffix to humanized bytes when the id matches both 
bytes and persecond', () => {
+    expect(pipe.transform(2048, 'bytesPerSecond')).toBe('2.00 KB / s');
+  });
+
+  it('humanizes bytes without a rate suffix when the id only matches bytes', 
() => {
+    expect(pipe.transform(2048, 'numBytesOut')).toBe('2.00 KB');
+  });
+
+  it('appends a per-second suffix to the raw value when the id only matches 
persecond', () => {
+    expect(pipe.transform(500, 'numRecordsInPerSecond')).toBe('500 / s');
+  });
+
+  it('humanizes time/latency metrics as a short duration', () => {
+    expect(pipe.transform(3665000, 'uptime')).toBe('1h 1m');
+    expect(pipe.transform(3665000, 'latency')).toBe('1h 1m');
+  });
+
+  it('falls back to the raw value for unrecognized metric ids', () => {
+    expect(pipe.transform(42, 'numRecords')).toBe('42');
+  });
+});
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
new file mode 100644
index 00000000000..01082214e8c
--- /dev/null
+++ 
b/flink-runtime-web/web-dashboard/src/app/components/humanize-date.pipe.spec.ts
@@ -0,0 +1,51 @@
+/*
+ * 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 { describe, expect, it } from 'vitest';
+
+import { HumanizeDatePipe } from './humanize-date.pipe';
+
+describe('HumanizeDatePipe', () => {
+  const pipe = new HumanizeDatePipe('en-US');
+
+  it('renders an en dash for null, empty string, NaN, or negative values', () 
=> {
+    expect(pipe.transform(null as unknown as number)).toBe('–');
+    expect(pipe.transform('')).toBe('–');
+    expect(pipe.transform(NaN)).toBe('–');
+    expect(pipe.transform(-1)).toBe('–');
+    expect(pipe.transform('-1')).toBe('–');
+  });
+
+  it('formats an epoch timestamp using the given format and timezone', () => {
+    expect(pipe.transform(0, 'yyyy-MM-dd', 'UTC')).toBe('1970-01-01');
+  });
+
+  it('formats a Date instance', () => {
+    expect(pipe.transform(new Date(0), 'yyyy-MM-dd', 
'UTC')).toBe('1970-01-01');
+  });
+
+  it('falls back to the default mediumDate format when none is provided', () 
=> {
+    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();
+  });
+});
diff --git 
a/flink-runtime-web/web-dashboard/src/app/components/humanize-duration.pipe.spec.ts
 
b/flink-runtime-web/web-dashboard/src/app/components/humanize-duration.pipe.spec.ts
new file mode 100644
index 00000000000..48ea3affb5f
--- /dev/null
+++ 
b/flink-runtime-web/web-dashboard/src/app/components/humanize-duration.pipe.spec.ts
@@ -0,0 +1,60 @@
+/*
+ * 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 { describe, expect, it } from 'vitest';
+
+import { HumanizeDurationPipe } from './humanize-duration.pipe';
+
+describe('HumanizeDurationPipe', () => {
+  const pipe = new HumanizeDurationPipe();
+
+  it('returns a dash for nil, NaN, or negative durations', () => {
+    expect(pipe.transform(null as unknown as number)).toBe('-');
+    expect(pipe.transform(undefined as unknown as number)).toBe('-');
+    expect(pipe.transform(NaN)).toBe('-');
+    expect(pipe.transform(-1)).toBe('-');
+  });
+
+  it('renders sub-second durations as milliseconds only', () => {
+    expect(pipe.transform(500)).toBe('500ms');
+  });
+
+  it('renders sub-minute durations as seconds and milliseconds', () => {
+    expect(pipe.transform(5000)).toBe('5s 0ms');
+  });
+
+  it('renders sub-hour durations as minutes, seconds, and milliseconds', () => 
{
+    expect(pipe.transform(65000)).toBe('1m 5s 0ms');
+  });
+
+  it('renders sub-day durations as hours, minutes, and seconds', () => {
+    expect(pipe.transform(3665000)).toBe('1h 1m 5s');
+  });
+
+  it('drops seconds in short mode once hours are involved', () => {
+    expect(pipe.transform(3665000, true)).toBe('1h 1m');
+  });
+
+  it('renders multi-day durations with full precision', () => {
+    expect(pipe.transform(90000000)).toBe('1d 1h 0m 0s');
+  });
+
+  it('collapses to days and hours only in short mode', () => {
+    expect(pipe.transform(90000000, true)).toBe('1d 1h');
+  });
+});
diff --git 
a/flink-runtime-web/web-dashboard/src/app/components/humanize-watermark.pipe.spec.ts
 
b/flink-runtime-web/web-dashboard/src/app/components/humanize-watermark.pipe.spec.ts
new file mode 100644
index 00000000000..6008b720d6c
--- /dev/null
+++ 
b/flink-runtime-web/web-dashboard/src/app/components/humanize-watermark.pipe.spec.ts
@@ -0,0 +1,67 @@
+/*
+ * 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 { ConfigService } from '@flink-runtime-web/services';
+import { describe, expect, it } from 'vitest';
+
+import { HumanizeWatermarkPipe, HumanizeWatermarkToDatetimePipe } from 
'./humanize-watermark.pipe';
+
+describe('HumanizeWatermarkPipe', () => {
+  const configService = new ConfigService();
+  const pipe = new HumanizeWatermarkPipe(configService);
+
+  it('passes a real watermark value through untouched', () => {
+    expect(pipe.transform(1_700_000_000_000)).toBe(1_700_000_000_000);
+  });
+
+  it('reports no watermark for the Long.MIN_VALUE sentinel used when EventTime 
is unset', () => {
+    expect(pipe.transform(configService.LONG_MIN_VALUE)).toBe(
+      'No Watermark (Watermarks are only available if EventTime is used)'
+    );
+  });
+
+  it('reports no watermark for values below the sentinel or NaN', () => {
+    expect(pipe.transform(configService.LONG_MIN_VALUE - 1)).toBe(
+      'No Watermark (Watermarks are only available if EventTime is used)'
+    );
+    expect(pipe.transform(NaN)).toBe('No Watermark (Watermarks are only 
available if EventTime is used)');
+  });
+});
+
+describe('HumanizeWatermarkToDatetimePipe', () => {
+  const configService = new ConfigService();
+  const pipe = new HumanizeWatermarkToDatetimePipe(configService);
+
+  it('returns N/A for nil, NaN, or sentinel values', () => {
+    expect(pipe.transform(null as unknown as number)).toBe('N/A');
+    expect(pipe.transform(NaN)).toBe('N/A');
+    expect(pipe.transform(configService.LONG_MIN_VALUE)).toBe('N/A');
+  });
+
+  it('formats an epoch timestamp in the requested timezone with its 
abbreviation', () => {
+    expect(pipe.transform(0, 'UTC')).toBe('1970-01-01 00:00:00 (UTC)');
+  });
+
+  it('defaults to UTC when no timezone is given', () => {
+    expect(pipe.transform(0)).toBe('1970-01-01 00:00:00 (UTC)');
+  });
+
+  it('falls back to a manually formatted UTC string when the timezone is 
invalid', () => {
+    expect(pipe.transform(0, 'not-a-real-timezone')).toBe('1970-01-01 00:00:00 
(UTC)');
+  });
+});
diff --git 
a/flink-runtime-web/web-dashboard/src/app/components/job-badge/job-badge.component.spec.ts
 
b/flink-runtime-web/web-dashboard/src/app/components/job-badge/job-badge.component.spec.ts
new file mode 100644
index 00000000000..88504422979
--- /dev/null
+++ 
b/flink-runtime-web/web-dashboard/src/app/components/job-badge/job-badge.component.spec.ts
@@ -0,0 +1,36 @@
+/*
+ * 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 { ConfigService } from '@flink-runtime-web/services';
+import { describe, expect, it } from 'vitest';
+
+import { JobBadgeComponent } from './job-badge.component';
+
+describe('JobBadgeComponent', () => {
+  const configService = new ConfigService();
+  const component = new JobBadgeComponent(configService);
+
+  it('resolves the color configured for a known state', () => {
+    
expect(component.backgroundColor('RUNNING')).toBe(configService.COLOR_MAP.RUNNING);
+    
expect(component.backgroundColor('CANCELED')).toBe(configService.COLOR_MAP.CANCELED);
+  });
+
+  it('returns undefined for an unrecognized state', () => {
+    expect(component.backgroundColor('NOT_A_REAL_STATE')).toBeUndefined();
+  });
+});
diff --git 
a/flink-runtime-web/web-dashboard/src/app/components/navigation/navigation.component.spec.ts
 
b/flink-runtime-web/web-dashboard/src/app/components/navigation/navigation.component.spec.ts
new file mode 100644
index 00000000000..a55af7f66bb
--- /dev/null
+++ 
b/flink-runtime-web/web-dashboard/src/app/components/navigation/navigation.component.spec.ts
@@ -0,0 +1,95 @@
+/*
+ * 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 { ChangeDetectorRef } from '@angular/core';
+import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
+import { Observable, of, Subject } from 'rxjs';
+
+import { RouterTab } from '@flink-runtime-web/core/module-config';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { NavigationComponent } from './navigation.component';
+
+describe('NavigationComponent', () => {
+  const listOfNavigation: RouterTab[] = [
+    { path: 'overview', title: 'Overview' },
+    { path: 'exceptions', title: 'Exceptions' },
+    { path: 'checkpoints', title: 'Checkpoints' }
+  ];
+
+  let activatedRoute: { firstChild: { data: Observable<{ path: string }> } | 
undefined };
+  let routerEvents: Subject<NavigationEnd>;
+  let navigate: ReturnType<typeof vi.fn>;
+  let cdr: { markForCheck: ReturnType<typeof vi.fn> };
+  let component: NavigationComponent;
+
+  beforeEach(() => {
+    activatedRoute = { firstChild: { data: of({ path: 'checkpoints' }) } };
+    routerEvents = new Subject<NavigationEnd>();
+    navigate = vi.fn().mockResolvedValue(true);
+    cdr = { markForCheck: vi.fn() };
+    component = new NavigationComponent(
+      activatedRoute as unknown as ActivatedRoute,
+      { events: routerEvents.asObservable(), navigate } as unknown as Router,
+      cdr as unknown as ChangeDetectorRef
+    );
+    component.listOfNavigation = listOfNavigation;
+  });
+
+  it('selects the tab matching the initially active child route without 
waiting for a navigation event', () => {
+    component.ngOnInit();
+
+    expect(component.navIndex).toBe(2);
+    expect(cdr.markForCheck).toHaveBeenCalled();
+  });
+
+  it('leaves navIndex untouched when there is no active child route yet', () 
=> {
+    activatedRoute.firstChild = undefined;
+
+    component.ngOnInit();
+
+    expect(component.navIndex).toBe(0);
+    expect(cdr.markForCheck).not.toHaveBeenCalled();
+  });
+
+  it('re-selects the tab when a subsequent navigation activates a different 
child route', () => {
+    component.ngOnInit();
+    expect(component.navIndex).toBe(2);
+
+    activatedRoute.firstChild = { data: of({ path: 'exceptions' }) };
+    routerEvents.next(new NavigationEnd(1, '/exceptions', '/exceptions'));
+
+    expect(component.navIndex).toBe(1);
+  });
+
+  it('stops reacting to navigation events once destroyed', () => {
+    component.ngOnInit();
+    component.ngOnDestroy();
+
+    activatedRoute.firstChild = { data: of({ path: 'exceptions' }) };
+    routerEvents.next(new NavigationEnd(1, '/exceptions', '/exceptions'));
+
+    expect(component.navIndex).toBe(2);
+  });
+
+  it('navigates relative to the current route when a tab is selected', () => {
+    component.navigateTo('exceptions');
+
+    expect(navigate).toHaveBeenCalledWith(['exceptions'], { relativeTo: 
activatedRoute });
+  });
+});
diff --git 
a/flink-runtime-web/web-dashboard/src/app/components/parse-int.pipe.spec.ts 
b/flink-runtime-web/web-dashboard/src/app/components/parse-int.pipe.spec.ts
new file mode 100644
index 00000000000..d2a8c185506
--- /dev/null
+++ b/flink-runtime-web/web-dashboard/src/app/components/parse-int.pipe.spec.ts
@@ -0,0 +1,45 @@
+/*
+ * 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 { describe, expect, it } from 'vitest';
+
+import { ParseIntPipe } from './parse-int.pipe';
+
+describe('ParseIntPipe', () => {
+  const pipe = new ParseIntPipe();
+
+  it('parses a numeric string to an integer', () => {
+    expect(pipe.transform('42')).toBe(42);
+  });
+
+  it('truncates a decimal string down to its integer part', () => {
+    expect(pipe.transform('42.9')).toBe(42);
+  });
+
+  it('parses the leading numeric portion of a mixed string', () => {
+    expect(pipe.transform('12px')).toBe(12);
+  });
+
+  it('returns null for a non-numeric string', () => {
+    expect(pipe.transform('not-a-number')).toBeNull();
+  });
+
+  it('returns null for an empty string', () => {
+    expect(pipe.transform('')).toBeNull();
+  });
+});
diff --git 
a/flink-runtime-web/web-dashboard/src/app/pages/application/modules/running-application/running-application.guard.spec.ts
 
b/flink-runtime-web/web-dashboard/src/app/pages/application/modules/running-application/running-application.guard.spec.ts
new file mode 100644
index 00000000000..8087c93c2f3
--- /dev/null
+++ 
b/flink-runtime-web/web-dashboard/src/app/pages/application/modules/running-application/running-application.guard.spec.ts
@@ -0,0 +1,87 @@
+/*
+ * 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 { ActivatedRouteSnapshot, Router, RouterStateSnapshot, UrlTree } from 
'@angular/router';
+import { firstValueFrom, Observable, of } from 'rxjs';
+
+import { ApplicationItem } from '@flink-runtime-web/interfaces';
+import { ApplicationService } from '@flink-runtime-web/services';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { RunningApplicationGuard } from './running-application.guard';
+
+function applicationItem(overrides: Partial<ApplicationItem>): ApplicationItem 
{
+  return { id: 'app-1', name: 'test-app', status: 'RUNNING', completed: false, 
...overrides } as ApplicationItem;
+}
+
+describe('RunningApplicationGuard', () => {
+  let loadApplications: ReturnType<typeof vi.fn>;
+  let navigate: ReturnType<typeof vi.fn>;
+  let guard: RunningApplicationGuard;
+
+  beforeEach(() => {
+    loadApplications = vi.fn();
+    navigate = vi.fn().mockResolvedValue(true);
+    guard = new RunningApplicationGuard(
+      { loadApplications } as unknown as ApplicationService,
+      { navigate } as unknown as Router
+    );
+  });
+
+  function route(id?: string): ActivatedRouteSnapshot {
+    return { params: { id } } as unknown as ActivatedRouteSnapshot;
+  }
+
+  function activate(id?: string): Observable<boolean | UrlTree> {
+    return guard.canActivate(route(id), {} as RouterStateSnapshot) as 
Observable<boolean | UrlTree>;
+  }
+
+  it('denies activation when the route has no application id', () => {
+    const result = guard.canActivate(route(undefined), {} as 
RouterStateSnapshot);
+
+    expect(result).toBe(false);
+    expect(loadApplications).not.toHaveBeenCalled();
+  });
+
+  it('redirects to the running-applications list when the application cannot 
be found', async () => {
+    loadApplications.mockReturnValue(of([applicationItem({ id: 'other-app' 
})]));
+
+    const result = await firstValueFrom(activate('app-1'));
+
+    expect(result).toBe(false);
+    expect(navigate).toHaveBeenCalledWith(['/', 'application', 'running']);
+  });
+
+  it('redirects to the completed-application page when the application has 
already finished', async () => {
+    loadApplications.mockReturnValue(of([applicationItem({ id: 'app-1', 
completed: true })]));
+
+    const result = await firstValueFrom(activate('app-1'));
+
+    expect(result).toBe(false);
+    expect(navigate).toHaveBeenCalledWith(['/', 'application', 'completed', 
'app-1']);
+  });
+
+  it('allows activation when the application exists and is still running', 
async () => {
+    loadApplications.mockReturnValue(of([applicationItem({ id: 'app-1', 
completed: false })]));
+
+    const result = await firstValueFrom(activate('app-1'));
+
+    expect(result).toBe(true);
+    expect(navigate).not.toHaveBeenCalled();
+  });
+});
diff --git 
a/flink-runtime-web/web-dashboard/src/app/pages/job-manager/metrics/job-manager-metrics.component.spec.ts
 
b/flink-runtime-web/web-dashboard/src/app/pages/job-manager/metrics/job-manager-metrics.component.spec.ts
new file mode 100644
index 00000000000..e090650bf84
--- /dev/null
+++ 
b/flink-runtime-web/web-dashboard/src/app/pages/job-manager/metrics/job-manager-metrics.component.spec.ts
@@ -0,0 +1,91 @@
+/*
+ * 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 { JobManagerService, StatusService } from '@flink-runtime-web/services';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { JobManagerMetricsComponent } from './job-manager-metrics.component';
+
+const gcCount = 'Status.JVM.GarbageCollector.G1_Young_Generation.Count';
+const gcTime = 'Status.JVM.GarbageCollector.G1_Young_Generation.Time';
+
+const mockMetrics = {
+  'Status.JVM.Memory.Heap.Used': 100,
+  'Status.JVM.Memory.Heap.Max': 400,
+  'Status.JVM.Memory.Heap.Committed': 200,
+  'Status.JVM.Memory.Metaspace.Used': 50,
+  'Status.JVM.Memory.Metaspace.Max': 250,
+  'Status.JVM.Memory.NonHeap.Committed': 60,
+  'Status.JVM.Memory.NonHeap.Used': 40,
+  'Status.JVM.Memory.NonHeap.Max': 300,
+  'Status.JVM.Memory.Direct.Count': 5,
+  'Status.JVM.Memory.Direct.MemoryUsed': 10,
+  'Status.JVM.Memory.Direct.TotalCapacity': 10,
+  'Status.JVM.Memory.Mapped.Count': 0,
+  'Status.JVM.Memory.Mapped.MemoryUsed': 0,
+  'Status.JVM.Memory.Mapped.TotalCapacity': 0,
+  [gcCount]: 12,
+  [gcTime]: 34
+};
+
+describe('JobManagerMetricsComponent', () => {
+  let fixture: ComponentFixture<JobManagerMetricsComponent>;
+  let element: HTMLElement;
+  const loadConfig = vi.fn();
+  const loadMetricsName = vi.fn();
+  const loadMetrics = vi.fn();
+
+  beforeEach(async () => {
+    loadConfig.mockReset().mockReturnValue(of([{ key: 'jobmanager.rpc.port', 
value: '6123' }]));
+    loadMetricsName.mockReset().mockReturnValue(of([gcCount, gcTime, 
'Status.JVM.CPU.Load']));
+    loadMetrics.mockReset().mockReturnValue(of(mockMetrics));
+    await TestBed.configureTestingModule({
+      imports: [JobManagerMetricsComponent],
+      providers: [
+        { provide: JobManagerService, useValue: { loadConfig, loadMetricsName, 
loadMetrics } },
+        { provide: StatusService, useValue: { refresh$: of(true) } }
+      ]
+    }).compileComponents();
+    fixture = TestBed.createComponent(JobManagerMetricsComponent);
+    element = fixture.nativeElement as HTMLElement;
+  });
+
+  it('loads config, garbage-collector names and memory metrics', () => {
+    fixture.detectChanges();
+
+    expect(loadConfig).toHaveBeenCalled();
+    expect(loadMetricsName).toHaveBeenCalled();
+    expect(loadMetrics).toHaveBeenCalled();
+
+    
expect(fixture.componentInstance.jmConfig['jobmanager.rpc.port']).toBe('6123');
+    // Only the GarbageCollector metric names are retained.
+    expect(fixture.componentInstance.listOfGCName).toEqual([gcCount, gcTime]);
+    expect(fixture.componentInstance.metrics).toEqual(mockMetrics);
+
+    expect(element.textContent).toContain('Flink Memory Model');
+  });
+
+  it('collapses garbage-collector metrics into per-collector rows', () => {
+    fixture.detectChanges();
+
+    expect(fixture.componentInstance.listOfGCMetric).toEqual([{ name: 
'G1_Young_Generation', count: 12, time: 34 }]);
+  });
+});
diff --git 
a/flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/job-checkpoints.component.spec.ts
 
b/flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/job-checkpoints.component.spec.ts
new file mode 100644
index 00000000000..7872f607f33
--- /dev/null
+++ 
b/flink-runtime-web/web-dashboard/src/app/pages/job/checkpoints/job-checkpoints.component.spec.ts
@@ -0,0 +1,94 @@
+/*
+ * 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, throwError } from 'rxjs';
+
+import { APP_ICONS } from '@flink-runtime-web/app-icons';
+import { JobService } from '@flink-runtime-web/services';
+import { provideNzIcons } from 'ng-zorro-antd/icon';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { JobCheckpointsComponent } from './job-checkpoints.component';
+import { JobLocalService } from '../job-local.service';
+
+const mockStats = {
+  counts: { restored: 0, total: 3, in_progress: 0, completed: 3, failed: 0 },
+  summary: null,
+  latest: {},
+  history: []
+};
+
+const mockConfig = {
+  mode: 'exactly_once',
+  checkpoint_storage: 'FileSystemCheckpointStorage',
+  state_backend: 'HashMapStateBackend',
+  interval: 10_000,
+  timeout: 600_000,
+  min_pause: 0,
+  max_concurrent: 1,
+  unaligned_checkpoints: false,
+  externalization: { enabled: false, delete_on_cancellation: false },
+  tolerable_failed_checkpoints: 0,
+  checkpoints_after_tasks_finish: false,
+  state_changelog_enabled: false
+};
+
+describe('JobCheckpointsComponent', () => {
+  let fixture: ComponentFixture<JobCheckpointsComponent>;
+  let element: HTMLElement;
+  const loadCheckpointStats = vi.fn();
+  const loadCheckpointConfig = vi.fn();
+
+  beforeEach(async () => {
+    loadCheckpointStats.mockReset().mockReturnValue(of(mockStats));
+    loadCheckpointConfig.mockReset().mockReturnValue(of(mockConfig));
+    await TestBed.configureTestingModule({
+      imports: [JobCheckpointsComponent],
+      providers: [
+        provideNzIcons(APP_ICONS),
+        { provide: JobService, useValue: { loadCheckpointStats, 
loadCheckpointConfig } },
+        { provide: JobLocalService, useValue: { jobDetailChanges: () => of({ 
jid: 'job-1' }) } }
+      ]
+    }).compileComponents();
+    fixture = TestBed.createComponent(JobCheckpointsComponent);
+    element = fixture.nativeElement as HTMLElement;
+  });
+
+  it('loads checkpoint stats and config for the current job', () => {
+    fixture.detectChanges();
+
+    expect(loadCheckpointStats).toHaveBeenCalledWith('job-1');
+    expect(loadCheckpointConfig).toHaveBeenCalledWith('job-1');
+    expect(fixture.componentInstance.checkPointStats).toEqual(mockStats);
+    expect(fixture.componentInstance.checkPointConfig).toEqual(mockConfig);
+
+    // The tabbed shell and the overview counts render once stats are present.
+    expect(element.textContent).toContain('Overview');
+    expect(element.textContent).toContain('Checkpoint Counts');
+  });
+
+  it('shows the empty state when the stats request fails', () => {
+    loadCheckpointStats.mockReturnValue(throwError(() => new 
Error('unreachable')));
+
+    fixture.detectChanges();
+
+    expect(fixture.componentInstance.checkPointStats).toBeUndefined();
+    expect(element.querySelector('nz-empty')).not.toBeNull();
+  });
+});
diff --git 
a/flink-runtime-web/web-dashboard/src/app/pages/job/configuration/job-configuration.component.spec.ts
 
b/flink-runtime-web/web-dashboard/src/app/pages/job/configuration/job-configuration.component.spec.ts
new file mode 100644
index 00000000000..82e503619bc
--- /dev/null
+++ 
b/flink-runtime-web/web-dashboard/src/app/pages/job/configuration/job-configuration.component.spec.ts
@@ -0,0 +1,77 @@
+/*
+ * 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 { JobService } from '@flink-runtime-web/services';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { JobConfigurationComponent } from './job-configuration.component';
+import { JobLocalService } from '../job-local.service';
+
+const mockConfig = {
+  jid: 'job-1',
+  name: 'demo',
+  'execution-config': {
+    'execution-mode': 'PIPELINED',
+    'restart-strategy': 'Cluster level default restart strategy',
+    'job-parallelism': 4,
+    'object-reuse-mode': false,
+    'user-config': {
+      'state.backend': 'hashmap',
+      'pipeline.name': 'my-pipeline'
+    }
+  }
+};
+
+describe('JobConfigurationComponent', () => {
+  let fixture: ComponentFixture<JobConfigurationComponent>;
+  let element: HTMLElement;
+  const loadJobConfig = vi.fn();
+
+  beforeEach(async () => {
+    loadJobConfig.mockReset().mockReturnValue(of(mockConfig));
+    await TestBed.configureTestingModule({
+      imports: [JobConfigurationComponent],
+      providers: [
+        { provide: JobService, useValue: { loadJobConfig } },
+        { provide: JobLocalService, useValue: { jobDetailChanges: () => of({ 
jid: 'job-1' }) } }
+      ]
+    }).compileComponents();
+    fixture = TestBed.createComponent(JobConfigurationComponent);
+    element = fixture.nativeElement as HTMLElement;
+  });
+
+  it('loads the job config and lists user config entries sorted by key', () => 
{
+    fixture.detectChanges();
+
+    expect(loadJobConfig).toHaveBeenCalledWith('job-1');
+    expect(fixture.componentInstance.config).toEqual(mockConfig);
+    expect(fixture.componentInstance.listOfUserConfig).toEqual([
+      { key: 'pipeline.name', value: 'my-pipeline' },
+      { key: 'state.backend', value: 'hashmap' }
+    ]);
+
+    const text = element.textContent ?? '';
+    expect(text).toContain('Execution Configuration');
+    expect(text).toContain('User Configuration');
+    expect(text).toContain('pipeline.name');
+    expect(text).toContain('my-pipeline');
+  });
+});
diff --git 
a/flink-runtime-web/web-dashboard/src/app/pages/job/dataskew/data-skew.component.spec.ts
 
b/flink-runtime-web/web-dashboard/src/app/pages/job/dataskew/data-skew.component.spec.ts
new file mode 100644
index 00000000000..b43e4703df8
--- /dev/null
+++ 
b/flink-runtime-web/web-dashboard/src/app/pages/job/dataskew/data-skew.component.spec.ts
@@ -0,0 +1,83 @@
+/*
+ * 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 { APP_ICONS } from '@flink-runtime-web/app-icons';
+import { MetricsService } from '@flink-runtime-web/services';
+import { provideNzIcons } from 'ng-zorro-antd/icon';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { DataSkewComponent } from './data-skew.component';
+import { JobLocalService } from '../job-local.service';
+
+const mockJobDetail = {
+  jid: 'job-1',
+  vertices: [
+    { id: 'v1', name: 'Source: generator' },
+    { id: 'v2', name: 'Sink: out' }
+  ]
+};
+
+describe('DataSkewComponent', () => {
+  let fixture: ComponentFixture<DataSkewComponent>;
+  let element: HTMLElement;
+  const loadAggregatedMetrics = vi.fn();
+
+  beforeEach(async () => {
+    // Return a higher skew for the first vertex so ordering can be asserted.
+    loadAggregatedMetrics
+      .mockReset()
+      .mockImplementation((_jid: string, vertexId: string) => of({ 
numRecordsIn: vertexId === 'v1' ? 42 : 7 }));
+    await TestBed.configureTestingModule({
+      imports: [DataSkewComponent],
+      providers: [
+        provideNzIcons(APP_ICONS),
+        { provide: MetricsService, useValue: { loadAggregatedMetrics } },
+        { provide: JobLocalService, useValue: { jobDetailChanges: () => 
of(mockJobDetail) } }
+      ]
+    }).compileComponents();
+    fixture = TestBed.createComponent(DataSkewComponent);
+    element = fixture.nativeElement as HTMLElement;
+  });
+
+  it('loads per-vertex skew and renders it sorted by descending percentage', 
() => {
+    fixture.detectChanges();
+
+    expect(loadAggregatedMetrics).toHaveBeenCalledWith('job-1', 'v1', 
['numRecordsIn'], 'skew');
+    expect(fixture.componentInstance.isLoading).toBe(false);
+    expect(fixture.componentInstance.listOfVerticesAndSkew).toEqual([
+      { vertexName: 'Source: generator', skewPct: 42 },
+      { vertexName: 'Sink: out', skewPct: 7 }
+    ]);
+
+    const text = element.textContent ?? '';
+    expect(text).toContain('What is Data Skew?');
+    expect(text).toContain('Source: generator');
+    expect(text).toContain('42%');
+  });
+
+  it('treats a non-numeric skew metric as zero', () => {
+    loadAggregatedMetrics.mockReturnValue(of({ numRecordsIn: NaN }));
+
+    fixture.detectChanges();
+
+    expect(fixture.componentInstance.listOfVerticesAndSkew.every(v => 
v.skewPct === 0)).toBe(true);
+  });
+});
diff --git 
a/flink-runtime-web/web-dashboard/src/app/pages/job/modules/completed-job/cluster-config.guard.spec.ts
 
b/flink-runtime-web/web-dashboard/src/app/pages/job/modules/completed-job/cluster-config.guard.spec.ts
new file mode 100644
index 00000000000..436983d1f97
--- /dev/null
+++ 
b/flink-runtime-web/web-dashboard/src/app/pages/job/modules/completed-job/cluster-config.guard.spec.ts
@@ -0,0 +1,59 @@
+/*
+ * 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 { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from 
'@angular/router';
+
+import { Configuration } from '@flink-runtime-web/interfaces';
+import { StatusService } from '@flink-runtime-web/services';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { ClusterConfigGuard } from './cluster-config.guard';
+
+describe('ClusterConfigGuard', () => {
+  let statusService: StatusService;
+  let navigate: ReturnType<typeof vi.fn>;
+  let guard: ClusterConfigGuard;
+
+  beforeEach(() => {
+    statusService = {
+      configuration: { features: { 'web-history': true } } as Configuration
+    } as unknown as StatusService;
+    navigate = vi.fn().mockResolvedValue(true);
+    guard = new ClusterConfigGuard(statusService, { navigate } as unknown as 
Router);
+  });
+
+  function route(jid: string): ActivatedRouteSnapshot {
+    return { parent: { params: { jid } } } as unknown as 
ActivatedRouteSnapshot;
+  }
+
+  it('allows activation when the history server feature is enabled', () => {
+    const result = guard.canActivate(route('job-1'), {} as 
RouterStateSnapshot);
+
+    expect(result).toBe(true);
+    expect(navigate).not.toHaveBeenCalled();
+  });
+
+  it('redirects to the job overview and denies activation when the history 
server feature is disabled', () => {
+    statusService.configuration.features['web-history'] = false;
+
+    const result = guard.canActivate(route('job-1'), {} as 
RouterStateSnapshot);
+
+    expect(result).toBe(false);
+    expect(navigate).toHaveBeenCalledWith(['/', 'job', 'completed', 'job-1', 
'overview']);
+  });
+});
diff --git 
a/flink-runtime-web/web-dashboard/src/app/pages/job/modules/running-job/running-job.guard.spec.ts
 
b/flink-runtime-web/web-dashboard/src/app/pages/job/modules/running-job/running-job.guard.spec.ts
new file mode 100644
index 00000000000..f611a8b4872
--- /dev/null
+++ 
b/flink-runtime-web/web-dashboard/src/app/pages/job/modules/running-job/running-job.guard.spec.ts
@@ -0,0 +1,84 @@
+/*
+ * 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 { ActivatedRouteSnapshot, Router, RouterStateSnapshot, UrlTree } from 
'@angular/router';
+import { firstValueFrom, Observable, of } from 'rxjs';
+
+import { JobsItem } from '@flink-runtime-web/interfaces';
+import { JobService } from '@flink-runtime-web/services';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { RunningJobGuard } from './running-job.guard';
+
+function jobItem(overrides: Partial<JobsItem>): JobsItem {
+  return { jid: 'job-1', name: 'test-job', state: 'RUNNING', completed: false, 
...overrides } as JobsItem;
+}
+
+describe('RunningJobGuard', () => {
+  let loadJobs: ReturnType<typeof vi.fn>;
+  let navigate: ReturnType<typeof vi.fn>;
+  let guard: RunningJobGuard;
+
+  beforeEach(() => {
+    loadJobs = vi.fn();
+    navigate = vi.fn().mockResolvedValue(true);
+    guard = new RunningJobGuard({ loadJobs } as unknown as JobService, { 
navigate } as unknown as Router);
+  });
+
+  function route(jid?: string): ActivatedRouteSnapshot {
+    return { params: { jid } } as unknown as ActivatedRouteSnapshot;
+  }
+
+  function activate(jid?: string): Observable<boolean | UrlTree> {
+    return guard.canActivate(route(jid), {} as RouterStateSnapshot) as 
Observable<boolean | UrlTree>;
+  }
+
+  it('denies activation when the route has no job id', () => {
+    const result = guard.canActivate(route(undefined), {} as 
RouterStateSnapshot);
+
+    expect(result).toBe(false);
+    expect(loadJobs).not.toHaveBeenCalled();
+  });
+
+  it('redirects to the running-jobs list when the job cannot be found', async 
() => {
+    loadJobs.mockReturnValue(of([jobItem({ jid: 'other-job' })]));
+
+    const result = await firstValueFrom(activate('job-1'));
+
+    expect(result).toBe(false);
+    expect(navigate).toHaveBeenCalledWith(['/', 'job', 'running']);
+  });
+
+  it('redirects to the completed-job page when the job has already finished', 
async () => {
+    loadJobs.mockReturnValue(of([jobItem({ jid: 'job-1', completed: true })]));
+
+    const result = await firstValueFrom(activate('job-1'));
+
+    expect(result).toBe(false);
+    expect(navigate).toHaveBeenCalledWith(['/', 'job', 'completed', 'job-1']);
+  });
+
+  it('allows activation when the job exists and is still running', async () => 
{
+    loadJobs.mockReturnValue(of([jobItem({ jid: 'job-1', completed: false 
})]));
+
+    const result = await firstValueFrom(activate('job-1'));
+
+    expect(result).toBe(true);
+    expect(navigate).not.toHaveBeenCalled();
+  });
+});
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
new file mode 100644
index 00000000000..a8ea1ccc2ed
--- /dev/null
+++ 
b/flink-runtime-web/web-dashboard/src/app/pages/job/overview/job-overview.component.spec.ts
@@ -0,0 +1,100 @@
+/*
+ * 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 { NgIf } from '@angular/common';
+import { CUSTOM_ELEMENTS_SCHEMA } 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 { JobService, MetricsService } from '@flink-runtime-web/services';
+import { NzAlertModule } from 'ng-zorro-antd/alert';
+import { NzNotificationService } from 'ng-zorro-antd/notification';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { JobOverviewComponent } from './job-overview.component';
+import { JobLocalService } from '../job-local.service';
+
+const activatedRoute = { parent: { parent: { snapshot: { params: { jid: 
'job-1' } } } } };
+
+describe('JobOverviewComponent', () => {
+  let fixture: ComponentFixture<JobOverviewComponent>;
+  let element: HTMLElement;
+  const navigate = vi.fn().mockResolvedValue(true);
+  const changeDesiredParallelism = vi.fn();
+  const success = vi.fn();
+
+  beforeEach(async () => {
+    navigate.mockClear();
+    changeDesiredParallelism.mockReset().mockReturnValue(of(undefined));
+    success.mockClear();
+    // Replace the graph/list/resize children with stubs: the Dagre graph 
relies on
+    // SVG layout APIs jsdom does not implement.
+    await TestBed.configureTestingModule({
+      imports: [JobOverviewComponent],
+      providers: [
+        { provide: Router, useValue: { navigate } },
+        { provide: ActivatedRoute, useValue: activatedRoute },
+        { provide: MetricsService, useValue: {} },
+        { provide: JobService, useValue: { changeDesiredParallelism } },
+        {
+          provide: JobLocalService,
+          useValue: { jobDetailChanges: () => EMPTY, selectedVertexChanges: () 
=> EMPTY }
+        },
+        { provide: NzNotificationService, useValue: { success } }
+      ]
+    })
+      .overrideComponent(JobOverviewComponent, {
+        set: { imports: [NgIf, NzAlertModule], schemas: 
[CUSTOM_ELEMENTS_SCHEMA] }
+      })
+      .compileComponents();
+    fixture = TestBed.createComponent(JobOverviewComponent);
+    element = fixture.nativeElement as HTMLElement;
+  });
+
+  it('shows the "not running yet" hint while no plan has arrived', () => {
+    fixture.detectChanges();
+
+    expect(fixture.componentInstance.nodes).toEqual([]);
+    expect(element.textContent).toContain('Job is not running yet.');
+  });
+
+  it('navigates to a vertex on node click', () => {
+    fixture.detectChanges();
+
+    fixture.componentInstance.onNodeClick({ id: 'vertex-1' } as 
NodesItemCorrect);
+
+    expect(navigate).toHaveBeenCalledWith(['vertex-1'], { relativeTo: 
expect.anything() });
+  });
+
+  it('requests a rescale for the current job and surfaces a success 
notification', () => {
+    fixture.detectChanges();
+    const component = fixture.componentInstance;
+    component.jobId = 'job-1';
+    const desiredParallelism = new Map([['vertex-1', 4]]);
+
+    component.onRescale(desiredParallelism);
+
+    expect(changeDesiredParallelism).toHaveBeenCalledWith('job-1', 
desiredParallelism);
+    expect(success).toHaveBeenCalledWith(
+      'Rescaling operation.',
+      'Job resources requirements have been updated. Job will now try to 
rescale.'
+    );
+  });
+});
diff --git 
a/flink-runtime-web/web-dashboard/src/app/pages/job/rescales/job-rescales.component.spec.ts
 
b/flink-runtime-web/web-dashboard/src/app/pages/job/rescales/job-rescales.component.spec.ts
new file mode 100644
index 00000000000..c6a452dd784
--- /dev/null
+++ 
b/flink-runtime-web/web-dashboard/src/app/pages/job/rescales/job-rescales.component.spec.ts
@@ -0,0 +1,97 @@
+/*
+ * 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 { APP_ICONS } from '@flink-runtime-web/app-icons';
+import { JobService } from '@flink-runtime-web/services';
+import { provideNzIcons } from 'ng-zorro-antd/icon';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { JobRescalesComponent } from './job-rescales.component';
+import { JobLocalService } from '../job-local.service';
+
+const mockOverview = {
+  rescalesCounts: { inProgress: 0, completed: 1, failed: 0, ignored: 0 },
+  latest: {}
+};
+
+describe('JobRescalesComponent', () => {
+  let fixture: ComponentFixture<JobRescalesComponent>;
+  let element: HTMLElement;
+  const loadRescalesOverview = vi.fn();
+  const loadRescalesSummary = vi.fn();
+  const loadRescalesHistory = vi.fn();
+  const loadRescalesConfig = vi.fn();
+  const loadRescaleDetail = vi.fn();
+
+  beforeEach(async () => {
+    loadRescalesOverview.mockReset().mockReturnValue(of(mockOverview));
+    loadRescalesSummary.mockReset().mockReturnValue(of(undefined));
+    loadRescalesHistory.mockReset().mockReturnValue(of(undefined));
+    loadRescalesConfig.mockReset().mockReturnValue(of({ maxParallelism: 128 
}));
+    loadRescaleDetail.mockReset().mockReturnValue(of(undefined));
+    await TestBed.configureTestingModule({
+      imports: [JobRescalesComponent],
+      providers: [
+        provideNzIcons(APP_ICONS),
+        {
+          provide: JobService,
+          useValue: {
+            loadRescalesOverview,
+            loadRescalesSummary,
+            loadRescalesHistory,
+            loadRescalesConfig,
+            loadRescaleDetail
+          }
+        },
+        { provide: JobLocalService, useValue: { jobDetailChanges: () => of({ 
jid: 'job-1' }) } }
+      ]
+    }).compileComponents();
+    fixture = TestBed.createComponent(JobRescalesComponent);
+    element = fixture.nativeElement as HTMLElement;
+  });
+
+  it('loads the rescale overview and config for the current job', () => {
+    fixture.detectChanges();
+
+    expect(loadRescalesOverview).toHaveBeenCalledWith('job-1');
+    expect(loadRescalesConfig).toHaveBeenCalledWith('job-1');
+    expect(fixture.componentInstance.rescalesOverview).toEqual(mockOverview);
+    expect(fixture.componentInstance.rescalesConfig).toBeDefined();
+
+    // The tab shell renders once the config resolves.
+    expect(element.textContent).toContain('Overview');
+  });
+
+  it('computes the total rescale count from the overview counts', () => {
+    fixture.detectChanges();
+
+    expect(fixture.componentInstance.getTotalRescaleCount()).toBe(1);
+  });
+
+  it('truncates uuids and long names for display', () => {
+    const component = fixture.componentInstance;
+
+    expect(component.truncateUuid('abcdef1234567890')).toBe('abcdef12');
+    expect(component.truncateUuid('')).toBe('');
+    expect(component.truncateName('short')).toBe('short');
+    
expect(component.truncateName('x'.repeat(40))).toBe(`${'x'.repeat(32)}...`);
+  });
+});
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
new file mode 100644
index 00000000000..70eb7985e4f
--- /dev/null
+++ 
b/flink-runtime-web/web-dashboard/src/app/pages/job/timeline/job-timeline.component.spec.ts
@@ -0,0 +1,112 @@
+/*
+ * 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 type { Chart } from '@antv/g2';
+import { JobService, ConfigService } from '@flink-runtime-web/services';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { JobTimelineComponent } from './job-timeline.component';
+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 }]
+};
+
+const mockSubTaskTimes = {
+  id: 'v1',
+  name: 'Source: generator',
+  now: 3000,
+  subtasks: [
+    {
+      subtask: 0,
+      endpoint: 'host-a',
+      duration: 500,
+      timestamps: {
+        CREATED: 1000,
+        RUNNING: 1200,
+        FAILING: 0,
+        RECONCILING: 0,
+        CANCELLING: 0,
+        RESTARTING: 0,
+        FINISHED: 1700
+      }
+    }
+  ]
+};
+
+// G2 charts draw to a canvas that jsdom cannot render, so the chart setup is
+// replaced with lightweight fakes that record the data/render calls.
+const createFakeChart = (): Chart =>
+  ({ width: 800, changeSize: vi.fn(), data: vi.fn(), scale: vi.fn(), render: 
vi.fn() }) as unknown as Chart;
+
+describe('JobTimelineComponent', () => {
+  let fixture: ComponentFixture<JobTimelineComponent>;
+  let component: JobTimelineComponent;
+  let fakeMain: Chart;
+  let fakeSub: Chart;
+  const loadSubTaskTimes = vi.fn();
+
+  beforeEach(async () => {
+    loadSubTaskTimes.mockReset().mockReturnValue(of(mockSubTaskTimes));
+    await TestBed.configureTestingModule({
+      imports: [JobTimelineComponent],
+      providers: [
+        { provide: ConfigService, useValue: { COLOR_MAP: {} } },
+        { provide: JobService, useValue: { loadSubTaskTimes } },
+        { provide: JobLocalService, useValue: { jobDetailChanges: () => 
of(mockJobDetail) } }
+      ]
+    }).compileComponents();
+    fixture = TestBed.createComponent(JobTimelineComponent);
+    component = fixture.componentInstance;
+
+    fakeMain = createFakeChart();
+    fakeSub = createFakeChart();
+    vi.spyOn(component, 'setUpMainChart').mockImplementation(() => {
+      component.mainChartInstance = fakeMain;
+    });
+    vi.spyOn(component, 'setUpSubTaskChart').mockImplementation(() => {
+      component.subTaskChartInstance = fakeSub;
+    });
+  });
+
+  it('maps the vertices into timeline ranges and renders the main chart', () 
=> {
+    fixture.detectChanges();
+
+    expect(component.jobDetail).toEqual(mockJobDetail);
+    expect(component.listOfVertex).toHaveLength(1);
+    expect(component.listOfVertex[0].range).toEqual([1000, 2000]);
+    expect(fakeMain.data).toHaveBeenCalledWith(component.listOfVertex);
+    expect(fakeMain.render).toHaveBeenCalled();
+  });
+
+  it('builds the subtask timeline from subtask time stamps', () => {
+    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(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
new file mode 100644
index 00000000000..74966d45867
--- /dev/null
+++ 
b/flink-runtime-web/web-dashboard/src/app/pages/overview/overview.component.spec.ts
@@ -0,0 +1,118 @@
+/*
+ * 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 { ApplicationService, OverviewService, StatusService } from 
'@flink-runtime-web/services';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { OverviewComponent } from './overview.component';
+
+const emptyJobStatus = {
+  CANCELED: 0,
+  CANCELING: 0,
+  CREATED: 0,
+  FAILED: 0,
+  FAILING: 0,
+  FINISHED: 0,
+  RECONCILING: 0,
+  RUNNING: 0,
+  RESTARTING: 0
+};
+
+const mockApplications = [
+  {
+    id: 'app-1',
+    name: 'running-app',
+    status: 'RUNNING',
+    'start-time': 100,
+    'end-time': -1,
+    duration: 10,
+    completed: false,
+    jobs: { ...emptyJobStatus, RUNNING: 1 }
+  },
+  {
+    id: 'app-2',
+    name: 'finished-app',
+    status: 'FINISHED',
+    'start-time': 50,
+    'end-time': 200,
+    duration: 150,
+    completed: true,
+    jobs: { ...emptyJobStatus, FINISHED: 1 }
+  }
+];
+
+const mockClusterOverview = {
+  taskmanagers: 3,
+  'taskmanagers-blocked': 0,
+  'slots-total': 12,
+  'slots-available': 5,
+  'slots-free-and-blocked': 0,
+  'flink-version': '2.4-SNAPSHOT',
+  'flink-commit': 'abcdef0'
+};
+
+describe('OverviewComponent', () => {
+  let fixture: ComponentFixture<OverviewComponent>;
+  let element: HTMLElement;
+  const loadApplications = vi.fn();
+  const loadOverview = vi.fn();
+  const navigate = vi.fn().mockResolvedValue(true);
+
+  beforeEach(async () => {
+    loadApplications.mockReset().mockReturnValue(of(mockApplications));
+    loadOverview.mockReset().mockReturnValue(of(mockClusterOverview));
+    navigate.mockClear();
+    await TestBed.configureTestingModule({
+      imports: [OverviewComponent],
+      providers: [
+        { provide: StatusService, useValue: { refresh$: of(true) } },
+        { provide: ApplicationService, useValue: { loadApplications } },
+        { provide: OverviewService, useValue: { loadOverview } },
+        { provide: Router, useValue: { navigate } }
+      ]
+    }).compileComponents();
+    fixture = TestBed.createComponent(OverviewComponent);
+    element = fixture.nativeElement as HTMLElement;
+  });
+
+  it('composes the statistic card and application lists from cluster data', () 
=> {
+    fixture.detectChanges();
+
+    expect(loadApplications).toHaveBeenCalled();
+    expect(loadOverview).toHaveBeenCalled();
+
+    const text = element.textContent ?? '';
+    // Statistic card (child component) is populated from the derived stats.
+    expect(text).toContain('Available Task Slots');
+    // Both application-list children render with their titles.
+    expect(text).toContain('Running Application List');
+    expect(text).toContain('Completed Application List');
+  });
+
+  it('navigates when an application is selected', () => {
+    fixture.detectChanges();
+
+    fixture.componentInstance.navigateToApplication(['application', 'running', 
'app-1']);
+
+    expect(navigate).toHaveBeenCalledWith(['application', 'running', 'app-1']);
+  });
+});
diff --git 
a/flink-runtime-web/web-dashboard/src/app/pages/task-manager/logs/task-manager-logs.component.spec.ts
 
b/flink-runtime-web/web-dashboard/src/app/pages/task-manager/logs/task-manager-logs.component.spec.ts
new file mode 100644
index 00000000000..c487c785e16
--- /dev/null
+++ 
b/flink-runtime-web/web-dashboard/src/app/pages/task-manager/logs/task-manager-logs.component.spec.ts
@@ -0,0 +1,69 @@
+/*
+ * 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 { ActivatedRoute } from '@angular/router';
+import { of, throwError } from 'rxjs';
+
+import { APP_ICONS } from '@flink-runtime-web/app-icons';
+import { TASK_MANAGER_MODULE_CONFIG } from 
'@flink-runtime-web/pages/task-manager/task-manager.config';
+import { ConfigService, TaskManagerService } from 
'@flink-runtime-web/services';
+import { provideNzIcons } from 'ng-zorro-antd/icon';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { TaskManagerLogsComponent } from './task-manager-logs.component';
+
+describe('TaskManagerLogsComponent', () => {
+  let fixture: ComponentFixture<TaskManagerLogsComponent>;
+  const loadLogs = vi.fn();
+
+  beforeEach(async () => {
+    loadLogs.mockReset().mockReturnValue(of('2026-01-01 INFO started\n'));
+    await TestBed.configureTestingModule({
+      imports: [TaskManagerLogsComponent],
+      providers: [
+        provideNzIcons(APP_ICONS),
+        { provide: TaskManagerService, useValue: { loadLogs } },
+        { provide: ConfigService, useValue: { BASE_URL: '/api' } },
+        { provide: ActivatedRoute, useValue: { parent: { snapshot: { params: { 
taskManagerId: 'tm-1' } } } } },
+        { provide: TASK_MANAGER_MODULE_CONFIG, useValue: {} }
+      ]
+    }).compileComponents();
+    fixture = TestBed.createComponent(TaskManagerLogsComponent);
+  });
+
+  it('loads logs for the task manager and derives the download target', () => {
+    fixture.detectChanges();
+
+    expect(loadLogs).toHaveBeenCalledWith('tm-1');
+    expect(fixture.componentInstance.taskManagerId).toBe('tm-1');
+    
expect(fixture.componentInstance.downloadUrl).toBe('/api/taskmanagers/tm-1/log');
+    
expect(fixture.componentInstance.downloadName).toBe('taskmanager_tm-1_log');
+    expect(fixture.componentInstance.logs).toBe('2026-01-01 INFO started\n');
+    expect(fixture.componentInstance.loading).toBe(false);
+  });
+
+  it('falls back to empty logs when the request fails', () => {
+    loadLogs.mockReturnValue(throwError(() => new Error('log unavailable')));
+
+    fixture.detectChanges();
+
+    expect(fixture.componentInstance.logs).toBe('');
+    expect(fixture.componentInstance.loading).toBe(false);
+  });
+});
diff --git 
a/flink-runtime-web/web-dashboard/src/app/pages/task-manager/metrics/task-manager-metrics.component.spec.ts
 
b/flink-runtime-web/web-dashboard/src/app/pages/task-manager/metrics/task-manager-metrics.component.spec.ts
new file mode 100644
index 00000000000..b95645bad30
--- /dev/null
+++ 
b/flink-runtime-web/web-dashboard/src/app/pages/task-manager/metrics/task-manager-metrics.component.spec.ts
@@ -0,0 +1,117 @@
+/*
+ * 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 { ActivatedRoute } from '@angular/router';
+import { of, throwError } from 'rxjs';
+
+import { StatusService, TaskManagerService } from 
'@flink-runtime-web/services';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { TaskManagerMetricsComponent } from './task-manager-metrics.component';
+
+const mockDetail = {
+  id: 'tm-1',
+  memoryConfiguration: {
+    frameworkHeap: 134_217_728,
+    frameworkOffHeap: 134_217_728,
+    jvmMetaspace: 268_435_456,
+    jvmOverhead: 201_326_592,
+    managedMemory: 536_870_912,
+    networkMemory: 134_217_728,
+    taskHeap: 402_653_184,
+    taskOffHeap: 0,
+    totalFlinkMemory: 1_476_395_008,
+    totalProcessMemory: 1_728_053_248
+  },
+  metrics: {
+    heapUsed: 100,
+    heapCommitted: 200,
+    heapMax: 400,
+    nonHeapUsed: 40,
+    nonHeapCommitted: 60,
+    nonHeapMax: 300,
+    directCount: 5,
+    directUsed: 10,
+    directMax: 10,
+    mappedCount: 0,
+    mappedUsed: 0,
+    mappedMax: 0,
+    memorySegmentsAvailable: 128,
+    memorySegmentsTotal: 256,
+    garbageCollectors: []
+  },
+  freeResource: { cpuCores: 4, taskHeapMemory: 100, taskOffHeapMemory: 0, 
managedMemory: 200, networkMemory: 50 },
+  totalResource: { cpuCores: 8, taskHeapMemory: 400, taskOffHeapMemory: 0, 
managedMemory: 500, networkMemory: 100 },
+  allocatedSlots: []
+};
+
+const mockMetrics = {
+  'Status.JVM.Memory.Heap.Used': 100,
+  'Status.JVM.Memory.Heap.Max': 400,
+  'Status.Shuffle.Netty.UsedMemory': 10,
+  'Status.Shuffle.Netty.TotalMemory': 100,
+  'Status.Flink.Memory.Managed.Used': 200,
+  'Status.Flink.Memory.Managed.Total': 500,
+  'Status.JVM.Memory.Metaspace.Used': 50,
+  'Status.JVM.Memory.Metaspace.Max': 250
+};
+
+describe('TaskManagerMetricsComponent', () => {
+  let fixture: ComponentFixture<TaskManagerMetricsComponent>;
+  let element: HTMLElement;
+  const loadManager = vi.fn();
+  const loadMetrics = vi.fn();
+
+  beforeEach(async () => {
+    loadManager.mockReset().mockReturnValue(of(mockDetail));
+    loadMetrics.mockReset().mockReturnValue(of(mockMetrics));
+    await TestBed.configureTestingModule({
+      imports: [TaskManagerMetricsComponent],
+      providers: [
+        { provide: TaskManagerService, useValue: { loadManager, loadMetrics } 
},
+        { provide: StatusService, useValue: { refresh$: of(true) } },
+        { provide: ActivatedRoute, useValue: { parent: { snapshot: { params: { 
taskManagerId: 'tm-1' } } } } }
+      ]
+    }).compileComponents();
+    fixture = TestBed.createComponent(TaskManagerMetricsComponent);
+    element = fixture.nativeElement as HTMLElement;
+  });
+
+  it('loads the manager detail and memory metrics on refresh', () => {
+    fixture.detectChanges();
+
+    expect(loadManager).toHaveBeenCalledWith('tm-1');
+    expect(loadMetrics).toHaveBeenCalledWith('tm-1', 
expect.arrayContaining(['Status.JVM.Memory.Heap.Used']));
+    expect(fixture.componentInstance.taskManagerDetail).toEqual(mockDetail);
+    expect(fixture.componentInstance.metrics).toEqual(mockMetrics);
+
+    const text = element.textContent ?? '';
+    expect(text).toContain('Memory');
+    expect(text).toContain('Flink Memory Model');
+  });
+
+  it('does not load metrics when the manager cannot be fetched', () => {
+    loadManager.mockReturnValue(throwError(() => new Error('gone')));
+
+    fixture.detectChanges();
+
+    expect(fixture.componentInstance.taskManagerDetail).toBeUndefined();
+    expect(loadMetrics).not.toHaveBeenCalled();
+  });
+});
diff --git 
a/flink-runtime-web/web-dashboard/src/app/services/application.service.spec.ts 
b/flink-runtime-web/web-dashboard/src/app/services/application.service.spec.ts
new file mode 100644
index 00000000000..7f1661dbc93
--- /dev/null
+++ 
b/flink-runtime-web/web-dashboard/src/app/services/application.service.spec.ts
@@ -0,0 +1,155 @@
+/*
+ * 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 { HttpClient } from '@angular/common/http';
+import { firstValueFrom, of, throwError, toArray } from 'rxjs';
+
+import { ApplicationDetail, ApplicationOverview } from 
'@flink-runtime-web/interfaces';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { ApplicationService } from './application.service';
+import { ConfigService } from './config.service';
+
+describe('ApplicationService', () => {
+  let httpClient: { get: ReturnType<typeof vi.fn>; post: ReturnType<typeof 
vi.fn> };
+  let service: ApplicationService;
+
+  beforeEach(() => {
+    httpClient = { get: vi.fn(), post: vi.fn() };
+    service = new ApplicationService(httpClient as unknown as HttpClient, new 
ConfigService());
+  });
+
+  describe('loadApplications', () => {
+    it('sums each job-status count into a TOTAL and derives the completed flag 
from status', async () => {
+      const overview: ApplicationOverview = {
+        applications: [
+          {
+            id: 'app-1',
+            name: 'running-app',
+            status: 'RUNNING',
+            'start-time': 0,
+            'end-time': -1,
+            duration: 100,
+            jobs: { RUNNING: 2, FINISHED: 1 } as 
ApplicationOverview['applications'][0]['jobs']
+          },
+          {
+            id: 'app-2',
+            name: 'finished-app',
+            status: 'FINISHED',
+            'start-time': 0,
+            'end-time': 100,
+            duration: 100,
+            jobs: { FINISHED: 3 } as 
ApplicationOverview['applications'][0]['jobs']
+          }
+        ]
+      };
+      httpClient.get.mockReturnValue(of(overview));
+
+      const result = await firstValueFrom(service.loadApplications());
+
+      expect(result[0].jobs.TOTAL).toBe(3);
+      expect(result[0].completed).toBe(false);
+      expect(result[1].jobs.TOTAL).toBe(3);
+      expect(result[1].completed).toBe(true);
+    });
+
+    it('swallows request errors into an empty stream', async () => {
+      httpClient.get.mockReturnValue(throwError(() => new Error('cluster 
unreachable')));
+
+      const emissions = await 
firstValueFrom(service.loadApplications().pipe(toArray()));
+
+      expect(emissions).toEqual([]);
+    });
+  });
+
+  describe('loadApplication', () => {
+    function detail(): ApplicationDetail {
+      return {
+        id: 'app-1',
+        name: 'test-app',
+        status: 'RUNNING',
+        'start-time': 0,
+        'end-time': -1,
+        duration: 100,
+        timestamps: {} as ApplicationDetail['timestamps'],
+        jobs: [
+          {
+            jid: 'job-1',
+            name: 'job-one',
+            state: 'RUNNING',
+            'start-time': 0,
+            'end-time': -1,
+            duration: 100,
+            'last-modification': 0,
+            'pending-operators': 2,
+            // Raw REST payload uses lowercase task-state keys; the service 
normalizes them to uppercase.
+            tasks: { running: 3, finished: 1 } as unknown as 
ApplicationDetail['jobs'][0]['tasks']
+          },
+          {
+            jid: 'job-2',
+            name: 'job-two',
+            state: 'FINISHED',
+            'start-time': 0,
+            'end-time': 100,
+            duration: 100,
+            'last-modification': 100,
+            tasks: { finished: 4 } as unknown as 
ApplicationDetail['jobs'][0]['tasks']
+          }
+        ]
+      };
+    }
+
+    it('normalizes task-status keys to uppercase and backfills PENDING from 
pending-operators', async () => {
+      httpClient.get.mockReturnValue(of(detail()));
+
+      const result = await firstValueFrom(service.loadApplication('app-1'));
+
+      expect(result.jobs[0].tasks).toEqual({ RUNNING: 3, FINISHED: 1, PENDING: 
2 });
+      expect(result.jobs[0].tasks).not.toHaveProperty('running');
+      // No pending-operators on job-two, so PENDING backfills to 0.
+      expect(result.jobs[1].tasks).toEqual({ FINISHED: 4, PENDING: 0 });
+    });
+
+    it('derives each job completed flag from its state', async () => {
+      httpClient.get.mockReturnValue(of(detail()));
+
+      const result = await firstValueFrom(service.loadApplication('app-1'));
+
+      expect(result.jobs[0].completed).toBe(false);
+      expect(result.jobs[1].completed).toBe(true);
+    });
+
+    it('tallies per-state job counts into status-counts', async () => {
+      httpClient.get.mockReturnValue(of(detail()));
+
+      const result = await firstValueFrom(service.loadApplication('app-1'));
+
+      expect(result['status-counts']?.RUNNING).toBe(1);
+      expect(result['status-counts']?.FINISHED).toBe(1);
+      expect(result['status-counts']?.TOTAL).toBe(2);
+    });
+
+    it('swallows request errors into an empty stream', async () => {
+      httpClient.get.mockReturnValue(throwError(() => new Error('cluster 
unreachable')));
+
+      const emissions = await 
firstValueFrom(service.loadApplication('app-1').pipe(toArray()));
+
+      expect(emissions).toEqual([]);
+    });
+  });
+});
diff --git 
a/flink-runtime-web/web-dashboard/src/app/services/jar.service.spec.ts 
b/flink-runtime-web/web-dashboard/src/app/services/jar.service.spec.ts
new file mode 100644
index 00000000000..bbac3602986
--- /dev/null
+++ b/flink-runtime-web/web-dashboard/src/app/services/jar.service.spec.ts
@@ -0,0 +1,190 @@
+/*
+ * 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 { HttpClient, HttpParams, HttpRequest } from '@angular/common/http';
+import { firstValueFrom, of, throwError } from 'rxjs';
+
+import { JarList, Plan } from '@flink-runtime-web/interfaces';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { ConfigService } from './config.service';
+import { JarService } from './jar.service';
+
+describe('JarService', () => {
+  let httpClient: {
+    get: ReturnType<typeof vi.fn>;
+    delete: ReturnType<typeof vi.fn>;
+    post: ReturnType<typeof vi.fn>;
+    request: ReturnType<typeof vi.fn>;
+  };
+  let configService: ConfigService;
+  let service: JarService;
+
+  beforeEach(() => {
+    httpClient = { get: vi.fn(), delete: vi.fn(), post: vi.fn(), request: 
vi.fn() };
+    configService = new ConfigService();
+    service = new JarService(httpClient as unknown as HttpClient, 
configService);
+  });
+
+  describe('loadJarList', () => {
+    it('passes through a successful response', async () => {
+      const jarList: JarList = { address: 'http://cluster', files: [] };
+      httpClient.get.mockReturnValue(of(jarList));
+
+      const result = await firstValueFrom(service.loadJarList());
+
+      
expect(httpClient.get).toHaveBeenCalledWith(`${configService.BASE_URL}/jars`);
+      expect(result).toBe(jarList);
+    });
+
+    it('falls back to an empty error result when the request fails', async () 
=> {
+      httpClient.get.mockReturnValue(throwError(() => new Error('cluster 
unreachable')));
+
+      const result = await firstValueFrom(service.loadJarList());
+
+      expect(result).toEqual({ address: '', error: true, files: [] });
+    });
+  });
+
+  describe('uploadJar', () => {
+    it('wraps the file in form data and issues a progress-reporting POST 
request', () => {
+      httpClient.request.mockReturnValue(of());
+      const file = new File(['contents'], 'my-job.jar');
+
+      service.uploadJar(file);
+
+      expect(httpClient.request).toHaveBeenCalledTimes(1);
+      const req = httpClient.request.mock.calls[0][0] as HttpRequest<FormData>;
+      expect(req.method).toBe('POST');
+      expect(req.url).toBe(`${configService.BASE_URL}/jars/upload`);
+      expect(req.reportProgress).toBe(true);
+      const uploaded = (req.body as FormData).get('jarfile') as File;
+      expect(uploaded).toBeInstanceOf(File);
+      expect(uploaded.name).toBe(file.name);
+      expect(uploaded.size).toBe(file.size);
+    });
+  });
+
+  describe('deleteJar', () => {
+    it('issues a DELETE request for the given jar id', () => {
+      httpClient.delete.mockReturnValue(of(undefined));
+
+      service.deleteJar('jar-1');
+
+      
expect(httpClient.delete).toHaveBeenCalledWith(`${configService.BASE_URL}/jars/jar-1`);
+    });
+  });
+
+  describe('runJob', () => {
+    it('includes every provided argument as both a body field and a matching 
query param', () => {
+      httpClient.post.mockReturnValue(of({ jobid: 'job-1' }));
+
+      service.runJob('jar-1', 'org.example.Main', '4', '--foo bar', 
'/tmp/savepoint-1', 'true');
+
+      expect(httpClient.post).toHaveBeenCalledTimes(1);
+      const [url, body, options] = httpClient.post.mock.calls[0] as [string, 
unknown, { params: HttpParams }];
+      expect(url).toBe(`${configService.BASE_URL}/jars/jar-1/run`);
+      expect(body).toEqual({
+        entryClass: 'org.example.Main',
+        parallelism: '4',
+        programArgs: '--foo bar',
+        savepointPath: '/tmp/savepoint-1',
+        allowNonRestoredState: 'true'
+      });
+      expect(options.params.get('entry-class')).toBe('org.example.Main');
+      expect(options.params.get('parallelism')).toBe('4');
+      expect(options.params.get('program-args')).toBe('--foo bar');
+      // Regression guard: the savepoint path must land under its own query 
key, not get
+      // shadowed by another field (see apache/flink#28722).
+      expect(options.params.get('savepointPath')).toBe('/tmp/savepoint-1');
+      expect(options.params.get('allowNonRestoredState')).toBe('true');
+    });
+
+    it('omits query params for arguments that are not provided', () => {
+      httpClient.post.mockReturnValue(of({ jobid: 'job-1' }));
+
+      service.runJob('jar-1', '', '', '', '', '');
+
+      const options = httpClient.post.mock.calls[0][2] as { params: HttpParams 
};
+      expect(options.params.get('entry-class')).toBeNull();
+      expect(options.params.get('parallelism')).toBeNull();
+      expect(options.params.get('program-args')).toBeNull();
+      expect(options.params.get('savepointPath')).toBeNull();
+      expect(options.params.get('allowNonRestoredState')).toBeNull();
+    });
+  });
+
+  describe('getPlan', () => {
+    it('builds vertex links from each node input and strips the detail field', 
async () => {
+      const plan: Plan = {
+        plan: {
+          jid: 'job-1',
+          name: 'test-job',
+          nodes: [
+            {
+              id: 'node-2',
+              parallelism: 1,
+              operator: 'Map',
+              operator_strategy: 'forward',
+              description: 'Map',
+              optimizer_properties: {},
+              inputs: [{ num: 0, id: 'node-1', ship_strategy: 'FORWARD', 
exchange: 'pipelined' }]
+            },
+            {
+              id: 'node-1',
+              parallelism: 1,
+              operator: 'Source',
+              operator_strategy: 'forward',
+              description: 'Source',
+              optimizer_properties: {}
+            }
+          ]
+        }
+      };
+      httpClient.get.mockReturnValue(of(plan));
+
+      const result = await firstValueFrom(service.getPlan('jar-1', '', '', 
''));
+
+      expect(result.nodes).toHaveLength(2);
+      expect(result.nodes.every(node => node.detail === undefined)).toBe(true);
+      expect(result.links).toEqual([
+        {
+          num: 0,
+          id: 'node-1-node-2',
+          ship_strategy: 'FORWARD',
+          exchange: 'pipelined',
+          source: 'node-1',
+          target: 'node-2'
+        }
+      ]);
+    });
+
+    it('returns empty nodes and links when the plan has no nodes', async () => 
{
+      const plan: Plan = { plan: { jid: 'job-1', name: 'test-job', nodes: [] } 
};
+      httpClient.get.mockReturnValue(of(plan));
+
+      const result = await firstValueFrom(service.getPlan('jar-1', 
'org.example.Main', '2', 'args'));
+
+      expect(result).toEqual({ nodes: [], links: [] });
+      const options = httpClient.get.mock.calls[0][1] as { params: HttpParams 
};
+      expect(options.params.get('entry-class')).toBe('org.example.Main');
+      expect(options.params.get('parallelism')).toBe('2');
+      expect(options.params.get('program-args')).toBe('args');
+    });
+  });
+});
diff --git 
a/flink-runtime-web/web-dashboard/src/app/services/metrics.service.spec.ts 
b/flink-runtime-web/web-dashboard/src/app/services/metrics.service.spec.ts
new file mode 100644
index 00000000000..ca1e636a95d
--- /dev/null
+++ b/flink-runtime-web/web-dashboard/src/app/services/metrics.service.spec.ts
@@ -0,0 +1,154 @@
+/*
+ * 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 { HttpClient } from '@angular/common/http';
+import { firstValueFrom, of } from 'rxjs';
+
+import { JobMetric } from '@flink-runtime-web/interfaces';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { ConfigService } from './config.service';
+import { MetricsService } from './metrics.service';
+
+describe('MetricsService', () => {
+  let httpClient: { get: ReturnType<typeof vi.fn> };
+  let configService: ConfigService;
+  let service: MetricsService;
+
+  beforeEach(() => {
+    httpClient = { get: vi.fn() };
+    configService = new ConfigService();
+    service = new MetricsService(httpClient as unknown as HttpClient, 
configService);
+  });
+
+  describe('loadAllAvailableMetrics', () => {
+    it('sorts the available metrics by id, case-insensitively', async () => {
+      const metrics: JobMetric[] = [
+        { id: 'Zebra', value: '1' },
+        { id: 'apple', value: '2' },
+        { id: 'Banana', value: '3' }
+      ];
+      httpClient.get.mockReturnValue(of(metrics));
+
+      const result = await 
firstValueFrom(service.loadAllAvailableMetrics('job-1', 'vertex-1'));
+
+      expect(result.map(item => item.id)).toEqual(['apple', 'Banana', 
'Zebra']);
+    });
+  });
+
+  describe('loadMetrics', () => {
+    it('parses each metric value to a float and stamps the result with a 
timestamp', async () => {
+      httpClient.get.mockReturnValue(
+        of([
+          { id: 'numRecordsIn', value: '12.5' },
+          { id: 'numRecordsOut', value: '7' }
+        ])
+      );
+
+      const before = Date.now();
+      const result = await firstValueFrom(service.loadMetrics('job-1', 
'vertex-1', ['numRecordsIn', 'numRecordsOut']));
+
+      expect(result.values).toEqual({ numRecordsIn: 12.5, numRecordsOut: 7 });
+      expect(result.timestamp).toBeGreaterThanOrEqual(before);
+      expect(httpClient.get).toHaveBeenCalledWith(expect.any(String), {
+        params: { get: 'numRecordsIn,numRecordsOut' }
+      });
+    });
+  });
+
+  describe('loadMetricsWithAllAggregates', () => {
+    it('coerces every aggregate field to a number', async () => {
+      httpClient.get.mockReturnValue(
+        of([{ id: 'numRecordsIn', min: '0', max: '10', avg: '5', sum: '15', 
skew: '66' }])
+      );
+
+      const result = await 
firstValueFrom(service.loadMetricsWithAllAggregates('job-1', 'vertex-1', 
['numRecordsIn']));
+
+      expect(result.numRecordsIn).toEqual({ min: 0, max: 10, avg: 5, sum: 15, 
skew: 66 });
+    });
+  });
+
+  describe('loadAggregatedMetrics', () => {
+    beforeEach(() => {
+      vi.spyOn(service, 'loadMetricsWithAllAggregates').mockReturnValue(
+        of({ numRecordsIn: { min: 1, max: 9, avg: 5, sum: 20, skew: 66 } })
+      );
+    });
+
+    it.each([
+      ['min', 1],
+      ['max', 9],
+      ['avg', 5],
+      ['sum', 20],
+      ['skew', 66]
+    ])('extracts the %s aggregate', async (aggregate, expected) => {
+      const result = await firstValueFrom(
+        service.loadAggregatedMetrics('job-1', 'vertex-1', ['numRecordsIn'], 
aggregate)
+      );
+
+      expect(result.numRecordsIn).toBe(expected);
+    });
+
+    it('defaults to the max aggregate when none is given', async () => {
+      const result = await 
firstValueFrom(service.loadAggregatedMetrics('job-1', 'vertex-1', 
['numRecordsIn']));
+
+      expect(result.numRecordsIn).toBe(9);
+    });
+
+    it('errors out for an unsupported aggregate type', async () => {
+      await expect(
+        firstValueFrom(service.loadAggregatedMetrics('job-1', 'vertex-1', 
['numRecordsIn'], 'median'))
+      ).rejects.toThrow('Unsupported aggregate: median');
+    });
+  });
+
+  describe('loadWatermarks', () => {
+    it('reports the lowest watermark across subtasks, keyed by subtask index', 
async () => {
+      httpClient.get.mockReturnValue(
+        of([
+          { id: '0.currentInputWatermark', value: '100' },
+          { id: '1.currentInputWatermark', value: '50' }
+        ])
+      );
+
+      const result = await firstValueFrom(service.loadWatermarks('job-1', 
'vertex-1'));
+
+      expect(result.watermarks).toEqual({ '0': 100, '1': 50 });
+      expect(result.lowWatermark).toBe(50);
+    });
+
+    it('reports NaN when every subtask is still at the Long.MIN_VALUE 
sentinel', async () => {
+      httpClient.get.mockReturnValue(
+        of([{ id: '0.currentInputWatermark', value: 
String(configService.LONG_MIN_VALUE) }])
+      );
+
+      const result = await firstValueFrom(service.loadWatermarks('job-1', 
'vertex-1'));
+
+      expect(result.lowWatermark).toBeNaN();
+    });
+
+    it('reports NaN when there are no subtasks', async () => {
+      httpClient.get.mockReturnValue(of([]));
+
+      const result = await firstValueFrom(service.loadWatermarks('job-1', 
'vertex-1'));
+
+      expect(result.lowWatermark).toBeNaN();
+      expect(result.watermarks).toEqual({});
+    });
+  });
+});
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
new file mode 100644
index 00000000000..dc73c3d1d85
--- /dev/null
+++ 
b/flink-runtime-web/web-dashboard/src/app/services/task-manager.service.spec.ts
@@ -0,0 +1,82 @@
+/*
+ * 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 { HttpClient } from '@angular/common/http';
+import { firstValueFrom, of, throwError, toArray } from 'rxjs';
+
+import { TaskManagerDetail, TaskManagersItem } from 
'@flink-runtime-web/interfaces';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { ConfigService } from './config.service';
+import { TaskManagerService } from './task-manager.service';
+
+describe('TaskManagerService', () => {
+  let httpClient: { get: ReturnType<typeof vi.fn> };
+  let service: TaskManagerService;
+
+  beforeEach(() => {
+    httpClient = { get: vi.fn() };
+    service = new TaskManagerService(httpClient as unknown as HttpClient, new 
ConfigService());
+  });
+
+  describe('loadManagers', () => {
+    it('passes through the taskmanagers list on success', async () => {
+      const manager = { id: 'tm-1' } as TaskManagersItem;
+      httpClient.get.mockReturnValue(of({ taskmanagers: [manager] }));
+
+      const result = await firstValueFrom(service.loadManagers());
+
+      expect(result).toEqual([manager]);
+    });
+
+    it('falls back to an empty list when the response has no taskmanagers 
field', async () => {
+      httpClient.get.mockReturnValue(of({}));
+
+      const result = await firstValueFrom(service.loadManagers());
+
+      expect(result).toEqual([]);
+    });
+
+    it('falls back to an empty list when the request fails', async () => {
+      httpClient.get.mockReturnValue(throwError(() => new Error('cluster 
unreachable')));
+
+      const result = await firstValueFrom(service.loadManagers());
+
+      expect(result).toEqual([]);
+    });
+  });
+
+  describe('loadManager', () => {
+    it('passes through the manager detail on success', async () => {
+      const detail = { id: 'tm-1' } as TaskManagerDetail;
+      httpClient.get.mockReturnValue(of(detail));
+
+      const result = await firstValueFrom(service.loadManager('tm-1'));
+
+      expect(result).toBe(detail);
+    });
+
+    it('swallows request errors into an empty stream, unlike loadManagers 
which falls back to []', async () => {
+      httpClient.get.mockReturnValue(throwError(() => new Error('cluster 
unreachable')));
+
+      const emissions = await 
firstValueFrom(service.loadManager('tm-1').pipe(toArray()));
+
+      expect(emissions).toEqual([]);
+    });
+  });
+});
diff --git a/flink-runtime-web/web-dashboard/src/test-setup.ts 
b/flink-runtime-web/web-dashboard/src/test-setup.ts
index 17adac6ea40..dacd2c1dfe8 100644
--- a/flink-runtime-web/web-dashboard/src/test-setup.ts
+++ b/flink-runtime-web/web-dashboard/src/test-setup.ts
@@ -46,5 +46,18 @@ class ObserverStub {
 (window as unknown as { ResizeObserver: unknown }).ResizeObserver = 
ObserverStub;
 (window as unknown as { IntersectionObserver: unknown }).IntersectionObserver 
= ObserverStub;
 
+// jsdom has no Web Worker. @antv/g2 spins one up at module load for its label
+// layout; a no-op stub lets the module import and lets chart-backed views 
render.
+class WorkerStub {
+  onmessage: unknown = null;
+  onmessageerror: unknown = null;
+  postMessage = vi.fn();
+  terminate = vi.fn();
+  addEventListener = vi.fn();
+  removeEventListener = vi.fn();
+}
+
+(window as unknown as { Worker: unknown }).Worker = WorkerStub;
+
 // jsdom logs "Not implemented" for scrolling; ng-zorro overlays call it.
 window.scrollTo = vi.fn();

Reply via email to