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();