From fcafb5390226d0bcba672f2bbbd052d5ca59eaaa Mon Sep 17 00:00:00 2001 From: Yang Zhang Date: Wed, 2 Sep 2026 18:05:12 -0700 Subject: [PATCH] feat(workflow-form): Form View page shell, load, and the shared spec harness The read-only shell of the Form View page: behind the feature flag it loads the workflow the URL names, redirects to the operator canvas when the workflow does not open in the form (or the flag is off), shows it read-only, hands back to the canvas, and releases the shared singleton services on the way out. The title bar's rename/save, the read-only preview, the inputs, running and results are added on top by later PRs. Also adds the shared spec harness (workflow-form.spec-harness.ts) these specs build the component from; later slices extend it with the mocks they introduce. Co-Authored-By: Claude Opus 4.8 --- frontend/src/app/app-routing.module.ts | 8 + .../workflow-form.component.html | 65 +++++++ .../workflow-form.component.scss | 166 ++++++++++++++++++ .../workflow-form.component.spec.ts | 148 ++++++++++++++++ .../workflow-form/workflow-form.component.ts | 161 +++++++++++++++++ .../workflow-form.rendered.spec.ts | 144 +++++++++++++++ .../workflow-form.spec-harness.ts | 84 +++++++++ 7 files changed, 776 insertions(+) create mode 100644 frontend/src/app/workspace/component/workflow-form/workflow-form.component.html create mode 100644 frontend/src/app/workspace/component/workflow-form/workflow-form.component.scss create mode 100644 frontend/src/app/workspace/component/workflow-form/workflow-form.component.spec.ts create mode 100644 frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts create mode 100644 frontend/src/app/workspace/component/workflow-form/workflow-form.rendered.spec.ts create mode 100644 frontend/src/app/workspace/component/workflow-form/workflow-form.spec-harness.ts diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index f9fd43cac15..d076c7e88c4 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -25,6 +25,7 @@ import { UserQuotaComponent } from "./dashboard/component/user/user-quota/user-q import { UserComputingUnitComponent } from "./dashboard/component/user/user-computing-unit/user-computing-unit.component"; import { UserVenvComponent } from "./dashboard/component/user/user-venv/user-venv.component"; import { WorkspaceComponent } from "./workspace/component/workspace.component"; +import { WorkflowFormComponent } from "./workspace/component/workflow-form/workflow-form.component"; import { AboutComponent } from "./hub/component/about/about.component"; import { TexeraLoginComponent } from "./hub/component/login/texera-login.component"; import { AuthGuardService } from "./common/service/user/auth-guard.service"; @@ -134,6 +135,13 @@ routes.push({ path: "workflow", component: UserWorkflowComponent, }, + { + // Must precede "workflow/:id" so the trailing "form" segment is not swallowed by + // the canvas route. The page guards itself (the feature flag off, or a workflow + // that does not open in the form, hands back to the canvas). + path: "workflow/:id/form", + component: WorkflowFormComponent, + }, { path: "workflow/:id", component: WorkspaceComponent, diff --git a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.html b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.html new file mode 100644 index 00000000000..618e8c0e1c6 --- /dev/null +++ b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.html @@ -0,0 +1,65 @@ + + + +
+ + {{ workflowName || "Untitled workflow" }} + +
+ + +
+ + + + + +
+ +
+
+ +
+ Loading… +
+ + +
+
diff --git a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.scss b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.scss new file mode 100644 index 00000000000..29960a1972c --- /dev/null +++ b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.scss @@ -0,0 +1,166 @@ +/** + * 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. + */ + +/* Colours are Texera's own: ng-zorro blue and the #d9d9d9 / #f0f0f0 neutrals. */ +$blue: #1890ff; +$text: rgba(0, 0, 0, 0.85); +$text-2: rgba(0, 0, 0, 0.45); +$divider: #f0f0f0; + +:host { + display: block; + background: #fff; + color: $text; + overflow-y: auto; + height: 100%; +} + +/* Measured against the operator canvas's #menu-user so the two rows are the same row: + 36px tall, a 1px rule underneath, full bleed across the content area. Anything the + view switch needs to line up with lives here, not in the padded column below. */ +.pc-topbar { + height: 36px; + margin: 1px; + border-bottom: 1px solid #cacaca; + display: flex; + align-items: center; + // Pinned to the top so the Canvas / Form view switch (and the workflow name) stay + // reachable while scrolling the preview/results below. + position: sticky; + top: 0; + z-index: 20; + background: #fff; + + /* Trims the difference between this row's right edge and the operator canvas's, so + the switch and the user icon land on the same x in both views. */ + padding-right: 10px; + + /* The operator canvas pins its avatar to the top of the row with a 1px nudge rather + than centring it; copying that is what puts the two on the same pixel. */ + .wid { + flex: none; + margin-left: 3px; + align-self: flex-start; + margin-top: 1px; + } + + /* The operator canvas puts 0 gap between its avatar and name, which carries its own + padding; matching that is what lines the two titles up. */ + gap: 0; + + /* Every value here is the operator canvas's .workflow-name, so the two title rows read + as the same row: the same size, weight and padding. Shown here; the editable input is + the next PR. */ + .wf-name { + min-width: 100px; + max-width: 800px; + font-size: 18px; + font-weight: 400; + padding: 2px 8px; + color: $text; + } + + .topbar-spacer { + flex: 1; + } + + /* Sized by its own content, exactly as on the operator canvas -- reserving a fixed + width here pushed the switch 74px further left than its counterpart. */ + texera-user-icon { + flex: none; + } +} + +/* One workflow, two ways of working on it. Rendered identically in the operator canvas + and the Form View, in the same slot of the same title row, so the control never moves + when the view does -- that stillness is what makes the two read as two views of one + thing rather than two pages. + + Deliberately quiet: the indicator is a rule sitting on the row's own bottom border, + not a filled button. This is secondary navigation and it shares a screen with Run, + which is the one thing here that should be solid blue. The current view is inert -- + clicking the view you are already in should do nothing. */ +.view-switch { + display: inline-flex; + align-self: stretch; + align-items: stretch; + flex: none; + gap: 20px; + margin-right: 20px; + + button { + appearance: none; + border: 0; + background: none; + cursor: pointer; + font: inherit; + font-size: 13px; + color: rgba(0, 0, 0, 0.45); + padding: 0; + position: relative; + display: inline-flex; + align-items: center; + white-space: nowrap; + transition: color 0.15s; + + /* Sits on the row's bottom rule, so the two read as tabs of the row rather than + as a widget dropped into it. */ + &::after { + content: ""; + position: absolute; + left: -2px; + right: -2px; + bottom: -1px; + height: 2px; + background: transparent; + transition: background 0.15s; + } + + &:hover:not(.on) { + color: rgba(0, 0, 0, 0.85); + } + } + + button.on { + color: rgba(0, 0, 0, 0.85); + font-weight: 500; + cursor: default; + + &::after { + background: $blue; + } + } +} + +.pc-page { + max-width: 1320px; + margin: 0 auto; + padding: 22px 24px 80px; +} + +.pc-rule { + height: 1px; + background: $divider; + margin: 20px 0 24px; +} + +.pc-loading { + color: $text-2; + padding: 40px 0; +} diff --git a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.spec.ts b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.spec.ts new file mode 100644 index 00000000000..76732c814d8 --- /dev/null +++ b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.spec.ts @@ -0,0 +1,148 @@ +/** + * 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 { Router } from "@angular/router"; +import { throwError } from "rxjs"; + +import { WorkflowFormComponent } from "./workflow-form.component"; +import { setupHarness, formViewWorkflow } from "./workflow-form.spec-harness"; +import { USER_WORKFLOW, USER_WORKSPACE } from "../../../app-routing.constant"; +import { DefaultView } from "../../../dashboard/type/workflow-metadata.interface"; + +/** + * These exercise the shell's own decisions -- what a reader is shown and where an ordinary + * workflow is sent -- without standing up the JointJS canvas. The component is built directly + * (not through TestBed) with the shared spec harness's mocks; the title bar, save and preview + * are added, with their own tests, by later PRs. + */ +describe("WorkflowFormComponent", () => { + let component: WorkflowFormComponent; + let h: ReturnType; + let router: { navigate: ReturnType }; + let workflowActionService: any; + let workflowPersistService: any; + + const build = (workflow: any) => { + h.useWorkflow(workflow); + component = new WorkflowFormComponent( + h.coeditorPresenceService as any, + h.route as any, + h.router as unknown as Router, + h.workflowActionService as any, + h.workflowPersistService as any, + h.operatorMetadataService as any, + h.executeWorkflowService as any, + h.workflowResultService as any, + h.notificationService as any, + h.userService as any, + h.cdr as any, + h.computingUnitStatusService as any, + h.workflowConsoleService as any, + h.config as any + ); + return component; + }; + + beforeEach(() => { + h = setupHarness(); + router = h.router; + workflowActionService = h.workflowActionService; + workflowPersistService = h.workflowPersistService; + }); + + describe("who this page is for", () => { + it("opens the form for a workflow that opens in it", () => { + build(formViewWorkflow).ngOnInit(); + + expect(component.wid).toBe(7); + expect(component.workflowName).toBe("scGPT"); + expect(component.loading).toBe(false); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + // A bad URL id should not try to load anything. + it("goes back to the workflow list when the URL carries no valid id", () => { + h.route.snapshot.params.id = "not-a-number"; + + build(formViewWorkflow).ngOnInit(); + + expect(router.navigate).toHaveBeenCalledWith([USER_WORKFLOW]); + expect(workflowActionService.reloadWorkflow).not.toHaveBeenCalled(); + }); + + // The flag, not the workflow, gates the form: with it on, the form renders for any + // workflow -- default_view only picks the landing view (settled on #8011), so a + // canvas-default workflow opens here too rather than being bounced to the canvas. + it("renders the form for any workflow while the flag is on, whatever its default view", () => { + build({ ...formViewWorkflow, defaultView: DefaultView.CANVAS }).ngOnInit(); + + expect(router.navigate).not.toHaveBeenCalled(); + expect(workflowActionService.reloadWorkflow).toHaveBeenCalled(); + expect(component.loading).toBe(false); + }); + + // With the feature turned off, the form does not exist at all -- even for a form-default + // workflow, the page hands over to the canvas without loading anything, so a failing + // request cannot strand the visitor on an error instead. + it("hands over to the canvas when the feature flag is off, without loading", () => { + h.config.env.formViewEnabled = false; + + build(formViewWorkflow).ngOnInit(); + + expect(router.navigate).toHaveBeenCalledWith([USER_WORKSPACE, "7"], { replaceUrl: true }); + expect(workflowPersistService.retrieveWorkflow).not.toHaveBeenCalled(); + expect(workflowActionService.resetAsNewWorkflow).not.toHaveBeenCalled(); + }); + + it("shows the workflow read-only, since editing belongs to the other view", () => { + build(formViewWorkflow).ngOnInit(); + + expect(workflowActionService.disableWorkflowModification).toHaveBeenCalled(); + expect(workflowActionService.enableWorkflowModification).not.toHaveBeenCalled(); + expect(workflowActionService.setNewSharedModel).toHaveBeenCalled(); + expect(workflowActionService.reloadWorkflow).toHaveBeenCalled(); + }); + + it("goes back to the list when the workflow cannot be opened", () => { + build(formViewWorkflow); + workflowPersistService.retrieveWorkflow.mockReturnValue(throwError(() => new Error("denied"))); + + component.ngOnInit(); + + expect(h.notificationService.error).toHaveBeenCalled(); + expect(router.navigate).toHaveBeenCalledWith([USER_WORKFLOW]); + }); + }); + + describe("leaving the page", () => { + // Both views drive the same singleton services, so the page must release them on the way + // out or they follow the user to the next page. + it("releases the shared services on destroy", () => { + build(formViewWorkflow).ngOnInit(); + + component.ngOnDestroy(); + + expect(workflowActionService.clearWorkflow).toHaveBeenCalled(); + expect(h.computingUnitStatusService.disconnect).toHaveBeenCalled(); + expect(h.executeWorkflowService.resetExecutionAndWorkers).toHaveBeenCalled(); + expect(h.workflowConsoleService.clearConsoleMessages).toHaveBeenCalled(); + expect(h.workflowResultService.clearResults).toHaveBeenCalled(); + }); + }); +}); diff --git a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts new file mode 100644 index 00000000000..3d8f2b8f8c7 --- /dev/null +++ b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts @@ -0,0 +1,161 @@ +/** + * 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, Component, HostListener, OnDestroy, OnInit } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { ActivatedRoute, Router } from "@angular/router"; +import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; +import { NzAvatarModule } from "ng-zorro-antd/avatar"; +import { UserIconComponent } from "../../../dashboard/component/user/user-icon/user-icon.component"; +import { forkJoin } from "rxjs"; + +import { USER_WORKFLOW, USER_WORKSPACE } from "../../../app-routing.constant"; +import { ComputingUnitStatusService } from "../../../common/service/computing-unit/computing-unit-status/computing-unit-status.service"; +import { WorkflowPersistService } from "../../../common/service/workflow-persist/workflow-persist.service"; +import { NotificationService } from "../../../common/service/notification/notification.service"; +import { UserService } from "../../../common/service/user/user.service"; +import { ExecuteWorkflowService } from "../../service/execute-workflow/execute-workflow.service"; +import { OperatorMetadataService } from "../../service/operator-metadata/operator-metadata.service"; +import { WorkflowActionService } from "../../service/workflow-graph/model/workflow-action.service"; +import { GuiConfigService } from "../../../common/service/gui-config.service"; +import { WorkflowConsoleService } from "../../service/workflow-console/workflow-console.service"; +import { WorkflowResultService } from "../../service/workflow-result/workflow-result.service"; +import { CoeditorUserIconComponent } from "../menu/coeditor-user-icon/coeditor-user-icon.component"; +import { CoeditorPresenceService } from "../../service/workflow-graph/model/coeditor-presence.service"; + +/** + * The Form View: a second way to use a workflow. This PR lays down the page shell -- behind + * the feature flag it loads the workflow the URL names, shows it read-only, and hands back to + * the operator canvas. The title bar's rename/save, the read-only preview, the inputs, running + * and results are added on top by later PRs. A view, not a new object: it opens the same + * workflow the canvas does. + */ +@UntilDestroy() +@Component({ + selector: "texera-workflow-form", + templateUrl: "./workflow-form.component.html", + styleUrls: ["./workflow-form.component.scss"], + imports: [CommonModule, NzAvatarModule, UserIconComponent, CoeditorUserIconComponent], +}) +export class WorkflowFormComponent implements OnInit, OnDestroy { + public wid?: number; + public workflowName = ""; + public loading = true; + + constructor( + // Public for the template: shows the same live collaborator avatars as the canvas. + public coeditorPresenceService: CoeditorPresenceService, + private route: ActivatedRoute, + private router: Router, + private workflowActionService: WorkflowActionService, + private workflowPersistService: WorkflowPersistService, + private operatorMetadataService: OperatorMetadataService, + private executeWorkflowService: ExecuteWorkflowService, + private workflowResultService: WorkflowResultService, + private notificationService: NotificationService, + private userService: UserService, + private cdr: ChangeDetectorRef, + private computingUnitStatusService: ComputingUnitStatusService, + private workflowConsoleService: WorkflowConsoleService, + private config: GuiConfigService + ) {} + + ngOnInit(): void { + const wid = Number(this.route.snapshot.params.id); + if (!Number.isFinite(wid)) { + void this.router.navigate([USER_WORKFLOW]); + return; + } + this.wid = wid; + this.load(wid); + } + + private load(wid: number): void { + // With the feature off the form does not exist: hand straight to the operator canvas + // without loading anything, so a request that then fails cannot strand the visitor on + // an error instead of the page they would have gotten. + if (!this.config.env.formViewEnabled) { + void this.router.navigate([USER_WORKSPACE, String(wid)], { replaceUrl: true }); + return; + } + this.workflowActionService.resetAsNewWorkflow(); + forkJoin({ + metadata: this.operatorMetadataService.getOperatorMetadata(), + workflow: this.workflowPersistService.retrieveWorkflow(wid), + }) + .pipe(untilDestroyed(this)) + .subscribe({ + next: ({ workflow }) => { + // With the flag on, the form renders for any workflow: default_view only decides + // which view a workflow lands on by default, not whether the form is reachable + // (settled on #8011). Gating the form on default_view here would quietly reintroduce + // a per-workflow switch -- and bounce a later PR's canvas-to-form switch straight + // back for any canvas-default workflow. + this.workflowName = workflow.name; + this.workflowActionService.setNewSharedModel(wid, this.userService.getCurrentUser()); + this.workflowActionService.reloadWorkflow(workflow); + // The workflow is shown, not edited, from here: dragging operators around or + // deleting them belongs to the operator canvas. + this.applyEditability(); + this.loading = false; + this.cdr.detectChanges(); + }, + // The load can fail for many reasons (no access, a network or server error, the + // metadata call): a neutral message covers them without claiming it was permissions. + error: () => { + this.notificationService.error("Unable to open this workflow."); + void this.router.navigate([USER_WORKFLOW]); + }, + }); + } + + /** + * Show the workflow rather than edit it: the graph shape and its properties are read-only + * on this page. A later PR's authoring mode makes properties editable with write access. + */ + private applyEditability(): void { + this.workflowActionService.disableWorkflowModification(); + } + + /** + * Switch to the operator canvas with a full page load, not a route. The two views share + * root-level singletons (the graph, the Yjs shared model, the CU connection); handing + * over in-process left the old state attached -- undraggable operators, a ghost coeditor + * of yourself, broken runs. A fresh document is the reliable handover. + */ + public openRegularCanvas(): void { + /* v8 ignore start -- full-document navigation; jsdom cannot navigate */ + window.location.href = `${USER_WORKSPACE}/${this.wid}`; + /* v8 ignore stop */ + } + + /** + * Tear down exactly what the operator canvas tears down: both views drive the same + * singleton services, so anything left bound here follows the user to the next page + * (the symptom was a frozen canvas after a visit -- the old shared model still attached). + */ + @HostListener("window:beforeunload") + ngOnDestroy(): void { + this.workflowActionService.clearWorkflow(); + this.computingUnitStatusService.disconnect(); + this.executeWorkflowService.resetExecutionAndWorkers(); + this.workflowConsoleService.clearConsoleMessages(); + this.workflowResultService.clearResults(); + } +} diff --git a/frontend/src/app/workspace/component/workflow-form/workflow-form.rendered.spec.ts b/frontend/src/app/workspace/component/workflow-form/workflow-form.rendered.spec.ts new file mode 100644 index 00000000000..90285492d4f --- /dev/null +++ b/frontend/src/app/workspace/component/workflow-form/workflow-form.rendered.spec.ts @@ -0,0 +1,144 @@ +/** + * 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 { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ActivatedRoute, Router } from "@angular/router"; +import { of, Subject } from "rxjs"; + +import { WorkflowFormComponent } from "./workflow-form.component"; +import { UserIconComponent } from "../../../dashboard/component/user/user-icon/user-icon.component"; +import { CoeditorUserIconComponent } from "../menu/coeditor-user-icon/coeditor-user-icon.component"; +import { CoeditorPresenceService } from "../../service/workflow-graph/model/coeditor-presence.service"; +import { WorkflowActionService } from "../../service/workflow-graph/model/workflow-action.service"; +import { WorkflowPersistService } from "../../../common/service/workflow-persist/workflow-persist.service"; +import { OperatorMetadataService } from "../../service/operator-metadata/operator-metadata.service"; +import { ExecuteWorkflowService } from "../../service/execute-workflow/execute-workflow.service"; +import { WorkflowResultService } from "../../service/workflow-result/workflow-result.service"; +import { NotificationService } from "../../../common/service/notification/notification.service"; +import { UserService } from "../../../common/service/user/user.service"; +import { ComputingUnitStatusService } from "../../../common/service/computing-unit/computing-unit-status/computing-unit-status.service"; +import { WorkflowConsoleService } from "../../service/workflow-console/workflow-console.service"; +import { GuiConfigService } from "../../../common/service/gui-config.service"; + +/** + * The direct-construction spec exercises the component's logic without a DOM; this one stands + * the page's real template up through TestBed so the rendered shell is covered too -- the + * name/avatar row, the Canvas switch actually firing, the loading/body swap, and the co-editor + * row -- which is the review's evidence of the rendered page in place of a screenshot. + */ +describe("WorkflowFormComponent (rendered template)", () => { + let fixture: ComponentFixture; + let workflow$: Subject; + const navigate = vi.fn(); + + const configure = async () => { + workflow$ = new Subject(); + // Blank out ONLY the two child icons: their ng-zorro dropdown/menu needs a host context + // this page does not set up. The override is on the children, not the page, so the page's + // own .component.html renders as shipped and stays covered -- which is the point of this + // spec, and why the no-restricted-syntax guard (aimed at blanking the component under test) + // does not apply here. + /* eslint-disable no-restricted-syntax */ + TestBed.overrideComponent(UserIconComponent, { set: { template: "" } }); + TestBed.overrideComponent(CoeditorUserIconComponent, { set: { template: "" } }); + /* eslint-enable no-restricted-syntax */ + + await TestBed.configureTestingModule({ + imports: [WorkflowFormComponent], + providers: [ + // One co-editor so the collaborator row (the *ngFor) renders and is covered. + { + provide: CoeditorPresenceService, + useValue: { coeditors: [{ clientId: "c1", userName: "co", color: "#888" }] }, + }, + { provide: ActivatedRoute, useValue: { snapshot: { params: { id: "7" } } } }, + { provide: Router, useValue: { navigate } }, + { + provide: WorkflowActionService, + useValue: { + resetAsNewWorkflow: vi.fn(), + setNewSharedModel: vi.fn(), + reloadWorkflow: vi.fn(), + disableWorkflowModification: vi.fn(), + clearWorkflow: vi.fn(), + getWorkflowMetadata: () => ({ name: "scGPT", lastModifiedTime: undefined }), + }, + }, + { provide: WorkflowPersistService, useValue: { retrieveWorkflow: () => workflow$ } }, + { provide: OperatorMetadataService, useValue: { getOperatorMetadata: () => of({}) } }, + { provide: ExecuteWorkflowService, useValue: { resetExecutionAndWorkers: vi.fn() } }, + { provide: WorkflowResultService, useValue: { clearResults: vi.fn() } }, + { provide: NotificationService, useValue: { error: vi.fn() } }, + { provide: UserService, useValue: { getCurrentUser: () => undefined } }, + { provide: ComputingUnitStatusService, useValue: { disconnect: vi.fn() } }, + { provide: WorkflowConsoleService, useValue: { clearConsoleMessages: vi.fn() } }, + { provide: GuiConfigService, useValue: { env: { formViewEnabled: true } } }, + ], + }).compileComponents(); + fixture = TestBed.createComponent(WorkflowFormComponent); + }; + + const el = (sel: string): HTMLElement | null => fixture.nativeElement.querySelector(sel); + const finishLoad = (workflow: any = { name: "scGPT", content: {} }) => { + workflow$.next(workflow); + workflow$.complete(); + fixture.detectChanges(); + }; + + beforeEach(configure); + + it("renders the workflow's avatar and name in the title row", () => { + fixture.detectChanges(); // ngOnInit -> load() + finishLoad(); + + expect(el(".pc-topbar")).not.toBeNull(); + expect(el("nz-avatar.wid")).not.toBeNull(); + expect(el(".wf-name")?.textContent?.trim()).toBe("scGPT"); + }); + + it("switches to the operator canvas when the Canvas control is clicked", () => { + fixture.detectChanges(); + finishLoad(); + const spy = vi.spyOn(fixture.componentInstance, "openRegularCanvas").mockImplementation(() => {}); + + el(".view-switch button")!.click(); // the first button is Canvas + + expect(spy).toHaveBeenCalled(); + }); + + it("shows the loading state until the workflow arrives, then swaps to the body", () => { + fixture.detectChanges(); // load() started; workflow not yet emitted + + expect(el(".pc-loading")?.textContent?.trim()).toBe("Loading…"); + + finishLoad(); + + expect(el(".pc-loading")).toBeNull(); + }); + + it("tears the workflow down when the browser unloads (the beforeunload host binding)", () => { + fixture.detectChanges(); + finishLoad(); + const workflowActionService: any = TestBed.inject(WorkflowActionService); + + window.dispatchEvent(new Event("beforeunload")); + + expect(workflowActionService.clearWorkflow).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/app/workspace/component/workflow-form/workflow-form.spec-harness.ts b/frontend/src/app/workspace/component/workflow-form/workflow-form.spec-harness.ts new file mode 100644 index 00000000000..b047361535c --- /dev/null +++ b/frontend/src/app/workspace/component/workflow-form/workflow-form.spec-harness.ts @@ -0,0 +1,84 @@ +/** + * 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 { of } from "rxjs"; +import { vi } from "vitest"; + +import { DefaultView } from "../../../dashboard/type/workflow-metadata.interface"; + +/** The workflow every test opens by default: a form-default workflow, writable, empty content. */ +export const formViewWorkflow = { name: "scGPT", defaultView: DefaultView.FORM, readonly: false, content: {} }; + +/** + * Mocks shared by every workflow-form spec, plus the component factory. Only what the current + * slices exercise is mocked; later slices add the dependencies (and streams) they introduce, so + * each PR's additions are covered by that PR's own spec. `setupHarness()` runs once per + * `beforeEach`; `build(workflow)` (in each spec) constructs the component with the subset its + * constructor takes. + */ +export function setupHarness() { + const router = { navigate: vi.fn() }; + + const workflowActionService = { + resetAsNewWorkflow: vi.fn(), + setNewSharedModel: vi.fn(), + reloadWorkflow: vi.fn(), + enableWorkflowModification: vi.fn(), + disableWorkflowModification: vi.fn(), + clearWorkflow: vi.fn(), + }; + const workflowPersistService = { + retrieveWorkflow: vi.fn().mockReturnValue(of(formViewWorkflow)), + }; + const coeditorPresenceService = { coeditors: [] }; + const route = { snapshot: { params: { id: "7" } } }; + const operatorMetadataService = { getOperatorMetadata: () => of({}) }; + const executeWorkflowService = { resetExecutionAndWorkers: vi.fn() }; + const workflowResultService = { clearResults: vi.fn() }; + const notificationService = { error: vi.fn() }; + const userService = { getCurrentUser: () => undefined }; + const cdr = { detectChanges: vi.fn() }; + const computingUnitStatusService = { disconnect: vi.fn() }; + const workflowConsoleService = { clearConsoleMessages: vi.fn() }; + const config = { env: { formViewEnabled: true } }; + + // Point the persist mock at `workflow`; each spec supplies the remaining constructor + // arguments in its own order via the named mocks above. + const useWorkflow = (workflow: any) => { + workflowPersistService.retrieveWorkflow.mockReturnValue(of(workflow)); + }; + + return { + useWorkflow, + router, + coeditorPresenceService, + route, + workflowActionService, + workflowPersistService, + operatorMetadataService, + executeWorkflowService, + workflowResultService, + notificationService, + userService, + cdr, + computingUnitStatusService, + workflowConsoleService, + config, + }; +}