This is an automated email from the ASF dual-hosted git repository.
Yicong-Huang pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/main by this push:
new bd445b5157 test(frontend): add unit tests for WorkspaceComponent
(#4969)
bd445b5157 is described below
commit bd445b5157253762209deb8cb4509e4343297e12
Author: Yicong Huang <[email protected]>
AuthorDate: Sun May 10 16:30:11 2026 -0700
test(frontend): add unit tests for WorkspaceComponent (#4969)
### What changes were proposed in this PR?
The placeholder spec at
`frontend/src/app/workspace/component/workspace.component.spec.ts` was a
license header only. Replace it with real tests covering the public
surface of `WorkspaceComponent`:
- `ngOnInit` — query-param `pid` parsing (numeric / non-numeric) and
`setHighlightingEnabled` wiring
- `ngAfterViewInit` — cold-start (no `wid`) vs warm-start (`wid`
present, must show loading and disable modification); also asserts
`HubService.postView` is called with the route's `wid` and the current
user's `uid`, falling back to `0` when anonymous
- `loadWorkflowWithId` — success / 403-error paths, the broken-workflow
branch (`checkIfWorkflowBroken` → `NotificationService.error` but still
loads), and URL-fragment handling (highlight when present, error + clear
when stale)
- `onWIDChange` — `writeAccess` syncs from `metadata.readonly` on each
`workflowMetaDataChanged` emission, and stays put when the metadata's
`wid` is still undefined
- `triggerCenter` — delegates to
`WorkflowActionService.getTexeraGraph().triggerCenterEvent()`
- `registerAutoPersistWorkflow` — idempotent (only one subscription
regardless of repeated calls)
- `ngOnDestroy` — persists the workflow when the user is signed in and
persist is enabled; skips persist when not signed in; clears the
workflow either way
- `copilotEnabled` — passes through
`GuiConfigService.env.copilotEnabled`
The fixture overrides the component template to `<div #codeEditor>` so
the heavyweight children (workflow editor, mini-map, left/right panels)
don't have to compile in the test build, and stubs all 14 injected
services. Drop the spec from the exclude lists in `tsconfig.spec.json`
and `angular.json`.
### Any related issues, documentation, discussions?
Closes #4967.
### How was this PR tested?
`yarn ng test --watch=false`: 267 pass, 8 skip, 2 todo (19 new tests in
`WorkspaceComponent`). `yarn format:ci` clean.
### Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Opus 4.7 (1M context)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
---
frontend/angular.json | 3 +-
.../component/workspace.component.spec.ts | 344 +++++++++++++++++++++
frontend/src/tsconfig.spec.json | 1 -
3 files changed, 345 insertions(+), 3 deletions(-)
diff --git a/frontend/angular.json b/frontend/angular.json
index 3c07ded34e..7ecded64fe 100644
--- a/frontend/angular.json
+++ b/frontend/angular.json
@@ -95,8 +95,7 @@
"setupFiles": ["src/jsdom-svg-polyfill.ts"],
"exclude": [
"**/app/common/service/user/config/user-config.service.spec.ts",
-
"**/app/workspace/component/workflow-editor/workflow-editor.component.spec.ts",
- "**/app/workspace/component/workspace.component.spec.ts"
+
"**/app/workspace/component/workflow-editor/workflow-editor.component.spec.ts"
]
}
}
diff --git a/frontend/src/app/workspace/component/workspace.component.spec.ts
b/frontend/src/app/workspace/component/workspace.component.spec.ts
index 51da6c0f2b..0eaaf7f5fb 100644
--- a/frontend/src/app/workspace/component/workspace.component.spec.ts
+++ b/frontend/src/app/workspace/component/workspace.component.spec.ts
@@ -16,3 +16,347 @@
* specific language governing permissions and limitations
* under the License.
*/
+
+import { Location } from "@angular/common";
+import { NO_ERRORS_SCHEMA } from "@angular/core";
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { HttpClientTestingModule } from "@angular/common/http/testing";
+import { ActivatedRoute, Router } from "@angular/router";
+import { NzMessageService } from "ng-zorro-antd/message";
+import { EMPTY, of, Subject, throwError } from "rxjs";
+
+import { NotificationService } from
"../../common/service/notification/notification.service";
+import { UserService } from "../../common/service/user/user.service";
+import { WorkflowPersistService } from
"../../common/service/workflow-persist/workflow-persist.service";
+import { Workflow } from "../../common/type/workflow";
+import { CodeEditorService } from "../service/code-editor/code-editor.service";
+import { WorkflowCompilingService } from
"../service/compile-workflow/workflow-compiling.service";
+import { OperatorMetadataService } from
"../service/operator-metadata/operator-metadata.service";
+import { UndoRedoService } from "../service/undo-redo/undo-redo.service";
+import { WorkflowConsoleService } from
"../service/workflow-console/workflow-console.service";
+import { WorkflowActionService } from
"../service/workflow-graph/model/workflow-action.service";
+import { OperatorReuseCacheStatusService } from
"../service/workflow-status/operator-reuse-cache-status.service";
+import { EntityType, HubService } from "../../hub/service/hub.service";
+import { commonTestProviders } from "../../common/testing/test-utils";
+import { WorkspaceComponent } from "./workspace.component";
+
+describe("WorkspaceComponent", () => {
+ let component: WorkspaceComponent;
+ let fixture: ComponentFixture<WorkspaceComponent>;
+
+ let workflowActionService: any;
+ let workflowPersistService: any;
+ let operatorMetadataService: any;
+ let userService: any;
+ let undoRedoService: any;
+ let notificationService: any;
+ let hubService: any;
+ let codeEditorService: any;
+ let messageService: any;
+ let routerMock: any;
+ let locationMock: any;
+ let metadataChangedSubject: Subject<void>;
+ let stubGraph: { triggerCenterEvent: ReturnType<typeof vi.fn>;
hasElementWithID: ReturnType<typeof vi.fn> };
+
+ const stubWorkflow: Workflow = {
+ wid: 42,
+ name: "test",
+ creationTime: 0,
+ lastModifiedTime: 0,
+ content: {
+ operators: [],
+ operatorPositions: {},
+ links: [],
+ commentBoxes: [],
+ settings: { dataTransferBatchSize: 100 },
+ },
+ } as unknown as Workflow;
+
+ function configureRoute(params: Record<string, any> = {}, queryParams:
Record<string, any> = {}) {
+ return {
+ snapshot: { params, queryParams, fragment: null as string | null },
+ };
+ }
+
+ async function createFixture(routeOverride: any = configureRoute()) {
+ metadataChangedSubject = new Subject<void>();
+ stubGraph = {
+ triggerCenterEvent: vi.fn(),
+ hasElementWithID: vi.fn().mockReturnValue(false),
+ };
+
+ workflowActionService = {
+ setHighlightingEnabled: vi.fn(),
+ resetAsNewWorkflow: vi.fn(),
+ disableWorkflowModification: vi.fn(),
+ enableWorkflowModification: vi.fn(),
+ reloadWorkflow: vi.fn(),
+ setNewSharedModel: vi.fn(),
+ setWorkflowMetadata: vi.fn(),
+ clearWorkflow: vi.fn(),
+ highlightElements: vi.fn(),
+ getTexeraGraph: vi.fn().mockReturnValue(stubGraph),
+ getWorkflow: vi.fn().mockReturnValue(stubWorkflow),
+ getWorkflowMetadata: vi.fn().mockReturnValue({ wid: 42, readonly: false
}),
+ workflowChanged: vi.fn().mockReturnValue(EMPTY),
+ workflowMetaDataChanged:
vi.fn().mockReturnValue(metadataChangedSubject.asObservable()),
+ };
+
+ workflowPersistService = {
+ isWorkflowPersistEnabled: vi.fn().mockReturnValue(true),
+ persistWorkflow: vi.fn().mockReturnValue(of(stubWorkflow)),
+ retrieveWorkflow: vi.fn().mockReturnValue(of(stubWorkflow)),
+ };
+
+ operatorMetadataService = {
+ getOperatorMetadata: vi.fn().mockReturnValue(of({})),
+ };
+
+ userService = {
+ isLogin: vi.fn().mockReturnValue(true),
+ getCurrentUser: vi.fn().mockReturnValue({ uid: 7 }),
+ };
+
+ undoRedoService = {
+ clearUndoStack: vi.fn(),
+ clearRedoStack: vi.fn(),
+ };
+
+ notificationService = { error: vi.fn() };
+ hubService = { postView: vi.fn().mockReturnValue(of(0)) };
+ codeEditorService = { vc: undefined };
+ messageService = { error: vi.fn() };
+
+ routerMock = { navigate: vi.fn() };
+ locationMock = { go: vi.fn() };
+
+ // TODO(#5015): drop this template override once CodeEditorComponent's
+ // own spec is fixed. Real child rendering would let us assert
+ // editor-lifecycle wiring; today we stub the host element so the
+ // heavyweight children don't compile in the test build.
+ TestBed.overrideComponent(WorkspaceComponent, {
+ set: { template: '<div #codeEditor class="stub-host"></div>', imports:
[], providers: [] },
+ });
+
+ await TestBed.configureTestingModule({
+ imports: [WorkspaceComponent, HttpClientTestingModule],
+ providers: [
+ { provide: WorkflowActionService, useValue: workflowActionService },
+ { provide: WorkflowPersistService, useValue: workflowPersistService },
+ { provide: OperatorMetadataService, useValue: operatorMetadataService
},
+ { provide: UserService, useValue: userService },
+ { provide: UndoRedoService, useValue: undoRedoService },
+ { provide: NotificationService, useValue: notificationService },
+ { provide: HubService, useValue: hubService },
+ { provide: CodeEditorService, useValue: codeEditorService },
+ { provide: NzMessageService, useValue: messageService },
+ { provide: Router, useValue: routerMock },
+ { provide: Location, useValue: locationMock },
+ { provide: ActivatedRoute, useValue: routeOverride },
+ // The three services listed in the constructor only to force their
+ // initialization aren't exercised by any test here; provide stubs.
+ { provide: WorkflowCompilingService, useValue: {} },
+ { provide: WorkflowConsoleService, useValue: {} },
+ { provide: OperatorReuseCacheStatusService, useValue: {} },
+ ...commonTestProviders,
+ ],
+ schemas: [NO_ERRORS_SCHEMA],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(WorkspaceComponent);
+ component = fixture.componentInstance;
+ // ngOnDestroy clears the ViewContainerRef bound to `#codeEditor`. Tests
that
+ // exercise individual methods skip change detection, so the @ViewChild
query
+ // is never resolved; assign a stub to keep TestBed teardown from throwing.
+ component.codeEditorViewRef = { clear: vi.fn() } as any;
+ }
+
+ describe("ngOnInit", () => {
+ it("parses numeric pid from route query params", async () => {
+ await createFixture(configureRoute({}, { pid: "13" }));
+ component.ngOnInit();
+ expect(component.pid).toBe(13);
+ });
+
+ it("treats non-numeric pid as undefined", async () => {
+ await createFixture(configureRoute({}, { pid: "not-a-number" }));
+ component.ngOnInit();
+ expect(component.pid).toBeUndefined();
+ });
+
+ it("enables highlighting on the workflow action service", async () => {
+ await createFixture();
+ component.ngOnInit();
+
expect(workflowActionService.setHighlightingEnabled).toHaveBeenCalledWith(true);
+ });
+ });
+
+ describe("ngAfterViewInit", () => {
+ it("cold start (no wid in route): does not flip isLoading and registers
metadata listener", async () => {
+ await createFixture(configureRoute({}));
+ fixture.detectChanges(); // triggers ngOnInit + ngAfterViewInit
+ expect(component.isLoading).toBe(false);
+
expect(workflowActionService.disableWorkflowModification).not.toHaveBeenCalled();
+ expect(operatorMetadataService.getOperatorMetadata).toHaveBeenCalled();
+ });
+
+ it("warm start (wid in route): sets isLoading=true and disables
modification before load", async () => {
+ await createFixture(configureRoute({ id: "42" }));
+ // retrieveWorkflow is consumed inside loadWorkflowWithId — keep it
pending so
+ // we can observe the pre-completion loading state.
+ workflowPersistService.retrieveWorkflow.mockReturnValue(new Subject());
+ fixture.detectChanges();
+ expect(component.isLoading).toBe(true);
+
expect(workflowActionService.disableWorkflowModification).toHaveBeenCalled();
+ });
+ });
+
+ describe("loadWorkflowWithId", () => {
+ it("on success: hands the workflow to the action service, clears
undo/redo, and turns off loading", async () => {
+ await createFixture(configureRoute({ id: "42" }));
+ fixture.detectChanges();
+ expect(workflowActionService.setNewSharedModel).toHaveBeenCalledWith(42,
{ uid: 7 });
+
expect(workflowActionService.reloadWorkflow).toHaveBeenCalledWith(stubWorkflow);
+ expect(undoRedoService.clearUndoStack).toHaveBeenCalled();
+ expect(undoRedoService.clearRedoStack).toHaveBeenCalled();
+ expect(component.isLoading).toBe(false);
+ });
+
+ it("on failure: resets to a new workflow, surfaces an access error, and
turns off loading", async () => {
+ await createFixture(configureRoute({ id: "42" }));
+ workflowPersistService.retrieveWorkflow.mockReturnValue(throwError(() =>
new Error("403")));
+ fixture.detectChanges();
+ expect(workflowActionService.resetAsNewWorkflow).toHaveBeenCalled();
+
expect(workflowActionService.enableWorkflowModification).toHaveBeenCalled();
+
expect(messageService.error).toHaveBeenCalledWith(expect.stringContaining("don't
have access"));
+ expect(component.isLoading).toBe(false);
+ });
+
+ it("flags broken workflows via NotificationService.error but still loads
them", async () => {
+ const brokenWorkflow = {
+ ...stubWorkflow,
+ content: {
+ ...stubWorkflow.content,
+ // link references operator IDs that aren't in `operators: []` →
broken.
+ links: [{ source: { operatorID: "ghost-a" }, target: { operatorID:
"ghost-b" } }],
+ },
+ } as unknown as Workflow;
+ await createFixture(configureRoute({ id: "42" }));
+
workflowPersistService.retrieveWorkflow.mockReturnValue(of(brokenWorkflow));
+ fixture.detectChanges();
+
expect(notificationService.error).toHaveBeenCalledWith(expect.stringContaining("broken"));
+ // Workflow still flows through reload — the error is informational, not
blocking.
+
expect(workflowActionService.reloadWorkflow).toHaveBeenCalledWith(brokenWorkflow);
+ });
+
+ it("when URL fragment matches an element in the graph, highlights it",
async () => {
+ const route = configureRoute({ id: "42" });
+ route.snapshot.fragment = "operator-1";
+ await createFixture(route);
+ stubGraph.hasElementWithID.mockReturnValue(true);
+ fixture.detectChanges();
+ expect(stubGraph.hasElementWithID).toHaveBeenCalledWith("operator-1");
+
expect(workflowActionService.highlightElements).toHaveBeenCalledWith(false,
"operator-1");
+ });
+
+ it("when URL fragment does not match any element, surfaces an error and
clears the fragment", async () => {
+ const route = configureRoute({ id: "42" });
+ route.snapshot.fragment = "stale-id";
+ await createFixture(route);
+ // Default mock already returns false, but state explicitly for clarity.
+ stubGraph.hasElementWithID.mockReturnValue(false);
+ fixture.detectChanges();
+
expect(notificationService.error).toHaveBeenCalledWith(expect.stringContaining("stale-id"));
+ // Two router.navigate calls: one preserving fragment, one clearing it.
+ expect(routerMock.navigate).toHaveBeenLastCalledWith([], { relativeTo:
route });
+ });
+ });
+
+ describe("triggerCenter", () => {
+ it("delegates to the texera graph", async () => {
+ await createFixture();
+ component.triggerCenter();
+ expect(stubGraph.triggerCenterEvent).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe("registerAutoPersistWorkflow", () => {
+ it("is idempotent — only subscribes to workflowChanged once across
repeated calls", async () => {
+ await createFixture();
+ component.registerAutoPersistWorkflow();
+ component.registerAutoPersistWorkflow();
+ component.registerAutoPersistWorkflow();
+ expect(workflowActionService.workflowChanged).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe("updateViewCount", () => {
+ it("posts a view event with the route's wid and the current user's uid",
async () => {
+ const route = configureRoute({ id: "42" });
+ await createFixture(route);
+ fixture.detectChanges();
+ expect(hubService.postView).toHaveBeenCalledWith("42", 7,
EntityType.Workflow);
+ });
+
+ it("falls back to uid=0 when no user is signed in", async () => {
+ const route = configureRoute({ id: "42" });
+ await createFixture(route);
+ userService.getCurrentUser.mockReturnValue(undefined);
+ // Re-trigger after mutating the mock; createFixture has already wired
it.
+ component.updateViewCount();
+ expect(hubService.postView).toHaveBeenCalledWith("42", 0,
EntityType.Workflow);
+ });
+ });
+
+ describe("onWIDChange", () => {
+ it("syncs writeAccess from metadata.readonly each time the metadata
changes", async () => {
+ await createFixture();
+ fixture.detectChanges();
+ expect(component.writeAccess).toBe(false); // default before any emission
+
+ workflowActionService.getWorkflowMetadata.mockReturnValue({ wid: 42,
readonly: false });
+ metadataChangedSubject.next();
+ expect(component.writeAccess).toBe(true);
+
+ workflowActionService.getWorkflowMetadata.mockReturnValue({ wid: 42,
readonly: true });
+ metadataChangedSubject.next();
+ expect(component.writeAccess).toBe(false);
+ });
+
+ it("ignores metadata emissions that have no wid yet", async () => {
+ await createFixture();
+ fixture.detectChanges();
+ workflowActionService.getWorkflowMetadata.mockReturnValue({ wid:
undefined, readonly: false });
+ metadataChangedSubject.next();
+ // writeAccess stays at its initial false — no metadata.wid means we
don't know
+ // whether the workflow is editable yet.
+ expect(component.writeAccess).toBe(false);
+ });
+ });
+
+ describe("ngOnDestroy", () => {
+ it("persists the workflow on destroy when the user is signed in and
persist is enabled", async () => {
+ await createFixture();
+ component.ngOnDestroy();
+
expect(workflowPersistService.persistWorkflow).toHaveBeenCalledWith(stubWorkflow);
+ expect(workflowActionService.clearWorkflow).toHaveBeenCalled();
+ });
+
+ it("skips the persist call when the user is not signed in", async () => {
+ await createFixture();
+ userService.isLogin.mockReturnValue(false);
+ component.ngOnDestroy();
+ expect(workflowPersistService.persistWorkflow).not.toHaveBeenCalled();
+ // Cleanup of the workflow state still happens regardless.
+ expect(workflowActionService.clearWorkflow).toHaveBeenCalled();
+ });
+ });
+
+ describe("copilotEnabled", () => {
+ it("passes through to GuiConfigService.env.copilotEnabled", async () => {
+ await createFixture();
+ // MockGuiConfigService defaults `copilotEnabled` to false.
+ expect(component.copilotEnabled).toBe(false);
+ });
+ });
+});
diff --git a/frontend/src/tsconfig.spec.json b/frontend/src/tsconfig.spec.json
index 5e9a1f049c..ec4745fc08 100644
--- a/frontend/src/tsconfig.spec.json
+++ b/frontend/src/tsconfig.spec.json
@@ -14,7 +14,6 @@
// Specs whose body is entirely commented out / placeholder — these
// need real test cases written before they can be re-enabled.
"**/app/common/service/user/config/user-config.service.spec.ts",
- "**/app/workspace/component/workspace.component.spec.ts",
// jointjs paper geometry: every test in this suite asserts on
// graph layout math (positions, link routing, hit testing) that