This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-6570-547d5051aade41c2fb0a38c6c0d714967a6c502f in repository https://gitbox.apache.org/repos/asf/texera.git
commit 8937f71c616ae2beb3606a43c8c38fe907380987 Author: Meng Wang <[email protected]> AuthorDate: Sun Jul 19 23:05:48 2026 -0700 test(frontend): add unit test coverage for WorkflowResultExportService, WorkflowExecutionsService, and areOperatorSchemasEqual (#6570) ### What changes were proposed in this PR? Adds/extends Vitest coverage for three previously-thin logic-layer targets (28 tests total, all jsdom, no browser mode): - **`areOperatorSchemasEqual`** (`workspace/types/operator-schema.interface.ts`, was ~9%, no spec) — new pure-function spec (no TestBed). Covers identical schemas → `true`, and `false` for differences in `operatorType` / `operatorVersion` / nested `jsonSchema` fields / `jsonSchema` array order / each `additionalMetadata` field / input & output port count, `displayName`, `disallowMultiLinks`, and port array order. - **`WorkflowResultExportService`** (`workspace/service/workflow-result-export/`, was ~42%, creation-only spec) — extended with `resetFlags` (resets the export-availability flags to defaults), plus a new suite for the exported `WorkflowResultDownloadability` helper class: `getExportableOperatorIds` / `getBlockedOperatorIds` partition operators against the restricted map, and `getBlockingDatasets` returns the deduped union of blocking labels. The HTTP/side-effecting `performExport` / `exportWorkflowExecutionResult` paths are intentionally out of scope. - **`WorkflowExecutionsService`** (`dashboard/service/user/workflow-executions/`, was ~33%, `it.todo` stub) — replaced the stub with `HttpClientTestingModule` + `HttpTestingController` tests asserting method / URL / body / query params for `retrieveLatestWorkflowExecution`, `retrieveWorkflowExecutions` (with and without a status filter), `groupSetIsBookmarked`, `groupDeleteWorkflowExecutions`, `updateWorkflowExecutionsName`, and `retrieveWorkflowRuntimeStatistics`. All tests are deterministic (pure functions, synchronous `of(...)`/flushed HTTP, `httpMock.verify()` in `afterEach`). No production code was changed. ### Any related issues, documentation, discussions? Closes #6567 ### How was this PR tested? New/extended unit tests, run locally in `frontend/` (all green; each file's failure path was verified by breaking an assertion to confirm the suite goes red): ``` ng test --watch=false --include src/app/workspace/types/operator-schema.interface.spec.ts # 14 passed ng test --watch=false --include src/app/workspace/service/workflow-result-export/workflow-result-export.service.spec.ts # 7 passed ng test --watch=false --include src/app/dashboard/service/user/workflow-executions/workflow-executions.service.spec.ts # 7 passed eslint <specs> # clean prettier --check # clean ``` ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Opus 4.8 [1M context]) --- .../workflow-executions.service.spec.ts | 94 ++++++++++++++- .../workflow-result-export.service.spec.ts | 50 +++++++- .../types/operator-schema.interface.spec.ts | 133 +++++++++++++++++++++ 3 files changed, 272 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/dashboard/service/user/workflow-executions/workflow-executions.service.spec.ts b/frontend/src/app/dashboard/service/user/workflow-executions/workflow-executions.service.spec.ts index 69bc54c80d..66d89ece5c 100644 --- a/frontend/src/app/dashboard/service/user/workflow-executions/workflow-executions.service.spec.ts +++ b/frontend/src/app/dashboard/service/user/workflow-executions/workflow-executions.service.spec.ts @@ -17,9 +17,95 @@ * under the License. */ +import { TestBed } from "@angular/core/testing"; +import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing"; +import { WORKFLOW_EXECUTIONS_API_BASE_URL, WorkflowExecutionsService } from "./workflow-executions.service"; +import { ExecutionState } from "../../../../workspace/types/execute-workflow.interface"; +import { WorkflowExecutionsEntry } from "../../../type/workflow-executions-entry"; +import { WorkflowRuntimeStatistics } from "../../../type/workflow-runtime-statistics"; + describe("WorkflowExecutionsService", () => { - // This spec was created without test bodies. The placeholder below keeps - // Vitest's discovery happy so the file compiles cleanly; real tests for - // WorkflowExecutionsService are tracked in #4861. - it.todo("add unit tests for WorkflowExecutionsService"); + let service: WorkflowExecutionsService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [WorkflowExecutionsService], + }); + service = TestBed.inject(WorkflowExecutionsService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it("retrieveLatestWorkflowExecution issues GET to the latest url", () => { + let result: WorkflowExecutionsEntry | undefined; + service.retrieveLatestWorkflowExecution(1).subscribe(res => (result = res)); + + const req = httpMock.expectOne(`${WORKFLOW_EXECUTIONS_API_BASE_URL}/1/latest`); + expect(req.request.method).toBe("GET"); + const entry = { eId: 7 } as unknown as WorkflowExecutionsEntry; + req.flush(entry); + expect(result).toEqual(entry); + }); + + it("retrieveWorkflowExecutions issues GET without a status param when no statuses are given", () => { + service.retrieveWorkflowExecutions(1).subscribe(); + + const req = httpMock.expectOne(r => r.url === `${WORKFLOW_EXECUTIONS_API_BASE_URL}/1`); + expect(req.request.method).toBe("GET"); + expect(req.request.params.has("status")).toBe(false); + req.flush([]); + }); + + it("retrieveWorkflowExecutions sets the status param when statuses are given", () => { + service.retrieveWorkflowExecutions(1, [ExecutionState.Running, ExecutionState.Completed]).subscribe(); + + const req = httpMock.expectOne(r => r.url === `${WORKFLOW_EXECUTIONS_API_BASE_URL}/1`); + expect(req.request.method).toBe("GET"); + expect(req.request.params.get("status")).toBe([ExecutionState.Running, ExecutionState.Completed].join(",")); + req.flush([]); + }); + + it("groupSetIsBookmarked issues PUT with the bookmark payload", () => { + service.groupSetIsBookmarked(1, [10, 11], true).subscribe(); + + const req = httpMock.expectOne(`${WORKFLOW_EXECUTIONS_API_BASE_URL}/set_execution_bookmarks`); + expect(req.request.method).toBe("PUT"); + expect(req.request.body).toEqual({ wid: 1, eIds: [10, 11], isBookmarked: true }); + req.flush({}); + }); + + it("groupDeleteWorkflowExecutions issues PUT with the delete payload", () => { + service.groupDeleteWorkflowExecutions(1, [10, 11]).subscribe(); + + const req = httpMock.expectOne(`${WORKFLOW_EXECUTIONS_API_BASE_URL}/delete_executions`); + expect(req.request.method).toBe("PUT"); + expect(req.request.body).toEqual({ wid: 1, eIds: [10, 11] }); + req.flush({}); + }); + + it("updateWorkflowExecutionsName issues POST with the rename payload", () => { + service.updateWorkflowExecutionsName(1, 10, "renamed").subscribe(); + + const req = httpMock.expectOne(`${WORKFLOW_EXECUTIONS_API_BASE_URL}/update_execution_name`); + expect(req.request.method).toBe("POST"); + expect(req.request.body).toEqual({ wid: 1, eId: 10, executionName: "renamed" }); + req.flush({}); + }); + + it("retrieveWorkflowRuntimeStatistics issues GET with the cuid param", () => { + let result: WorkflowRuntimeStatistics[] | undefined; + service.retrieveWorkflowRuntimeStatistics(1, 10, 5).subscribe(res => (result = res)); + + const req = httpMock.expectOne(r => r.url === `${WORKFLOW_EXECUTIONS_API_BASE_URL}/1/stats/10`); + expect(req.request.method).toBe("GET"); + expect(req.request.params.get("cuid")).toBe("5"); + const stats: WorkflowRuntimeStatistics[] = []; + req.flush(stats); + expect(result).toEqual(stats); + }); }); diff --git a/frontend/src/app/workspace/service/workflow-result-export/workflow-result-export.service.spec.ts b/frontend/src/app/workspace/service/workflow-result-export/workflow-result-export.service.spec.ts index 01900ccffb..e53d8ca0ab 100644 --- a/frontend/src/app/workspace/service/workflow-result-export/workflow-result-export.service.spec.ts +++ b/frontend/src/app/workspace/service/workflow-result-export/workflow-result-export.service.spec.ts @@ -18,7 +18,7 @@ */ import { TestBed } from "@angular/core/testing"; -import { WorkflowResultExportService } from "./workflow-result-export.service"; +import { WorkflowResultDownloadability, WorkflowResultExportService } from "./workflow-result-export.service"; import { HttpClientTestingModule } from "@angular/common/http/testing"; import { WorkflowWebsocketService } from "../workflow-websocket/workflow-websocket.service"; import { WorkflowActionService } from "../workflow-graph/model/workflow-action.service"; @@ -126,4 +126,52 @@ describe("WorkflowResultExportService", () => { it("should be created", () => { expect(service).toBeTruthy(); }); + + it("resetFlags clears the highlighted flag and resets the all-operators subject to false", () => { + service.hasResultToExportOnHighlightedOperators = true; + service.hasResultToExportOnAllOperators.next(true); + + service.resetFlags(); + + expect(service.hasResultToExportOnHighlightedOperators).toBe(false); + expect(service.hasResultToExportOnAllOperators.value).toBe(false); + }); +}); + +describe("WorkflowResultDownloadability", () => { + const restrictedMap = new Map<string, Set<string>>([ + ["opB", new Set(["ds1 ([email protected])"])], + ["opD", new Set(["ds2 ([email protected])", "ds1 ([email protected])"])], + ]); + const ids = ["opA", "opB", "opC", "opD"]; + + it("getExportableOperatorIds keeps only operators absent from the restricted map", () => { + const downloadability = new WorkflowResultDownloadability(restrictedMap); + expect(downloadability.getExportableOperatorIds(ids)).toEqual(["opA", "opC"]); + }); + + it("getBlockedOperatorIds keeps only operators present in the restricted map", () => { + const downloadability = new WorkflowResultDownloadability(restrictedMap); + expect(downloadability.getBlockedOperatorIds(ids)).toEqual(["opB", "opD"]); + }); + + it("getBlockingDatasets returns the deduped union of blocking dataset labels", () => { + const downloadability = new WorkflowResultDownloadability(restrictedMap); + const result = downloadability.getBlockingDatasets(["opB", "opD"]); + // ds1 is shared by opB and opD, so the union must be deduped (length 2, not 3). + // The return builds a Set, so assert membership order-independently. + expect(result).toHaveLength(2); + expect([...result].sort()).toEqual(["ds1 ([email protected])", "ds2 ([email protected])"]); + }); + + it("getBlockingDatasets returns an empty array for unrestricted operators", () => { + const downloadability = new WorkflowResultDownloadability(restrictedMap); + expect(downloadability.getBlockingDatasets(["opA", "opC"])).toEqual([]); + }); + + it("treats every operator as exportable when the restricted map is empty", () => { + const downloadability = new WorkflowResultDownloadability(new Map()); + expect(downloadability.getExportableOperatorIds(ids)).toEqual(ids); + expect(downloadability.getBlockedOperatorIds(ids)).toEqual([]); + }); }); diff --git a/frontend/src/app/workspace/types/operator-schema.interface.spec.ts b/frontend/src/app/workspace/types/operator-schema.interface.spec.ts new file mode 100644 index 0000000000..069f5bd78f --- /dev/null +++ b/frontend/src/app/workspace/types/operator-schema.interface.spec.ts @@ -0,0 +1,133 @@ +/** + * 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 { OperatorSchema, areOperatorSchemasEqual } from "./operator-schema.interface"; + +function schema( + overrides: { + operatorType?: string; + operatorVersion?: string; + jsonSchema?: object; + userFriendlyName?: string; + operatorGroupName?: string; + operatorDescription?: string; + supportReconfiguration?: boolean; + allowPortCustomization?: boolean; + inputPorts?: { displayName?: string; disallowMultiLinks?: boolean }[]; + outputPorts?: { displayName?: string }[]; + } = {} +): OperatorSchema { + return { + operatorType: overrides.operatorType ?? "ScanSource", + operatorVersion: overrides.operatorVersion ?? "1.0", + jsonSchema: (overrides.jsonSchema ?? { + type: "object", + properties: { a: { type: "string" }, b: { type: "number" } }, + }) as unknown as OperatorSchema["jsonSchema"], + additionalMetadata: { + userFriendlyName: overrides.userFriendlyName ?? "Scan", + operatorGroupName: overrides.operatorGroupName ?? "Source", + operatorDescription: overrides.operatorDescription ?? "reads data", + inputPorts: overrides.inputPorts ?? [{ displayName: "in0", disallowMultiLinks: false }], + outputPorts: overrides.outputPorts ?? [{ displayName: "out0" }], + supportReconfiguration: overrides.supportReconfiguration ?? false, + allowPortCustomization: overrides.allowPortCustomization ?? false, + }, + }; +} + +describe("areOperatorSchemasEqual", () => { + it("returns true for two identically-shaped schemas", () => { + expect(areOperatorSchemasEqual(schema(), schema())).toBe(true); + }); + + it("returns false when operatorType differs", () => { + expect(areOperatorSchemasEqual(schema(), schema({ operatorType: "CSVFileScan" }))).toBe(false); + }); + + it("returns false when operatorVersion differs", () => { + expect(areOperatorSchemasEqual(schema(), schema({ operatorVersion: "2.0" }))).toBe(false); + }); + + it("returns false when a nested jsonSchema field differs", () => { + const other = schema({ jsonSchema: { type: "object", properties: { a: { type: "integer" } } } }); + expect(areOperatorSchemasEqual(schema(), other)).toBe(false); + }); + + it("returns false when jsonSchema arrays differ only in order", () => { + const a = schema({ jsonSchema: { type: "object", required: ["a", "b"] } }); + const b = schema({ jsonSchema: { type: "object", required: ["b", "a"] } }); + expect(areOperatorSchemasEqual(a, b)).toBe(false); + }); + + it("returns false when additionalMetadata.userFriendlyName differs", () => { + expect(areOperatorSchemasEqual(schema(), schema({ userFriendlyName: "Scanner" }))).toBe(false); + }); + + it("returns false when additionalMetadata.operatorGroupName differs", () => { + expect(areOperatorSchemasEqual(schema(), schema({ operatorGroupName: "Analysis" }))).toBe(false); + }); + + it("returns false when a boolean metadata flag differs", () => { + expect(areOperatorSchemasEqual(schema(), schema({ supportReconfiguration: true }))).toBe(false); + }); + + it("returns false when additionalMetadata.operatorDescription differs", () => { + expect(areOperatorSchemasEqual(schema(), schema({ operatorDescription: "different" }))).toBe(false); + }); + + it("returns false when additionalMetadata.allowPortCustomization differs", () => { + expect(areOperatorSchemasEqual(schema(), schema({ allowPortCustomization: true }))).toBe(false); + }); + + it("returns false when the number of input ports differs", () => { + const other = schema({ inputPorts: [{ displayName: "in0" }, { displayName: "in1" }] }); + expect(areOperatorSchemasEqual(schema(), other)).toBe(false); + }); + + it("returns false when an input port's displayName differs", () => { + expect(areOperatorSchemasEqual(schema(), schema({ inputPorts: [{ displayName: "renamed" }] }))).toBe(false); + }); + + it("returns false when an input port's disallowMultiLinks differs", () => { + const other = schema({ inputPorts: [{ displayName: "in0", disallowMultiLinks: true }] }); + expect(areOperatorSchemasEqual(schema(), other)).toBe(false); + }); + + it("returns false when input ports match as a set but differ in order", () => { + const a = schema({ inputPorts: [{ displayName: "in0" }, { displayName: "in1" }] }); + const b = schema({ inputPorts: [{ displayName: "in1" }, { displayName: "in0" }] }); + expect(areOperatorSchemasEqual(a, b)).toBe(false); + }); + + it("returns false when the number of output ports differs", () => { + const other = schema({ outputPorts: [{ displayName: "out0" }, { displayName: "out1" }] }); + expect(areOperatorSchemasEqual(schema(), other)).toBe(false); + }); + + it("returns false when an output port's displayName differs", () => { + expect(areOperatorSchemasEqual(schema(), schema({ outputPorts: [{ displayName: "renamed" }] }))).toBe(false); + }); + + it("returns false when output ports match as a set but differ in order", () => { + const a = schema({ outputPorts: [{ displayName: "out0" }, { displayName: "out1" }] }); + const b = schema({ outputPorts: [{ displayName: "out1" }, { displayName: "out0" }] }); + expect(areOperatorSchemasEqual(a, b)).toBe(false); + }); +});
