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