You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@texera.apache.org by gi...@apache.org on 2026/08/09 09:40:01 UTC

(texera) branch main updated (1e899aa494 -> fb1a5c4255)

This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a change to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git


    from 1e899aa494 test(frontend): pin the admin settings form's wiring (#7426)
     new f9deb66dcc test(frontend): render the workflow list item's editing gates and row actions (#7424)
     new fb1a5c4255 test(frontend): cover the debugger's breakpoint gutter (#7425)

The 2 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 .../user-workflow-list-item.component.spec.ts      | 245 ++++++++++++++++++++-
 .../code-debugger.component.spec.ts                | 220 ++++++++++++++++++
 2 files changed, 464 insertions(+), 1 deletion(-)


(texera) 02/02: test(frontend): cover the debugger's breakpoint gutter (#7425)

Posted by gi...@apache.org.
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

commit fb1a5c4255f03e195e4ff36821d29acb71348071
Author: Xinyuan Lin <xi...@uci.edu>
AuthorDate: Sun Aug 9 02:27:00 2026 -0700

    test(frontend): cover the debugger's breakpoint gutter (#7425)
    
    ### What changes were proposed in this PR?
    
    `setupMonacoBreakpointMethods` was the component's one uncovered block.
    The existing suite stubs it out — with a comment saying so — because the
    minimal editor mock cannot back a real `MonacoBreakpoint`, so neither of
    the two overrides it installs was exercised.
    
    Adds 13 tests over both.
    
    **The glyph override** decides what the gutter shows:
    
    ```
    exists && condition present   ->  monaco-conditional-breakpoint
    exists && no condition        ->  monaco-breakpoint
    hovering only                 ->  monaco-hover-breakpoint
    ```
    
    Covered including the `Boolean(condition?.trim())` guard — a condition
    left as whitespace must render as an ordinary breakpoint rather than
    claiming a condition the debugger will not apply — and the lookup
    happening at `range.startLineNumber`, since reading `endLineNumber`
    would attribute another line's condition to this glyph.
    
    **The mouse-down override** replaces the library's own handler. The
    `dispose()` before re-registering is load-bearing: two live handlers
    would add and immediately remove a breakpoint on a single click. A left
    click toggles; a right click opens the condition input instead of
    toggling, and only for a line that already has a breakpoint; clicks
    below the last line and outside the gutter do nothing.
    
    **Verified by mutation**, all reverted (production diff empty):
    
    | Mutation | Result |
    |---|---|
    | treat a blank condition as a condition | red |
    | read the condition from the range's end line | red |
    | key the condition lookup to a fixed operator | red |
    | swap the conditional and plain glyphs | red |
    | swap the exists and hover arms | red |
    | skip disposing the previous mouse-down handler | red |
    | drop the gutter target-type check | red |
    | drop the after-lines guard | red |
    | invert the left/right button branch | red |
    
    The stand-in editor is a `Proxy` that answers any unstubbed `on*`
    listener with an inert disposable, so the spec does not have to track
    which events `monaco-breakpoints` subscribes to — the first attempt
    failed on `onDidChangeCursorPosition`, and guessing at the rest would
    have been fragile.
    
    No production file is touched.
    
    ### Any related issues, documentation, discussions?
    
    Closes #7422
    
    ### How was this PR tested?
    
    ```
    npx ng test --watch=false --include="**/code-debugger.component.spec.ts"
    ```
    
    ```
     Test Files  1 passed (1)
          Tests  29 passed (29)
    ```
    
    13 new on top of the existing 16. `yarn format:ci` passes.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 5)
---
 .../code-debugger.component.spec.ts                | 220 +++++++++++++++++++++
 1 file changed, 220 insertions(+)

diff --git a/frontend/src/app/workspace/component/code-editor-dialog/code-debugger.component.spec.ts b/frontend/src/app/workspace/component/code-editor-dialog/code-debugger.component.spec.ts
index cac2331de8..dbbfe40e10 100644
--- a/frontend/src/app/workspace/component/code-editor-dialog/code-debugger.component.spec.ts
+++ b/frontend/src/app/workspace/component/code-editor-dialog/code-debugger.component.spec.ts
@@ -30,6 +30,7 @@ import { commonTestProviders } from "../../../common/testing/test-utils";
 import type { Mocked } from "vitest";
 import type { MonacoBreakpoint } from "monaco-breakpoints";
 import type * as monaco from "monaco-editor";
