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 08295bb130b [FLINK-40601][runtime-web] Add Vitest specs for remaining
web dashboard views. This closes #29138
08295bb130b is described below
commit 08295bb130bc98bf53468a6a205811d958d27f55
Author: Purushottam Sinha <[email protected]>
AuthorDate: Wed Sep 23 23:44:20 2026 +0530
[FLINK-40601][runtime-web] Add Vitest specs for remaining web dashboard
views. This closes #29138
- Core services: config, overview, status, job-manager and job
- Job-detail drawer views under pages/job/overview
- JobManager log, stdout, thread-dump and profiler pages
- TaskManager stdout, thread-dump and profiler pages
- job-local and application-local services
- Job and application list and detail shells
- Job chart, its resize service and the submit page
Generated-by: Claude Code (claude-sonnet-5)
---
.../job-chart/job-chart.component.spec.ts | 74 ++++++++
.../components/job-chart/job-chart.service.spec.ts | 33 ++++
.../application-detail.component.spec.ts | 70 +++++++
.../application/application-local.service.spec.ts | 50 +++++
.../application/application.component.spec.ts | 85 +++++++++
.../logs/job-manager-logs.component.spec.ts | 66 +++++++
.../job-manager-profiler.component.spec.ts | 77 ++++++++
.../stdout/job-manager-stdout.component.spec.ts | 64 +++++++
.../job-manager-thread-dump.component.spec.ts | 77 ++++++++
.../job/job-detail/job-detail.component.spec.ts | 74 ++++++++
.../src/app/pages/job/job-local.service.spec.ts | 72 +++++++
.../src/app/pages/job/job.component.spec.ts | 85 +++++++++
...-overview-drawer-accumulators.component.spec.ts | 80 ++++++++
...-overview-drawer-backpressure.component.spec.ts | 78 ++++++++
.../job-overview-drawer-chart.component.spec.ts | 80 ++++++++
.../job-overview-drawer-detail.component.spec.ts | 59 ++++++
...ob-overview-drawer-flamegraph.component.spec.ts | 92 +++++++++
.../job-overview-drawer-subtasks.component.spec.ts | 87 +++++++++
...-overview-drawer-taskmanagers.component.spec.ts | 66 +++++++
...ob-overview-drawer-watermarks.component.spec.ts | 71 +++++++
.../src/app/pages/submit/submit.component.spec.ts | 128 +++++++++++++
.../task-manager-profiler.component.spec.ts | 81 ++++++++
.../stdout/task-manager-stdout.component.spec.ts | 68 +++++++
.../task-manager-thread-dump.component.spec.ts | 89 +++++++++
.../src/app/services/config.service.spec.ts | 57 ++++++
.../src/app/services/job-manager.service.spec.ts | 208 +++++++++++++++++++++
.../src/app/services/job.service.spec.ts | 169 +++++++++++++++++
.../src/app/services/overview.service.spec.ts | 54 ++++++
.../src/app/services/status.service.spec.ts | 94 ++++++++++
29 files changed, 2388 insertions(+)
diff --git
a/flink-runtime-web/web-dashboard/src/app/components/job-chart/job-chart.component.spec.ts
b/flink-runtime-web/web-dashboard/src/app/components/job-chart/job-chart.component.spec.ts
new file mode 100644
index 00000000000..9220f5ff2d2
--- /dev/null
+++
b/flink-runtime-web/web-dashboard/src/app/components/job-chart/job-chart.component.spec.ts
@@ -0,0 +1,74 @@
+/*
+ * 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 type { Chart } from '@antv/g2';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { JobChartComponent } from './job-chart.component';
+import { JobChartService } from './job-chart.service';
+
+// Constructed directly: ngAfterViewInit builds a real G2 chart against a
canvas jsdom can't
+// provide, so these specs drive refresh()/close() with the chart instance
left as a fake.
+const cdr = { detectChanges: vi.fn(), detach: vi.fn(), markForCheck: vi.fn() }
as unknown as ChangeDetectorRef;
+
+describe('JobChartComponent', () => {
+ let component: JobChartComponent;
+
+ beforeEach(() => {
+ component = new JobChartComponent(cdr, new JobChartService());
+ component.title = 'cpu';
+ });
+
+ it('appends the metric value for its own title and forwards data to the
chart', () => {
+ const changeData = vi.fn();
+ component.chartInstance = { changeData } as unknown as Chart;
+
+ component.refresh({ timestamp: 1000, values: { cpu: 42, mem: 7 } });
+
+ expect(component.latestValue).toBe(42);
+ expect(component.data).toEqual([{ time: 1000, value: 42, type: 'cpu' }]);
+ expect(changeData).toHaveBeenCalledWith(component.data);
+ });
+
+ it('keeps only the most recent 20 samples', () => {
+ for (let i = 0; i < 25; i++) {
+ component.refresh({ timestamp: i, values: { cpu: i } });
+ }
+
+ expect(component.data).toHaveLength(20);
+ expect(component.data[0].time).toBe(5);
+ expect(component.data[19].time).toBe(24);
+ });
+
+ it('emits its title when closed', () => {
+ const closed = vi.fn();
+ component.closed.subscribe(closed);
+
+ component.close();
+
+ expect(closed).toHaveBeenCalledWith('cpu');
+ });
+
+ it('reports the big layout only when sized big', () => {
+ expect(component.isBig).toBe(false);
+ component.size = 'big';
+ expect(component.isBig).toBe(true);
+ });
+});
diff --git
a/flink-runtime-web/web-dashboard/src/app/components/job-chart/job-chart.service.spec.ts
b/flink-runtime-web/web-dashboard/src/app/components/job-chart/job-chart.service.spec.ts
new file mode 100644
index 00000000000..ada54fbc708
--- /dev/null
+++
b/flink-runtime-web/web-dashboard/src/app/components/job-chart/job-chart.service.spec.ts
@@ -0,0 +1,33 @@
+/*
+ * 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, vi } from 'vitest';
+
+import { JobChartService } from './job-chart.service';
+
+describe('JobChartService', () => {
+ it('broadcasts a resize event to subscribers', () => {
+ const service = new JobChartService();
+ const listener = vi.fn();
+ service.resize$.subscribe(listener);
+
+ service.resize();
+
+ expect(listener).toHaveBeenCalledTimes(1);
+ });
+});
diff --git
a/flink-runtime-web/web-dashboard/src/app/pages/application/application-detail/application-detail.component.spec.ts
b/flink-runtime-web/web-dashboard/src/app/pages/application/application-detail/application-detail.component.spec.ts
new file mode 100644
index 00000000000..c321645f862
--- /dev/null
+++
b/flink-runtime-web/web-dashboard/src/app/pages/application/application-detail/application-detail.component.spec.ts
@@ -0,0 +1,70 @@
+/*
+ * 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 } from '@angular/router';
+import { of, throwError } from 'rxjs';
+
+import { ApplicationLocalService } from
'@flink-runtime-web/pages/application/application-local.service';
+import { ApplicationService, StatusService } from
'@flink-runtime-web/services';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { ApplicationDetailComponent } from './application-detail.component';
+
+const cdr = { markForCheck: vi.fn() } as unknown as ChangeDetectorRef;
+const activatedRoute = { snapshot: { params: { id: 'app-1' } } } as unknown as
ActivatedRoute;
+
+describe('ApplicationDetailComponent', () => {
+ const loadApplication = vi.fn();
+ const setApplicationDetail = vi.fn();
+ let component: ApplicationDetailComponent;
+
+ beforeEach(() => {
+ loadApplication.mockReset();
+ setApplicationDetail.mockReset();
+ component = new ApplicationDetailComponent(
+ { loadApplication } as unknown as ApplicationService,
+ { setApplicationDetail } as unknown as ApplicationLocalService,
+ { refresh$: of(true) } as unknown as StatusService,
+ activatedRoute,
+ cdr
+ );
+ });
+
+ it('publishes the loaded application detail to the local service', () => {
+ const application = { id: 'app-1' };
+ loadApplication.mockReturnValue(of(application));
+
+ component.ngOnInit();
+
+ expect(loadApplication).toHaveBeenCalledWith('app-1');
+ expect(setApplicationDetail).toHaveBeenCalledWith(application);
+ expect(component.isLoading).toBe(false);
+ expect(component.isError).toBe(false);
+ });
+
+ it('flags an error when the application fails to load', () => {
+ loadApplication.mockReturnValue(throwError(() => new Error('boom')));
+
+ component.ngOnInit();
+
+ expect(component.isError).toBe(true);
+ expect(component.isLoading).toBe(false);
+ expect(setApplicationDetail).not.toHaveBeenCalled();
+ });
+});
diff --git
a/flink-runtime-web/web-dashboard/src/app/pages/application/application-local.service.spec.ts
b/flink-runtime-web/web-dashboard/src/app/pages/application/application-local.service.spec.ts
new file mode 100644
index 00000000000..a53bfaa9ffc
--- /dev/null
+++
b/flink-runtime-web/web-dashboard/src/app/pages/application/application-local.service.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 { firstValueFrom } from 'rxjs';
+
+import { ApplicationDetail } from '@flink-runtime-web/interfaces';
+import { beforeEach, describe, expect, it } from 'vitest';
+
+import { ApplicationLocalService } from './application-local.service';
+
+describe('ApplicationLocalService', () => {
+ let service: ApplicationLocalService;
+
+ beforeEach(() => {
+ service = new ApplicationLocalService();
+ });
+
+ it('replays the latest application detail to a late subscriber', async () =>
{
+ const application = { id: 'app-1' } as ApplicationDetail;
+ service.setApplicationDetail(application);
+
+ const result = await firstValueFrom(service.applicationDetailChanges());
+
+ expect(result).toBe(application);
+ });
+
+ it('replays only the most recent application detail', async () => {
+ service.setApplicationDetail({ id: 'app-1' } as ApplicationDetail);
+ service.setApplicationDetail({ id: 'app-2' } as ApplicationDetail);
+
+ const result = await firstValueFrom(service.applicationDetailChanges());
+
+ expect(result.id).toBe('app-2');
+ });
+});
diff --git
a/flink-runtime-web/web-dashboard/src/app/pages/application/application.component.spec.ts
b/flink-runtime-web/web-dashboard/src/app/pages/application/application.component.spec.ts
new file mode 100644
index 00000000000..9f8bb6087ac
--- /dev/null
+++
b/flink-runtime-web/web-dashboard/src/app/pages/application/application.component.spec.ts
@@ -0,0 +1,85 @@
+/*
+ * 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 { Subject } from 'rxjs';
+
+import { ApplicationItem } from '@flink-runtime-web/interfaces';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { ApplicationComponent } from './application.component';
+
+const cdr = { markForCheck: vi.fn() } as unknown as ChangeDetectorRef;
+const activatedRoute = {} as ActivatedRoute;
+const seg = (path: string): { path: string; toString: () => string } => ({
path, toString: () => path });
+
+function urlTree(paths: string[]): unknown {
+ return { root: { children: { primary: { segments: paths.map(seg) } } } };
+}
+
+describe('ApplicationComponent', () => {
+ const events = new Subject<unknown>();
+ const parseUrl = vi.fn();
+ const navigate = vi.fn().mockResolvedValue(true);
+ let component: ApplicationComponent;
+
+ beforeEach(() => {
+ parseUrl.mockReset();
+ navigate.mockClear();
+ const router = { events, url: '/application/running/app-1', parseUrl,
navigate } as unknown as Router;
+ component = new ApplicationComponent(activatedRoute, router, cdr);
+ });
+
+ it('reads the selected application id and running state from the url', () =>
{
+ parseUrl.mockReturnValue(urlTree(['application', 'running', 'app-1']));
+
+ component.ngOnInit();
+
+ expect(component.applicationIdSelected).toBe('app-1');
+ expect(component.isCompleted).toBe(false);
+ expect(component.cardTitle).toBe('Running Applications');
+ });
+
+ it('recognises the completed list with no application selected', () => {
+ parseUrl.mockReturnValue(urlTree(['application', 'completed']));
+
+ component.ngOnInit();
+
+ expect(component.applicationIdSelected).toBeUndefined();
+ expect(component.isCompleted).toBe(true);
+ expect(component.cardTitle).toBe('Completed Applications');
+ });
+
+ it('re-reads the url on navigation end', () => {
+ parseUrl.mockReturnValue(urlTree(['application', 'running', 'app-1']));
+ component.ngOnInit();
+
+ parseUrl.mockReturnValue(urlTree(['application', 'completed', 'app-2']));
+ events.next(new NavigationEnd(1, '/application/completed/app-2',
'/application/completed/app-2'));
+
+ expect(component.applicationIdSelected).toBe('app-2');
+ expect(component.isCompleted).toBe(true);
+ });
+
+ it('navigates to an application relative to the current route', () => {
+ component.navigateToApplication({ id: 'app-9' } as ApplicationItem);
+
+ expect(navigate).toHaveBeenCalledWith(['app-9'], { relativeTo:
activatedRoute });
+ });
+});
diff --git
a/flink-runtime-web/web-dashboard/src/app/pages/job-manager/logs/job-manager-logs.component.spec.ts
b/flink-runtime-web/web-dashboard/src/app/pages/job-manager/logs/job-manager-logs.component.spec.ts
new file mode 100644
index 00000000000..0f056e175e9
--- /dev/null
+++
b/flink-runtime-web/web-dashboard/src/app/pages/job-manager/logs/job-manager-logs.component.spec.ts
@@ -0,0 +1,66 @@
+/*
+ * 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 { of, throwError } from 'rxjs';
+
+import { JOB_MANAGER_MODULE_DEFAULT_CONFIG } from
'@flink-runtime-web/pages/job-manager/job-manager.config';
+import { ConfigService, JobManagerService } from '@flink-runtime-web/services';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { JobManagerLogsComponent } from './job-manager-logs.component';
+
+// The log/stdout/thread-dump pages embed a monaco code editor; constructing
them directly and
+// driving reload() exercises the controller without rendering that editor in
jsdom.
+const cdr = { markForCheck: vi.fn() } as unknown as ChangeDetectorRef;
+
+describe('JobManagerLogsComponent', () => {
+ const loadLogs = vi.fn();
+ let component: JobManagerLogsComponent;
+
+ beforeEach(() => {
+ loadLogs.mockReset();
+ component = new JobManagerLogsComponent(
+ { loadLogs } as unknown as JobManagerService,
+ { BASE_URL: '/api' } as unknown as ConfigService,
+ cdr,
+ JOB_MANAGER_MODULE_DEFAULT_CONFIG
+ );
+ });
+
+ it('loads the jobmanager log and exposes the download target', () => {
+ loadLogs.mockReturnValue(of('2026-01-01 INFO started\n'));
+
+ component.ngOnInit();
+
+ expect(loadLogs).toHaveBeenCalled();
+ expect(component.logs).toBe('2026-01-01 INFO started\n');
+ expect(component.downloadUrl).toBe('/api/jobmanager/log');
+ expect(component.downloadName).toBe('jobmanager_log');
+ expect(component.loading).toBe(false);
+ });
+
+ it('falls back to empty logs when the request fails', () => {
+ loadLogs.mockReturnValue(throwError(() => new Error('log unavailable')));
+
+ component.ngOnInit();
+
+ expect(component.logs).toBe('');
+ expect(component.loading).toBe(false);
+ });
+});
diff --git
a/flink-runtime-web/web-dashboard/src/app/pages/job-manager/profiler/job-manager-profiler.component.spec.ts
b/flink-runtime-web/web-dashboard/src/app/pages/job-manager/profiler/job-manager-profiler.component.spec.ts
new file mode 100644
index 00000000000..443aad28f0c
--- /dev/null
+++
b/flink-runtime-web/web-dashboard/src/app/pages/job-manager/profiler/job-manager-profiler.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 { ChangeDetectorRef } from '@angular/core';
+import { of } from 'rxjs';
+
+import { ProfilingDetail } from '@flink-runtime-web/interfaces/job-profiler';
+import { JobManagerService, StatusService } from '@flink-runtime-web/services';
+import { NzMessageService } from 'ng-zorro-antd/message';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { JobManagerProfilerComponent } from './job-manager-profiler.component';
+
+const cdr = { markForCheck: vi.fn() } as unknown as ChangeDetectorRef;
+
+describe('JobManagerProfilerComponent', () => {
+ const loadProfilingList = vi.fn();
+ const createProfilingInstance = vi.fn();
+ const warning = vi.fn();
+ let component: JobManagerProfilerComponent;
+
+ beforeEach(() => {
+ loadProfilingList.mockReset().mockReturnValue(of({ profilingList: [] }));
+ createProfilingInstance.mockReset();
+ warning.mockReset();
+ component = new JobManagerProfilerComponent(
+ { loadProfilingList, createProfilingInstance } as unknown as
JobManagerService,
+ { refresh$: of(true) } as unknown as StatusService,
+ { warning } as unknown as NzMessageService,
+ cdr
+ );
+ });
+
+ it('loads the profiling list and marks profiling enabled on refresh', () => {
+ loadProfilingList.mockReturnValue(of({ profilingList: [{ status:
'FINISHED' } as ProfilingDetail] }));
+
+ component.ngOnInit();
+
+ expect(component.profilingList).toEqual([{ status: 'FINISHED' }]);
+ expect(component.isEnabled).toBe(true);
+ expect(component.isLoading).toBe(false);
+ });
+
+ it('prepends a newly created profiling instance', () => {
+ createProfilingInstance.mockReturnValue(of({ status: 'RUNNING' } as
ProfilingDetail));
+
+ component.createProfilingInstance();
+
+ expect(createProfilingInstance).toHaveBeenCalledWith('ITIMER', 30);
+ expect(component.profilingList[0]).toEqual({ status: 'RUNNING' });
+ expect(component.isCreating).toBe(false);
+ });
+
+ it('refuses to start a new profiling run while one is still running', () => {
+ component.profilingList = [{ status: 'RUNNING' } as ProfilingDetail];
+
+ component.createProfilingInstance();
+
+ expect(warning).toHaveBeenCalled();
+ expect(createProfilingInstance).not.toHaveBeenCalled();
+ });
+});
diff --git
a/flink-runtime-web/web-dashboard/src/app/pages/job-manager/stdout/job-manager-stdout.component.spec.ts
b/flink-runtime-web/web-dashboard/src/app/pages/job-manager/stdout/job-manager-stdout.component.spec.ts
new file mode 100644
index 00000000000..cf82d6b290d
--- /dev/null
+++
b/flink-runtime-web/web-dashboard/src/app/pages/job-manager/stdout/job-manager-stdout.component.spec.ts
@@ -0,0 +1,64 @@
+/*
+ * 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 { of, throwError } from 'rxjs';
+
+import { JOB_MANAGER_MODULE_DEFAULT_CONFIG } from
'@flink-runtime-web/pages/job-manager/job-manager.config';
+import { ConfigService, JobManagerService } from '@flink-runtime-web/services';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { JobManagerStdoutComponent } from './job-manager-stdout.component';
+
+const cdr = { markForCheck: vi.fn() } as unknown as ChangeDetectorRef;
+
+describe('JobManagerStdoutComponent', () => {
+ const loadStdout = vi.fn();
+ let component: JobManagerStdoutComponent;
+
+ beforeEach(() => {
+ loadStdout.mockReset();
+ component = new JobManagerStdoutComponent(
+ { loadStdout } as unknown as JobManagerService,
+ { BASE_URL: '/api' } as unknown as ConfigService,
+ cdr,
+ JOB_MANAGER_MODULE_DEFAULT_CONFIG
+ );
+ });
+
+ it('loads the jobmanager stdout and exposes the download target', () => {
+ loadStdout.mockReturnValue(of('hello stdout\n'));
+
+ component.ngOnInit();
+
+ expect(loadStdout).toHaveBeenCalled();
+ expect(component.stdout).toBe('hello stdout\n');
+ expect(component.downloadUrl).toBe('/api/jobmanager/stdout');
+ expect(component.downloadName).toBe('jobmanager_stdout');
+ expect(component.loading).toBe(false);
+ });
+
+ it('falls back to empty stdout when the request fails', () => {
+ loadStdout.mockReturnValue(throwError(() => new Error('stdout
unavailable')));
+
+ component.ngOnInit();
+
+ expect(component.stdout).toBe('');
+ expect(component.loading).toBe(false);
+ });
+});
diff --git
a/flink-runtime-web/web-dashboard/src/app/pages/job-manager/thread-dump/job-manager-thread-dump.component.spec.ts
b/flink-runtime-web/web-dashboard/src/app/pages/job-manager/thread-dump/job-manager-thread-dump.component.spec.ts
new file mode 100644
index 00000000000..8fc31a5310a
--- /dev/null
+++
b/flink-runtime-web/web-dashboard/src/app/pages/job-manager/thread-dump/job-manager-thread-dump.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 { ChangeDetectorRef } from '@angular/core';
+import { of, throwError } from 'rxjs';
+
+import { JOB_MANAGER_MODULE_DEFAULT_CONFIG } from
'@flink-runtime-web/pages/job-manager/job-manager.config';
+import { ConfigService, JobManagerService } from '@flink-runtime-web/services';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { JobManagerThreadDumpComponent } from
'./job-manager-thread-dump.component';
+
+const cdr = { markForCheck: vi.fn() } as unknown as ChangeDetectorRef;
+
+describe('JobManagerThreadDumpComponent', () => {
+ const loadThreadDump = vi.fn();
+ let component: JobManagerThreadDumpComponent;
+
+ beforeEach(() => {
+ loadThreadDump.mockReset().mockReturnValue(of('thread dump\n'));
+ component = new JobManagerThreadDumpComponent(
+ { loadThreadDump } as unknown as JobManagerService,
+ { BASE_URL: '/api' } as unknown as ConfigService,
+ cdr,
+ JOB_MANAGER_MODULE_DEFAULT_CONFIG
+ );
+ });
+
+ it('loads the thread dump with no mode by default', () => {
+ component.ngOnInit();
+
+ expect(loadThreadDump).toHaveBeenCalledWith(undefined);
+ expect(component.dump).toBe('thread dump\n');
+ expect(component.downloadUrl).toBe('/api/jobmanager/thread-dump');
+ expect(component.loading).toBe(false);
+ });
+
+ it('updates mode and download url on selection and reloads with that mode',
() => {
+ component.selectMode('full');
+ expect(component.mode).toBe('full');
+
expect(component.downloadUrl).toBe('/api/jobmanager/thread-dump?mode=full');
+
+ component.reload();
+ expect(loadThreadDump).toHaveBeenLastCalledWith('full');
+ });
+
+ it('ignores re-selecting the current mode', () => {
+ component.selectMode('lite');
+ component.selectMode('lite');
+
+ expect(component.mode).toBe('lite');
+ });
+
+ it('falls back to an empty dump when the request fails', () => {
+ loadThreadDump.mockReturnValue(throwError(() => new Error('unavailable')));
+
+ component.ngOnInit();
+
+ expect(component.dump).toBe('');
+ expect(component.loading).toBe(false);
+ });
+});
diff --git
a/flink-runtime-web/web-dashboard/src/app/pages/job/job-detail/job-detail.component.spec.ts
b/flink-runtime-web/web-dashboard/src/app/pages/job/job-detail/job-detail.component.spec.ts
new file mode 100644
index 00000000000..5d541bb9063
--- /dev/null
+++
b/flink-runtime-web/web-dashboard/src/app/pages/job/job-detail/job-detail.component.spec.ts
@@ -0,0 +1,74 @@
+/*
+ * 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 } from '@angular/router';
+import { of, throwError } from 'rxjs';
+
+import { JobLocalService } from
'@flink-runtime-web/pages/job/job-local.service';
+import { JobService, StatusService } from '@flink-runtime-web/services';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { JobDetailComponent } from './job-detail.component';
+
+const cdr = { markForCheck: vi.fn() } as unknown as ChangeDetectorRef;
+const activatedRoute = { snapshot: { params: { jid: 'job-1' } } } as unknown
as ActivatedRoute;
+
+describe('JobDetailComponent', () => {
+ const loadJob = vi.fn();
+ const loadExceptions = vi.fn();
+ const setJobDetail = vi.fn();
+ let component: JobDetailComponent;
+
+ beforeEach(() => {
+ loadJob.mockReset();
+ loadExceptions.mockReset().mockReturnValue(of({ 'root-exception': '' }));
+ setJobDetail.mockReset();
+ component = new JobDetailComponent(
+ { loadJob, loadExceptions } as unknown as JobService,
+ { setJobDetail } as unknown as JobLocalService,
+ { refresh$: of(true) } as unknown as StatusService,
+ activatedRoute,
+ cdr
+ );
+ });
+
+ it('publishes the loaded job detail to the local service', () => {
+ const job = { plan: { jid: 'job-1' } };
+ loadJob.mockReturnValue(of(job));
+
+ component.ngOnInit();
+
+ expect(loadJob).toHaveBeenCalledWith('job-1');
+ expect(setJobDetail).toHaveBeenCalledWith(job);
+ expect(component.isLoading).toBe(false);
+ expect(component.isError).toBe(false);
+ });
+
+ it('surfaces the root exception when the job fails to load', () => {
+ loadJob.mockReturnValue(throwError(() => new Error('boom')));
+ loadExceptions.mockReturnValue(of({ 'root-exception':
'java.lang.RuntimeException: boom' }));
+
+ component.ngOnInit();
+
+ expect(component.isError).toBe(true);
+ expect(component.isLoading).toBe(false);
+ expect(loadExceptions).toHaveBeenCalledWith('job-1', 10);
+ expect(component.errorDetails).toBe('java.lang.RuntimeException: boom');
+ });
+});
diff --git
a/flink-runtime-web/web-dashboard/src/app/pages/job/job-local.service.spec.ts
b/flink-runtime-web/web-dashboard/src/app/pages/job/job-local.service.spec.ts
new file mode 100644
index 00000000000..31564f0397c
--- /dev/null
+++
b/flink-runtime-web/web-dashboard/src/app/pages/job/job-local.service.spec.ts
@@ -0,0 +1,72 @@
+/*
+ * 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 { firstValueFrom } from 'rxjs';
+
+import { JobDetailCorrect, NodesItemCorrect } from
'@flink-runtime-web/interfaces';
+import { beforeEach, describe, expect, it } from 'vitest';
+
+import { JobLocalService, JobWithVertex } from './job-local.service';
+
+describe('JobLocalService', () => {
+ let service: JobLocalService;
+ const job = { plan: { jid: 'job-1' } } as JobDetailCorrect;
+ const vertex = { id: 'v1' } as NodesItemCorrect;
+
+ beforeEach(() => {
+ service = new JobLocalService();
+ });
+
+ it('replays the latest job detail', async () => {
+ service.setJobDetail(job);
+
+ expect(await firstValueFrom(service.jobDetailChanges())).toBe(job);
+ });
+
+ it('replays the latest selected vertex', async () => {
+ service.setSelectedVertex(vertex);
+
+ expect(await firstValueFrom(service.selectedVertexChanges())).toBe(vertex);
+ });
+
+ it('pairs the selected vertex with the latest job detail', async () => {
+ service.setJobDetail(job);
+ service.setSelectedVertex(vertex);
+
+ expect(await firstValueFrom(service.jobWithVertexChanges())).toEqual({
job, vertex });
+ });
+
+ it('suppresses the paired stream while no vertex is selected', () => {
+ service.setJobDetail(job);
+ const emissions: JobWithVertex[] = [];
+ const sub = service.jobWithVertexChanges().subscribe(val =>
emissions.push(val));
+
+ // A null selection must be filtered out; the next real vertex is what
surfaces.
+ service.setSelectedVertex(null);
+ expect(emissions).toEqual([]);
+
+ service.setSelectedVertex(vertex);
+ expect(emissions).toEqual([{ job, vertex }]);
+
+ sub.unsubscribe();
+ });
+
+ it('exposes a metrics cache map', () => {
+ expect(service.metricsCacheMap).toBeInstanceOf(Map);
+ });
+});
diff --git
a/flink-runtime-web/web-dashboard/src/app/pages/job/job.component.spec.ts
b/flink-runtime-web/web-dashboard/src/app/pages/job/job.component.spec.ts
new file mode 100644
index 00000000000..bfd6381cbe2
--- /dev/null
+++ b/flink-runtime-web/web-dashboard/src/app/pages/job/job.component.spec.ts
@@ -0,0 +1,85 @@
+/*
+ * 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 { Subject } from 'rxjs';
+
+import { JobsItem } from '@flink-runtime-web/interfaces';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { JobComponent } from './job.component';
+
+const cdr = { markForCheck: vi.fn() } as unknown as ChangeDetectorRef;
+const activatedRoute = {} as ActivatedRoute;
+const seg = (path: string): { path: string; toString: () => string } => ({
path, toString: () => path });
+
+function urlTree(paths: string[]): unknown {
+ return { root: { children: { primary: { segments: paths.map(seg) } } } };
+}
+
+describe('JobComponent', () => {
+ const events = new Subject<unknown>();
+ const parseUrl = vi.fn();
+ const navigate = vi.fn().mockResolvedValue(true);
+ let component: JobComponent;
+
+ beforeEach(() => {
+ parseUrl.mockReset();
+ navigate.mockClear();
+ const router = { events, url: '/job/running/job-1', parseUrl, navigate }
as unknown as Router;
+ component = new JobComponent(activatedRoute, router, cdr);
+ });
+
+ it('reads the selected job id and running state from the url', () => {
+ parseUrl.mockReturnValue(urlTree(['job', 'running', 'job-1']));
+
+ component.ngOnInit();
+
+ expect(component.jobIdSelected).toBe('job-1');
+ expect(component.isCompleted).toBe(false);
+ expect(component.cardTitle).toBe('Running Jobs');
+ });
+
+ it('recognises the completed list with no job selected', () => {
+ parseUrl.mockReturnValue(urlTree(['job', 'completed']));
+
+ component.ngOnInit();
+
+ expect(component.jobIdSelected).toBeUndefined();
+ expect(component.isCompleted).toBe(true);
+ expect(component.cardTitle).toBe('Completed Jobs');
+ });
+
+ it('re-reads the url on navigation end', () => {
+ parseUrl.mockReturnValue(urlTree(['job', 'running', 'job-1']));
+ component.ngOnInit();
+
+ parseUrl.mockReturnValue(urlTree(['job', 'completed', 'job-2']));
+ events.next(new NavigationEnd(1, '/job/completed/job-2',
'/job/completed/job-2'));
+
+ expect(component.jobIdSelected).toBe('job-2');
+ expect(component.isCompleted).toBe(true);
+ });
+
+ it('navigates to a job relative to the current route', () => {
+ component.navigateToJob({ jid: 'job-9' } as JobsItem);
+
+ expect(navigate).toHaveBeenCalledWith(['job-9'], { relativeTo:
activatedRoute });
+ });
+});
diff --git
a/flink-runtime-web/web-dashboard/src/app/pages/job/overview/accumulators/job-overview-drawer-accumulators.component.spec.ts
b/flink-runtime-web/web-dashboard/src/app/pages/job/overview/accumulators/job-overview-drawer-accumulators.component.spec.ts
new file mode 100644
index 00000000000..8b61156b395
--- /dev/null
+++
b/flink-runtime-web/web-dashboard/src/app/pages/job/overview/accumulators/job-overview-drawer-accumulators.component.spec.ts
@@ -0,0 +1,80 @@
+/*
+ * 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 { of, throwError } from 'rxjs';
+
+import { JobService } from '@flink-runtime-web/services';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { JobOverviewDrawerAccumulatorsComponent } from
'./job-overview-drawer-accumulators.component';
+import { JobLocalService } from '../../job-local.service';
+
+// The drawer components pull their data through services in ngOnInit and,
when rendered, drag in
+// chart/dynamic-host children jsdom cannot draw. These specs construct the
component directly and
+// assert the controller logic, rather than rendering the template through
TestBed.
+const cdr = { markForCheck: vi.fn() } as unknown as ChangeDetectorRef;
+const jobLocalService = {
+ jobWithVertexChanges: () => of({ job: { jid: 'job-1' }, vertex: { id: 'v1' }
})
+} as unknown as JobLocalService;
+
+describe('JobOverviewDrawerAccumulatorsComponent', () => {
+ const loadAccumulators = vi.fn();
+ let component: JobOverviewDrawerAccumulatorsComponent;
+
+ beforeEach(() => {
+ loadAccumulators.mockReset();
+ component = new JobOverviewDrawerAccumulatorsComponent(
+ { loadAccumulators } as unknown as JobService,
+ jobLocalService,
+ cdr
+ );
+ });
+
+ it('flattens the per-subtask user accumulators into individual rows', () => {
+ loadAccumulators.mockReturnValue(
+ of({
+ main: [{ name: 'records', type: 'Long', value: '5' }],
+ subtasks: [{ subtask: 0, 'user-accumulators': [{ name: 'records',
type: 'Long', value: '5' }] }]
+ })
+ );
+
+ component.ngOnInit();
+
+ expect(loadAccumulators).toHaveBeenCalledWith('job-1', 'v1');
+ expect(component.listOfAccumulator).toEqual([{ name: 'records', type:
'Long', value: '5' }]);
+ expect(component.listOfSubTaskAccumulator).toHaveLength(1);
+ expect(component.listOfSubTaskAccumulator[0]).toMatchObject({
+ subtask: 0,
+ name: 'records',
+ type: 'Long',
+ value: '5'
+ });
+ expect(component.isLoading).toBe(false);
+ });
+
+ it('falls back to empty tables when the accumulators request fails', () => {
+ loadAccumulators.mockReturnValue(throwError(() => new Error('boom')));
+
+ component.ngOnInit();
+
+ expect(component.listOfAccumulator).toEqual([]);
+ expect(component.listOfSubTaskAccumulator).toEqual([]);
+ expect(component.isLoading).toBe(false);
+ });
+});
diff --git
a/flink-runtime-web/web-dashboard/src/app/pages/job/overview/backpressure/job-overview-drawer-backpressure.component.spec.ts
b/flink-runtime-web/web-dashboard/src/app/pages/job/overview/backpressure/job-overview-drawer-backpressure.component.spec.ts
new file mode 100644
index 00000000000..09e32406cba
--- /dev/null
+++
b/flink-runtime-web/web-dashboard/src/app/pages/job/overview/backpressure/job-overview-drawer-backpressure.component.spec.ts
@@ -0,0 +1,78 @@
+/*
+ * 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 { of, throwError } from 'rxjs';
+
+import { JOB_OVERVIEW_MODULE_DEFAULT_CONFIG } from
'@flink-runtime-web/pages/job/overview/job-overview.config';
+import { JobService } from '@flink-runtime-web/services';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { JobOverviewDrawerBackpressureComponent } from
'./job-overview-drawer-backpressure.component';
+import { JobLocalService } from '../../job-local.service';
+
+const cdr = { markForCheck: vi.fn() } as unknown as ChangeDetectorRef;
+const jobLocalService = {
+ jobWithVertexChanges: () => of({ job: { jid: 'job-1' }, vertex: { id: 'v1' }
})
+} as unknown as JobLocalService;
+
+describe('JobOverviewDrawerBackpressureComponent', () => {
+ const loadOperatorBackPressure = vi.fn();
+ const loadSubTasks = vi.fn();
+ let component: JobOverviewDrawerBackpressureComponent;
+
+ beforeEach(() => {
+ loadOperatorBackPressure.mockReset();
+ loadSubTasks.mockReset().mockReturnValue(of({ subtasks: [] }));
+ component = new JobOverviewDrawerBackpressureComponent(
+ { loadOperatorBackPressure, loadSubTasks } as unknown as JobService,
+ jobLocalService,
+ cdr,
+ JOB_OVERVIEW_MODULE_DEFAULT_CONFIG
+ );
+ });
+
+ it('renders backpressure ratios as rounded percentages and N/A for NaN', ()
=> {
+ expect(component.prettyPrint(NaN)).toBe('N/A');
+ expect(component.prettyPrint(0.1234)).toBe('12%');
+ expect(component.prettyPrint(1)).toBe('100%');
+ });
+
+ it('stores the backpressure response and indexes subtasks by index', () => {
+ loadOperatorBackPressure.mockReturnValue(of({ status: 'ok', subtasks: [{
subtask: 0, ratio: 0.5 }] }));
+ loadSubTasks.mockReturnValue(of({ subtasks: [{ subtask: 0 }, { subtask: 1
}] }));
+
+ component.ngOnInit();
+
+ expect(component.selectedVertex).toEqual({ id: 'v1' });
+ expect(component.backpressure).toEqual({ status: 'ok', subtasks: [{
subtask: 0, ratio: 0.5 }] });
+ expect(component.listOfSubTaskBackpressure).toEqual([{ subtask: 0, ratio:
0.5 }]);
+ expect(component.mapOfSubtask.get(1)).toEqual({ subtask: 1 });
+ expect(component.isLoading).toBe(false);
+ });
+
+ it('falls back to an empty backpressure view when the request fails', () => {
+ loadOperatorBackPressure.mockReturnValue(throwError(() => new
Error('boom')));
+
+ component.ngOnInit();
+
+ expect(component.backpressure).toEqual({});
+ expect(component.listOfSubTaskBackpressure).toEqual([]);
+ expect(component.isLoading).toBe(false);
+ });
+});
diff --git
a/flink-runtime-web/web-dashboard/src/app/pages/job/overview/chart/job-overview-drawer-chart.component.spec.ts
b/flink-runtime-web/web-dashboard/src/app/pages/job/overview/chart/job-overview-drawer-chart.component.spec.ts
new file mode 100644
index 00000000000..f53c8d84188
--- /dev/null
+++
b/flink-runtime-web/web-dashboard/src/app/pages/job/overview/chart/job-overview-drawer-chart.component.spec.ts
@@ -0,0 +1,80 @@
+/*
+ * 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 { of } from 'rxjs';
+
+import { MetricsService } from '@flink-runtime-web/services';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { JobOverviewDrawerChartComponent } from
'./job-overview-drawer-chart.component';
+import { JobLocalService } from '../../job-local.service';
+
+const cdr = { markForCheck: vi.fn() } as unknown as ChangeDetectorRef;
+
+describe('JobOverviewDrawerChartComponent', () => {
+ const loadAllAvailableMetrics = vi.fn();
+ let metricsCacheMap: Map<string, string[]>;
+ let component: JobOverviewDrawerChartComponent;
+
+ beforeEach(() => {
+ loadAllAvailableMetrics.mockReset().mockReturnValue(of([{ id: 'm1' }, {
id: 'm2' }, { id: 'm3' }]));
+ metricsCacheMap = new Map();
+ const jobLocalService = {
+ jobWithVertexChanges: () => of({ job: { jid: 'job-1' }, vertex: { id:
'v1' } }),
+ metricsCacheMap
+ } as unknown as JobLocalService;
+ component = new JobOverviewDrawerChartComponent(
+ { loadAllAvailableMetrics, loadMetrics: vi.fn().mockReturnValue(of({}))
} as unknown as MetricsService,
+ jobLocalService,
+ cdr
+ );
+ });
+
+ it('lists all available metrics as unselected when nothing is cached', () =>
{
+ component.ngOnInit();
+
+ expect(component.cacheMetricKey).toBe('job-1/v1');
+ expect(component.listOfMetricName).toEqual(['m1', 'm2', 'm3']);
+ expect(component.listOfSelectedMetric).toEqual([]);
+ expect(component.listOfUnselectedMetric).toEqual(['m1', 'm2', 'm3']);
+ });
+
+ it('restores the previously selected metrics from the per-vertex cache', ()
=> {
+ metricsCacheMap.set('job-1/v1', ['m2']);
+
+ component.ngOnInit();
+
+ expect(component.listOfSelectedMetric).toEqual(['m2']);
+ expect(component.listOfUnselectedMetric).toEqual(['m1', 'm3']);
+ });
+
+ it('moves a metric between the selected and unselected lists and writes
through to the cache', () => {
+ component.ngOnInit();
+
+ component.updateMetric('m1');
+ expect(component.listOfSelectedMetric).toEqual(['m1']);
+ expect(component.listOfUnselectedMetric).toEqual(['m2', 'm3']);
+ expect(metricsCacheMap.get('job-1/v1')).toEqual(['m1']);
+
+ component.closeMetric('m1');
+ expect(component.listOfSelectedMetric).toEqual([]);
+ expect(component.listOfUnselectedMetric).toEqual(['m1', 'm2', 'm3']);
+ expect(metricsCacheMap.get('job-1/v1')).toEqual([]);
+ });
+});
diff --git
a/flink-runtime-web/web-dashboard/src/app/pages/job/overview/detail/job-overview-drawer-detail.component.spec.ts
b/flink-runtime-web/web-dashboard/src/app/pages/job/overview/detail/job-overview-drawer-detail.component.spec.ts
new file mode 100644
index 00000000000..206664c180a
--- /dev/null
+++
b/flink-runtime-web/web-dashboard/src/app/pages/job/overview/detail/job-overview-drawer-detail.component.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 { ChangeDetectorRef } from '@angular/core';
+import { of } from 'rxjs';
+
+import { NodesItemCorrect } from '@flink-runtime-web/interfaces';
+import { JOB_OVERVIEW_MODULE_DEFAULT_CONFIG } from
'@flink-runtime-web/pages/job/overview/job-overview.config';
+import { describe, expect, it, vi } from 'vitest';
+
+import { JobOverviewDrawerDetailComponent } from
'./job-overview-drawer-detail.component';
+import { JobLocalService } from '../../job-local.service';
+
+const cdr = { markForCheck: vi.fn() } as unknown as ChangeDetectorRef;
+
+function createComponent(node: NodesItemCorrect | null):
JobOverviewDrawerDetailComponent {
+ const jobLocalService = { selectedVertexChanges: () => of(node) } as unknown
as JobLocalService;
+ return new JobOverviewDrawerDetailComponent(jobLocalService, cdr,
JOB_OVERVIEW_MODULE_DEFAULT_CONFIG);
+}
+
+describe('JobOverviewDrawerDetailComponent', () => {
+ it('rewrites <br/> tags in the vertex description to newlines', () => {
+ const component = createComponent({ id: 'v1', description:
'line-a<br/>line-b<br/>line-c' } as NodesItemCorrect);
+
+ component.ngOnInit();
+
+ expect(component.node?.description).toBe('line-a\nline-b\nline-c');
+ });
+
+ it('leaves a description without <br/> untouched', () => {
+ const component = createComponent({ id: 'v1', description: 'plain
description' } as NodesItemCorrect);
+
+ component.ngOnInit();
+
+ expect(component.node?.description).toBe('plain description');
+ });
+
+ it('handles a null selected vertex without throwing', () => {
+ const component = createComponent(null);
+
+ expect(() => component.ngOnInit()).not.toThrow();
+ expect(component.node).toBeNull();
+ });
+});
diff --git
a/flink-runtime-web/web-dashboard/src/app/pages/job/overview/flamegraph/job-overview-drawer-flamegraph.component.spec.ts
b/flink-runtime-web/web-dashboard/src/app/pages/job/overview/flamegraph/job-overview-drawer-flamegraph.component.spec.ts
new file mode 100644
index 00000000000..f1d882a3665
--- /dev/null
+++
b/flink-runtime-web/web-dashboard/src/app/pages/job/overview/flamegraph/job-overview-drawer-flamegraph.component.spec.ts
@@ -0,0 +1,92 @@
+/*
+ * 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 { of, throwError } from 'rxjs';
+
+import { FlameGraphType } from '@flink-runtime-web/interfaces';
+import { JobService } from '@flink-runtime-web/services';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { JobOverviewDrawerFlameGraphComponent } from
'./job-overview-drawer-flamegraph.component';
+import { JobLocalService } from '../../job-local.service';
+
+const cdr = { markForCheck: vi.fn() } as unknown as ChangeDetectorRef;
+const jobLocalService = {
+ jobWithVertexChanges: () => of({ job: { jid: 'job-1' }, vertex: { id: 'v1' }
})
+} as unknown as JobLocalService;
+
+describe('JobOverviewDrawerFlameGraphComponent', () => {
+ const loadOperatorFlameGraph = vi.fn();
+ const loadOperatorFlameGraphForSingleSubtask = vi.fn();
+ const loadSubTasks = vi.fn();
+ let component: JobOverviewDrawerFlameGraphComponent;
+
+ beforeEach(() => {
+ loadOperatorFlameGraph.mockReset().mockReturnValue(of({ endTimestamp: 123
}));
+ loadOperatorFlameGraphForSingleSubtask.mockReset().mockReturnValue(of({
endTimestamp: 456 }));
+ loadSubTasks.mockReset().mockReturnValue(of({ subtasks: [] }));
+ component = new JobOverviewDrawerFlameGraphComponent(
+ { loadOperatorFlameGraph, loadOperatorFlameGraphForSingleSubtask,
loadSubTasks } as unknown as JobService,
+ jobLocalService,
+ cdr
+ );
+ });
+
+ it('offers only running/initializing subtasks (plus "all") as sampleable',
() => {
+ loadSubTasks.mockReturnValue(
+ of({
+ subtasks: [
+ { subtask: 0, status: 'RUNNING' },
+ { subtask: 1, status: 'FINISHED' },
+ { subtask: 2, status: 'INITIALIZING' }
+ ]
+ })
+ );
+
+ component.ngOnInit();
+
+ expect(component.listOfSampleableSubtasks).toEqual(['all', '0', '2']);
+ });
+
+ it('loads the whole-vertex flame graph by default and stores the graph
type', () => {
+ component.ngOnInit();
+
+ expect(loadOperatorFlameGraph).toHaveBeenCalledWith('job-1', 'v1',
FlameGraphType.ON_CPU);
+ expect(component.flameGraph.endTimestamp).toBe(123);
+ expect(component.flameGraph.graphType).toBe(FlameGraphType.ON_CPU);
+ expect(component.isLoading).toBe(false);
+ });
+
+ it('switches to the single-subtask flame graph once a subtask is selected',
() => {
+ component.ngOnInit();
+
+ component.selectSubtask('0');
+
+
expect(loadOperatorFlameGraphForSingleSubtask).toHaveBeenCalledWith('job-1',
'v1', FlameGraphType.ON_CPU, '0');
+ expect(component.flameGraph.endTimestamp).toBe(456);
+ });
+
+ it('keeps just "all" sampleable when the subtasks request fails', () => {
+ loadSubTasks.mockReturnValue(throwError(() => new Error('boom')));
+
+ component.ngOnInit();
+
+ expect(component.listOfSampleableSubtasks).toEqual(['all']);
+ });
+});
diff --git
a/flink-runtime-web/web-dashboard/src/app/pages/job/overview/subtasks/job-overview-drawer-subtasks.component.spec.ts
b/flink-runtime-web/web-dashboard/src/app/pages/job/overview/subtasks/job-overview-drawer-subtasks.component.spec.ts
new file mode 100644
index 00000000000..6ea4d5f58b2
--- /dev/null
+++
b/flink-runtime-web/web-dashboard/src/app/pages/job/overview/subtasks/job-overview-drawer-subtasks.component.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 { ChangeDetectorRef } from '@angular/core';
+import { of, throwError } from 'rxjs';
+
+import { JobVertexStatusDuration } from '@flink-runtime-web/interfaces';
+import { JOB_OVERVIEW_MODULE_DEFAULT_CONFIG } from
'@flink-runtime-web/pages/job/overview/job-overview.config';
+import { JobService } from '@flink-runtime-web/services';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { JobOverviewDrawerSubtasksComponent } from
'./job-overview-drawer-subtasks.component';
+import { JobLocalService } from '../../job-local.service';
+
+const cdr = { markForCheck: vi.fn() } as unknown as ChangeDetectorRef;
+const jobLocalService = {
+ jobWithVertexChanges: () => of({ job: { jid: 'job-1' }, vertex: { id: 'v1' }
})
+} as unknown as JobLocalService;
+
+describe('JobOverviewDrawerSubtasksComponent', () => {
+ const loadSubTasks = vi.fn();
+ let component: JobOverviewDrawerSubtasksComponent;
+
+ beforeEach(() => {
+ loadSubTasks.mockReset();
+ component = new JobOverviewDrawerSubtasksComponent(
+ { loadSubTasks } as unknown as JobService,
+ jobLocalService,
+ cdr,
+ JOB_OVERVIEW_MODULE_DEFAULT_CONFIG
+ );
+ });
+
+ it('loads the subtasks and aggregated metrics for the selected vertex', ()
=> {
+ loadSubTasks.mockReturnValue(of({ subtasks: [{ subtask: 0 }], aggregated:
{ 'read-bytes': { min: 1 } } }));
+
+ component.ngOnInit();
+
+ expect(loadSubTasks).toHaveBeenCalledWith('job-1', 'v1');
+ expect(component.listOfTask).toEqual([{ subtask: 0 }]);
+ expect(component.aggregated).toEqual({ 'read-bytes': { min: 1 } });
+ expect(component.isLoading).toBe(false);
+ });
+
+ it('falls back to an empty task list when the request fails', () => {
+ loadSubTasks.mockReturnValue(throwError(() => new Error('boom')));
+
+ component.ngOnInit();
+
+ expect(component.listOfTask).toEqual([]);
+ expect(component.aggregated).toBeUndefined();
+ expect(component.isLoading).toBe(false);
+ });
+
+ it('orders the status durations by lifecycle stage', () => {
+ const statusDuration = {
+ CREATED: 10,
+ SCHEDULED: 20,
+ DEPLOYING: 30,
+ INITIALIZING: 40,
+ RUNNING: 50
+ } as unknown as JobVertexStatusDuration<number>;
+
+ expect(component.convertStatusDuration(statusDuration)).toEqual([
+ { state: 'CREATED', duration: 10 },
+ { state: 'SCHEDULED', duration: 20 },
+ { state: 'DEPLOYING', duration: 30 },
+ { state: 'INITIALIZING', duration: 40 },
+ { state: 'RUNNING', duration: 50 }
+ ]);
+ });
+});
diff --git
a/flink-runtime-web/web-dashboard/src/app/pages/job/overview/taskmanagers/job-overview-drawer-taskmanagers.component.spec.ts
b/flink-runtime-web/web-dashboard/src/app/pages/job/overview/taskmanagers/job-overview-drawer-taskmanagers.component.spec.ts
new file mode 100644
index 00000000000..2b628a91341
--- /dev/null
+++
b/flink-runtime-web/web-dashboard/src/app/pages/job/overview/taskmanagers/job-overview-drawer-taskmanagers.component.spec.ts
@@ -0,0 +1,66 @@
+/*
+ * 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 { of, throwError } from 'rxjs';
+
+import { JOB_OVERVIEW_MODULE_DEFAULT_CONFIG } from
'@flink-runtime-web/pages/job/overview/job-overview.config';
+import { JobService } from '@flink-runtime-web/services';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { JobOverviewDrawerTaskmanagersComponent } from
'./job-overview-drawer-taskmanagers.component';
+import { JobLocalService } from '../../job-local.service';
+
+const cdr = { markForCheck: vi.fn() } as unknown as ChangeDetectorRef;
+const jobLocalService = {
+ jobWithVertexChanges: () => of({ job: { jid: 'job-1' }, vertex: { id: 'v1' }
})
+} as unknown as JobLocalService;
+
+describe('JobOverviewDrawerTaskmanagersComponent', () => {
+ const loadTaskManagers = vi.fn();
+ let component: JobOverviewDrawerTaskmanagersComponent;
+
+ beforeEach(() => {
+ loadTaskManagers.mockReset();
+ component = new JobOverviewDrawerTaskmanagersComponent(
+ { loadTaskManagers } as unknown as JobService,
+ jobLocalService,
+ cdr,
+ JOB_OVERVIEW_MODULE_DEFAULT_CONFIG
+ );
+ });
+
+ it('extracts the taskmanagers list for the selected vertex', () => {
+ loadTaskManagers.mockReturnValue(of({ taskmanagers: [{ endpoint: 'host-a'
}, { endpoint: 'host-b' }] }));
+
+ component.ngOnInit();
+
+ expect(loadTaskManagers).toHaveBeenCalledWith('job-1', 'v1');
+ expect(component.listOfTaskManager).toEqual([{ endpoint: 'host-a' }, {
endpoint: 'host-b' }]);
+ expect(component.isLoading).toBe(false);
+ });
+
+ it('falls back to an empty list when the request fails', () => {
+ loadTaskManagers.mockReturnValue(throwError(() => new Error('boom')));
+
+ component.ngOnInit();
+
+ expect(component.listOfTaskManager).toEqual([]);
+ expect(component.isLoading).toBe(false);
+ });
+});
diff --git
a/flink-runtime-web/web-dashboard/src/app/pages/job/overview/watermarks/job-overview-drawer-watermarks.component.spec.ts
b/flink-runtime-web/web-dashboard/src/app/pages/job/overview/watermarks/job-overview-drawer-watermarks.component.spec.ts
new file mode 100644
index 00000000000..d20f25f14f3
--- /dev/null
+++
b/flink-runtime-web/web-dashboard/src/app/pages/job/overview/watermarks/job-overview-drawer-watermarks.component.spec.ts
@@ -0,0 +1,71 @@
+/*
+ * 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 { of, throwError } from 'rxjs';
+
+import { MetricsService } from '@flink-runtime-web/services';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { JobOverviewDrawerWatermarksComponent } from
'./job-overview-drawer-watermarks.component';
+import { JobLocalService } from '../../job-local.service';
+
+const cdr = { markForCheck: vi.fn() } as unknown as ChangeDetectorRef;
+const jobLocalService = {
+ jobWithVertexChanges: () => of({ job: { jid: 'job-1' }, vertex: { id: 'v1' }
})
+} as unknown as JobLocalService;
+
+describe('JobOverviewDrawerWatermarksComponent', () => {
+ const loadWatermarks = vi.fn();
+ let component: JobOverviewDrawerWatermarksComponent;
+
+ beforeEach(() => {
+ loadWatermarks.mockReset();
+ component = new JobOverviewDrawerWatermarksComponent(
+ jobLocalService,
+ { loadWatermarks } as unknown as MetricsService,
+ cdr
+ );
+ });
+
+ it('turns the watermark map into indexed rows', () => {
+ loadWatermarks.mockReturnValue(of({ watermarks: { '0': 100, '1': 200 } }));
+
+ component.ngOnInit();
+
+ expect(loadWatermarks).toHaveBeenCalledWith('job-1', 'v1');
+ expect(component.listOfWaterMark).toEqual([
+ { subTaskIndex: 0, watermark: 100 },
+ { subTaskIndex: 1, watermark: 200 }
+ ]);
+ expect(component.isLoading).toBe(false);
+ });
+
+ it('falls back to an empty table when the watermark request fails', () => {
+ loadWatermarks.mockReturnValue(throwError(() => new Error('boom')));
+
+ component.ngOnInit();
+
+ expect(component.listOfWaterMark).toEqual([]);
+ expect(component.isLoading).toBe(false);
+ });
+
+ it('sorts watermarks ascending', () => {
+ expect(component.sortWatermark({ subTaskIndex: 0, watermark: 5 }, {
subTaskIndex: 1, watermark: 3 })).toBe(2);
+ });
+});
diff --git
a/flink-runtime-web/web-dashboard/src/app/pages/submit/submit.component.spec.ts
b/flink-runtime-web/web-dashboard/src/app/pages/submit/submit.component.spec.ts
new file mode 100644
index 00000000000..7136bb34e38
--- /dev/null
+++
b/flink-runtime-web/web-dashboard/src/app/pages/submit/submit.component.spec.ts
@@ -0,0 +1,128 @@
+/*
+ * 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 { HttpEventType } from '@angular/common/http';
+import { ChangeDetectorRef } from '@angular/core';
+import { UntypedFormBuilder } from '@angular/forms';
+import { Router } from '@angular/router';
+import { of, throwError } from 'rxjs';
+
+import { JarFilesItem } from '@flink-runtime-web/interfaces';
+import { JarService, StatusService } from '@flink-runtime-web/services';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { SubmitComponent } from './submit.component';
+
+const cdr = { markForCheck: vi.fn(), detectChanges: vi.fn() } as unknown as
ChangeDetectorRef;
+
+describe('SubmitComponent', () => {
+ const loadJarList = vi.fn();
+ const uploadJar = vi.fn();
+ const deleteJar = vi.fn();
+ const runJob = vi.fn();
+ const forceRefresh = vi.fn();
+ const navigate = vi.fn().mockResolvedValue(true);
+ let component: SubmitComponent;
+
+ beforeEach(() => {
+ loadJarList.mockReset().mockReturnValue(of({ files: [], address:
'http://jm', error: undefined }));
+ uploadJar.mockReset();
+ deleteJar.mockReset().mockReturnValue(of(undefined));
+ runJob.mockReset();
+ forceRefresh.mockReset();
+ navigate.mockClear();
+ component = new SubmitComponent(
+ { loadJarList, uploadJar, deleteJar, runJob } as unknown as JarService,
+ { refresh$: of(true), forceRefresh } as unknown as StatusService,
+ new UntypedFormBuilder(),
+ { navigate } as unknown as Router,
+ cdr
+ );
+ });
+
+ it('loads the uploaded jars on init', () => {
+ loadJarList.mockReturnValue(of({ files: [{ id: 'jar-1' }], address:
'http://jm', error: undefined }));
+
+ component.ngOnInit();
+
+ expect(component.listOfJar).toEqual([{ id: 'jar-1' }]);
+ expect(component.address).toBe('http://jm');
+ expect(component.isLoading).toBe(false);
+ expect(component.noAccess).toBe(false);
+ expect(component.isYarn).toBe(false);
+ });
+
+ it('marks no-access when the jar list carries an error', () => {
+ loadJarList.mockReturnValue(of({ files: [], address: '', error:
'forbidden' }));
+
+ component.ngOnInit();
+
+ expect(component.noAccess).toBe(true);
+ });
+
+ it('marks no-access when the jar list request fails', () => {
+ loadJarList.mockReturnValue(throwError(() => new Error('boom')));
+
+ component.ngOnInit();
+
+ expect(component.noAccess).toBe(true);
+ expect(component.isLoading).toBe(false);
+ });
+
+ it('expands a jar and prefills the entry class from its first entry', () => {
+ component.ngOnInit();
+
+ component.expandJar({ id: 'jar-1', entry: [{ name: 'com.example.Main' }] }
as JarFilesItem);
+
+ expect(component.expandedMap.get('jar-1')).toBe(true);
+
expect(component.validateForm.get('entryClass')!.value).toBe('com.example.Main');
+
+ // Expanding the same jar again collapses it.
+ component.expandJar({ id: 'jar-1', entry: [{ name: 'com.example.Main' }] }
as JarFilesItem);
+ expect(component.expandedMap.get('jar-1')).toBe(false);
+ });
+
+ it('refreshes and collapses after deleting a jar', () => {
+ component.ngOnInit();
+ component.expandedMap.set('jar-1', true);
+
+ component.deleteJar({ id: 'jar-1' } as JarFilesItem);
+
+ expect(deleteJar).toHaveBeenCalledWith('jar-1');
+ expect(forceRefresh).toHaveBeenCalled();
+ expect(component.expandedMap.get('jar-1')).toBe(false);
+ });
+
+ it('tracks upload progress from http events', () => {
+ uploadJar.mockReturnValue(of({ type: HttpEventType.UploadProgress, loaded:
50, total: 100 }));
+
+ component.uploadJar(new File([''], 'app.jar'));
+
+ expect(component.isUploading).toBe(true);
+ expect(component.progress).toBe(50);
+ });
+
+ it('navigates to the new job after submitting', () => {
+ component.ngOnInit();
+ runJob.mockReturnValue(of({ jobid: 'job-9' }));
+
+ component.submitJob({ id: 'jar-1' } as JarFilesItem);
+
+ expect(navigate).toHaveBeenCalledWith(['job', 'running', 'job-9']);
+ });
+});
diff --git
a/flink-runtime-web/web-dashboard/src/app/pages/task-manager/profiler/task-manager-profiler.component.spec.ts
b/flink-runtime-web/web-dashboard/src/app/pages/task-manager/profiler/task-manager-profiler.component.spec.ts
new file mode 100644
index 00000000000..5ebaf56cb1d
--- /dev/null
+++
b/flink-runtime-web/web-dashboard/src/app/pages/task-manager/profiler/task-manager-profiler.component.spec.ts
@@ -0,0 +1,81 @@
+/*
+ * 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 } from '@angular/router';
+import { of } from 'rxjs';
+
+import { ProfilingDetail } from '@flink-runtime-web/interfaces/job-profiler';
+import { StatusService, TaskManagerService } from
'@flink-runtime-web/services';
+import { NzMessageService } from 'ng-zorro-antd/message';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { TaskManagerProfilerComponent } from
'./task-manager-profiler.component';
+
+const cdr = { markForCheck: vi.fn() } as unknown as ChangeDetectorRef;
+const activatedRoute = { parent: { snapshot: { params: { taskManagerId: 'tm-1'
} } } } as unknown as ActivatedRoute;
+
+describe('TaskManagerProfilerComponent', () => {
+ const loadProfilingList = vi.fn();
+ const createProfilingInstance = vi.fn();
+ const warning = vi.fn();
+ let component: TaskManagerProfilerComponent;
+
+ beforeEach(() => {
+ loadProfilingList.mockReset().mockReturnValue(of({ profilingList: [] }));
+ createProfilingInstance.mockReset();
+ warning.mockReset();
+ component = new TaskManagerProfilerComponent(
+ { loadProfilingList, createProfilingInstance } as unknown as
TaskManagerService,
+ activatedRoute,
+ { refresh$: of(true) } as unknown as StatusService,
+ { warning } as unknown as NzMessageService,
+ cdr
+ );
+ });
+
+ it('loads the profiling list for the routed task manager on refresh', () => {
+ loadProfilingList.mockReturnValue(of({ profilingList: [{ status:
'FINISHED' } as ProfilingDetail] }));
+
+ component.ngOnInit();
+
+ expect(loadProfilingList).toHaveBeenCalledWith('tm-1');
+ expect(component.profilingList).toEqual([{ status: 'FINISHED' }]);
+ expect(component.isEnabled).toBe(true);
+ expect(component.isLoading).toBe(false);
+ });
+
+ it('creates a profiling instance for the routed task manager and prepends
it', () => {
+ createProfilingInstance.mockReturnValue(of({ status: 'RUNNING' } as
ProfilingDetail));
+
+ component.createProfilingInstance();
+
+ expect(createProfilingInstance).toHaveBeenCalledWith('tm-1', 'ITIMER', 30);
+ expect(component.profilingList[0]).toEqual({ status: 'RUNNING' });
+ expect(component.isCreating).toBe(false);
+ });
+
+ it('refuses to start a new profiling run while one is still running', () => {
+ component.profilingList = [{ status: 'RUNNING' } as ProfilingDetail];
+
+ component.createProfilingInstance();
+
+ expect(warning).toHaveBeenCalled();
+ expect(createProfilingInstance).not.toHaveBeenCalled();
+ });
+});
diff --git
a/flink-runtime-web/web-dashboard/src/app/pages/task-manager/stdout/task-manager-stdout.component.spec.ts
b/flink-runtime-web/web-dashboard/src/app/pages/task-manager/stdout/task-manager-stdout.component.spec.ts
new file mode 100644
index 00000000000..f4df0ff0e37
--- /dev/null
+++
b/flink-runtime-web/web-dashboard/src/app/pages/task-manager/stdout/task-manager-stdout.component.spec.ts
@@ -0,0 +1,68 @@
+/*
+ * 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 } from '@angular/router';
+import { of, throwError } from 'rxjs';
+
+import { TASK_MANAGER_MODULE_DEFAULT_CONFIG } from
'@flink-runtime-web/pages/task-manager/task-manager.config';
+import { ConfigService, TaskManagerService } from
'@flink-runtime-web/services';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { TaskManagerStdoutComponent } from './task-manager-stdout.component';
+
+const cdr = { markForCheck: vi.fn() } as unknown as ChangeDetectorRef;
+const activatedRoute = { parent: { snapshot: { params: { taskManagerId: 'tm-1'
} } } } as unknown as ActivatedRoute;
+
+describe('TaskManagerStdoutComponent', () => {
+ const loadStdout = vi.fn();
+ let component: TaskManagerStdoutComponent;
+
+ beforeEach(() => {
+ loadStdout.mockReset();
+ component = new TaskManagerStdoutComponent(
+ { loadStdout } as unknown as TaskManagerService,
+ { BASE_URL: '/api' } as unknown as ConfigService,
+ activatedRoute,
+ cdr,
+ TASK_MANAGER_MODULE_DEFAULT_CONFIG
+ );
+ });
+
+ it('loads stdout for the routed task manager and derives the download
target', () => {
+ loadStdout.mockReturnValue(of('hello stdout\n'));
+
+ component.ngOnInit();
+
+ expect(loadStdout).toHaveBeenCalledWith('tm-1');
+ expect(component.taskManagerId).toBe('tm-1');
+ expect(component.stdout).toBe('hello stdout\n');
+ expect(component.downloadUrl).toBe('/api/taskmanagers/tm-1/stdout');
+ expect(component.downloadName).toBe('taskmanager_tm-1_stdout');
+ expect(component.loading).toBe(false);
+ });
+
+ it('falls back to empty stdout when the request fails', () => {
+ loadStdout.mockReturnValue(throwError(() => new Error('unavailable')));
+
+ component.ngOnInit();
+
+ expect(component.stdout).toBe('');
+ expect(component.loading).toBe(false);
+ });
+});
diff --git
a/flink-runtime-web/web-dashboard/src/app/pages/task-manager/thread-dump/task-manager-thread-dump.component.spec.ts
b/flink-runtime-web/web-dashboard/src/app/pages/task-manager/thread-dump/task-manager-thread-dump.component.spec.ts
new file mode 100644
index 00000000000..90994f532ca
--- /dev/null
+++
b/flink-runtime-web/web-dashboard/src/app/pages/task-manager/thread-dump/task-manager-thread-dump.component.spec.ts
@@ -0,0 +1,89 @@
+/*
+ * 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 } from '@angular/router';
+import { of, throwError } from 'rxjs';
+
+import { TASK_MANAGER_MODULE_DEFAULT_CONFIG } from
'@flink-runtime-web/pages/task-manager/task-manager.config';
+import { ConfigService, TaskManagerService } from
'@flink-runtime-web/services';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { TaskManagerThreadDumpComponent } from
'./task-manager-thread-dump.component';
+
+const cdr = { markForCheck: vi.fn() } as unknown as ChangeDetectorRef;
+const activatedRoute = {
+ parent: { snapshot: { params: { taskManagerId: 'tm-1' } } },
+ queryParams: of({ vertexName: 'Source%3A%20generator' })
+} as unknown as ActivatedRoute;
+
+describe('TaskManagerThreadDumpComponent', () => {
+ const loadThreadDump = vi.fn();
+ let component: TaskManagerThreadDumpComponent;
+
+ beforeEach(() => {
+ loadThreadDump.mockReset().mockReturnValue(of('thread dump\n'));
+ component = new TaskManagerThreadDumpComponent(
+ { loadThreadDump } as unknown as TaskManagerService,
+ { BASE_URL: '/api' } as unknown as ConfigService,
+ activatedRoute,
+ cdr,
+ TASK_MANAGER_MODULE_DEFAULT_CONFIG
+ );
+ });
+
+ it('derives ids and download target and decodes the vertex name on init', ()
=> {
+ component.ngOnInit();
+
+ expect(component.taskManagerId).toBe('tm-1');
+ expect(component.downloadName).toBe('taskmanager_tm-1_thread_dump');
+ expect(component.downloadUrl).toBe('/api/taskmanagers/tm-1/thread-dump');
+ expect(component.vertexName).toBe('Source: generator');
+ });
+
+ it('loads the dump with the current mode via reload', () => {
+ component.ngOnInit();
+
+ component.reload();
+
+ expect(loadThreadDump).toHaveBeenCalledWith('tm-1', undefined);
+ expect(component.dump).toBe('thread dump\n');
+ expect(component.loading).toBe(false);
+ });
+
+ it('updates mode and download url on selection and reloads with that mode',
() => {
+ component.ngOnInit();
+
+ component.selectMode('full');
+ expect(component.mode).toBe('full');
+
expect(component.downloadUrl).toBe('/api/taskmanagers/tm-1/thread-dump?mode=full');
+
+ component.reload();
+ expect(loadThreadDump).toHaveBeenLastCalledWith('tm-1', 'full');
+ });
+
+ it('falls back to an empty dump when the request fails', () => {
+ loadThreadDump.mockReturnValue(throwError(() => new Error('unavailable')));
+ component.ngOnInit();
+
+ component.reload();
+
+ expect(component.dump).toBe('');
+ expect(component.loading).toBe(false);
+ });
+});
diff --git
a/flink-runtime-web/web-dashboard/src/app/services/config.service.spec.ts
b/flink-runtime-web/web-dashboard/src/app/services/config.service.spec.ts
new file mode 100644
index 00000000000..4324649b16b
--- /dev/null
+++ b/flink-runtime-web/web-dashboard/src/app/services/config.service.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 { ColorKey, ConfigService } from './config.service';
+
+// Every status the UI can render a badge/timeline bar for. A key missing from
COLOR_MAP would
+// render blank instead of coloured, so this list guards against a status
being added without one.
+const COLOR_KEYS: ColorKey[] = [
+ 'TOTAL',
+ 'RUNNING',
+ 'FAILED',
+ 'FINISHED',
+ 'CANCELED',
+ 'CANCELING',
+ 'CREATED',
+ 'DEPLOYING',
+ 'RECONCILING',
+ 'IN_PROGRESS',
+ 'SCHEDULED',
+ 'COMPLETED',
+ 'RESTARTING',
+ 'PENDING',
+ 'INITIALIZING',
+ 'IGNORED'
+];
+
+describe('ConfigService', () => {
+ const service = new ConfigService();
+
+ it('defaults the REST base url to the current origin', () => {
+ expect(service.BASE_URL).toBe('.');
+ });
+
+ it('maps every known status key to a hex colour with no extras', () => {
+
expect(Object.keys(service.COLOR_MAP).sort()).toEqual([...COLOR_KEYS].sort());
+ for (const key of COLOR_KEYS) {
+ expect(service.COLOR_MAP[key]).toMatch(/^#[0-9a-f]{6}$/i);
+ }
+ });
+});
diff --git
a/flink-runtime-web/web-dashboard/src/app/services/job-manager.service.spec.ts
b/flink-runtime-web/web-dashboard/src/app/services/job-manager.service.spec.ts
new file mode 100644
index 00000000000..c0e370a5b83
--- /dev/null
+++
b/flink-runtime-web/web-dashboard/src/app/services/job-manager.service.spec.ts
@@ -0,0 +1,208 @@
+/*
+ * 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 { ClusterConfiguration, EnvironmentInfo, JobManagerLogItem } from
'@flink-runtime-web/interfaces';
+import { ProfilingDetail, ProfilingList } from
'@flink-runtime-web/interfaces/job-profiler';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { ConfigService } from './config.service';
+import { JobManagerService } from './job-manager.service';
+
+describe('JobManagerService', () => {
+ let httpClient: { get: ReturnType<typeof vi.fn>; post: ReturnType<typeof
vi.fn> };
+ let service: JobManagerService;
+
+ beforeEach(() => {
+ httpClient = { get: vi.fn(), post: vi.fn() };
+ service = new JobManagerService(httpClient as unknown as HttpClient, new
ConfigService());
+ });
+
+ describe('loadConfig', () => {
+ it('passes the cluster configuration through', async () => {
+ const config = [{ key: 'k', value: 'v' }] as ClusterConfiguration[];
+ httpClient.get.mockReturnValue(of(config));
+
+ const result = await firstValueFrom(service.loadConfig());
+
+ expect(httpClient.get).toHaveBeenCalledWith('./jobmanager/config');
+ expect(result).toBe(config);
+ });
+ });
+
+ describe('loadEnvironment', () => {
+ it('passes the environment info through', async () => {
+ const env = { jvm: {} } as unknown as EnvironmentInfo;
+ httpClient.get.mockReturnValue(of(env));
+
+ const result = await firstValueFrom(service.loadEnvironment());
+
+ expect(httpClient.get).toHaveBeenCalledWith('./jobmanager/environment');
+ expect(result).toBe(env);
+ });
+ });
+
+ describe('loadLogs / loadStdout', () => {
+ it('requests the plain log text with a no-cache header', async () => {
+ httpClient.get.mockReturnValue(of('log contents'));
+
+ const result = await firstValueFrom(service.loadLogs());
+
+ expect(httpClient.get).toHaveBeenCalledWith('./jobmanager/log',
expect.anything());
+ expect(result).toBe('log contents');
+ });
+
+ it('requests the plain stdout text', async () => {
+ httpClient.get.mockReturnValue(of('stdout contents'));
+
+ const result = await firstValueFrom(service.loadStdout());
+
+ expect(httpClient.get).toHaveBeenCalledWith('./jobmanager/stdout',
expect.anything());
+ expect(result).toBe('stdout contents');
+ });
+ });
+
+ describe('loadLogList', () => {
+ it('extracts the logs array from the response', async () => {
+ const log = { name: 'jobmanager.log' } as JobManagerLogItem;
+ httpClient.get.mockReturnValue(of({ logs: [log] }));
+
+ const result = await firstValueFrom(service.loadLogList());
+
+ expect(httpClient.get).toHaveBeenCalledWith('./jobmanager/logs');
+ expect(result).toEqual([log]);
+ });
+ });
+
+ describe('loadLog', () => {
+ it('pairs the raw log text with the request url', async () => {
+ httpClient.get.mockReturnValue(of('log contents'));
+
+ const result = await firstValueFrom(service.loadLog('jobmanager.log'));
+
+ expect(result.data).toBe('log contents');
+ expect(result.url).toBe('./jobmanager/logs/jobmanager.log');
+ });
+ });
+
+ describe('loadThreadDump', () => {
+ it('joins the stringified thread infos into a single dump', async () => {
+ httpClient.get.mockReturnValue(
+ of({ threadInfos: [{ stringifiedThreadInfo: 'thread-A\n' }, {
stringifiedThreadInfo: 'thread-B\n' }] })
+ );
+
+ const result = await firstValueFrom(service.loadThreadDump());
+
+ expect(httpClient.get).toHaveBeenCalledWith('./jobmanager/thread-dump');
+ expect(result).toBe('thread-A\nthread-B\n');
+ });
+
+ it('appends the mode query parameter when provided', async () => {
+ httpClient.get.mockReturnValue(of({ threadInfos: [] }));
+
+ await firstValueFrom(service.loadThreadDump('full'));
+
+
expect(httpClient.get).toHaveBeenCalledWith('./jobmanager/thread-dump?mode=full');
+ });
+ });
+
+ describe('loadMetricsName', () => {
+ it('maps the metric descriptors down to their ids', async () => {
+ httpClient.get.mockReturnValue(of([{ id: 'Status.JVM.CPU.Load' }, { id:
'Status.JVM.Memory.Heap.Used' }]));
+
+ const result = await firstValueFrom(service.loadMetricsName());
+
+ expect(result).toEqual(['Status.JVM.CPU.Load',
'Status.JVM.Memory.Heap.Used']);
+ });
+ });
+
+ describe('loadMetrics', () => {
+ it('parses the metric values into a numeric map keyed by id', async () => {
+ httpClient.get.mockReturnValue(of([{ id: 'Status.JVM.CPU.Load', value:
'0.42' }]));
+
+ const result = await
firstValueFrom(service.loadMetrics(['Status.JVM.CPU.Load']));
+
+ expect(httpClient.get).toHaveBeenCalledWith('./jobmanager/metrics', {
params: { get: 'Status.JVM.CPU.Load' } });
+ expect(result).toEqual({ 'Status.JVM.CPU.Load': 0.42 });
+ });
+ });
+
+ describe('loadHistoryServerConfig / loadHistoryServerEnvironment /
loadHistoryServerJobManagerLogUrl', () => {
+ it('requests the history-server cluster configuration for a job', async ()
=> {
+ const config = [] as ClusterConfiguration[];
+ httpClient.get.mockReturnValue(of(config));
+
+ const result = await
firstValueFrom(service.loadHistoryServerConfig('job-1'));
+
+
expect(httpClient.get).toHaveBeenCalledWith('./jobs/job-1/jobmanager/config');
+ expect(result).toBe(config);
+ });
+
+ it('requests the history-server environment for a job', async () => {
+ const env = {} as EnvironmentInfo;
+ httpClient.get.mockReturnValue(of(env));
+
+ const result = await
firstValueFrom(service.loadHistoryServerEnvironment('job-1'));
+
+
expect(httpClient.get).toHaveBeenCalledWith('./jobs/job-1/jobmanager/environment');
+ expect(result).toBe(env);
+ });
+
+ it('extracts the history-server log url for a job', async () => {
+ httpClient.get.mockReturnValue(of({ url: 'http://history/log' }));
+
+ const result = await
firstValueFrom(service.loadHistoryServerJobManagerLogUrl('job-1'));
+
+
expect(httpClient.get).toHaveBeenCalledWith('./jobs/job-1/jobmanager/log-url');
+ expect(result).toBe('http://history/log');
+ });
+ });
+
+ describe('profiling', () => {
+ it('passes the profiling list through', async () => {
+ const list = { profiling: [] } as unknown as ProfilingList;
+ httpClient.get.mockReturnValue(of(list));
+
+ const result = await firstValueFrom(service.loadProfilingList());
+
+ expect(httpClient.get).toHaveBeenCalledWith('./jobmanager/profiler');
+ expect(result).toBe(list);
+ });
+
+ it('posts the requested profiling mode and duration', async () => {
+ const detail = { status: 'RUNNING' } as ProfilingDetail;
+ httpClient.post.mockReturnValue(of(detail));
+
+ const result = await
firstValueFrom(service.createProfilingInstance('ITIMER', 30));
+
+ expect(httpClient.post).toHaveBeenCalledWith('./jobmanager/profiler', {
mode: 'ITIMER', duration: 30 });
+ expect(result).toBe(detail);
+ });
+
+ it('pairs the raw profiling result text with the request url', async () =>
{
+ httpClient.get.mockReturnValue(of('profile contents'));
+
+ const result = await
firstValueFrom(service.loadProfilingResult('profile.jfr'));
+
+ expect(result.data).toBe('profile contents');
+ expect(result.url).toBe('./jobmanager/profiler/profile.jfr');
+ });
+ });
+});
diff --git
a/flink-runtime-web/web-dashboard/src/app/services/job.service.spec.ts
b/flink-runtime-web/web-dashboard/src/app/services/job.service.spec.ts
new file mode 100644
index 00000000000..9da4661ed15
--- /dev/null
+++ b/flink-runtime-web/web-dashboard/src/app/services/job.service.spec.ts
@@ -0,0 +1,169 @@
+/*
+ * 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 { JobDetail, JobOverview } from '@flink-runtime-web/interfaces';
+import { JobResourceRequirements } from
'@flink-runtime-web/interfaces/job-resource-requirements';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { ConfigService } from './config.service';
+import { JobService } from './job.service';
+
+describe('JobService', () => {
+ let httpClient: { get: ReturnType<typeof vi.fn>; put: ReturnType<typeof
vi.fn> };
+ let service: JobService;
+
+ beforeEach(() => {
+ httpClient = { get: vi.fn(), put: vi.fn() };
+ service = new JobService(httpClient as unknown as HttpClient, new
ConfigService());
+ });
+
+ describe('loadJobs', () => {
+ it('uppercases task-state keys, folds in pending operators, and derives
the completed flag', async () => {
+ const overview = {
+ jobs: [
+ { jid: 'job-1', state: 'RUNNING', tasks: { running: 2, finished: 1
}, 'pending-operators': 4 },
+ { jid: 'job-2', state: 'FINISHED', tasks: {} }
+ ]
+ } as unknown as JobOverview;
+ httpClient.get.mockReturnValue(of(overview));
+
+ const result = await firstValueFrom(service.loadJobs());
+
+ expect(httpClient.get).toHaveBeenCalledWith('./jobs/overview');
+ expect(result[0].tasks).toEqual({ RUNNING: 2, FINISHED: 1, PENDING: 4 });
+ expect(result[0].completed).toBe(false);
+ // No pending-operators field falls back to 0, and FINISHED counts as
completed.
+ expect(result[1].tasks).toEqual({ PENDING: 0 });
+ expect(result[1].completed).toBe(true);
+ });
+
+ it('swallows request errors into an empty stream', async () => {
+ httpClient.get.mockReturnValue(throwError(() => new Error('boom')));
+
+ const emissions = await
firstValueFrom(service.loadJobs().pipe(toArray()));
+
+ expect(emissions).toEqual([]);
+ });
+ });
+
+ describe('loadJob', () => {
+ it('builds graph nodes/links from the plan and stream graph and sorts
nodes by vertex order', async () => {
+ const job = {
+ jid: 'job-1',
+ vertices: [{ id: 'v1' }, { id: 'v2' }],
+ plan: {
+ jid: 'job-1',
+ // Deliberately out of vertex order so the sort has something to do.
+ nodes: [{ id: 'v2', inputs: [{ id: 'v1' }] }, { id: 'v1' }]
+ },
+ 'stream-graph': { nodes: [{ id: 'sn1', inputs: [{ id: 'sn0' }] }, {
id: 'sn0' }] },
+ 'status-counts': {},
+ 'pending-operators': 3
+ } as unknown as JobDetail;
+ httpClient.get.mockReturnValue(of(job));
+
+ const result = await firstValueFrom(service.loadJob('job-1'));
+
+ expect(httpClient.get).toHaveBeenCalledWith('./jobs/job-1');
+ expect(result.plan.nodes.map(n => n.id)).toEqual(['v1', 'v2']);
+ expect(result.plan.nodes[0]).toMatchObject({ id: 'v1', job_vertex_id:
'v1', detail: { id: 'v1' } });
+ expect(result.plan.links).toEqual([{ id: 'v1-v2', source: 'v1', target:
'v2' }]);
+ expect(result.plan.streamNodes.map(n => n.id)).toEqual(['sn1', 'sn0']);
+ expect(result.plan.streamLinks).toEqual([{ id: 'sn0-sn1', source: 'sn0',
target: 'sn1' }]);
+ // The stream graph carries pending operators into the status counts.
+ expect(result['status-counts']['PENDING']).toBe(3);
+ });
+
+ it('swallows request errors into an empty stream', async () => {
+ httpClient.get.mockReturnValue(throwError(() => new Error('boom')));
+
+ const emissions = await
firstValueFrom(service.loadJob('job-1').pipe(toArray()));
+
+ expect(emissions).toEqual([]);
+ });
+ });
+
+ describe('loadAccumulators', () => {
+ it('combines the user and subtask accumulator responses', async () => {
+ httpClient.get
+ .mockReturnValueOnce(of({ 'user-accumulators': [{ name: 'records' }]
}))
+ .mockReturnValueOnce(of({ subtasks: [{ subtask: 0 }] }));
+
+ const result = await firstValueFrom(service.loadAccumulators('job-1',
'v1'));
+
+ expect(httpClient.get).toHaveBeenNthCalledWith(1,
'./jobs/job-1/vertices/v1/accumulators');
+ expect(httpClient.get).toHaveBeenNthCalledWith(2,
'./jobs/job-1/vertices/v1/subtasks/accumulators');
+ expect(result).toEqual({ main: [{ name: 'records' }], subtasks: [{
subtask: 0 }] });
+ });
+ });
+
+ describe('changeDesiredParallelism', () => {
+ it('raises the upper bound only for the requested vertices and puts the
updated requirements', async () => {
+ const requirements: JobResourceRequirements = {
+ v1: { parallelism: { lowerBound: 1, upperBound: 2 } },
+ v2: { parallelism: { lowerBound: 1, upperBound: 2 } }
+ };
+ httpClient.get.mockReturnValue(of(requirements));
+ httpClient.put.mockReturnValue(of(undefined));
+
+ await firstValueFrom(service.changeDesiredParallelism('job-1', new
Map([['v1', 4]])));
+
+
expect(httpClient.put).toHaveBeenCalledWith('./jobs/job-1/resource-requirements',
{
+ v1: { parallelism: { lowerBound: 1, upperBound: 4 } },
+ v2: { parallelism: { lowerBound: 1, upperBound: 2 } }
+ });
+ });
+ });
+
+ describe('URL construction', () => {
+ it('cancels a job through the yarn-cancel handler', async () => {
+ httpClient.get.mockReturnValue(of(undefined));
+
+ await firstValueFrom(service.cancelJob('job-1'));
+
+ expect(httpClient.get).toHaveBeenCalledWith('./jobs/job-1/yarn-cancel');
+ });
+
+ it('passes the max-exceptions cap as a query parameter', async () => {
+ httpClient.get.mockReturnValue(of({}));
+
+ await firstValueFrom(service.loadExceptions('job-1', 25));
+
+
expect(httpClient.get).toHaveBeenCalledWith('./jobs/job-1/exceptions?maxExceptions=25');
+ });
+
+ it('embeds the checkpoint id in the details path', async () => {
+ httpClient.get.mockReturnValue(of({}));
+
+ await firstValueFrom(service.loadCheckpointDetails('job-1', 7));
+
+
expect(httpClient.get).toHaveBeenCalledWith('./jobs/job-1/checkpoints/details/7');
+ });
+
+ it('passes both the flame-graph type and subtask index', async () => {
+ httpClient.get.mockReturnValue(of({}));
+
+ await
firstValueFrom(service.loadOperatorFlameGraphForSingleSubtask('job-1', 'v1',
'ON_CPU', '3'));
+
+
expect(httpClient.get).toHaveBeenCalledWith('./jobs/job-1/vertices/v1/flamegraph?type=ON_CPU&subtaskindex=3');
+ });
+ });
+});
diff --git
a/flink-runtime-web/web-dashboard/src/app/services/overview.service.spec.ts
b/flink-runtime-web/web-dashboard/src/app/services/overview.service.spec.ts
new file mode 100644
index 00000000000..0f271ae62ab
--- /dev/null
+++ b/flink-runtime-web/web-dashboard/src/app/services/overview.service.spec.ts
@@ -0,0 +1,54 @@
+/*
+ * 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 { Overview } from '@flink-runtime-web/interfaces';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { ConfigService } from './config.service';
+import { OverviewService } from './overview.service';
+
+describe('OverviewService', () => {
+ let httpClient: { get: ReturnType<typeof vi.fn> };
+ let service: OverviewService;
+
+ beforeEach(() => {
+ httpClient = { get: vi.fn() };
+ service = new OverviewService(httpClient as unknown as HttpClient, new
ConfigService());
+ });
+
+ it('passes the cluster overview through on success', async () => {
+ const overview = { 'slots-total': 12 } as Overview;
+ httpClient.get.mockReturnValue(of(overview));
+
+ const result = await firstValueFrom(service.loadOverview());
+
+ expect(httpClient.get).toHaveBeenCalledWith('./overview');
+ expect(result).toBe(overview);
+ });
+
+ it('swallows request errors into an empty stream', async () => {
+ httpClient.get.mockReturnValue(throwError(() => new Error('cluster
unreachable')));
+
+ const emissions = await
firstValueFrom(service.loadOverview().pipe(toArray()));
+
+ expect(emissions).toEqual([]);
+ });
+});
diff --git
a/flink-runtime-web/web-dashboard/src/app/services/status.service.spec.ts
b/flink-runtime-web/web-dashboard/src/app/services/status.service.spec.ts
new file mode 100644
index 00000000000..40186164efe
--- /dev/null
+++ b/flink-runtime-web/web-dashboard/src/app/services/status.service.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 { HttpClient } from '@angular/common/http';
+import { ChangeDetectorRef } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import { Router } from '@angular/router';
+import { EMPTY, firstValueFrom, of } from 'rxjs';
+
+import { Configuration } from '@flink-runtime-web/interfaces';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { ConfigService } from './config.service';
+import { StatusService } from './status.service';
+
+const mockConfig = { 'refresh-interval': 1000, features: {}, 'flink-version':
'2.4', 'flink-revision': 'abc' };
+
+describe('StatusService', () => {
+ let service: StatusService;
+ const get = vi.fn();
+
+ beforeEach(() => {
+ get.mockReset().mockReturnValue(of(mockConfig));
+ TestBed.configureTestingModule({
+ providers: [
+ StatusService,
+ { provide: HttpClient, useValue: { get } },
+ { provide: ConfigService, useValue: new ConfigService() },
+ { provide: Router, useValue: { events: EMPTY } }
+ ]
+ });
+ service = TestBed.inject(StatusService);
+ });
+
+ it('starts with no cached errors and a zero network-failure count', () => {
+ expect(service.listOfErrorMessage).toEqual([]);
+ expect(service.networkFailureCount).toBe(0);
+ expect(service.networkFailureThreshold).toBe(5);
+ expect(service.networkErrorNotificationId).toBeNull();
+ });
+
+ it('forwards markAppForCheck to the registered change detector', () => {
+ const markForCheck = vi.fn();
+ service.registerAppCdr({ markForCheck } as unknown as ChangeDetectorRef);
+
+ service.markAppForCheck();
+
+ expect(markForCheck).toHaveBeenCalledOnce();
+ });
+
+ it('is a no-op when markAppForCheck runs before any detector is registered',
() => {
+ expect(() => service.markAppForCheck()).not.toThrow();
+ });
+
+ it('caches the fetched configuration on boot', async () => {
+ const result = await firstValueFrom(service.boot());
+
+ expect(get).toHaveBeenCalledWith('./config');
+ expect(result).toEqual(mockConfig);
+ expect(service.configuration).toEqual(mockConfig as unknown as
Configuration);
+ });
+
+ it('emits on the refresh stream once it is wired up by boot', async () => {
+ vi.useFakeTimers();
+ try {
+ await firstValueFrom(service.boot());
+
+ const emitted: boolean[] = [];
+ const sub = service.refresh$.subscribe(value => emitted.push(value));
+ // The merged trigger stream is debounced by 300ms before the interval
kicks in.
+ vi.advanceTimersByTime(300);
+ sub.unsubscribe();
+
+ expect(emitted).toContain(true);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+});