This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] 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 a268c1cc0d feat(workflow-form): run the workflow from the form and
show the author's instruction (#8440)
a268c1cc0d is described below
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,