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-8440-209fc4152e33eb7acd4fde7a33b8402962e508dc in repository https://gitbox.apache.org/repos/asf/texera.git
commit a268c1cc0d36ef9452fee54de6336483aefae17a Author: yangzhang75 <[email protected]> AuthorDate: Mon Sep 7 11:53:35 2026 +0000 feat(workflow-form): run the workflow from the form and show the author's instruction (#8440) ### What changes were proposed in this PR? Closes #8023. Part of the Form View stack (parent issue #8011), stacked on #8438 (PR11) and #8437 (PR10). The Form View can now render inputs and write them back. This PR makes it usable end to end: it runs the workflow, and shows the author's instruction above the inputs. - **Run.** A Run button that shares the operator canvas's disable conditions (an invalid or empty workflow, a unit still connecting, or no unit chosen each disable it and say why), but deliberately simplifies the execution states a reader needs down to Run and Stop, with no pause/resume. The unit is chosen in the embedded computing-unit selector. Running is the same execution call the canvas makes, so empty or invalid inputs surface as a real engine error rather than a client-side gate; a run clock counts off the engine's own event, and a failed run collapses an opaque trace to one plain sentence (a required input left empty reads as "please fill in the required fields"). - **Instruction.** The author's optional instruction renders above the inputs as a collapsible, read-only markdown card, shown only when there is text. It is bound through `[innerHTML]` with Angular's default sanitizer, so author markdown cannot inject script. Editing it is part of the authoring PR. Showing the chosen results is the next PR (#8024). ### Any related issues, documentation, discussions? Closes #8023. Part of the Form View feature (parent issue #8011). ### How was this PR tested? Unit tests (vitest). Direct-construction tests cover every run-button precedence branch, `onRun` (kill while running, no-op when disabled, else execute and clear the error), the five failed-run message paths, the run clock and its tick, and the status/validation/connection subscriptions; the instruction is covered for the markdown render, `hasInstruction`, and the toggle. TestBed template tests stand up the instruction card, the run bar, and the run-button click binding. 100% statement and function coverage on the changed source (the remaining uncovered branches are the pre-existing defensive `??`/`||` fallbacks). `ng test` (99 tests), `ng build gui`, eslint and prettier all pass, in both the single-user and collaboration paths. #### Screenshot The run bar (Run button + computing-unit selector + clock) and the author's instruction card above the inputs. <img width="1323" height="488" alt="Screenshot 2026-09-06 at 12 07 57 PM" src="https://github.com/user-attachments/assets/024d3bf5-be01-4449-8a0f-7538961cb83a" /> ### Was this PR authored or co-authored using generative AI tooling? Yes. Co-authored with Claude (Anthropic), reviewed line by line by the author before submission. Co-authored-by: Claude Opus 4.8 <[email protected]> Co-authored-by: Meng Wang <[email protected]> --- .../workflow-form/workflow-form.component.html | 86 +++++- .../workflow-form/workflow-form.component.scss | 173 ++++++++++++ .../workflow-form/workflow-form.component.spec.ts | 292 ++++++++++++++++++++- .../workflow-form/workflow-form.component.ts | 290 +++++++++++++++++++- .../workflow-form/workflow-form.rendered.spec.ts | 144 +++++++++- .../workflow-form/workflow-form.spec-harness.ts | 47 +++- 6 files changed, 1018 insertions(+), 14 deletions(-) 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 index c924532d05..d0c0ce0f0e 100644 --- a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.html +++ b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.html @@ -70,6 +70,39 @@ </div> <div [hidden]="loading"> + <!-- The author's one piece of guidance, shown as rendered markdown, collapsible, and only when + there is instruction text to show. Editing it is part of the authoring PR. --> + <section + class="card instr" + *ngIf="hasInstruction" + [class.open]="instructionOpen"> + <button + type="button" + class="instr-bar" + [attr.aria-expanded]="instructionOpen" + (click)="toggleInstruction()"> + <i + nz-icon + nzType="info-circle" + class="lead" + aria-hidden="true"></i> + <h2>{{ instructionTitle || "How to use this" }}</h2> + <i + nz-icon + nzType="down" + class="chev" + aria-hidden="true"></i> + </button> + + <div + class="instr-body" + [hidden]="!instructionOpen"> + <div + class="md" + [innerHTML]="instructionPreviewHtml"></div> + </div> + </section> + <!-- The inputs an author exposed, each rendered as its operator's own field. --> <div class="pc-section-head"> <span class="label">Inputs</span> @@ -108,8 +141,57 @@ </section> </div> - <!-- The workflow, out of the way unless the reader goes looking. Running and results are added - on top of this by the following PRs. --> + <!-- Run and the unit it runs on sit together, because one gates the other, but they stay two + controls: the selector draws its own bordered box. Adjacency and equal height say they + belong to each other. --> + <div class="runbar"> + <div class="run-group"> + <!-- One button in this page's own style. Its DISABLE conditions follow the operator canvas + (menu.component's getRunButtonBehavior) exactly -- disabled for an invalid or empty + graph, while a unit's socket comes up ("Connecting"), and before a unit is chosen + ("Connect") -- so the reader is never sent to press a button that does nothing. The + labels and icons are this page's own simplified Run/Stop set, shorter than the canvas's + ("Invalid" not "Invalid Workflow", a caret-right Run), and it drops the canvas's + Pause/Resume/Submitting: a run in flight simply shows "Stop". --> + <button + class="run" + [class.stop]="isRunning" + [class.connecting]="runButtonState.disabled && !isRunning" + [disabled]="runButtonState.disabled" + (click)="onRun()"> + <i + nz-icon + [nzType]="runButtonState.icon" + aria-hidden="true"></i> + {{ runButtonState.label }} + </button> + <!-- The page opens its own connection on load, so this control no longer decides whether + running works by where it is mounted. --> + <div class="run-unit"> + <texera-computing-unit-selection></texera-computing-unit-selection> + </div> + <!-- How long this has been going, from the same engine event the operator canvas counts. It + appears only once there is something to count, so a form at rest is not carrying a + 0:00:00 around. --> + <span + class="run-clock" + *ngIf="executionDuration > 0"> + {{ executionDuration | date: "H:mm:ss" : "UTC" }} + </span> + </div> + </div> + <!-- On its own line: a message that explains or blocks the run should not push the two controls + apart, and it is absent most of the time. --> + <p + class="run-note" + [class.err]="runError" + [attr.role]="runError ? 'alert' : 'status'" + *ngIf="runError || isRunning"> + {{ runError || "Running -- this keeps going if you look away." }} + </p> + + <!-- The workflow, out of the way unless the reader goes looking. Results are added on top of + this by the following PR. --> <section class="card wf" [class.open]="workflowOpen"> 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 index 54e7ffd7bc..93ef44ad83 100644 --- a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.scss +++ b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.scss @@ -190,6 +190,82 @@ $shell: #fafafa; background: #fff; } +/* ---------- instruction ---------- */ + +.instr { + margin-bottom: 26px; + + .instr-bar { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 13px 16px; + cursor: pointer; + user-select: none; + appearance: none; + border: 0; + background: none; + color: inherit; + font: inherit; + text-align: left; + + h2 { + margin: 0; + font-size: 15px; + font-weight: 600; + flex: 1; + } + + .lead { + color: $blue; + } + + .chev { + color: $text-2; + transform: rotate(-90deg); + transition: transform 0.2s; + } + } + + &.open .instr-bar .chev { + transform: rotate(0deg); + } + + .instr-body { + border-top: 1px solid $divider; + padding: 16px; + /* A long explanation scrolls rather than pushing the form off screen, and can be dragged + taller by anyone who wants to read it all at once. */ + max-height: 340px; + overflow-y: auto; + resize: vertical; + } +} + +.md { + :first-child { + margin-top: 0; + } + + :last-child { + margin-bottom: 0; + } + + img { + max-width: 100%; + border: 1px solid $divider; + border-radius: 6px; + } + + code { + background: $divider; + padding: 1px 6px; + border-radius: 4px; + font-size: 12.5px; + } +} + /* ---------- inputs ---------- */ .pc-section-head { @@ -311,6 +387,103 @@ $shell: #fafafa; } } +/* ---------- run ---------- */ + +.runbar { + display: flex; + align-items: center; + margin-top: 26px; +} + +/* Run and its computing unit as one control. The seam between them is what says the two belong + together. */ +.run-group { + display: inline-flex; + align-items: center; + gap: 12px; + + /* Quiet beside Run: it is something you glance at, not a control. Tabular figures stop the row + twitching as the seconds tick over. */ + .run-clock { + align-self: center; + margin-left: 4px; + color: #5c6672; + font-size: 13px; + font-variant-numeric: tabular-nums; + letter-spacing: 0.02em; + } + + .run-unit { + display: flex; + align-items: center; + + /* The selector reserves 220px and right-aligns inside it, which on the toolbar keeps a row of + controls steady but here left a wide gap between Run and a box that belongs next to it. Let + it be as wide as its content. */ + ::ng-deep .computing-units-selection { + min-width: 0; + } + + /* Matched to Run's height so the pair reads as one row of controls. */ + ::ng-deep .computing-units-dropdown-button { + height: 40px; + } + } +} + +.run { + height: 40px; + // Fixed width, content centred, so the button is the same size in every state (Run / Stop / + // Connect / Connecting / Invalid / Empty) instead of shrinking to "Run" and jumping wide for the + // others. Sized to the longest label ("Connecting"); the disabled states are kept to one word so + // this stays compact. + min-width: 160px; + padding: 0 26px; + font-size: 15px; + font-weight: 600; + border-radius: 8px; + border: 1px solid $blue; + background: $blue; + color: #fff; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 9px; + cursor: pointer; + + &:hover { + background: #40a9ff; + border-color: #40a9ff; + } + + &.stop { + background: #fff; + color: #ff4d4f; + border-color: #ff4d4f; + + &:hover { + background: #fff1f0; + } + } + + &:disabled { + background: $shell; + border-color: $border; + color: rgba(0, 0, 0, 0.25); + cursor: not-allowed; + } +} + +.run-note { + font-size: 13px; + color: $text-2; + margin: 9px 0 0; + + &.err { + color: #ff4d4f; + } +} + /* ---------- workflow preview ---------- */ .wf { 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 index 543eb94005..d3ba936e21 100644 --- 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 @@ -17,7 +17,7 @@ * under the License. */ -import { FormControl } from "@angular/forms"; +import { FormArray, FormControl, FormGroup, Validators } from "@angular/forms"; import { Router } from "@angular/router"; import { of, throwError } from "rxjs"; @@ -26,6 +26,8 @@ import { setupHarness, formViewWorkflow, resolved } from "./workflow-form.spec-h import { USER_WORKFLOW, USER_WORKSPACE } from "../../../app-routing.constant"; import { DefaultView } from "../../../dashboard/type/workflow-metadata.interface"; import { FORM_DEBOUNCE_TIME_MS } from "../../service/execute-workflow/execute-workflow.service"; +import { ExecutionState } from "../../types/execute-workflow.interface"; +import { ComputingUnitState } from "../../../common/type/computing-unit-connection.interface"; /** * These exercise the page's own decisions -- what a reader is shown, where an ordinary @@ -56,14 +58,17 @@ describe("WorkflowFormComponent", () => { h.workflowResultService as any, h.notificationService as any, h.userService as any, + h.markdownService as any, h.formlyJsonschema as any, h.cdr as any, h.dynamicSchemaService as any, h.workflowCompilingService as any, h.computingUnitStatusService as any, h.workflowConsoleService as any, + h.workflowWebsocketService as any, h.host as any, h.datePipe as any, + h.validationWorkflowService as any, h.config as any ); return component; @@ -882,4 +887,289 @@ describe("WorkflowFormComponent", () => { document.body.removeChild(editable); }); }); + + describe("the author's instruction", () => { + it("shows the instruction as rendered markdown when there is one", async () => { + formBindingService.getConfig.mockReturnValue({ + instruction: { title: "Read me", body: "**bold**" }, + fields: [], + resultOperatorIds: [], + }); + build(formViewWorkflow).ngOnInit(); + // renderInstruction resolves the parsed markdown on a microtask; let it settle. + await Promise.resolve(); + + expect(component.hasInstruction).toBe(true); + expect(component.instructionTitle).toBe("Read me"); + // The markdown mock returns its input; the point is renderInstruction populated the html. + expect(component.instructionPreviewHtml).toBe("**bold**"); + }); + + it("has no instruction when the body is blank", async () => { + formBindingService.getConfig.mockReturnValue({ + instruction: { title: "T", body: " " }, + fields: [], + resultOperatorIds: [], + }); + build(formViewWorkflow).ngOnInit(); + await Promise.resolve(); + + expect(component.hasInstruction).toBe(false); + expect(component.instructionPreviewHtml).toBe(""); + }); + + it("discards a stale instruction render when the body changed while parsing", async () => { + build(formViewWorkflow).ngOnInit(); + (component as any).instructionBody = "first"; + const pending = (component as any).renderInstruction(); + // A newer readConfig sets a different body before the parse microtask resolves. + (component as any).instructionBody = "second"; + await pending; + + // The stale "first" result is dropped rather than overwriting the newer body's render. + expect(component.instructionPreviewHtml).not.toBe("first"); + }); + + it("toggles the instruction open and closed", () => { + build(formViewWorkflow).ngOnInit(); + expect(component.instructionOpen).toBe(true); + + component.toggleInstruction(); + + expect(component.instructionOpen).toBe(false); + }); + }); + + describe("the run button, mirroring the operator canvas", () => { + // Put the page in a ready-to-run state: a WRITE-access unit is up, the socket is connected, the + // graph valid. + const makeReady = () => { + h.workflowWebsocketService.isConnected = true; + h.statusStream.next(ComputingUnitState.Running); + (component as any).selectedUnit = { accessPrivilege: "WRITE" }; + h.validationStream.next({ errors: {}, workflowEmpty: false }); + }; + + it("stores the selected unit from the status service so Run can gate on write access", () => { + build(formViewWorkflow).ngOnInit(); + + h.selectedUnitStream.next({ accessPrivilege: "WRITE" }); + + expect((component as any).selectedUnit).toEqual({ accessPrivilege: "WRITE" }); + }); + + it("offers Connect before a unit is chosen", () => { + build(formViewWorkflow).ngOnInit(); + + expect(component.runButtonState).toEqual({ label: "Connect", icon: "plus-circle", disabled: true }); + }); + + it("offers Run once a unit is up and the graph is valid", () => { + build(formViewWorkflow).ngOnInit(); + makeReady(); + + expect(component.runButtonState.label).toBe("Run"); + expect(component.runButtonState.disabled).toBe(false); + }); + + it("shows Stop while running", () => { + build(formViewWorkflow).ngOnInit(); + h.executionStateStream.next({ current: { state: ExecutionState.Running } }); + + expect(component.isRunning).toBe(true); + expect(component.runButtonState).toEqual({ label: "Stop", icon: "stop", disabled: false }); + }); + + it("disables and says Invalid for a broken graph", () => { + build(formViewWorkflow).ngOnInit(); + makeReady(); + h.validationStream.next({ errors: { op: {} }, workflowEmpty: false }); + + expect(component.runButtonState).toEqual({ label: "Invalid", icon: "warning", disabled: true }); + }); + + it("disables and says Empty for an empty graph", () => { + build(formViewWorkflow).ngOnInit(); + makeReady(); + h.validationStream.next({ errors: {}, workflowEmpty: true }); + + expect(component.runButtonState).toEqual({ label: "Empty", icon: "info-circle", disabled: true }); + }); + + it("disables and says Connecting while the unit's socket comes up", () => { + build(formViewWorkflow).ngOnInit(); + h.statusStream.next(ComputingUnitState.Running); + h.validationStream.next({ errors: {}, workflowEmpty: false }); + h.workflowWebsocketService.isConnected = false; + + expect(component.runButtonState).toEqual({ label: "Connecting", icon: "loading", disabled: true }); + }); + + it("disables with No access when the chosen unit is shared read-only", () => { + build(formViewWorkflow).ngOnInit(); + h.workflowWebsocketService.isConnected = true; + h.statusStream.next(ComputingUnitState.Running); + h.validationStream.next({ errors: {}, workflowEmpty: false }); + (component as any).selectedUnit = { accessPrivilege: "READ" }; + + expect(component.runButtonState).toEqual({ label: "No access", icon: "lock", disabled: true }); + }); + + it("does not offer a dead Stop when the socket drops mid-run", () => { + build(formViewWorkflow).ngOnInit(); + h.statusStream.next(ComputingUnitState.Running); // a unit is selected + h.executionStateStream.next({ current: { state: ExecutionState.Running } }); // a run is in flight + h.workflowWebsocketService.isConnected = false; // its socket drops + + // Still "running", but the button must not offer a Stop that would kill through a dead socket. + expect(component.isRunning).toBe(true); + expect(component.runButtonState).toEqual({ label: "Connecting", icon: "loading", disabled: true }); + }); + + it("repaints when the websocket connection status changes", () => { + build(formViewWorkflow).ngOnInit(); + h.cdr.markForCheck.mockClear(); + + h.connectionStream.next(true); + + expect(h.cdr.markForCheck).toHaveBeenCalled(); + }); + }); + + describe("running", () => { + const makeReady = () => { + h.workflowWebsocketService.isConnected = true; + h.statusStream.next(ComputingUnitState.Running); + (component as any).selectedUnit = { accessPrivilege: "WRITE" }; + h.validationStream.next({ errors: {}, workflowEmpty: false }); + }; + + it("runs the workflow with its name and clears any prior error", () => { + build(formViewWorkflow).ngOnInit(); + makeReady(); + component.runError = "old error"; + + component.onRun(); + + expect(h.executeWorkflowService.executeWorkflow).toHaveBeenCalledWith("scGPT"); + expect(component.runError).toBe(""); + }); + + it("stops a running workflow instead of starting another", () => { + build(formViewWorkflow).ngOnInit(); + h.executionStateStream.next({ current: { state: ExecutionState.Running } }); + + component.onRun(); + + expect(h.executeWorkflowService.killWorkflow).toHaveBeenCalled(); + expect(h.executeWorkflowService.executeWorkflow).not.toHaveBeenCalled(); + }); + + it("does nothing when the button is disabled", () => { + build(formViewWorkflow).ngOnInit(); + // Default state is "Connect" (disabled): no unit chosen. + + component.onRun(); + + expect(h.executeWorkflowService.executeWorkflow).not.toHaveBeenCalled(); + expect(h.executeWorkflowService.killWorkflow).not.toHaveBeenCalled(); + }); + + it("counts the run clock off the engine's duration event", () => { + build(formViewWorkflow).ngOnInit(); + + h.durationEvents.next({ duration: 5000, isRunning: false }); + + expect(component.executionDuration).toBe(5000); + }); + + it("ticks the clock a second at a time while a run is going", () => { + vi.useFakeTimers(); + build(formViewWorkflow).ngOnInit(); + + h.durationEvents.next({ duration: 1000, isRunning: true }); + vi.advanceTimersByTime(1000); + vi.useRealTimers(); + + expect(component.executionDuration).toBe(2000); + }); + }); + + describe("reporting a failed run", () => { + it("blames empty required inputs when a required field is left empty", () => { + build(formViewWorkflow).ngOnInit(); + const form = new FormGroup({ v: new FormControl("", Validators.required) }); + component.rendered = [{ form } as any]; + + h.executionStateStream.next({ current: { state: ExecutionState.Failed, errorMessages: [{ message: "x" }] } }); + + expect(component.runError).toBe("Run failed: please fill in the required fields."); + }); + + it("finds a required error nested inside an array input", () => { + build(formViewWorkflow).ngOnInit(); + const form = new FormGroup({ arr: new FormArray([new FormControl("", Validators.required)]) }); + component.rendered = [{ form } as any]; + + h.executionStateStream.next({ current: { state: ExecutionState.Failed, errorMessages: [{ message: "x" }] } }); + + expect(component.runError).toBe("Run failed: please fill in the required fields."); + }); + + it("does not blame required fields for a non-required validation error", () => { + build(formViewWorkflow).ngOnInit(); + // A pattern failure, not an empty required field: the reader gets the engine message, not + // "fill in the required fields". + const form = new FormGroup({ v: new FormControl("abc", Validators.pattern(/^\d+$/)) }); + component.rendered = [{ form } as any]; + + h.executionStateStream.next({ current: { state: ExecutionState.Failed, errorMessages: [{ message: "boom" }] } }); + + expect(component.runError).toBe("Run failed: boom"); + }); + + it("keeps a short human message, dropping the exception prefix", () => { + build(formViewWorkflow).ngOnInit(); + + h.executionStateStream.next({ + current: { + state: ExecutionState.Failed, + errorMessages: [{ message: "java.lang.RuntimeException: too many rows" }], + }, + }); + + expect(component.runError).toBe("Run failed: too many rows"); + }); + + it("collapses an opaque engine trace to a reload sentence", () => { + build(formViewWorkflow).ngOnInit(); + + h.executionStateStream.next({ + current: { + state: ExecutionState.Failed, + errorMessages: [{ message: "org.jooq.DataAccessException: SQL [..]" }], + }, + }); + + expect(component.runError).toBe("Run failed -- please reload and try again."); + }); + + it("collapses an empty error message to the reload sentence too", () => { + build(formViewWorkflow).ngOnInit(); + + h.executionStateStream.next({ current: { state: ExecutionState.Failed, errorMessages: [] } }); + + expect(component.runError).toBe("Run failed -- please reload and try again."); + }); + + it("gives a generic tail when the message cleans down to nothing", () => { + build(formViewWorkflow).ngOnInit(); + + h.executionStateStream.next({ + current: { state: ExecutionState.Failed, errorMessages: [{ message: "requirement failed: " }] }, + }); + + expect(component.runError).toBe("Run failed: please check your inputs and try again."); + }); + }); }); 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 index 260f9c2303..f1070a7ccf 100644 --- a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts +++ b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts @@ -19,7 +19,7 @@ import { ChangeDetectorRef, Component, ElementRef, HostListener, OnDestroy, OnInit } from "@angular/core"; import { CommonModule, DatePipe } from "@angular/common"; -import { FormGroup, FormsModule, ReactiveFormsModule } from "@angular/forms"; +import { AbstractControl, FormArray, FormGroup, FormsModule, ReactiveFormsModule } from "@angular/forms"; import { FormlyFieldConfig, FormlyModule } from "@ngx-formly/core"; import { FormlyJsonschema } from "@ngx-formly/core/json-schema"; import { ActivatedRoute, Router } from "@angular/router"; @@ -28,12 +28,15 @@ import { NzAvatarModule } from "ng-zorro-antd/avatar"; import { NzIconModule } from "ng-zorro-antd/icon"; import { UserIconComponent } from "../../../dashboard/component/user/user-icon/user-icon.component"; import { cloneDeep } from "lodash-es"; -import { forkJoin, Subject } from "rxjs"; -import { debounceTime, takeUntil } from "rxjs/operators"; +import { MarkdownService } from "ngx-markdown"; +import { EMPTY, forkJoin, Subject, timer } from "rxjs"; +import { debounceTime, switchMap, takeUntil, tap } from "rxjs/operators"; import { USER_WORKFLOW, USER_WORKSPACE } from "../../../app-routing.constant"; import { FormFieldBinding, Workflow, WorkflowContent } from "../../../common/type/workflow"; import { ComputingUnitStatusService } from "../../../common/service/computing-unit/computing-unit-status/computing-unit-status.service"; +import { ComputingUnitState } from "../../../common/type/computing-unit-connection.interface"; +import { DashboardWorkflowComputingUnit } from "../../../common/type/workflow-computing-unit"; 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"; @@ -44,10 +47,14 @@ import { ExecuteWorkflowService, FORM_DEBOUNCE_TIME_MS } from "../../service/exe import { OperatorMetadataService } from "../../service/operator-metadata/operator-metadata.service"; import { FormBindingService, ResolvedField } from "../../service/form-binding/form-binding.service"; import { WorkflowActionService } from "../../service/workflow-graph/model/workflow-action.service"; +import { ValidationWorkflowService } from "../../service/validation/validation-workflow.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 { WorkflowWebsocketService } from "../../service/workflow-websocket/workflow-websocket.service"; +import { ExecutionState } from "../../types/execute-workflow.interface"; import { Point } from "../../types/workflow-common.interface"; +import { ComputingUnitSelectionComponent } from "../power-button/computing-unit-selection.component"; import { WorkflowEditorComponent } from "../workflow-editor/workflow-editor.component"; import { MiniMapComponent } from "../workflow-editor/mini-map/mini-map.component"; import { CoeditorUserIconComponent } from "../menu/coeditor-user-icon/coeditor-user-icon.component"; @@ -72,8 +79,10 @@ interface RenderedField { * operator's own formly field, so a file property gets the real picker and an attribute a column * dropdown -- and writes a filled-in value straight back to its operator, the same edit the canvas * makes, with each sub-field of a nested or repeated property renamed and hidden as the author set - * it up. Running the workflow and showing results are added by later PRs. A view, not a new object: - * it opens the same workflow the canvas does. + * it up. It also shows the author's instruction above the inputs, and runs the workflow: a Run + * button (a reader's simplified Run/Stop, sharing the canvas's disable conditions), the + * computing-unit selector, a run clock and plain-language failure messages. Showing the results is + * added by a later PR. A view, not a new object: it opens the same workflow the canvas does. */ @UntilDestroy() @Component({ @@ -88,6 +97,7 @@ interface RenderedField { NzAvatarModule, NzIconModule, UserIconComponent, + ComputingUnitSelectionComponent, WorkflowEditorComponent, MiniMapComponent, CoeditorUserIconComponent, @@ -108,6 +118,31 @@ export class WorkflowFormComponent implements OnInit, OnDestroy { /** Torn down and replaced whenever the form is rebuilt, so an old field's write-back stops. */ private formsRebuilt = new Subject<void>(); + /** The author's instruction, rendered above the inputs; the reader always sees it as markdown. */ + public instructionOpen = true; + public instructionTitle = ""; + public instructionBody = ""; + public instructionPreviewHtml = ""; + + /** + * Milliseconds the current run has been going, counted from the same engine event the operator + * canvas counts: the engine reports the real elapsed time, and a local 1s timer fills in between + * reports so the display ticks instead of jumping. + */ + public executionDuration = 0; + public executionState: ExecutionState = ExecutionState.Uninitialized; + public runError = ""; + /** The picked unit's connection state, mirrored from the same stream the operator canvas reads, + * so "Connecting" here means exactly what it means there. */ + public computingUnitStatus: ComputingUnitState = ComputingUnitState.NoComputingUnit; + /** The picked unit itself, kept so Run can be gated on write access to it -- the same gate the + * operator canvas applies (a READ/NONE-shared unit can be viewed but not executed on). */ + private selectedUnit: DashboardWorkflowComputingUnit | null = null; + /** Workflow validity, read from the same validation stream the operator canvas uses, so Run is + * disabled ("Invalid" / "Empty") in the same cases. */ + public isWorkflowValid = true; + public isWorkflowEmpty = false; + /** The collapsible workflow preview: closed until the reader opens it. */ public workflowOpen = false; /** The embedded canvas is built the first time the strip opens, never while collapsed. */ @@ -137,6 +172,7 @@ export class WorkflowFormComponent implements OnInit, OnDestroy { private workflowResultService: WorkflowResultService, private notificationService: NotificationService, private userService: UserService, + private markdownService: MarkdownService, private formlyJsonschema: FormlyJsonschema, private cdr: ChangeDetectorRef, // Injected for its side effect: it fills its map from the operator-add stream, so it has to @@ -150,8 +186,12 @@ export class WorkflowFormComponent implements OnInit, OnDestroy { private workflowCompilingService: WorkflowCompilingService, private computingUnitStatusService: ComputingUnitStatusService, private workflowConsoleService: WorkflowConsoleService, + private workflowWebsocketService: WorkflowWebsocketService, private host: ElementRef<HTMLElement>, private datePipe: DatePipe, + // Same source the operator canvas reads its "Invalid" / "Empty" states from, so Run is + // disabled here exactly when it is disabled there. + private validationWorkflowService: ValidationWorkflowService, private config: GuiConfigService ) {} @@ -164,6 +204,77 @@ export class WorkflowFormComponent implements OnInit, OnDestroy { this.wid = wid; this.load(wid); + // The run clock, reusing the operator canvas's source outright rather than timing anything + // here: the engine is the only thing that knows when the run really began, so a stopwatch + // started at the click would drift and would be wrong after a reload. + this.workflowWebsocketService + .subscribeToEvent("ExecutionDurationUpdateEvent") + .pipe( + tap(event => (this.executionDuration = event.duration)), + switchMap(event => (event.isRunning ? timer(1000, 1000) : EMPTY)), + untilDestroyed(this) + ) + .subscribe(() => { + this.executionDuration += 1000; + this.cdr.markForCheck(); + }); + + // The run button's state is read from getters, so a change in unit/connection/validity has to + // repaint the view. markForCheck, not detectChanges: a synchronous pass can be thrown out of by + // an unrelated component's NG0100, killing the subscription. + this.computingUnitStatusService + .getSelectedComputingUnit() + .pipe(untilDestroyed(this)) + .subscribe(unit => { + this.selectedUnit = unit; + this.cdr.markForCheck(); + }); + this.computingUnitStatusService + .getStatus() + .pipe(untilDestroyed(this)) + .subscribe(status => { + this.computingUnitStatus = status; + this.cdr.markForCheck(); + }); + this.workflowWebsocketService + .getConnectionStatusStream() + .pipe(untilDestroyed(this)) + .subscribe(() => this.cdr.markForCheck()); + // Validity from the canvas's own stream, so a broken graph disables Run ("Invalid") here + // exactly as it does there. + this.validationWorkflowService + .getWorkflowValidationErrorStream() + .pipe(untilDestroyed(this)) + .subscribe(value => { + this.isWorkflowEmpty = value.workflowEmpty; + this.isWorkflowValid = Object.keys(value.errors).length === 0; + this.cdr.markForCheck(); + }); + + this.executeWorkflowService + .getExecutionStateStream() + .pipe(untilDestroyed(this)) + .subscribe(({ current }) => { + this.executionState = current.state; + // Surface a failed run. Without this the spinner just stops and the form gives zero + // feedback -- the opposite of what a reader needs. onRun() clears runError before the next + // run, so a stale error never lingers. + if (current.state === ExecutionState.Failed) { + // A required input left empty is by far the commonest reason a run fails here, and the + // engine reports it as an opaque "... is not contained in the schema". Answer with the + // same word the field itself already shows ("required"), so the two messages are + // consistent -- and it covers every operator, not just this one. + this.runError = this.hasEmptyRequiredInputs() + ? "Run failed: please fill in the required fields." + : this.friendlyRunError(current.errorMessages?.[0]?.message?.trim() ?? ""); + } + // markForCheck, not detectChanges: this is the one subscription the page cannot afford to + // lose. A synchronous detectChanges can be thrown out of by an unrelated component's NG0100, + // which would kill this stream and freeze the Run button on a stale state with no error + // shown; marking dirty and letting the next pass render avoids that. + this.cdr.markForCheck(); + }); + // Attribute boxes become dropdowns only after compilation writes the column enums into each // operator's dynamic schema -- which lands after these cards were built. Rebuild on the // compilation-state stream, a ReplaySubject(1) so a late subscriber (this page reloads fresh @@ -261,7 +372,12 @@ export class WorkflowFormComponent implements OnInit, OnDestroy { } private readConfig(): void { + const config = this.formBindingService.getConfig(); this.parameters = this.formBindingService.resolveFields(); + this.instructionTitle = config.instruction?.title ?? ""; + this.instructionBody = config.instruction?.body ?? ""; + // A reader always sees the instruction as rendered markdown. + void this.renderInstruction(); this.buildForm(); } @@ -511,6 +627,170 @@ export class WorkflowFormComponent implements OnInit, OnDestroy { return rendered.resolved.binding.id; } + // --------------------------------------------------------------------------- + // Instruction: the author's one piece of guidance, shown as rendered markdown + // --------------------------------------------------------------------------- + + public get hasInstruction(): boolean { + return this.instructionBody.trim().length > 0; + } + + private async renderInstruction(): Promise<void> { + // Capture the body this render is for: parsing can resolve on a later microtask, and a fresh + // readConfig() may start another render meanwhile. If the configured body changed while we were + // parsing, this result is stale -- drop it so the newer render's output stands. + const body = this.instructionBody; + const html = body.trim() ? await Promise.resolve(this.markdownService.parse(body)) : ""; + if (body !== this.instructionBody) { + return; + } + this.instructionPreviewHtml = html; + this.cdr.detectChanges(); + } + + public toggleInstruction(): void { + this.instructionOpen = !this.instructionOpen; + } + + // --------------------------------------------------------------------------- + // Running the same workflow the canvas runs, through the same execute/kill service. The canvas + // wraps its run with completion-email options (executeWorkflowWithEmailNotification); this page + // runs plainly (executeWorkflow), so a form-started run does not send that email. + // --------------------------------------------------------------------------- + + public get isRunning(): boolean { + return ( + this.executionState !== ExecutionState.Uninitialized && + this.executionState !== ExecutionState.Completed && + this.executionState !== ExecutionState.Failed && + this.executionState !== ExecutionState.Killed && + this.executionState !== ExecutionState.Terminated + ); + } + + /** + * A unit is picked but its socket is still coming up -- the same window the operator canvas shows + * "Connecting" and disables its run button. Read from the exact condition the canvas uses + * (menu.component's getRunButtonBehavior), so the two stay in step. + */ + public get isConnecting(): boolean { + return ( + this.computingUnitStatus !== ComputingUnitState.NoComputingUnit && !this.workflowWebsocketService.isConnected + ); + } + + /** No unit chosen yet: the button shows a disabled "Connect" hint and the unit is picked in the + * embedded selector -- unlike the canvas, where the Connect button is itself the click target. */ + public get hasNoComputingUnit(): boolean { + return this.computingUnitStatus === ComputingUnitState.NoComputingUnit; + } + + /** Write access to the chosen unit: the canvas gates execution on this (a READ/NONE-shared unit + * can be selected and viewed but not run on), so the form must too, or a reader could execute on + * a unit they only have read access to. */ + public get hasUnitWriteAccess(): boolean { + return this.selectedUnit?.accessPrivilege === "WRITE"; + } + + /** + * The Run button's label, icon and disabled state. It shares the operator canvas's disable + * conditions -- an invalid or empty workflow, a unit still connecting, or no unit chosen each + * disable it and say why -- but deliberately simplifies the execution states a reader needs down + * to Run and Stop, with no pause/resume: while a run is in flight the button stops (kills) it, + * otherwise it runs. (The canvas offers Pause/Resume/Submitting and a clickable Connect; a form + * reader does not, and picks the unit in the embedded selector instead.) + */ + public get runButtonState(): { label: string; icon: string; disabled: boolean } { + // Connecting is checked before Stop on purpose: if the socket drops mid-run, a "Stop" would + // send killWorkflow() through a dead socket and do nothing, so a disconnected unit disables the + // button (as the canvas does) rather than offering a kill that cannot be delivered. + if (this.isConnecting) { + return { label: "Connecting", icon: "loading", disabled: true }; + } + if (this.isRunning) { + return { label: "Stop", icon: "stop", disabled: false }; + } + if (!this.isWorkflowValid) { + return { label: "Invalid", icon: "warning", disabled: true }; + } + if (this.isWorkflowEmpty) { + return { label: "Empty", icon: "info-circle", disabled: true }; + } + if (this.hasNoComputingUnit) { + return { label: "Connect", icon: "plus-circle", disabled: true }; + } + // A unit is chosen and connected, but shared to this reader read-only: the canvas gates + // execution on write access to the unit, so the form disables Run rather than sending a request + // that the unit would reject. + if (!this.hasUnitWriteAccess) { + return { label: "No access", icon: "lock", disabled: true }; + } + return { label: "Run", icon: "caret-right", disabled: false }; + } + + public onRun(): void { + if (this.isRunning) { + this.executeWorkflowService.killWorkflow(); + return; + } + // The button is disabled in exactly the states a run cannot start from (invalid/empty workflow, + // connecting, or no unit), so a stray call here would be a silent no-op. + if (this.runButtonState.disabled) { + return; + } + this.runError = ""; + // Run as-is, like the canvas -- no client-side "fill everything first" gate (it diverged from + // the canvas and could not guarantee success anyway). Empty/invalid inputs surface as a real + // engine error via the execution-state stream (see the Failed handler). + this.executeWorkflowService.executeWorkflow(this.workflowName); + } + + /** + * Turn an engine error into something a reader can act on: raw SQL/jOOQ/Java traces collapse to + * one plain sentence, a short human message is kept (minus any Java prefix). The full text is + * always logged for developers. + */ + private friendlyRunError(raw: string): string { + if (raw) { + // eslint-disable-next-line no-console + console.error("[workflow-form] run failed:", raw); + } + const opaque = + !raw || /\bSQL \[|org\.jooq|org\.apache|org\.postgresql|foreign key|constraint|jdbc|\bat [\w.$]+\(/i.test(raw); + if (opaque) { + return "Run failed -- please reload and try again."; + } + const cleaned = raw + .replace(/^[\w.$]+(?:Exception|Error):\s*/, "") + .replace(/^requirement failed:\s*/i, "") + .trim(); + return `Run failed: ${cleaned || "please check your inputs and try again."}`; + } + + /** + * Whether any exposed input that is required is still empty. Reuses formly's own per-field + * required validation -- the very thing that renders "This field is required" under the box -- so + * the run-failure message stays consistent with the field hint. + */ + private hasEmptyRequiredInputs(): boolean { + // Specifically a `required` error (a required field left empty), not just any invalid control: + // a pattern or range failure is a different problem and should not be answered with "fill in the + // required fields". Walk the control tree for a real required error. + const hasRequiredError = (control: AbstractControl): boolean => { + if (control.hasError("required")) { + return true; + } + if (control instanceof FormGroup) { + return Object.values(control.controls).some(hasRequiredError); + } + if (control instanceof FormArray) { + return control.controls.some(hasRequiredError); + } + return false; + }; + return this.rendered.some(r => hasRequiredError(r.form)); + } + /** Open or close the workflow preview; opening it builds the canvas the first time. */ public toggleWorkflow(): void { this.workflowOpen = !this.workflowOpen; 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 index d6e6a4cb93..bca2ee4e04 100644 --- 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 @@ -23,6 +23,17 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ActivatedRoute, Router } from "@angular/router"; import { FormlyForm, FormlyModule } from "@ngx-formly/core"; import { FormlyJsonschema } from "@ngx-formly/core/json-schema"; +import { NZ_ICONS } from "ng-zorro-antd/icon"; +import { + InfoCircleOutline, + DownOutline, + PlusCircleOutline, + CaretRightOutline, + StopOutline, + WarningOutline, + LoadingOutline, + LockOutline, +} from "@ant-design/icons-angular/icons"; import { EMPTY, of, Subject } from "rxjs"; import { WorkflowFormComponent } from "./workflow-form.component"; @@ -39,8 +50,18 @@ import { ExecuteWorkflowService } from "../../service/execute-workflow/execute-w 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 { MarkdownService } from "ngx-markdown"; import { ComputingUnitStatusService } from "../../../common/service/computing-unit/computing-unit-status/computing-unit-status.service"; import { WorkflowConsoleService } from "../../service/workflow-console/workflow-console.service"; +import { WorkflowWebsocketService } from "../../service/workflow-websocket/workflow-websocket.service"; +import { ValidationWorkflowService } from "../../service/validation/validation-workflow.service"; +import { ComputingUnitSelectionComponent } from "../power-button/computing-unit-selection.component"; +import { WorkflowComputingUnitManagingService } from "../../../common/service/computing-unit/workflow-computing-unit/workflow-computing-unit-managing.service"; +import { WorkflowExecutionsService } from "../../../dashboard/service/user/workflow-executions/workflow-executions.service"; +import { ComputingUnitActionsService } from "../../../common/service/computing-unit/computing-unit-actions/computing-unit-actions.service"; +import { WorkflowPveService } from "../../service/virtual-environment/virtual-environment.service"; +import { NzModalService } from "ng-zorro-antd/modal"; +import { ExecutionState } from "../../types/execute-workflow.interface"; import { GuiConfigService } from "../../../common/service/gui-config.service"; /** @@ -71,6 +92,11 @@ describe("WorkflowFormComponent (rendered template)", () => { // the page's own inputs markup -- the section head, the empty state, the card and the form // wrapper -- rendered and covered. TestBed.overrideComponent(FormlyForm, { set: { template: "" } }); + // Blank the computing-unit selector's own template (a child, not the page): its real markup + // needs a modal/executions/PVE service chain out of scope here. Blanking the child -- rather + // than overriding the page's imports, which would JIT-recompile the page and drop its + // host-binding coverage -- keeps the run bar around it rendered and the page fully covered. + TestBed.overrideComponent(ComputingUnitSelectionComponent, { set: { template: "" } }); /* eslint-enable no-restricted-syntax */ await TestBed.configureTestingModule({ @@ -118,7 +144,17 @@ describe("WorkflowFormComponent (rendered template)", () => { { provide: OperatorMetadataService, useValue: { getOperatorMetadata: () => of({}) } }, { provide: FormBindingService, - useValue: { resolveFields: () => [], readValue: () => undefined, writeValue: vi.fn() }, + useValue: { + // An instruction so the instruction card renders and is covered. + getConfig: () => ({ + instruction: { title: "How to use this", body: "Fill in the inputs." }, + fields: [], + resultOperatorIds: [], + }), + resolveFields: () => [], + readValue: () => undefined, + writeValue: vi.fn(), + }, }, { provide: FormlyJsonschema, useValue: { toFieldConfig: () => ({ fieldGroup: [] }) } }, { provide: DynamicSchemaService, useValue: { getDynamicSchema: () => ({ jsonSchema: {} }) } }, @@ -126,13 +162,58 @@ describe("WorkflowFormComponent (rendered template)", () => { provide: WorkflowCompilingService, useValue: { getCompilationStateInfoChangedStream: () => EMPTY }, }, - { provide: ExecuteWorkflowService, useValue: { resetExecutionAndWorkers: vi.fn() } }, + { + provide: ExecuteWorkflowService, + useValue: { + getExecutionStateStream: () => EMPTY, + executeWorkflow: vi.fn(), + killWorkflow: vi.fn(), + resetExecutionAndWorkers: vi.fn(), + }, + }, { provide: WorkflowResultService, useValue: { clearResults: vi.fn() } }, { provide: NotificationService, useValue: { error: vi.fn() } }, { provide: UserService, useValue: { getCurrentUser: () => undefined, isLogin: () => false } }, - { provide: ComputingUnitStatusService, useValue: { disconnect: vi.fn() } }, + { provide: MarkdownService, useValue: { parse: (s: string) => s } }, + { + provide: ComputingUnitStatusService, + useValue: { + disconnect: vi.fn(), + getSelectedComputingUnit: () => EMPTY, + getStatus: () => EMPTY, + // Read by the (blanked) computing-unit selector's own ngOnInit. + getAllComputingUnits: () => EMPTY, + }, + }, + // The blanked computing-unit selector still constructs and runs ngOnInit; give it the few + // services it reads so it does not throw. It renders nothing (its template is blanked). + { provide: WorkflowComputingUnitManagingService, useValue: { getComputingUnitLimitOptions: () => EMPTY } }, + { provide: WorkflowExecutionsService, useValue: {} }, + { provide: ComputingUnitActionsService, useValue: {} }, + { provide: WorkflowPveService, useValue: {} }, + { provide: NzModalService, useValue: {} }, { provide: WorkflowConsoleService, useValue: { clearConsoleMessages: vi.fn() } }, + { + provide: WorkflowWebsocketService, + useValue: { subscribeToEvent: () => EMPTY, isConnected: true, getConnectionStatusStream: () => EMPTY }, + }, + { provide: ValidationWorkflowService, useValue: { getWorkflowValidationErrorStream: () => EMPTY } }, { provide: GuiConfigService, useValue: { env: { formViewEnabled: true } } }, + // Register the icons the run bar and instruction use, so nz-icon renders them inline instead + // of fetching each SVG over HTTP (an unresolved fetch that would hang fixture.whenStable). + { + provide: NZ_ICONS, + useValue: [ + InfoCircleOutline, + DownOutline, + PlusCircleOutline, + CaretRightOutline, + StopOutline, + WarningOutline, + LoadingOutline, + LockOutline, + ], + }, DatePipe, ], }).compileComponents(); @@ -253,6 +334,63 @@ describe("WorkflowFormComponent (rendered template)", () => { expect(el(".param.read-only")).not.toBeNull(); }); + it("renders the author's instruction card and toggles it", async () => { + fixture.detectChanges(); + finishLoad(); + // renderInstruction resolves the markdown on a microtask. + await fixture.whenStable(); + fixture.detectChanges(); + + expect(el(".card.instr")).not.toBeNull(); + expect(el(".instr .instr-bar h2")?.textContent?.trim()).toBe("How to use this"); + expect(el(".instr .md")?.innerHTML).toContain("Fill in the inputs."); + + (el(".instr-bar") as HTMLButtonElement).click(); + expect(fixture.componentInstance.instructionOpen).toBe(false); + }); + + it("renders the run bar with the run button and the computing-unit selector", () => { + fixture.detectChanges(); + finishLoad(); + + expect(el(".runbar .run")).not.toBeNull(); + // Default state: no unit chosen, so the button reads Connect and is disabled. + expect(el(".runbar .run")?.textContent?.trim()).toContain("Connect"); + expect((el(".runbar .run") as HTMLButtonElement).disabled).toBe(true); + expect(el(".runbar texera-computing-unit-selection")).not.toBeNull(); + // At rest there is nothing to count and no run note. + expect(el(".run-clock")).toBeNull(); + expect(el(".run-note")).toBeNull(); + }); + + it("fires onRun when the enabled run button is clicked", () => { + fixture.detectChanges(); + finishLoad(); + // A running state makes the button "Stop" (enabled); a disabled button would swallow the click. + fixture.componentInstance.executionState = ExecutionState.Running; + fixture.detectChanges(); + const run = vi.spyOn(fixture.componentInstance, "onRun").mockImplementation(() => {}); + + el(".runbar .run")!.click(); + + expect(run).toHaveBeenCalled(); + }); + + it("announces a run failure as an alert and a running note as a status", () => { + fixture.detectChanges(); + finishLoad(); + const c = fixture.componentInstance; + + c.runError = "Run failed: boom"; + fixture.detectChanges(); + expect(el(".run-note")?.getAttribute("role")).toBe("alert"); + + c.runError = ""; + c.executionState = ExecutionState.Running; + fixture.detectChanges(); + expect(el(".run-note")?.getAttribute("role")).toBe("status"); + }); + it("tears the workflow down when the browser unloads (the beforeunload host binding)", () => { fixture.detectChanges(); finishLoad(); 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 index 842cbea78d..4a9b7fa605 100644 --- 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 @@ -56,6 +56,15 @@ export function setupHarness() { const workflowMetaDataChangedStream = new Subject<unknown>(); // Compilation reports column names late; the form rebuilds its inputs off this stream. const compilationChanged = new Subject<unknown>(); + // Run-related streams the run tests drive: execution state, the engine's duration event, the + // computing-unit connection status, the workflow validity, and the websocket connection. + const executionStateStream = new Subject<any>(); + const durationEvents = new Subject<{ duration: number; isRunning: boolean }>(); + const statusStream = new Subject<any>(); + // The picked computing unit (with its accessPrivilege), separate from the connection status. + const selectedUnitStream = new Subject<any>(); + const validationStream = new Subject<{ errors: Record<string, unknown>; workflowEmpty: boolean }>(); + const connectionStream = new Subject<boolean>(); // The operators the graph holds: `hasOperatorIds` gates operatorSchemaFor, `graphOperators` // supplies each operator's type (which picks the custom widget). Tests add to them as needed. const hasOperatorIds = new Set<string>(); @@ -87,6 +96,9 @@ export function setupHarness() { // Resolves the exposed inputs and reads/writes their values. Tests point `resolveFields` at the // inputs they want rendered; `readValue` seeds the write-back guard. const formBindingService = { + // The presentation config: the instruction plus the fields. Tests override getConfig to give an + // instruction; resolveFields drives which inputs render. + getConfig: vi.fn().mockReturnValue({ instruction: undefined, fields: [], resultOperatorIds: [] }), resolveFields: vi.fn().mockReturnValue([]), readValue: vi.fn().mockReturnValue(undefined), writeValue: vi.fn(), @@ -160,14 +172,34 @@ export function setupHarness() { const coeditorPresenceService = { coeditors: [] }; const route = { snapshot: { params: { id: "7" } } }; const operatorMetadataService = { getOperatorMetadata: () => of({}) }; - const executeWorkflowService = { resetExecutionAndWorkers: vi.fn() }; + const executeWorkflowService = { + getExecutionStateStream: () => executionStateStream.asObservable(), + executeWorkflow: vi.fn(), + killWorkflow: vi.fn(), + resetExecutionAndWorkers: vi.fn(), + }; const workflowResultService = { clearResults: vi.fn() }; const notificationService = { error: vi.fn() }; // Not logged in by default so opening a workflow does not save; the save tests log in. const userService = { getCurrentUser: () => undefined, isLogin: vi.fn().mockReturnValue(false) }; - const cdr = { detectChanges: vi.fn() }; - const computingUnitStatusService = { disconnect: vi.fn() }; + const markdownService = { parse: (s: string) => s }; + const cdr = { detectChanges: vi.fn(), markForCheck: vi.fn() }; + const computingUnitStatusService = { + disconnect: vi.fn(), + getSelectedComputingUnit: () => selectedUnitStream.asObservable(), + getStatus: () => statusStream.asObservable(), + }; const workflowConsoleService = { clearConsoleMessages: vi.fn() }; + // The websocket the run clock and the "Connecting" state read. `isConnected` is a plain settable + // flag so a test can put the page in the connecting window. + const workflowWebsocketService = { + subscribeToEvent: (_: string) => durationEvents.asObservable(), + isConnected: true, + getConnectionStatusStream: () => connectionStream.asObservable(), + }; + const validationWorkflowService = { + getWorkflowValidationErrorStream: () => validationStream.asObservable(), + }; // The name field is measured off the host; querySelector returns null so the measuring // (DOM-layout, jsdom has none) short-circuits. `contains` drives isTypingInTheForm; false by // default so a rebuild is never suppressed, and overridden by the tests that probe typing. @@ -195,18 +227,27 @@ export function setupHarness() { workflowResultService, notificationService, userService, + markdownService, formlyJsonschema, cdr, dynamicSchemaService, workflowCompilingService, computingUnitStatusService, workflowConsoleService, + workflowWebsocketService, + validationWorkflowService, host, datePipe, config, workflowChangedStream, workflowMetaDataChangedStream, compilationChanged, + executionStateStream, + durationEvents, + statusStream, + selectedUnitStream, + validationStream, + connectionStream, hasOperatorIds, graphOperators, triggerCenterEvent,
