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-8178-c9b75c048cb1af200c53e27fb9c8f428fdb01d8e in repository https://gitbox.apache.org/repos/asf/texera.git
commit d2eb275da60949ee249b12ff8d451a340f593887 Author: Xinyuan Lin <[email protected]> AuthorDate: Mon Aug 31 19:16:10 2026 +0000 test(frontend): close eight small service and component gaps (#8178) ### What changes were proposed in this PR? Eight small frontend gaps, each too small to be worth a PR alone. **+9 fully-covered lines and +4 branch arms.** | File | Codecov | Closed | |---|---|---| | `dashboard-entry.ts` | 117/119 → **119/119** | 205, 206 | | `settings.component.ts` | 27/28 → **28/28** | 78 | | `codearea-custom-template.component.ts` | 30/31 → **31/31** | 72 | | `result-panel-modal.component.html` | 23/24 → **24/24** | 56 | | `breakpoint-condition-input.component.html` | 8/9 → **9/9** | 27 (arms 0/2 → **2/2**) | | `hub-workflow-detail.component.html` | 45/46 → **46/46** | 27 | | `search.component.ts` | 54/56 → 55/56 | 119 | | `ui-udf-parameters-parser.service.ts` | 135/142 → 136/142 | 364 | Six of the eight reach 100%. ### Verification Measured against the **full** suite with no `--include` filter at all — 203 files / 5227 tests before, 203 / 5242 after, both green — because the `DA` line set for a file depends on which specs are in the run, so a filtered before/after pair can fabricate a delta. Parsed from `lcov.info` with Codecov's rule applied directly: a line counts only if its `DA` hit is non-zero **and** every `BRDA` arm on it is taken. Two reviewers returned ten findings; all repaired. **The repair pass moved coverage by exactly zero** — its three added tests are mutation-strength, not coverage. The builder's figures reproduced line for line. ### Two equivalent mutants, reported rather than papered over - `ui-udf-parameters-parser.service.ts:364` — `return parts.length ? parts : undefined` → `return parts;` survives the full suite. Both callers of `readMemberPath` treat `[]` and `undefined` identically, so it is not killable. No test was written to pretend otherwise. - `:349` — relaxing `parts?.length !== 2` to `parts.length < 2` survives for the same reason. Seven of the sixteen baseline gap lines across these files are given up rather than chased. Two files touched only by repaired specs are **not** claimed as gains: `result-panel-modal.component.ts` (46/47) and `hub-workflow-detail.component.ts` (98/99) are unchanged. `yarn format:ci` passes. `frontend/junit.xml` is regenerated by every run, is not gitignored, and is not committed. No production file is touched. ### Any related issues, documentation, discussions? Closes #8176 ### How was this PR tested? ``` npx ng test --watch=false --include="**/ui-udf-parameters-parser.service.spec.ts" --include="**/dashboard-entry.spec.ts" --include="**/search.component.spec.ts" --include="**/settings.component.spec.ts" --include="**/codearea-custom-template.component.spec.ts" --include="**/result-panel-modal.component.spec.ts" --include="**/breakpoint-condition-input.component.spec.ts" --include="**/hub-workflow-detail.component.spec.ts" ``` ``` Test Files 8 passed (8) ``` Re-run after rebasing onto current `main`. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Opus 5) --- .../component/user/search/search.component.spec.ts | 58 +++++++++- .../src/app/dashboard/type/dashboard-entry.spec.ts | 55 +++++++++ .../detail/hub-workflow-detail.component.spec.ts | 21 +++- .../breakpoint-condition-input.component.spec.ts | 31 +++++ .../codearea-custom-template.component.spec.ts | 126 +++++++++++++++++++-- .../left-panel/settings/settings.component.spec.ts | 20 ++++ .../result-panel-modal.component.spec.ts | 21 ++++ .../ui-udf-parameters-parser.service.spec.ts | 24 ++++ 8 files changed, 343 insertions(+), 13 deletions(-) diff --git a/frontend/src/app/dashboard/component/user/search/search.component.spec.ts b/frontend/src/app/dashboard/component/user/search/search.component.spec.ts index 381ed6f52a..3eafea2569 100644 --- a/frontend/src/app/dashboard/component/user/search/search.component.spec.ts +++ b/frontend/src/app/dashboard/component/user/search/search.component.spec.ts @@ -73,10 +73,10 @@ class MockSearchResultsComponent { // A plain filters double for the unit tests that drive component methods // directly (no rendering / no ViewChild). -function makeFiltersDouble(keywords: string[] = []) { +function makeFiltersDouble(keywords: string[] = [], masterFilterList: ReadonlyArray<string> = []) { return { masterFilterListChange: EMPTY, - masterFilterList: [] as ReadonlyArray<string>, + masterFilterList, getSearchKeywords: () => keywords, getSearchFilterParameters: () => ({}), } as unknown as FiltersComponent; @@ -216,6 +216,60 @@ describe("SearchComponent", () => { expect(results.reset).not.toHaveBeenCalled(); }); + // The duplicate-search guard above compares list *lengths* and then the terms themselves. With + // two empty lists the term comparison never runs at all, so these three drive it with non-empty + // lists: equal terms still short-circuit, differing terms do not, and a shorter incoming list + // does not sneak past `Array.every` (which is vacuously true on a shorter receiver). + // + // Every case below deliberately gives the double a *different* keyword list from its filter list. + // The guard reads `filters.masterFilterList`, and `filters.getSearchKeywords()` is right next to + // it in the same object; with the two set to equal arrays the receiver could be swapped for the + // keywords and nothing would notice. + it("skips a duplicate search when a non-empty filter list is unchanged", async () => { + component.filters = makeFiltersDouble(["kw"], ["alpha"]); + const results = makeSearchResultsDouble(); + component.searchResultsComponent = results as unknown as SearchResultsComponent; + component.masterFilterList = ["alpha"]; + component.lastSortMethod = component.sortMethod; + component.lastSelectedType = component.selectedType; + + await component.search(); + + expect(results.reset).not.toHaveBeenCalled(); + }); + + it("re-runs the search when a same-length filter list holds different terms", async () => { + component.filters = makeFiltersDouble(["kw"], ["alpha"]); + const results = makeSearchResultsDouble(); + component.searchResultsComponent = results as unknown as SearchResultsComponent; + component.masterFilterList = ["beta"]; + component.lastSortMethod = component.sortMethod; + component.lastSelectedType = component.selectedType; + + await component.search(); + + expect(results.reset).toHaveBeenCalledTimes(1); + expect(component.masterFilterList).toEqual(["alpha"]); + }); + + it("re-runs the search when the new filter list is a prefix of the last one", async () => { + // The element-wise comparison alone cannot see this: `["alpha"].every((v, i) => v === ["alpha", + // "beta"][i])` is true, so without the length conjunct every narrowing of the filter box - + // including clearing it - would be dismissed as "same list" and the panel would keep showing + // the previous query's results. + component.filters = makeFiltersDouble(["kw"], ["alpha"]); + const results = makeSearchResultsDouble(); + component.searchResultsComponent = results as unknown as SearchResultsComponent; + component.masterFilterList = ["alpha", "beta"]; + component.lastSortMethod = component.sortMethod; + component.lastSelectedType = component.selectedType; + + await component.search(); + + expect(results.reset).toHaveBeenCalledTimes(1); + expect(component.masterFilterList).toEqual(["alpha"]); + }); + it("throws when the results component is missing", async () => { component.filters = makeFiltersDouble(["x"]); component.searchResultsComponent = undefined; diff --git a/frontend/src/app/dashboard/type/dashboard-entry.spec.ts b/frontend/src/app/dashboard/type/dashboard-entry.spec.ts index 9fa2c62595..76ad406673 100644 --- a/frontend/src/app/dashboard/type/dashboard-entry.spec.ts +++ b/frontend/src/app/dashboard/type/dashboard-entry.spec.ts @@ -22,6 +22,7 @@ import { EntityType } from "../../hub/service/hub.service"; import { DashboardWorkflow } from "./dashboard-workflow.interface"; import { DashboardFile } from "./dashboard-file.interface"; import { DashboardDataset } from "./dashboard-dataset.interface"; +import { DashboardModel } from "./dashboard-model.interface"; import { DashboardWorkflowComputingUnit } from "../../common/type/workflow-computing-unit"; import { ExecutionMode } from "../../common/type/workflow"; @@ -92,6 +93,28 @@ function makeDataset(): DashboardDataset { }; } +function makeModel(): DashboardModel { + return { + isOwner: false, + ownerEmail: "[email protected]", + model: { + mid: 606, + ownerUid: 60, + name: "My Model", + repositoryName: "my-model", + isPublic: true, + isDownloadable: false, + description: "A sample model", + creationTime: 1700000006000, + coverImage: "http://example.com/model-cover.png", + framework: "pytorch", + format: "safetensors", + }, + accessPrivilege: "WRITE", + size: 8765, + }; +} + function makeComputingUnit(): DashboardWorkflowComputingUnit { return { computingUnit: { @@ -181,6 +204,32 @@ describe("DashboardEntry", () => { expect(entry.coverImageUrl).toBe("http://example.com/dataset-cover.png"); }); + it("maps a DashboardModel to the Model entity and copies model fields", () => { + const entry = new DashboardEntry(makeModel()); + + expect(entry.type).toBe(EntityType.Model); + expect(entry.id).toBe(606); + expect(entry.name).toBe("My Model"); + expect(entry.description).toBe("A sample model"); + expect(entry.creationTime).toBe(1700000006000); + expect(entry.lastModifiedTime).toBe(1700000006000); + expect(entry.accessLevel).toBe("WRITE"); + expect(entry.ownerEmail).toBe("[email protected]"); + expect(entry.ownerId).toBe(60); + expect(entry.size).toBe(8765); + expect(entry.coverImageUrl).toBe("http://example.com/model-cover.png"); + // The model branch has no hub-side counters to copy, so it hard-codes placeholders. They are + // read straight by the card templates, so leaving them unasserted would let any of the seven + // be changed to a fabricated value with nothing failing. + expect(entry.ownerName).toBe(""); + expect(entry.ownerAvatar).toBe(""); + expect(entry.viewCount).toBe(0); + expect(entry.cloneCount).toBe(0); + expect(entry.likeCount).toBe(0); + expect(entry.isLiked).toBe(false); + expect(entry.accessibleUserIds).toEqual([]); + }); + it("maps a DashboardWorkflowComputingUnit to the ComputingUnit entity and copies computing-unit fields", () => { const entry = new DashboardEntry(makeComputingUnit()); @@ -273,6 +322,12 @@ describe("DashboardEntry", () => { expect(() => new DashboardEntry(makeWorkflow()).dataset).toThrowError("Value is not of type DashboardDataset"); }); + it("model getter returns the value for a model entry and throws for others", () => { + const modelValue = makeModel(); + expect(new DashboardEntry(modelValue).model).toBe(modelValue); + expect(() => new DashboardEntry(makeWorkflow()).model).toThrowError("Value is not of type DashboardModel"); + }); + it("computingUnit getter returns the value for a computing-unit entry and throws for others", () => { const computingUnitValue = makeComputingUnit(); expect(new DashboardEntry(computingUnitValue).computingUnit).toBe(computingUnitValue); diff --git a/frontend/src/app/hub/component/workflow/detail/hub-workflow-detail.component.spec.ts b/frontend/src/app/hub/component/workflow/detail/hub-workflow-detail.component.spec.ts index 31c3c8e0a4..25e91bbc82 100644 --- a/frontend/src/app/hub/component/workflow/detail/hub-workflow-detail.component.spec.ts +++ b/frontend/src/app/hub/component/workflow/detail/hub-workflow-detail.component.spec.ts @@ -550,8 +550,11 @@ describe("HubWorkflowDetailComponent", () => { */ describe("HubWorkflowDetailComponent rendered with its real children", () => { let fixture: ComponentFixture<HubWorkflowDetailComponent>; + // goBack() chains .catch() onto the navigation result, so this has to be a real promise. + let renderedRouter: { navigateByUrl: ReturnType<typeof vi.fn>; navigate: ReturnType<typeof vi.fn> }; function render(opts: { isHub: boolean }): void { + renderedRouter = { navigateByUrl: vi.fn().mockResolvedValue(true), navigate: vi.fn().mockResolvedValue(true) }; TestBed.resetTestingModule(); TestBed.configureTestingModule({ imports: [ @@ -572,7 +575,7 @@ describe("HubWorkflowDetailComponent rendered with its real children", () => { provide: ActivatedRoute, useValue: { snapshot: { params: opts.isHub ? { id: "5" } : {} } }, }, - { provide: Router, useValue: { navigateByUrl: vi.fn(), navigate: vi.fn() } }, + { provide: Router, useValue: renderedRouter }, { provide: HubService, useValue: { @@ -621,6 +624,22 @@ describe("HubWorkflowDetailComponent rendered with its real children", () => { expect((fixture.nativeElement as HTMLElement).querySelector(".go-back-button")).not.toBeNull(); }); + it("navigates back to the hub listing when the back button is clicked", () => { + // The goBack() unit test above calls the method directly, so nothing pinned the button's + // (click) binding: drop it from the template and that test still passes while the arrow + // becomes inert. + render({ isHub: true }); + + (fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>(".go-back-button")!.click(); + + // Asserted as a literal, not as HUB_WORKFLOW_RESULT: the component navigates with that same + // symbol, so a symbolic assertion moves with it and cannot see the destination change. The + // route table in app-routing.module.ts spells the segments out as literals and does not import + // the constant, so the two really can drift apart into a navigation to a dead route. + expect(renderedRouter.navigateByUrl).toHaveBeenCalledWith("/hub/workflow/result"); + expect(HUB_WORKFLOW_RESULT).toBe("/hub/workflow/result"); + }); + it("hides the back button when the wid arrived as modal data", () => { // The converse. Without it the `*ngIf` could be replaced by a constant and the positive case // above would still pass. diff --git a/frontend/src/app/workspace/component/code-editor-dialog/breakpoint-condition-input/breakpoint-condition-input.component.spec.ts b/frontend/src/app/workspace/component/code-editor-dialog/breakpoint-condition-input/breakpoint-condition-input.component.spec.ts index 18f16f05a4..b96eac284d 100644 --- a/frontend/src/app/workspace/component/code-editor-dialog/breakpoint-condition-input/breakpoint-condition-input.component.spec.ts +++ b/frontend/src/app/workspace/component/code-editor-dialog/breakpoint-condition-input/breakpoint-condition-input.component.spec.ts @@ -178,6 +178,37 @@ describe("BreakpointConditionInputComponent", () => { }); }); + describe("the condition textarea", () => { + const textarea = (): HTMLTextAreaElement => fixture.nativeElement.querySelector("textarea.condition-textarea"); + + it("carries the current condition into the textarea", async () => { + component.condition = "x > 1"; + fixture.detectChanges(); + await fixture.whenStable(); + + expect(textarea().value).toBe("x > 1"); + }); + + it("saves what the user types, not what the class was holding", async () => { + // Every other test in this file writes `condition` from the class side, so the *inbound* + // half of the two-way binding is all that is pinned: downgrade the template to a one-way + // [ngModel] and the whole suite stays green while the popup silently discards every + // keystroke. Drive a real input event and then let the save path read it back. + component.condition = "stale"; + fixture.detectChanges(); + await fixture.whenStable(); + + textarea().value = " y != 2 "; + textarea().dispatchEvent(new Event("input")); + + expect(component.condition).toBe(" y != 2 "); + + component.handleEvent(new KeyboardEvent("keydown", { key: "Enter" })); + + expect(mockUdfDebugService.doUpdateBreakpointCondition).toHaveBeenCalledWith("test-operator", 1, "y != 2"); + }); + }); + describe("visibility", () => { it("is visible only while a line is targeted", () => { // The template keys its *ngIf on this, so an inverted getter leaves the popup stuck open. diff --git a/frontend/src/app/workspace/component/codearea-custom-template/codearea-custom-template.component.spec.ts b/frontend/src/app/workspace/component/codearea-custom-template/codearea-custom-template.component.spec.ts index 6b1a81cc21..7bf63d610e 100644 --- a/frontend/src/app/workspace/component/codearea-custom-template/codearea-custom-template.component.spec.ts +++ b/frontend/src/app/workspace/component/codearea-custom-template/codearea-custom-template.component.spec.ts @@ -27,7 +27,7 @@ import { FormControl } from "@angular/forms"; import { commonTestProviders } from "../../../common/testing/test-utils"; import { CodeEditorService } from "../../service/code-editor/code-editor.service"; import { CoeditorPresenceService } from "../../service/workflow-graph/model/coeditor-presence.service"; -import { Subject } from "rxjs"; +import { config as rxjsConfig, Subject, take } from "rxjs"; describe("CodeareaCustomTemplateComponent", () => { let component: CodeareaCustomTemplateComponent; @@ -78,6 +78,21 @@ describe("CodeareaCustomTemplateComponent", () => { return TestBed.inject(WorkflowActionService).getJointGraphWrapper().getCurrentHighlightedOperatorIDs()[0]; } + // The shared flag as the service currently publishes it for one operator. `getEditorState` + // hands back a BehaviorSubject as an observable, so the current value arrives synchronously on + // subscribe; `take(1)` consumes that one value and completes, instead of leaving a live + // subscription on a service-owned subject behind for the rest of the test. Returns + // `boolean | undefined` on purpose: a service that stopped replaying a current value would + // yield `undefined` here and fail the caller's assertion rather than quietly reading stale. + function publishedEditorState(operatorId: string): boolean | undefined { + let published: boolean | undefined; + codeEditorService + .getEditorState(operatorId) + .pipe(take(1)) + .subscribe(v => (published = v)); + return published; + } + beforeEach(() => { const workflowActionService = TestBed.inject(WorkflowActionService); vi.spyOn(workflowActionService.getJointGraphWrapper(), "getCurrentHighlightedOperatorIDs").mockReturnValue([ @@ -107,9 +122,7 @@ describe("CodeareaCustomTemplateComponent", () => { // detached from the operator property it is meant to edit. expect(component.componentRef!.instance.formControl).toBe(component.field.formControl); - let published: boolean | undefined; - codeEditorService.getEditorState(highlightedOperatorId()).subscribe(v => (published = v)); - expect(published).toBe(true); + expect(publishedEditorState(highlightedOperatorId())).toBe(true); }); it("clears the flag again when the editor component is destroyed", () => { @@ -117,9 +130,7 @@ describe("CodeareaCustomTemplateComponent", () => { component.componentRef!.destroy(); expect(component.isEditorOpen).toBe(false); - let published: boolean | undefined; - codeEditorService.getEditorState(highlightedOperatorId()).subscribe(v => (published = v)); - expect(published).toBe(false); + expect(publishedEditorState(highlightedOperatorId())).toBe(false); }); it("opens the editor when a co-editor opens one", () => { @@ -147,6 +158,103 @@ describe("CodeareaCustomTemplateComponent", () => { expect(remoteComponent.componentRef).toBeDefined(); }); + it("tears this client's editor down when any co-editor closes one, whichever operator it names", () => { + // The mirror image of the open case above, and the half that was missing: a co-editor + // closing the dialog has to close it here too, or this client keeps typing into an editor + // the other side has already dismissed. + const closed = new Subject<{ operatorId: string }>(); + vi.spyOn(TestBed.inject(CoeditorPresenceService), "getCoeditorClosedCodeEditorSubject").mockReturnValue( + closed.asObservable() + ); + + const remoteFixture = TestBed.createComponent(CodeareaCustomTemplateComponent); + const remoteComponent = remoteFixture.componentInstance; + remoteComponent.field = { props: {}, formControl: new FormControl() } as any; + remoteFixture.detectChanges(); + remoteComponent.openEditor(); + expect(remoteComponent.isEditorOpen).toBe(true); + + // The subscriber binds the payload to `_` and discards it, so a deliberately foreign operator + // id is the honest fixture: naming this panel's own operator would advertise a targeting + // filter the component does not have, and would leave a later filter free to be added without + // any test noticing. + const destroySpy = vi.spyOn(remoteComponent.componentRef!, "destroy"); + closed.next({ operatorId: "a-completely-unrelated-operator" }); + + // The spy is what proves teardown. The shared flag below is reachable *without* any teardown: + // a subscriber that merely published `false` for this operator would satisfy it through + // ngOnInit's getEditorState subscription while the dialog stayed on screen. vi.spyOn calls + // through, so the real destroy still runs and the flag assertions still describe the result. + expect(destroySpy).toHaveBeenCalledTimes(1); + expect(remoteComponent.isEditorOpen).toBe(false); + expect(publishedEditorState(highlightedOperatorId())).toBe(false); + }); + + it("stays quiet when a co-editor closes an editor this panel never opened", async () => { + // componentRef is still undefined here; the optional call is what keeps a close broadcast + // from throwing in every panel that happens to be mounted but closed. A close broadcast + // reaches EVERY mounted codearea, so most recipients are in exactly this state. + const closed = new Subject<{ operatorId: string }>(); + vi.spyOn(TestBed.inject(CoeditorPresenceService), "getCoeditorClosedCodeEditorSubject").mockReturnValue( + closed.asObservable() + ); + + const remoteFixture = TestBed.createComponent(CodeareaCustomTemplateComponent); + const remoteComponent = remoteFixture.componentInstance; + remoteComponent.field = { props: {}, formControl: new FormControl() } as any; + remoteFixture.detectChanges(); + + // A throw inside a subscriber does NOT propagate out of Subject.next() - RxJS swallows it and + // reports it on its unhandled-error channel one macrotask later. So `expect(...).not.toThrow()` + // would pass even with the optional call removed; watch that channel instead. + const unhandled = vi.fn(); + const previousHandler = rxjsConfig.onUnhandledError; + // Two restores, for two different exits. The `finally` below is the normal one, and it puts + // the handler back before the assertions so anything RxJS reports afterwards is reported, + // not swallowed. It is skipped, though, if the awaited flush never resolves and Vitest kills + // this test on its 20s timeout, and a handler left installed silently eats every later + // unhandled error. That blast radius is not limited to this file: the Angular unit-test + // builder defaults `isolate: false`, so one forked worker keeps a single module registry + // across all the spec FILES it runs, and this `rxjs` `config` is therefore the same object + // every one of them sees. (Measured with three probe specs pinned to one worker: same pid, + // and each file read the mutation the previous file had left behind.) `onTestFinished` runs + // however the test ends - verified against a never-resolving await, where a `finally` + // provably cannot - so it closes that exit; re-assigning the same value after the `finally` + // already ran is a no-op. Deliberately not hoisted into beforeEach/afterEach: that would + // install this swallowing handler for all eight tests. + // + // Files sharing a worker do run one at a time, so the reverse hazard a reviewer raised - a + // CONCURRENT spec file having its unhandled error captured by this `unhandled` spy - cannot + // happen. Sequential execution is the reason, not per-file isolation, which does not exist. + onTestFinished(() => { + rxjsConfig.onUnhandledError = previousHandler; + }); + rxjsConfig.onUnhandledError = unhandled; + try { + closed.next({ operatorId: "a-completely-unrelated-operator" }); + // A real macrotask, not fake timers. `next()` schedules RxJS's report timer synchronously, + // before this line creates its own, and Node fires equal-delay timers in insertion order - + // so the report always lands before the await resumes. That order is load-bearing, not + // just tidy: `reportUnhandledError` reads `config.onUnhandledError` INSIDE its timer + // callback, so were the two to swap, the `finally` would already have restored and the + // error would escape as a genuine unhandled throw instead of reaching the spy. Fake timers + // would buy nothing here (~1ms of a 167ms file) and would cost the flush being a + // whole-timer-API swap while an Angular fixture is live, draining whatever else the + // fixture had queued - and any leaked fake-timer state would follow the shared worker into + // later spec files, the same way a leaked handler would. + await new Promise(resolve => setTimeout(resolve, 0)); + } finally { + rxjsConfig.onUnhandledError = previousHandler; + } + + expect(unhandled).not.toHaveBeenCalled(); + // Trivially satisfied against pristine production - nothing in this test can set the flag - + // but not decorative: this is the assertion that catches the open and close subscriber bodies + // being swapped, in which case a close broadcast would OPEN an editor in a panel that had + // none. Measured: with this line the swap fails 3 tests here, without it only 2. + expect(remoteComponent.isEditorOpen).toBe(false); + }); + it("persists the open flag on destroy so a reopened panel restores it", () => { component.openEditor(); @@ -154,9 +262,7 @@ describe("CodeareaCustomTemplateComponent", () => { // ngOnDestroy writes the CURRENT flag rather than a hardcoded false, so a component torn // down with its editor still up comes back open. - let published: boolean | undefined; - codeEditorService.getEditorState(highlightedOperatorId()).subscribe(v => (published = v)); - expect(published).toBe(true); + expect(publishedEditorState(highlightedOperatorId())).toBe(true); }); it("tracks an external state change through ngOnInit's subscription", () => { diff --git a/frontend/src/app/workspace/component/left-panel/settings/settings.component.spec.ts b/frontend/src/app/workspace/component/left-panel/settings/settings.component.spec.ts index 7d73319ab9..ab13c540f3 100644 --- a/frontend/src/app/workspace/component/left-panel/settings/settings.component.spec.ts +++ b/frontend/src/app/workspace/component/left-panel/settings/settings.component.spec.ts @@ -68,6 +68,11 @@ class StubWorkflowActionService { workflowChanged(): Observable<unknown> { return this.workflowChangedSubject.asObservable(); } + + /** Stands in for the real service's own emissions: undo/redo, a reloaded workflow, a co-editor's edit. */ + emitWorkflowChanged(): void { + this.workflowChangedSubject.next(undefined); + } } describe("SettingsComponent", () => { @@ -180,6 +185,21 @@ describe("SettingsComponent", () => { expect(updateModeSpy).toHaveBeenCalledWith(ExecutionMode.MATERIALIZED); }); + it("should re-sync the form when the workflow changes underneath it", () => { + // The settings can move without the form: undo/redo, a reloaded workflow, a co-editor's edit. + workflowActionService.setWorkflowDataTransferBatchSize(777); + workflowActionService.updateExecutionMode(ExecutionMode.MATERIALIZED); + + workflowActionService.emitWorkflowChanged(); + + expect(component.settingsForm.get("dataTransferBatchSize")!.value).toBe(777); + expect(component.settingsForm.get("executionMode")!.value).toBe(ExecutionMode.MATERIALIZED); + // The patch is applied with { emitEvent: false }. Without that, writing the incoming values + // back into the form would re-fire both valueChanges subscriptions and echo the very state we + // just received straight back to the server on every remote change. + expect(workflowPersistSpy.persistWorkflow).not.toHaveBeenCalled(); + }); + it("should ignore form value changes that fail validation", () => { const setBatchSizeSpy = vi.spyOn(workflowActionService, "setWorkflowDataTransferBatchSize"); diff --git a/frontend/src/app/workspace/component/result-panel/result-panel-modal.component.spec.ts b/frontend/src/app/workspace/component/result-panel/result-panel-modal.component.spec.ts index f82bd871c3..84a1eeff8e 100644 --- a/frontend/src/app/workspace/component/result-panel/result-panel-modal.component.spec.ts +++ b/frontend/src/app/workspace/component/result-panel/result-panel-modal.component.spec.ts @@ -249,6 +249,7 @@ describe("RowModalComponent (template rendering)", () => { const rowData = { video: "data:video/mp4;base64,vid123", + audio: "data:audio/mp3;base64,aud123", image: "data:image/png;base64,img123", }; @@ -281,6 +282,26 @@ describe("RowModalComponent (template rendering)", () => { expect(videoEl.src).toBe(rowData.video); }); + it("should bind the audio element's src to the audio entry's mediaSrc", () => { + const audioEl = fixture.debugElement.query(By.css("audio")).nativeElement as HTMLAudioElement; + expect(audioEl.src).toBe(rowData.audio); + }); + + it("should re-render the audio src when the entry's mediaSrc is replaced", () => { + // For a data-URI fixture `mediaSrc` and `value` hold the same string, so the assertion above + // cannot tell the two apart - `[src]="entry.value"` satisfies it. They diverge exactly where it + // matters: for a remote http(s) URL `mediaSrc` starts empty and is later replaced by the + // `blob:` URL the SSRF-allowlisted proxy produced, while `value` keeps the raw remote URL. + // Binding `value` would load remote media directly and bypass that proxy. This drives the same + // late replacement the proxy path performs, without needing the HTTP round trip. + const audioEntry = component.rowEntries.find(entry => entry.key === "audio")!; + audioEntry.mediaSrc = "data:audio/mp3;base64,REBOUND"; + fixture.detectChanges(); + + const audioEl = fixture.debugElement.query(By.css("audio")).nativeElement as HTMLAudioElement; + expect(audioEl.src).toBe("data:audio/mp3;base64,REBOUND"); + }); + it("should bind the image element's src to the image entry's mediaSrc", () => { const imgEl = fixture.debugElement.query(By.css("img")).nativeElement as HTMLImageElement; expect(imgEl.src).toBe(rowData.image); diff --git a/frontend/src/app/workspace/service/code-editor/ui-udf-parameters-parser.service.spec.ts b/frontend/src/app/workspace/service/code-editor/ui-udf-parameters-parser.service.spec.ts index 94a92148ad..b9241ce1e2 100644 --- a/frontend/src/app/workspace/service/code-editor/ui-udf-parameters-parser.service.spec.ts +++ b/frontend/src/app/workspace/service/code-editor/ui-udf-parameters-parser.service.spec.ts @@ -154,6 +154,30 @@ describe("UiUdfParametersParserService", () => { `, [], ], + [ + // `"s".` parses as a member access whose receiver is a string literal, so the node carries + // no VariableName/PropertyName children at all and the member path it yields is empty + // rather than one- or two-part. Such a call has to be dropped, not turned into a parameter + // with a fabricated receiver and type. + "ignore a type written as a member access with no name parts", + ` + self.UiParameter(name="x", type="s".) + self.UiParameter("valid", AttributeType.STRING) + `, + [parameter("valid", "string")], + ], + [ + // A tuple is not a member access, but its direct children are two VariableNames that read + // exactly like `AttributeType` + `STRING`. Only the node-kind check in front of the member + // path stops `(AttributeType, STRING)` from being accepted as the STRING type, so this is + // the case that keeps that check honest. + "ignore a type written as a tuple that reads like a member path", + ` + self.UiParameter(name="x", type=(AttributeType, STRING)) + self.UiParameter("valid", AttributeType.STRING) + `, + [parameter("valid", "string")], + ], ] as ReadonlyArray<readonly [string, string, UiUdfParameter[]]> ).forEach(([description, openBody, expectedParameters]) => { it(`should ${description}`, () => {
