You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@texera.apache.org by gi...@apache.org on 2026/07/20 00:28:23 UTC
(texera) 01/01: test(frontend): extend WorkflowCompilingService, ListItemComponent, and DownloadService coverage (#6557)
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-6557-7ef4e0397ac99cd78d23c1a0f11e2173b5ad50be
in repository https://gitbox.apache.org/repos/asf/texera.git
commit aa5a20bf546e1fa7e52318335770eb7e3d47fd54
Author: Meng Wang <me...@uci.edu>
AuthorDate: Sun Jul 19 17:28:16 2026 -0700
test(frontend): extend WorkflowCompilingService, ListItemComponent, and DownloadService coverage (#6557)
### What changes were proposed in this PR?
Extends three existing frontend specs to cover previously untested
methods
(all Vitest; no production code changed).
**`WorkflowCompilingService`** (+9 tests) —
`getWorkflowCompilationState`,
`getWorkflowCompilationErrors` (empty while succeeded/uninitialized,
surfaces
operator errors when failed), `getCompilationStateInfoChangedStream`
(replays
the latest state), `getOperatorOutputSchemaMap`, `getPortInputSchema`,
and
`getOperatorInputAttributeType`. The private compilation-state snapshot
is set
directly and the port-lookup delegates are spied so each getter's own
logic is
exercised.
**`ListItemComponent`** (+8 tests) — `onEditName`, `onEditDescription`
(opens the
edit modal and applies / closes the change; no-op when not editable),
`onCheckboxChange`, `toggleLike` (like / unlike success paths + the
no-user
guard), and `openDetailModal` (opens the modal and bumps the view
count).
`NzModalService` and `HubService` are spied.
**`DownloadService`** (+3 tests) — `exportWorkflowResultToDataset` (POST
body /
headers, plus the kubernetes `?cuid=` URL branch, via
`HttpTestingController`)
and `exportWorkflowResultToLocal` (builds and submits the hidden form
carrying
the request + token, then cleans up). The download form's `submit` and
the
cleanup `setTimeout` are stubbed so no real navigation or leaked timer
occurs.
### Any related issues, documentation, discussions?
Closes #6552
### How was this PR tested?
Extended unit tests, run locally in `frontend/` (all green; each spec's
failure
path was verified by breaking a new assertion to confirm it goes red):
```
ng test --watch=false --include src/app/workspace/service/compile-workflow/workflow-compiling.service.spec.ts # 16 passed
ng test --watch=false --include src/app/dashboard/component/user/list-item/list-item.component.spec.ts # 20 passed
ng test --watch=false --include src/app/dashboard/service/user/download/download.service.spec.ts # 17 passed (1 pre-existing skip)
prettier --write <specs> # clean
eslint <specs> # clean
```
All collaborators are stubbed, so the suite makes no network calls,
opens no
real modal, and triggers no real download (see `frontend/TESTING.md`).
### Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Opus 4.8 [1M context])
---
.../user/list-item/list-item.component.spec.ts | 105 ++++++++++++++-
.../service/user/download/download.service.spec.ts | 149 ++++++++++++++++++++-
.../workflow-compiling.service.spec.ts | 88 ++++++++++++
3 files changed, 340 insertions(+), 2 deletions(-)
diff --git a/frontend/src/app/dashboard/component/user/list-item/list-item.component.spec.ts b/frontend/src/app/dashboard/component/user/list-item/list-item.component.spec.ts
index 10b07b82ce..eca823a4ee 100644
--- a/frontend/src/app/dashboard/component/user/list-item/list-item.component.spec.ts
+++ b/frontend/src/app/dashboard/component/user/list-item/list-item.component.spec.ts
@@ -22,7 +22,8 @@ import { ListItemComponent } from "./list-item.component";
import { WorkflowPersistService } from "src/app/common/service/workflow-persist/workflow-persist.service";
import { HttpClientTestingModule } from "@angular/common/http/testing";
import { NzModalService } from "ng-zorro-antd/modal";
-import { of, throwError } from "rxjs";
+import { of, Subject, throwError } from "rxjs";
+import { ActionType, HubService } from "../../../../hub/service/hub.service";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { RouterTestingModule } from "@angular/router/testing";
import { StubUserService } from "../../../../common/service/user/stub-user.service";
@@ -45,6 +46,8 @@ describe("ListItemComponent", () => {
let fixture: ComponentFixture<ListItemComponent>;
let workflowPersistService: Mocked<WorkflowPersistService>;
let datasetService: Mocked<DatasetService>;
+ let hubService: HubService;
+ let modalService: NzModalService;
beforeEach(async () => {
const workflowPersistServiceSpy = { updateWorkflowName: vi.fn(), updateWorkflowDescription: vi.fn() };
@@ -65,6 +68,8 @@ describe("ListItemComponent", () => {
component = fixture.componentInstance;
workflowPersistService = TestBed.inject(WorkflowPersistService) as unknown as Mocked<WorkflowPersistService>;
datasetService = TestBed.inject(DatasetService) as unknown as Mocked<DatasetService>;
+ hubService = TestBed.inject(HubService);
+ modalService = TestBed.inject(NzModalService);
// initializeEntry() needs a fully-formed workflow entry to avoid throwing
// when the template renders for the first time. Each test below overwrites
// component.entry directly, which exercises confirm methods without going
@@ -247,4 +252,102 @@ describe("ListItemComponent", () => {
expect(component.entry.name).toBe("old-name");
expect(component.editingName).toBe(false);
});
+
+ describe("edit + interaction handlers", () => {
+ it("onEditName captures the original name and enters edit mode", () => {
+ component.entry = { id: 1, name: "My Name", type: "workflow" } as unknown as DashboardEntry;
+
+ component.onEditName();
+
+ expect(component.editingName).toBe(true);
+ expect(component.originalName).toBe("My Name");
+ });
+
+ it("onEditDescription opens the edit modal and applies the change, then closes it", () => {
+ component.editable = true;
+ component.entry = { id: 1, description: "old", type: "workflow" } as unknown as DashboardEntry;
+ const descriptionChange = new Subject<string>();
+ const modalRef = { componentInstance: { descriptionChange }, destroy: vi.fn() };
+ const createSpy = vi.spyOn(modalService, "create").mockReturnValue(modalRef as any);
+ const confirmSpy = vi.spyOn(component, "confirmUpdateCustomDescription").mockImplementation(() => {});
+
+ component.onEditDescription();
+ expect(createSpy).toHaveBeenCalled();
+
+ descriptionChange.next("new description");
+ expect(confirmSpy).toHaveBeenCalledWith("new description");
+ expect(modalRef.destroy).toHaveBeenCalled();
+ });
+
+ it("onEditDescription is a no-op when the entry is not editable", () => {
+ component.editable = false;
+ const createSpy = vi.spyOn(modalService, "create");
+
+ component.onEditDescription();
+
+ expect(createSpy).not.toHaveBeenCalled();
+ });
+
+ it("onCheckboxChange toggles the entry's checked flag and emits the change", () => {
+ const entry = { checked: false } as unknown as DashboardEntry;
+ const emitSpy = vi.fn();
+ component.checkboxChanged.subscribe(emitSpy);
+
+ component.onCheckboxChange(entry);
+
+ expect(entry.checked).toBe(true);
+ expect(emitSpy).toHaveBeenCalled();
+ });
+
+ it("toggleLike likes the entry and refreshes the like count on success", () => {
+ component.currentUid = 1;
+ component.entry = { id: 5, type: "workflow" } as unknown as DashboardEntry;
+ component.isLiked = false;
+ vi.spyOn(hubService, "postLike").mockReturnValue(of(true));
+ vi.spyOn(hubService, "getCounts").mockReturnValue(of([{ counts: { like: 3 } }] as any));
+
+ component.toggleLike();
+
+ expect(hubService.postLike).toHaveBeenCalledWith(5, "workflow");
+ expect(component.isLiked).toBe(true);
+ expect(component.likeCount).toBe(3);
+ });
+
+ it("toggleLike unlikes the entry and refreshes the like count on success", () => {
+ component.currentUid = 1;
+ component.entry = { id: 5, type: "workflow" } as unknown as DashboardEntry;
+ component.isLiked = true;
+ vi.spyOn(hubService, "postUnlike").mockReturnValue(of(true));
+ vi.spyOn(hubService, "getCounts").mockReturnValue(of([{ counts: { like: 1 } }] as any));
+
+ component.toggleLike();
+
+ expect(hubService.postUnlike).toHaveBeenCalledWith(5, "workflow");
+ expect(component.isLiked).toBe(false);
+ expect(component.likeCount).toBe(1);
+ });
+
+ it("toggleLike does nothing when there is no current user", () => {
+ component.currentUid = undefined;
+ component.entry = { id: 5, type: "workflow" } as unknown as DashboardEntry;
+ const postLikeSpy = vi.spyOn(hubService, "postLike");
+
+ component.toggleLike();
+
+ expect(postLikeSpy).not.toHaveBeenCalled();
+ });
+
+ it("openDetailModal opens the detail modal and increments the view count", () => {
+ component.entry = { id: 9, type: "workflow" } as unknown as DashboardEntry;
+ const modalRef = { componentInstance: {}, destroy: vi.fn() };
+ vi.spyOn(modalService, "create").mockReturnValue(modalRef as any);
+ vi.spyOn(hubService, "getCounts").mockReturnValue(of([{ counts: { view: 4 } }] as any));
+
+ component.openDetailModal(9);
+
+ expect(modalService.create).toHaveBeenCalled();
+ expect(hubService.getCounts).toHaveBeenCalledWith(["workflow"], [9], [ActionType.View]);
+ expect(component.viewCount).toBe(5); // 4 + 1
+ });
+ });
});
diff --git a/frontend/src/app/dashboard/service/user/download/download.service.spec.ts b/frontend/src/app/dashboard/service/user/download/download.service.spec.ts
index d0588665c1..30bd053381 100644
--- a/frontend/src/app/dashboard/service/user/download/download.service.spec.ts
+++ b/frontend/src/app/dashboard/service/user/download/download.service.spec.ts
@@ -19,7 +19,7 @@
import { TestBed } from "@angular/core/testing";
import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing";
-import { DownloadService } from "./download.service";
+import { DownloadService, EXPORT_BASE_URL } from "./download.service";
import { DatasetService } from "../dataset/dataset.service";
import { FileSaverService } from "../file/file-saver.service";
import { NotificationService } from "../../../../common/service/notification/notification.service";
@@ -27,6 +27,13 @@ import { WorkflowPersistService } from "../../../../common/service/workflow-pers
import { firstValueFrom, lastValueFrom, of, throwError } from "rxjs";
import { commonTestProviders } from "../../../../common/testing/test-utils";
import type { Mocked } from "vitest";
+import { WORKFLOW_EXECUTIONS_API_BASE_URL } from "../workflow-executions/workflow-executions.service";
+import { DashboardWorkflowComputingUnit } from "../../../../common/type/workflow-computing-unit";
+
+function computingUnit(type: string, cuid: number): DashboardWorkflowComputingUnit {
+ return { computingUnit: { cuid, type } } as unknown as DashboardWorkflowComputingUnit;
+}
+const EXPORT_OPERATORS = [{ id: "op1", outputType: "csv" }];
describe("DownloadService", () => {
let downloadService: DownloadService;
@@ -278,4 +285,144 @@ describe("DownloadService", () => {
const map = await promise;
expect(map).toEqual({ "op-1": ["my-dataset"], "op-2": [] });
});
+
+ // ─── exportWorkflowResultToDataset ────────────────────────────────────────
+
+ it("POSTs the dataset export request and returns the response body", async () => {
+ const promise = lastValueFrom(
+ downloadService.exportWorkflowResultToDataset(
+ "csv",
+ 1,
+ "WF",
+ EXPORT_OPERATORS,
+ [7],
+ 0,
+ 0,
+ "out.csv",
+ computingUnit("local", 5)
+ )
+ );
+
+ const req = httpMock.expectOne(`${WORKFLOW_EXECUTIONS_API_BASE_URL}/${EXPORT_BASE_URL}/dataset`);
+ expect(req.request.method).toBe("POST");
+ expect(req.request.body).toMatchObject({
+ exportType: "csv",
+ workflowId: 1,
+ datasetIds: [7],
+ filename: "out.csv",
+ computingUnitId: 5,
+ });
+ expect(req.request.headers.get("Accept")).toBe("application/json");
+ req.flush({ status: "ok", message: "done" });
+
+ const res = await promise;
+ expect(res.body).toEqual({ status: "ok", message: "done" });
+ });
+
+ it("appends the cuid query param for a kubernetes computing unit", () => {
+ downloadService
+ .exportWorkflowResultToDataset(
+ "csv",
+ 1,
+ "WF",
+ EXPORT_OPERATORS,
+ [7],
+ 0,
+ 0,
+ "out.csv",
+ computingUnit("kubernetes", 9)
+ )
+ .subscribe();
+
+ const req = httpMock.expectOne(`${WORKFLOW_EXECUTIONS_API_BASE_URL}/${EXPORT_BASE_URL}/dataset?cuid=9`);
+ expect(req.request.method).toBe("POST");
+ req.flush({ status: "ok", message: "done" });
+ });
+
+ // ─── exportWorkflowResultToLocal ──────────────────────────────────────────
+
+ describe("exportWorkflowResultToLocal", () => {
+ let submitSpy: ReturnType<typeof vi.spyOn>;
+ let setTimeoutSpy: ReturnType<typeof vi.spyOn>;
+ let cleanup: (() => void) | undefined;
+
+ beforeEach(() => {
+ // Stub form.submit (jsdom does not implement it) so we can assert it fired, and
+ // capture the 10s cleanup callback instead of scheduling a real timer that could
+ // fire during a later test (a leaked timer is flaky). Fake timers are avoided
+ // because they make localStorage unavailable in this environment.
+ submitSpy = vi.spyOn(HTMLFormElement.prototype, "submit").mockImplementation(() => {});
+ cleanup = undefined;
+ setTimeoutSpy = vi.spyOn(window, "setTimeout").mockImplementation((handler: TimerHandler) => {
+ cleanup = handler as () => void;
+ return 0 as unknown as ReturnType<typeof setTimeout>;
+ });
+ // localStorage is not available in this jsdom service-test environment; stub it so
+ // the method can read the auth token deterministically.
+ vi.stubGlobal("localStorage", {
+ getItem: vi.fn().mockReturnValue("tok-123"),
+ setItem: vi.fn(),
+ removeItem: vi.fn(),
+ });
+ });
+
+ afterEach(() => {
+ submitSpy.mockRestore();
+ setTimeoutSpy.mockRestore();
+ vi.unstubAllGlobals();
+ document
+ .querySelectorAll('form[target="download-iframe"], iframe[name="download-iframe"]')
+ .forEach(el => el.remove());
+ });
+
+ it("builds and submits a hidden form carrying the request and token, then cleans up on timeout", () => {
+ downloadService.exportWorkflowResultToLocal(
+ "csv",
+ 1,
+ "WF",
+ EXPORT_OPERATORS,
+ 0,
+ 0,
+ "out.csv",
+ computingUnit("local", 5)
+ );
+
+ const form = document.querySelector('form[target="download-iframe"]') as HTMLFormElement;
+ expect(form).toBeTruthy();
+ expect(form.getAttribute("action")).toBe(`${WORKFLOW_EXECUTIONS_API_BASE_URL}/${EXPORT_BASE_URL}/local`);
+ expect(form.method).toBe("post");
+ expect(submitSpy).toHaveBeenCalledTimes(1);
+
+ const requestInput = form.querySelector('input[name="request"]') as HTMLInputElement;
+ expect(JSON.parse(requestInput.value)).toMatchObject({
+ exportType: "csv",
+ workflowId: 1,
+ computingUnitId: 5,
+ datasetIds: [],
+ });
+ expect((form.querySelector('input[name="token"]') as HTMLInputElement).value).toBe("tok-123");
+
+ // Running the captured cleanup callback removes the form and the iframe.
+ expect(document.querySelector('iframe[name="download-iframe"]')).toBeTruthy();
+ cleanup?.();
+ expect(document.querySelector('form[target="download-iframe"]')).toBeNull();
+ expect(document.querySelector('iframe[name="download-iframe"]')).toBeNull();
+ });
+
+ it("targets the cuid-scoped endpoint for a kubernetes computing unit", () => {
+ downloadService.exportWorkflowResultToLocal(
+ "csv",
+ 1,
+ "WF",
+ EXPORT_OPERATORS,
+ 0,
+ 0,
+ "out.csv",
+ computingUnit("kubernetes", 9)
+ );
+
+ const form = document.querySelector('form[target="download-iframe"]') as HTMLFormElement;
+ expect(form.getAttribute("action")).toBe(`${WORKFLOW_EXECUTIONS_API_BASE_URL}/${EXPORT_BASE_URL}/local?cuid=9`);
+ });
+ });
});
diff --git a/frontend/src/app/workspace/service/compile-workflow/workflow-compiling.service.spec.ts b/frontend/src/app/workspace/service/compile-workflow/workflow-compiling.service.spec.ts
index b456205f22..78f6923665 100644
--- a/frontend/src/app/workspace/service/compile-workflow/workflow-compiling.service.spec.ts
+++ b/frontend/src/app/workspace/service/compile-workflow/workflow-compiling.service.spec.ts
@@ -31,6 +31,8 @@ import { UndoRedoService } from "../undo-redo/undo-redo.service";
import { mockPoint, mockScanPredicate } from "../workflow-graph/model/mock-workflow-data";
import { serializePortIdentity } from "../../../common/util/port-identity-serde";
import { commonTestImports, commonTestProviders } from "../../../common/testing/test-utils";
+import { firstValueFrom } from "rxjs";
+import { CompilationState } from "../../types/workflow-compiling.interface";
describe("WorkflowCompilingService.dropInvalidAttributeValues", () => {
// A schema shaped like the Aggregate operator after schema propagation has filled in the
@@ -240,3 +242,89 @@ describe("WorkflowCompilingService schema propagation property cleanup", () => {
expect(workflowActionService.getTexeraGraph().getOperator(operatorID).operatorProperties.attribute).toBe("col_y");
});
});
+
+describe("WorkflowCompilingService public getters", () => {
+ let service: WorkflowCompilingService;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [...commonTestImports],
+ providers: [
+ { provide: OperatorMetadataService, useClass: StubOperatorMetadataService },
+ JointUIService,
+ WorkflowActionService,
+ WorkflowUtilService,
+ UndoRedoService,
+ DynamicSchemaService,
+ ValidationWorkflowService,
+ WorkflowCompilingService,
+ ...commonTestProviders,
+ ],
+ });
+ service = TestBed.inject(WorkflowCompilingService);
+ });
+
+ // Overwrite the private compilation-state snapshot the getters read from.
+ const setState = (info: unknown): void => {
+ (service as any).currentCompilationStateInfo = info;
+ };
+
+ it("getWorkflowCompilationState returns the current state", () => {
+ setState({ state: CompilationState.Succeeded });
+ expect(service.getWorkflowCompilationState()).toBe(CompilationState.Succeeded);
+ });
+
+ it("getWorkflowCompilationErrors is empty while succeeded or uninitialized", () => {
+ setState({ state: CompilationState.Succeeded, operatorErrors: { op1: { message: "x" } } });
+ expect(service.getWorkflowCompilationErrors()).toEqual({});
+
+ setState({ state: CompilationState.Uninitialized });
+ expect(service.getWorkflowCompilationErrors()).toEqual({});
+ });
+
+ it("getWorkflowCompilationErrors surfaces the operator errors when compilation failed", () => {
+ const errors = { op1: { message: "boom" } };
+ setState({ state: CompilationState.Failed, operatorOutputPortSchemaMap: {}, operatorErrors: errors });
+ expect(service.getWorkflowCompilationErrors()).toBe(errors);
+ });
+
+ it("getCompilationStateInfoChangedStream replays the latest state", async () => {
+ (service as any).compilationStateInfoChangedStream.next(CompilationState.Succeeded);
+ expect(await firstValueFrom(service.getCompilationStateInfoChangedStream())).toBe(CompilationState.Succeeded);
+ });
+
+ it("getOperatorOutputSchemaMap returns undefined when uninitialized", () => {
+ setState({ state: CompilationState.Uninitialized });
+ expect(service.getOperatorOutputSchemaMap("op1")).toBeUndefined();
+ });
+
+ it("getOperatorOutputSchemaMap returns the operator's output port schema map", () => {
+ const opMap = {
+ [serializePortIdentity({ id: 0, internal: false })]: [{ attributeName: "a", attributeType: "string" }],
+ };
+ setState({ state: CompilationState.Succeeded, operatorOutputPortSchemaMap: { op1: opMap } });
+ expect(service.getOperatorOutputSchemaMap("op1")).toBe(opMap);
+ });
+
+ it("getPortInputSchema looks the port up by its serialized identity", () => {
+ const portSchema = [{ attributeName: "a", attributeType: "string" }];
+ vi.spyOn(service, "getOperatorInputSchemaMap").mockReturnValue({
+ [serializePortIdentity({ id: 0, internal: false })]: portSchema,
+ } as any);
+ expect(service.getPortInputSchema("op1", 0)).toBe(portSchema);
+ });
+
+ it("getPortInputSchema returns undefined when the operator has no input schema map", () => {
+ vi.spyOn(service, "getOperatorInputSchemaMap").mockReturnValue(undefined);
+ expect(service.getPortInputSchema("op1", 0)).toBeUndefined();
+ });
+
+ it("getOperatorInputAttributeType finds the named attribute's type on the input port", () => {
+ vi.spyOn(service, "getPortInputSchema").mockReturnValue([
+ { attributeName: "a", attributeType: "string" },
+ { attributeName: "b", attributeType: "integer" },
+ ]);
+ expect(service.getOperatorInputAttributeType("op1", 0, "b")).toBe("integer");
+ expect(service.getOperatorInputAttributeType("op1", 0, "missing")).toBeUndefined();
+ });
+});