+import * as monacoNs from "monaco-editor";
 describe("CodeDebuggerComponent", () => {
   let component: CodeDebuggerComponent;
   let fixture: ComponentFixture<CodeDebuggerComponent>;
@@ -392,3 +393,222 @@ describe("CodeDebuggerComponent", () => {
     });
   });
 });
+/**
+ * setupMonacoBreakpointMethods is the one uncovered block in the component: the suite above stubs it
+ * out because the minimal editor mock cannot back a real MonacoBreakpoint. It decides which gutter
+ * glyph a line gets and what a gutter click does, so it is worth driving directly.
+ */
+describe("CodeDebuggerComponent breakpoint gutter", () => {
+  const GUTTER = monacoNs.editor.MouseTargetType.GUTTER_GLYPH_MARGIN;
+  const CONTENT_TEXT = monacoNs.editor.MouseTargetType.CONTENT_TEXT;
+
+  let component: CodeDebuggerComponent;
+  let fixture: ComponentFixture<CodeDebuggerComponent>;
+  let debugService: {
+    getDebugState: ReturnType<typeof vi.fn>;
+    doModifyBreakpoint: ReturnType<typeof vi.fn>;
+    getCondition: ReturnType<typeof vi.fn>;
+  };
+  let editor: any;
+  let mouseDown: ((evt: any) => void) | undefined;
+  let firstMouseDownDisposable: { dispose: ReturnType<typeof vi.fn> } | undefined;
+
+  const operatorId = "op-under-debug";
+
+  /**
+   * A stand-in editor exposing only what MonacoBreakpoint and the override touch. Every listener
+   * hands back its own spy disposable so the dispose-before-reregister step is observable.
+   */
+  function makeEditor() {
+    const disposable = () => ({ dispose: vi.fn() });
+    const model = {
+      deltaDecorations: vi.fn(() => [] as string[]),
+      getLineCount: vi.fn(() => 100),
+      getValue: vi.fn(() => ""),
+      onDidChangeContent: vi.fn(disposable),
+      uri: { toString: () => "inmemory://model/1" },
+    };
+    let downCount = 0;
+    const base: Record<string, unknown> = {
+      onMouseDown: vi.fn((h: (evt: any) => void) => {
+        downCount += 1;
+        const d = disposable();
+        if (downCount === 1) {
+          // The MonacoBreakpoint constructor registers first; the override replaces it.
+          firstMouseDownDisposable = d;
+        } else {
+          mouseDown = h;
+        }
+        return d;
+      }),
+      getModel: vi.fn(() => model),
+      deltaDecorations: vi.fn(() => [] as string[]),
+      createDecorationsCollection: vi.fn(() => ({ set: vi.fn(), clear: vi.fn() })),
+      dispose: vi.fn(),
+    };
+    // MonacoBreakpoint subscribes to a handful of editor events whose exact set is its own business;
+    // anything named on* that is not explicitly stubbed above yields an inert disposable.
+    const editor = new Proxy(base, {
+      get(target, prop) {
+        if (prop in target) {
+          return target[prop as string];
+        }
+        if (typeof prop === "string" && prop.startsWith("on")) {
+          return () => disposable();
+        }
+        return undefined;
+      },
+      has() {
+        return true;
+      },
+    });
+    return { model, editor };
+  }
+
+  function gutterClick(lineNumber: number, opts: { leftButton?: boolean; isAfterLines?: boolean; type?: number } = {}) {
+    return {
+      target: {
+        type: opts.type ?? GUTTER,
+        detail: { isAfterLines: opts.isAfterLines ?? false },
+        position: { lineNumber },
+      },
+      event: { leftButton: opts.leftButton ?? true },
+    };
+  }
+
+  /** Calls the installed override the way monaco-breakpoints would. exists=true means Exist. */
+  function glyphFor(lineNumber: number, exists: boolean): string {
+    const range = { startLineNumber: lineNumber, endLineNumber: lineNumber + 5 } as any;
+    const decoration = (component.monacoBreakpoint as any)["createBreakpointDecoration"](range, exists ? 0 : 1);
+    return decoration.options.glyphMarginClassName;
+  }
+
+  beforeEach(async () => {
+    mouseDown = undefined;
+    firstMouseDownDisposable = undefined;
+    debugService = {
+      getDebugState: vi.fn(() => new Y.Doc().getMap<BreakpointInfo>("debug")),
+      doModifyBreakpoint: vi.fn(),
+      getCondition: vi.fn(() => undefined),
+    };
+
+    TestBed.resetTestingModule();
+    await TestBed.configureTestingModule({
+      imports: [CodeDebuggerComponent],
+      schemas: [CUSTOM_ELEMENTS_SCHEMA],
+      providers: [
+        {
+          provide: WorkflowStatusService,
+          useValue: { getStatusUpdateStream: vi.fn(() => new Subject().asObservable()) },
+        },
+        { provide: UdfDebugService, useValue: debugService },
+        ...commonTestProviders,
+      ],
+    }).compileComponents();
+
+    fixture = TestBed.createComponent(CodeDebuggerComponent);
+    component = fixture.componentInstance;
+    component.currentOperatorId = operatorId;
+
+    const fake = makeEditor();
+    editor = fake.editor;
+    component.monacoEditor = editor;
+    component.setupMonacoBreakpointMethods(editor);
+  });
+
+  afterEach(() => {
+    // This suite drives a real MonacoBreakpoint, so tear it down the way the component does. Left
+    // alive, its editor listeners would outlive the test that registered them.
+    component.removeMonacoBreakpointMethods();
+    component.monacoEditor?.dispose();
+  });
+
+  describe("gutter glyph", () => {
+    it("marks a plain breakpoint", () => {
+      expect(glyphFor(3, true)).toBe("monaco-breakpoint");
+    });
+
+    it("marks a breakpoint that carries a condition", () => {
+      debugService.getCondition.mockReturnValue("count > 1");
+
+      expect(glyphFor(3, true)).toBe("monaco-conditional-breakpoint");
+    });
+
+    it("does not treat a blank condition as a condition", () => {
+      // A condition input left as whitespace must render as an ordinary breakpoint, otherwise the
+      // gutter claims a condition the debugger will not apply.
+      debugService.getCondition.mockReturnValue("   ");
+
+      expect(glyphFor(3, true)).toBe("monaco-breakpoint");
+    });
+
+    it("marks a line that is only being hovered", () => {
+      expect(glyphFor(3, false)).toBe("monaco-hover-breakpoint");
+    });
+
+    it("looks the condition up on the line the glyph starts at", () => {
+      // The range spans several lines; reading endLineNumber would attribute another line's
+      // condition to this glyph.
+      glyphFor(7, true);
+
+      expect(debugService.getCondition).toHaveBeenCalledWith(operatorId, 7);
+    });
+
+    it("keys the lookup to the operator being debugged", () => {
+      glyphFor(2, true);
+
+      expect(debugService.getCondition).toHaveBeenCalledWith(operatorId, expect.anything());
+    });
+  });
+
+  describe("gutter clicks", () => {
+    it("replaces the library's own mouse-down handler rather than adding to it", () => {
+      // Two live handlers would both toggle, so a single click would add and immediately remove a
+      // breakpoint.
+      expect(firstMouseDownDisposable!.dispose).toHaveBeenCalledTimes(1);
+      expect(editor.onMouseDown).toHaveBeenCalledTimes(2);
+    });
+
+    it("toggles the breakpoint on a left click in the gutter", () => {
+      mouseDown!(gutterClick(12));
+
+      expect(debugService.doModifyBreakpoint).toHaveBeenCalledWith(operatorId, 12);
+    });
+
+    it("ignores a click below the last line", () => {
+      mouseDown!(gutterClick(12, { isAfterLines: true }));
+
+      expect(debugService.doModifyBreakpoint).not.toHaveBeenCalled();
+    });
+
+    it("ignores a click that is not on the gutter", () => {
+      mouseDown!(gutterClick(12, { type: CONTENT_TEXT }));
+
+      expect(debugService.doModifyBreakpoint).not.toHaveBeenCalled();
+    });
+
+    it("does not toggle on a right click", () => {
+      // The right button opens the condition input instead; toggling as well would remove the
+      // breakpoint the user is trying to annotate.
+      mouseDown!(gutterClick(12, { leftButton: false }));
+
+      expect(debugService.doModifyBreakpoint).not.toHaveBeenCalled();
+    });
+
+    it("opens the condition input for a right click on an existing breakpoint", () => {
+      (component.monacoBreakpoint as any)["lineNumberAndDecorationIdMap"] = new Map([[12, "dec-1"]]);
+
+      mouseDown!(gutterClick(12, { leftButton: false }));
+
+      expect(component.breakpointConditionLine).toBe(12);
+    });
+
+    it("opens nothing for a right click on a line with no breakpoint", () => {
+      (component.monacoBreakpoint as any)["lineNumberAndDecorationIdMap"] = new Map();
+
+      mouseDown!(gutterClick(12, { leftButton: false }));
+
+      expect(component.breakpointConditionLine).toBeUndefined();
+    });
+  });
+});


(texera) 01/02: test(frontend): render the workflow list item's editing gates and row actions (#7424)

Posted by gi...@apache.org.
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

commit f9deb66dcccfb3b5994be659350021c2e0aa8e06
Author: Xinyuan Lin <xi...@uci.edu>
AuthorDate: Sun Aug 9 02:18:12 2026 -0700

    test(frontend): render the workflow list item's editing gates and row actions (#7424)
    
    ### What changes were proposed in this PR?
    
    The existing suite calls the component's methods directly, so the
    template's own decisions were never rendered: which control a click
    reaches, what value an edit forwards, and which actions a row offers.
    
    Adds 13 tests. The ones that carry real weight:
    
    - **Renaming forwards the text that was typed.** The input is seeded
    with the current name, so binding `workflow.name` instead would look
    right on screen while silently discarding every rename.
    - **The inline description gate is `editingDescription = editable`.**
    Without it a shared read-only row opens an editor whose save the backend
    then rejects.
    - **The shared-access tooltip composes `accessLevel` then `ownerName`,**
    and is shown only to a non-owner.
    - **The per-tag remove passes the `ngFor` loop variable,** not the
    component's `pid`.
    - **Duplicate and delete stay on their own outputs,** and delete is
    disabled for a non-owner.
    - **The executions action appears only when execution tracking is
    configured on.**
    
    **Verified by mutation**, all reverted (template diff empty):
    
    | Mutation | Result |
    |---|---|
    | rename forwards the old name instead of the typed one | red |
    | drop the read-only gate on inline description editing | red |
    | swap the two interpolations in the shared-access tooltip | red |
    | show the shared-access marker to the owner too | red |
    | pass the component's `pid` to the per-tag remove | red |
    | stop disabling delete for a non-owner | red |
    | always show the executions action | red |
    | make duplicate emit `deleted` | red |
    | invert the avatar indent | red |
    | invert the light/dark tag arms | red |
    | invert the tag text colour | red |
    
    The light/dark test **survived its first mutation**: inverting both arms
    merely swaps which tag gets which class, so an assertion that "both
    classes appear somewhere" cannot see it. It now checks each tag
    individually, and both that mutation and the text-colour one fail.
    
    Three things worth recording, all commented in the spec:
    
    - ng-zorro consumes the `nz-tooltip` attribute, and an interpolated
    title is a property binding that never reaches the DOM at all. Elements
    are located through `NzTooltipDirective` and its `directiveTitle`; an
    attribute selector finds nothing.
    - `StubWorkflowPersistService` does not declare `updateWorkflowName`, so
    it cannot be spied on — the suite supplies its own persist stub.
    - The project colours are supplied locally. The shared
    `testUserProjects` fixture stores colours that already carry a `'#'`
    while the template prepends one, so every tag fails the format check and
    takes the dark arm, leaving the light arm unreachable.
    
    No production file is touched.
    
    ### Any related issues, documentation, discussions?
    
    Closes #7421
    
    ### How was this PR tested?
    
    ```
    npx ng test --watch=false --include="**/user-workflow-list-item.component.spec.ts"
    ```
    
    ```
     Test Files  1 passed (1)
          Tests  28 passed (28)
    ```
    
    13 new on top of the existing 15. `yarn format:ci` passes.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 5)
    
    ---------
    
    Signed-off-by: Xinyuan Lin <xi...@uci.edu>
    Co-authored-by: Copilot Autofix powered by AI <17...@users.noreply.github.com>
---
 .../user-workflow-list-item.component.spec.ts      | 245 ++++++++++++++++++++-
 1 file changed, 244 insertions(+), 1 deletion(-)

diff --git a/frontend/src/app/dashboard/component/user/user-workflow/user-workflow-list-item/user-workflow-list-item.component.spec.ts b/frontend/src/app/dashboard/component/user/user-workflow/user-workflow-list-item/user-workflow-list-item.component.spec.ts
index 4174dc05d4..0fee1f9cf4 100644
--- a/frontend/src/app/dashboard/component/user/user-workflow/user-workflow-list-item/user-workflow-list-item.component.spec.ts
+++ b/frontend/src/app/dashboard/component/user/user-workflow/user-workflow-list-item/user-workflow-list-item.component.spec.ts
@@ -39,9 +39,11 @@ import { NzModalModule, NzModalService } from "ng-zorro-antd/modal";
 import { HttpClientTestingModule } from "@angular/common/http/testing";
 import { provideRouter } from "@angular/router";
 import { DashboardEntry } from "../../../../type/dashboard-entry";
-import { NzTooltipModule } from "ng-zorro-antd/tooltip";
+import { NzTooltipDirective, NzTooltipModule } from "ng-zorro-antd/tooltip";
 import { commonTestProviders } from "../../../../../common/testing/test-utils";
 import type { Mocked } from "vitest";
+import { GuiConfigService } from "../../../../../common/service/gui-config.service";
+import { MockGuiConfigService } from "../../../../../common/service/gui-config.service.mock";
 
 // UserWorkflowListItemComponent is rooted at <nz-list-item>; instantiating it
 // outside an <nz-list> host throws "No provider found for NzListComponent".
@@ -289,3 +291,244 @@ describe("UserWorkflowListItemComponent", () => {
     return fixture.whenStable();
   }
 });
+/**
+ * The list item's template carries decisions the class does not: which control a click reaches, what
+ * value an edit forwards, whether a read-only row may still be edited inline, and which actions the
+ * row offers. The suite above calls the component's methods directly, so none of that was rendered.
+ */
+describe("UserWorkflowListItemComponent rendering", () => {
+  let fixture: ComponentFixture<TestHostComponent>;
+  let component: UserWorkflowListItemComponent;
+  let projectService: {
+    getProjectList: ReturnType<typeof vi.fn>;
+    removeWorkflowFromProject: ReturnType<typeof vi.fn>;
+  };
+
+  /**
+   * Projects are stored with a bare hex colour and the template prepends the '#'. The shared
+   * testUserProjects fixture already includes one, which makes every tag fail the format check and
+   * take the dark arm, so this suite supplies its own colours to reach both.
+   */
+  const PROJECTS = [
+    { pid: 1, name: "Light", description: "", ownerId: 1, color: "ffffff", creationTime: 0, accessLevel: "WRITE" },
+    { pid: 2, name: "Dark", description: "", ownerId: 1, color: "101010", creationTime: 0, accessLevel: "WRITE" },
+  ];
+
+  let persistService: { updateWorkflowName: ReturnType<typeof vi.fn> };
+
+  async function setup(opts: { executionsTracking?: boolean } = {}) {
+    // StubWorkflowPersistService does not declare updateWorkflowName, so it cannot be spied on.
+    persistService = { updateWorkflowName: vi.fn(() => of({} as Response)) };
+    projectService = {
+      getProjectList: vi.fn(() => of(PROJECTS as any)),
+      removeWorkflowFromProject: vi.fn(() => of({} as Response)),
+    };
+    TestBed.resetTestingModule();
+    await TestBed.configureTestingModule({
+      imports: [TestHostComponent, NzModalModule, HttpClientTestingModule, NzTooltipModule],
+      providers: [
+        { provide: WorkflowPersistService, useValue: persistService },
+        { provide: UserProjectService, useValue: projectService },
+        { provide: FileSaverService, useValue: { saveAs: vi.fn() } },
+        provideRouter([]),
+        ...commonTestProviders,
+      ],
+    }).compileComponents();
+    if (opts.executionsTracking) {
+      (TestBed.inject(GuiConfigService) as unknown as MockGuiConfigService).setConfig({
+        workflowExecutionsTrackingEnabled: true,
+      });
+    }
+  }
+
+  /** Renders one row. */
+  function render(entry: DashboardEntry, editable = true): HTMLElement {
+    fixture = TestBed.createComponent(TestHostComponent);
+    fixture.componentInstance.entry = entry;
+    fixture.componentInstance.editable = editable;
+    fixture.detectChanges();
+    component = fixture.componentInstance.inner;
+    return fixture.nativeElement as HTMLElement;
+  }
+
+  /**
+   * Tooltip titles on the row, read off the directive: ng-zorro consumes the nz-tooltip attribute,
+   * and an interpolated title is a property binding that never appears in the DOM at all.
+   */
+  function tooltipTitles(): unknown[] {
+    return fixture.debugElement
+      .queryAll(By.directive(NzTooltipDirective))
+      .map(d => (d.injector.get(NzTooltipDirective) as NzTooltipDirective).directiveTitle);
+  }
+
+  /** The elements whose tooltip title satisfies the predicate, in document order. */
+  function byTooltip(pred: (title: string) => boolean): HTMLElement[] {
+    return fixture.debugElement
+      .queryAll(By.directive(NzTooltipDirective))
+      .filter(d => {
+        const t = (d.injector.get(NzTooltipDirective) as NzTooltipDirective).directiveTitle;
+        return typeof t === "string" && pred(t);
+      })
+      .map(d => d.nativeElement as HTMLElement);
+  }
+
+  beforeEach(async () => {
+    await setup();
+  });
+
+  describe("renaming", () => {
+    it("forwards the text that was typed, not the name it started with", () => {
+      // The input is seeded with the current name, so binding workflow.name instead of the input's
+      // value would still look right on screen while silently discarding every rename.
+      render(makeWorkflowEntry({ wid: 7, name: "before" }));
+
+      component.editingName = true;
+      fixture.detectChanges();
+
+      const input = (fixture.nativeElement as HTMLElement).querySelector<HTMLInputElement>("input")!;
+      expect(input.value).toBe("before");
+      input.value = "after";
+      input.dispatchEvent(new Event("focusout"));
+
+      expect(persistService.updateWorkflowName).toHaveBeenCalledWith(7, "after");
+    });
+  });
+
+  describe("inline description editing", () => {
+    it("opens for a viewer who may edit", () => {
+      const el = render(makeWorkflowEntry(), true);
+
+      el.querySelector<HTMLElement>(".workflow-description")?.click();
+      fixture.detectChanges();
+
+      expect(component.editingDescription).toBe(true);
+    });
+
+    it("stays shut for a read-only viewer", () => {
+      // The gate is the template expression editingDescription = editable; without it a shared
+      // read-only row opens an editor whose save the backend then rejects.
+      const el = render(makeWorkflowEntry(), false);
+
+      el.querySelector<HTMLElement>(".workflow-description")?.click();
+      fixture.detectChanges();
+
+      expect(component.editingDescription).toBe(false);
+    });
+  });
+
+  describe("ownership", () => {
+    it("tells a non-owner what access they have and who shared it", () => {
+      const entry = makeWorkflowEntry();
+      entry.workflow.isOwner = false;
+      entry.workflow.accessLevel = "READ";
+      entry.workflow.ownerName = "Bob";
+      render(entry);
+
+      // Pins the order of the two interpolations.
+      expect(tooltipTitles()).toContain("READ access shared by Bob");
+    });
+
+    it("shows no shared-access marker to the owner", () => {
+      const entry = makeWorkflowEntry();
+      entry.workflow.isOwner = true;
+      render(entry);
+
+      expect(tooltipTitles().some(t => typeof t === "string" && t.includes("access shared by"))).toBe(false);
+    });
+
+    it("disables deleting a workflow the viewer does not own", () => {
+      const entry = makeWorkflowEntry();
+      entry.workflow.isOwner = false;
+      const el = render(entry);
+
+      expect(el.querySelector<HTMLButtonElement>("button[nz-popconfirm]")?.disabled).toBe(true);
+    });
+  });
+
+  describe("project tags", () => {
+    it("removes the project whose tag was clicked, not the first one", () => {
+      const el = render(makeWorkflowEntry({ wid: 7 }, [1, 2]));
+
+      void el;
+      const removers = byTooltip(t => t === "Remove from project");
+      expect(removers.length).toBe(2);
+      // Second tag is pid 2; passing the loop variable is what makes this land on 2 and not 1.
+      removers[1].click();
+
+      expect(projectService.removeWorkflowFromProject).toHaveBeenCalledWith(2, 7);
+    });
+
+    it("darkens the text on a light tag and lightens it on a dark one", () => {
+      // Asserted per tag rather than "both classes appear somewhere": inverting both arms merely
+      // swaps which tag gets which class, and a set-level check cannot see that.
+      render(makeWorkflowEntry({ wid: 7 }, [1, 2]));
+
+      const [lightTag] = byTooltip(t => t === "Light");
+      const [darkTag] = byTooltip(t => t === "Dark");
+
+      expect(lightTag.classList).toContain("light-color");
+      expect(lightTag.classList).not.toContain("dark-color");
+      expect(lightTag.style.color).toBe("black");
+
+      expect(darkTag.classList).toContain("dark-color");
+      expect(darkTag.classList).not.toContain("light-color");
+      expect(darkTag.style.color).toBe("white");
+    });
+  });
+
+  describe("row actions", () => {
+    it("withholds the executions action while execution tracking is off", () => {
+      render(makeWorkflowEntry());
+
+      expect(tooltipTitles().some(t => typeof t === "string" && t.startsWith("Executions of the workflow"))).toBe(
+        false
+      );
+    });
+
+    it("offers the executions action once execution tracking is on", async () => {
+      await setup({ executionsTracking: true });
+      render(makeWorkflowEntry());
+
+      expect(tooltipTitles().some(t => typeof t === "string" && t.startsWith("Executions of the workflow"))).toBe(true);
+    });
+
+    it("keeps duplicate and delete on their own outputs", () => {
+      // Adjacent icon buttons; emitting the wrong one of these would be destructive.
+      render(makeWorkflowEntry());
+      const duplicated = vi.fn();
+      const deleted = vi.fn();
+      component.duplicated.subscribe(duplicated);
+      component.deleted.subscribe(deleted);
+
+      const dup = byTooltip(t => t.startsWith("Duplicate"));
+      expect(dup.length).toBe(1);
+      dup[0].click();
+      expect(duplicated).toHaveBeenCalledTimes(1);
+      expect(deleted).not.toHaveBeenCalled();
+
+      fixture.debugElement.query(By.css("button[nz-popconfirm]")).triggerEventHandler("nzOnConfirm", null);
+      expect(deleted).toHaveBeenCalledTimes(1);
+      expect(duplicated).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  describe("selection checkbox", () => {
+    it("indents the avatar only when the checkbox is absent", () => {
+      const withBox = render(makeWorkflowEntry(), true).querySelector<HTMLElement>("nz-list-item-meta-avatar")!;
+      expect(withBox.style.marginLeft).not.toBe("16px");
+
+      const withoutBox = render(makeWorkflowEntry(), false).querySelector<HTMLElement>("nz-list-item-meta-avatar")!;
+      expect(withoutBox.style.marginLeft).toBe("16px");
+      expect(withoutBox.querySelector(".workflow-item-checkbox")).toBeNull();
+    });
+
+    it("records the selection on the entry", () => {
+      const entry = makeWorkflowEntry();
+      render(entry, true);
+
+      fixture.debugElement.query(By.css(".workflow-item-checkbox")).triggerEventHandler("ngModelChange", true);
+
+      expect(entry.checked).toBe(true);
+    });
+  });
+});