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 875aa72add test(frontend): cover the code editor's genuinely untested 
half (#7735)
875aa72add is described below

commit 875aa72add3b7cb7ce23f024ce09ae1e983d39b7
Author: Xinyuan Lin <[email protected]>
AuthorDate: Thu Aug 20 05:22:45 2026 +0000

    test(frontend): cover the code editor's genuinely untested half (#7735)
    
    ### What changes were proposed in this PR?
    
    `code-editor.component.ts` is the largest uncovered file in the repo.
    Its reported 47.4% is misleading: the component's tests are split across
    two targets, and only one of them reaches Codecov.
    
    `angular.json` has `gui:test` (jsdom, which `exclude`s
    `**/*.browser.spec.ts`) and `gui:test-browser` (Playwright/Chromium,
    which includes only those). `build.yml:164` runs the browser target with
    **no `--coverage`**, and the upload takes only the jsdom lcov. Measuring
    both and taking the per-line union over the 247 instrumented lines:
    
    | suite | covered | missed | % |
    |---|---|---|---|
    | jsdom only (what Codecov sees) | 114 | 133 | 46.2% |
    | browser only | 156 | 91 | 63.2% |
    | **true union** | **197** | **50** | **79.8%** |
    
    So **83 lines were already exercised and merely unreported; 50 were
    genuinely untested.** Only the latter is worth writing, and that is what
    this PR does — plus the 6 genuinely untested lines in the template.
    
    | file | Codecov-visible (jsdom) | true union |
    |---|---|---|
    | `code-editor.component.ts` | 46.2% -> **77.7%** | 79.8% -> **96.0%** |
    | `code-editor.component.html` | 70.0% -> **100%** | 70.0% -> **100%** |
    
    Tests: jsdom **25 -> 65**; the browser target **18 -> 22**. 46 of the 50
    genuinely-untested lines are now covered. The bulk deliberately went
    into the **jsdom** spec, since that is the only suite Codecov currently
    reads — browser-spec work scores zero today.
    
    Covered: the coeditor cursor-style generator and both of its
    input-sanitising guards, the bulk type-annotation walk and its
    same-line/new-line offset handling, the suggestion panel's staging and
    routing, container clamping against the viewport, and the template's
    `*ngFor` and accept/decline wiring.
    
    ### A blocker that PR #7586 needs
    
    `ng run gui:test-browser --coverage` **fails out of the box**:
    `TypeError: Failed to fetch dynamically imported module:
    /@id/@vitest/coverage-v8/browser`, because Vite does not pre-bundle the
    coverage provider's browser entry. The one-line fix is
    `optimizeDeps.include: ["buffer", "@vitest/coverage-v8/browser"]` in
    `vitest.browser.config.ts`. It was applied here only to take the
    measurement above and then reverted — this PR touches no config.
    Flagging it because enabling the browser-coverage upload without it
    would produce an empty report.
    
    ### Verification
    
    12 mutations, **12 killed, no survivors**. Each anchor was pre-verified
    to occur exactly once in a dry run; one mutation at a time; `git diff`
    on production confirmed empty after every revert.
    
    | Mutation | Killed by |
    |---|---|
    | **exchange** the two operands of `replace("0.8", "0.5")` | scopes
    every rule to the coeditor id and dims only the selection background |
    | `SAFE_CLIENT_ID` `\d{1,10}` -> `\d{1,12}` | emits no CSS for a
    clientId longer than ten digits |
    | `SAFE_CSS_COLOR` drop trailing `$` | emits no CSS for a colour
    carrying a style-tag escape |
    | **exchange** the same-line / new-line offset branch bodies | offsets a
    same-line argument and resets on a new line |
    | **exchange** `position.top` / `position.left` | stages the trimmed
    suggestion into the rendered panel |
    | **exchange** code / suggestion pushed to the panel | same test,
    distinct assertion |
    | **exchange** `rect.left` / `rect.top` in the clamp | clamps a
    container overflowing on both axes |
    | `*ngFor` renders only the first coeditor | renders one scoped
    cursor-style block per coeditor |
    | **exchange** the `(accept)` / `(decline)` handlers | routes accept and
    decline to the matching handlers |
    | tokenize sweep `1..<=n` -> `0..<n` | (browser) force-tokenizes every
    line |
    | diff operator predicate `===` -> `!==` | (browser) diffs this
    operator's latest version against the shared text |
    | **exchange** the diff / plain bring-up branches | 10 tests |
    
    No `vi.mock` was added — the private editor-consuming methods are driven
    with a plain recording stand-in, given the `isolate:false` hazard where
    the first importer of a module pins it for the whole run.
    
    ### Deliberately not included
    
    10 lines remain uncovered: the `monacoWorkerFactory` label switch, the
    dynamic codingame extension imports, the retry-clearing catch, and the
    LSP timeout reject. All sit behind `ensureVscodeApiStarted()`, a
    process-wide singleton both suites stub; reaching them means booting the
    real codingame stack, which the browser spec's own header explicitly
    declines to do.
    
    A production observation, reported and not pinned:
    `acceptCurrentAnnotation()` emits on `userResponseSubject` **before**
    clearing `showAnnotationSuggestion`. The bulk "Add All Type Annotations"
    walk works only because `getTypeAnnotations` answers asynchronously over
    HTTP — the next suggestion lands after the clear. A first draft of the
    fixture used a synchronous `of(...)` and reproduced the failure exactly:
    the walk stalls after the first argument with the panel hidden. Not a
    live bug, but if that call ever gains a cache or a local model it breaks
    silently. `rejectCurrentAnnotation()` clears first, then emits, so it is
    not exposed. The fixture models the real async service and carries a
    comment explaining the dependency.
    
    No production file is touched.
    
    ### Two unrelated specs had to stop relying on automatic change
    detection
    
    Two spec files in the diff are not the code editor. Neither is a new
    defect: both carried a latent dependency that this PR is what finally
    tripped.
    
    **Once any test in `code-editor.component.spec.ts` has run in a Vitest
    worker, Angular's automatic tick stops firing for the rest of that
    process.** The suite runs with `isolate: false`, so every spec scheduled
    behind it in the same worker loses auto-CD. Confirmed by bisection:
    skipping the whole root `describe` — the file is still imported, zero
    tests run — leaves the downstream spec green; letting any single one of
    its tests run turns it red. So it is running a test, not importing the
    module graph. Growing this spec from 25 to 65 tests moved it earlier in
    Vitest's size-ordered queue, which changed which specs sit behind it.
    
    | spec | symptom behind the code editor | fix |
    |---|---|---|
    | `agent-panel` | `NzTabSetComponent` queues its `nzSelectedIndexChange`
    emit as a microtask from its own `ngAfterContentChecked`. Solo, zone.js
    drains it inside the click's own task. Behind Monaco: **0 calls** after
    `click()`, 0 after `detectChanges()`, 1 after one macrotask turn. |
    yield a macrotask turn before asserting |
    | `console-frame` | The dropdown renders into a CDK overlay — a view
    hanging off `ApplicationRef`, not off the fixture — so `detectChanges()`
    alone never renders it. `nzOpen` is `true` and `nz-option-container`
    exists, but there are **0 `nz-option-item`**. | `detectChanges()` →
    `flush()` → `detectChanges()`; the `detectChanges()` *before* the flush
    is the load-bearing part |
    
    Two dead ends, recorded so nobody retries them: **`fixture.whenStable()`
    never resolves** in such a worker — a permanently pending task is what
    kills auto-CD — so awaiting it fails on the 20s `testTimeout` instead,
    and it times out even running the spec alone.
    `TestBed.inject(ApplicationRef).tick()` does not render the overlay
    either.
    
    ### Verification
    
    Both reproduced deterministically before fixing, by running the pair in
    one process (`fileParallelism: false`; Vitest orders files
    largest-first, so the code-editor spec leads) and confirming the run
    order off the output. Red without each change, green with it, and green
    standalone.
    
    Whole suite in a single process — all 201 files sharing one registry,
    stricter than CI's workers — is green at **4779 passed, 1 skipped**.
    Plain `main` under the identical condition is green too, so this PR adds
    no new failure. That configuration is local only; no config file is
    touched.
    
    One thing it did surface, unrelated and **not** fixed here:
    `menu.component.spec.ts` failed once and then passed on a repeat. It
    does `vi.mock("file-saver")` while two other specs import `file-saver`
    for real, which is the known shared-registry pinning hazard — the mock
    silently does not apply and `saveAs` reads 0 calls. Flaky under one
    shared registry, and not something this PR changes.
    
    ### Any related issues, documentation, discussions?
    
    Closes #7734
    
    ### How was this PR tested?
    
    ```
    npx ng test --watch=false --include="**/code-editor.component.spec.ts"
    npx ng run gui:test-browser
    ```
    
    ```
     Test Files  1 passed (1)
          Tests  65 passed (65)
    
     Test Files  3 passed (3)
          Tests  22 passed (22)
    ```
    
    Both suites run twice with identical results. The whole
    `code-editor-dialog` directory is green at 113 tests across 4 spec
    files. `yarn format:ci` passes.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 5)
---
 .../agent-panel/agent-panel.component.spec.ts      |  11 +-
 .../code-editor.component.browser.spec.ts          |  91 +++-
 .../code-editor.component.spec.ts                  | 594 ++++++++++++++++++++-
 .../console-frame/console-frame.component.spec.ts  |   8 +-
 4 files changed, 694 insertions(+), 10 deletions(-)

diff --git 
a/frontend/src/app/workspace/component/agent/agent-panel/agent-panel.component.spec.ts
 
b/frontend/src/app/workspace/component/agent/agent-panel/agent-panel.component.spec.ts
index 5e92ffe58a..b067665d35 100644
--- 
a/frontend/src/app/workspace/component/agent/agent-panel/agent-panel.component.spec.ts
+++ 
b/frontend/src/app/workspace/component/agent/agent-panel/agent-panel.component.spec.ts
@@ -745,6 +745,13 @@ describe("AgentPanelComponent", () => {
       return fixture.nativeElement.querySelector("#agent-container") as 
HTMLElement;
     }
 
+    // A header click does not reach the panel in the same turn: 
NzTabSetComponent
+    // queues the nzSelectedIndexChange emit as a microtask from its own
+    // ngAfterContentChecked, so onTabSelectChange can run a turn after
+    // detectChanges(). Yielding a macrotask turn drains it — Angular's 
whenStable()
+    // is not an option here, since this fixture never reaches stability.
+    const settle = (): Promise<void> => new Promise(resolve => 
setTimeout(resolve, 0));
+
     function tabHeaders(): HTMLElement[] {
       return 
Array.from(fixture.nativeElement.querySelectorAll(".ant-tabs-tab"));
     }
@@ -838,7 +845,7 @@ describe("AgentPanelComponent", () => {
       expect(fixture.nativeElement.querySelectorAll("texera-agent-registration 
.model-card").length).toBe(1);
     });
 
-    it("clicking an agent's tab header activates that agent and marks only its 
chat active", () => {
+    it("clicking an agent's tab header activates that agent and marks only its 
chat active", async () => {
       service.agentList = [makeAgent("a"), makeAgent("b")];
       createComponent();
       expect(chats().map(c => c.isActive)).toEqual([false, false]);
@@ -846,6 +853,8 @@ describe("AgentPanelComponent", () => {
       // Header 0 is the registration tab, so agent "b" sits behind header 2.
       (tabHeaders()[2].querySelector(".ant-tabs-tab-btn") as 
HTMLElement).click();
       fixture.detectChanges();
+      await settle();
+      fixture.detectChanges();
 
       expect(service.activateAgent).toHaveBeenCalledWith("b");
       expect(chats().map(c => c.isActive)).toEqual([false, true]);
diff --git 
a/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.browser.spec.ts
 
b/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.browser.spec.ts
index 677dd54c1f..fac2b91dac 100644
--- 
a/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.browser.spec.ts
+++ 
b/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.browser.spec.ts
@@ -144,12 +144,26 @@ interface FakeEditor {
   getScrolledVisiblePosition: ReturnType<typeof vi.fn>;
   createDecorationsCollection: ReturnType<typeof vi.fn>;
   actions: monaco.editor.IActionDescriptor[];
+  model: { getLineCount: () => number; tokenization: { forceTokenization: 
ReturnType<typeof vi.fn> } };
 }
 
 function makeFakeEditor(): FakeEditor {
   const actions: monaco.editor.IActionDescriptor[] = [];
+  // The component calls `getModel()` several times per pass (once for the
+  // MonacoBinding, again for the re-tokenize sweep). A single memoized model
+  // keeps those views consistent so a test can assert on what the component
+  // did to it.
+  const model = {
+    getValue: () => "x = 1\ny = 2\n",
+    getValueInRange: () => "x",
+    onDidChangeContent: () => ({ dispose: () => {} }),
+    getOffsetAt: () => 0,
+    getLineCount: () => 3,
+    tokenization: { forceTokenization: vi.fn() },
+  };
   return {
     actions,
+    model,
     addAction: vi.fn((action: monaco.editor.IActionDescriptor) => {
       actions.push(action);
       return { dispose: vi.fn(), id: action.id, label: action.label };
@@ -157,12 +171,7 @@ function makeFakeEditor(): FakeEditor {
     updateOptions: vi.fn(),
     layout: vi.fn(),
     getSelection: vi.fn(() => new monaco.Selection(1, 1, 1, 5)),
-    getModel: vi.fn(() => ({
-      getValue: () => "x = 1\ny = 2\n",
-      getValueInRange: () => "x",
-      onDidChangeContent: () => ({ dispose: () => {} }),
-      getOffsetAt: () => 0,
-    })),
+    getModel: vi.fn(() => model),
     getScrolledVisiblePosition: vi.fn(() => ({ top: 50, left: 100, height: 18 
})),
     createDecorationsCollection: vi.fn(() => ({ clear: vi.fn() })),
   };
@@ -357,6 +366,76 @@ describe("CodeEditorComponent (browser)", () => {
     expect((fixture.componentInstance as any).isMultipleVariables).toBe(false);
   });
 
+  it("initializeMonacoEditor: leaves the binding unwired when the operator has 
no shared code", async () => {
+    const { fixture, editor, c } = makeFixtureWithFakes();
+    (c as any).code = undefined;
+
+    fixture.detectChanges();
+    await flush();
+
+    // readOnly still went through, so the subscribe body was entered and then
+    // bailed out at the missing-code guard rather than never running.
+    expect(editor.updateOptions).toHaveBeenCalledWith({ readOnly: false });
+    expect(monacoBindingCalls).toHaveLength(0);
+    expect(editor.addAction).not.toHaveBeenCalled();
+  });
+
+  it("initializeMonacoEditor: force-tokenizes every line so the first paint 
carries syntax colours", async () => {
+    const { fixture, editor } = makeFixtureWithFakes();
+
+    fixture.detectChanges();
+    await flush();
+
+    // The stand-in model reports three lines, and Monaco line numbers are
+    // 1-based and inclusive, so exactly 1, 2, 3 should be swept.
+    const forceTokenization = editor.model.tokenization.forceTokenization;
+    expect(forceTokenization.mock.calls.map(call => call[0])).toEqual([1, 2, 
3]);
+  });
+
+  it("initializeMonacoEditor: replaces the previous binding and detaches the 
previous listener on re-init", async () => {
+    const { fixture, c } = makeFixtureWithFakes();
+    fixture.detectChanges();
+    await flush();
+    expect(monacoBindingCalls).toHaveLength(1);
+    const firstBinding = (c as any).monacoBinding as { destroy: 
ReturnType<typeof vi.fn> };
+    const detach = vi.fn();
+    (c as any).detachYCodeListener = detach;
+
+    // Re-emitting on the version stream re-runs the editor bring-up that
+    // ngAfterViewInit subscribed to.
+    displayVersionStream$.next(false);
+    await flush();
+
+    expect(monacoBindingCalls).toHaveLength(2);
+    expect(firstBinding.destroy).toHaveBeenCalledOnce();
+    expect(detach).toHaveBeenCalledOnce();
+  });
+
+  it("initializeDiffEditor: diffs this operator's latest-version code against 
the shared text", async () => {
+    const { fixture, c } = makeFixtureWithFakes();
+    const sharedText = new Y.Doc().getText("code");
+    sharedText.insert(0, "shared editing text");
+    (c as any).code = sharedText;
+    // The decoy operator is listed FIRST, so picking anything other than the
+    // entry matching `currentOperatorId` surfaces the wrong version.
+    vi.spyOn(workflowActionService, "getTempWorkflow").mockReturnValue({
+      content: {
+        operators: [
+          { operatorID: "a-different-operator", operatorProperties: { code: 
"some other operator's code" } },
+          { operatorID: fixture.componentInstance.currentOperatorId, 
operatorProperties: { code: "latest version" } },
+        ],
+      },
+    } as any);
+    displayVersionStream$.next(true);
+
+    fixture.detectChanges();
+    await flush();
+
+    const config = editorAppMock.configs[0] as any;
+    expect(config.codeResources.modified.text).toBe("latest version");
+    expect(config.codeResources.original.text).toBe("shared editing text");
+  });
+
   it("onWindowResize: calls editor.layout() through adjustEditorSize", async 
() => {
     const { fixture, editor } = makeFixtureWithFakes();
     fixture.detectChanges();
diff --git 
a/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.spec.ts
 
b/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.spec.ts
index 6c9bd5af88..fd6e403d20 100644
--- 
a/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.spec.ts
+++ 
b/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.spec.ts
@@ -28,11 +28,18 @@ import { mockOperatorMetaData } from 
"../../service/operator-metadata/mock-opera
 import { commonTestProviders } from "../../../common/testing/test-utils";
 import { OperatorPredicate } from "../../types/workflow-common.interface";
 import { OperatorSchema } from "../../types/operator-schema.interface";
-import { of, Subject } from "rxjs";
-import { AIAssistantService } from 
"../../service/ai-assistant/ai-assistant.service";
+import { BehaviorSubject, defer, of, config as rxjsConfig, Subject } from 
"rxjs";
+import {
+  AIAssistantService,
+  TypeAnnotationResponse,
+  UnannotatedArgument,
+} from "../../service/ai-assistant/ai-assistant.service";
 import * as monaco from "monaco-editor";
 import { UiUdfParametersSyncService } from 
"../../service/code-editor/ui-udf-parameters-sync.service";
 import { NotificationService } from 
"../../../common/service/notification/notification.service";
+import { ComponentRef, SecurityContext } from "@angular/core";
+import { DomSanitizer, SafeHtml } from "@angular/platform-browser";
+import { Coeditor } from "../../../common/type/user";
 
 // Operator types that the constructor's language-detection branch must map
 // to a specific language. `RUDFSource` / `RUDF` -> `r`; the three V2 Python
@@ -91,14 +98,99 @@ const buildPredicate = (operatorID: string, operatorType: 
string): OperatorPredi
   isDisabled: false,
 });
 
+// A plain recording stand-in for `IStandaloneCodeEditor`. The component never
+// introspects the editor beyond the handful of methods below, so the private
+// editor-consuming methods (`setupAIAssistantActions`, `handleTypeAnnotation`,
+// `insertTypeAnnotations`) can be driven directly with this object — no Monaco
+// runtime, and no module mocking, which `@angular/build`'s `isolate: false`
+// makes unreliable.
+//
+// The numbers here are deliberately all distinct (`top` 50 vs `left` 100,
+// `getOffsetAt` 42 vs the `?? 0` fallback) so that a mutation swapping two of
+// them cannot render an identical result.
+interface FakeEditor {
+  actions: monaco.editor.IActionDescriptor[];
+  addAction: ReturnType<typeof vi.fn>;
+  getSelection: ReturnType<typeof vi.fn>;
+  getModel: ReturnType<typeof vi.fn>;
+  getScrolledVisiblePosition: ReturnType<typeof vi.fn>;
+  createDecorationsCollection: ReturnType<typeof vi.fn>;
+  layout: ReturnType<typeof vi.fn>;
+}
+
+function makeFakeEditor(): FakeEditor {
+  const actions: monaco.editor.IActionDescriptor[] = [];
+  return {
+    actions,
+    addAction: vi.fn((action: monaco.editor.IActionDescriptor) => {
+      actions.push(action);
+      return { dispose: vi.fn(), id: action.id, label: action.label };
+    }),
+    getSelection: vi.fn(() => new monaco.Selection(1, 1, 1, 5)),
+    getModel: vi.fn(() => ({
+      getValue: () => "def f(a, b):\n  return a\n",
+      getValueInRange: () => "a, b",
+      getOffsetAt: () => 42,
+    })),
+    getScrolledVisiblePosition: vi.fn(() => ({ top: 50, left: 100, height: 18 
})),
+    createDecorationsCollection: vi.fn(() => ({ clear: vi.fn() })),
+    layout: vi.fn(),
+  };
+}
+
+const asEditor = (editor: FakeEditor) => editor as unknown as 
monaco.editor.ICodeEditor;
+
+// The component reads `getBoundingClientRect()` on the container to decide
+// whether to clamp it to the viewport. jsdom always reports an all-zero rect,
+// so tests stage their own.
+function stubRect(left: number, top: number, right: number, bottom: number): 
DOMRect {
+  return {
+    x: left,
+    y: top,
+    left,
+    top,
+    right,
+    bottom,
+    width: right - left,
+    height: bottom - top,
+    toJSON: () => ({}),
+  } as DOMRect;
+}
+
+// `window.innerWidth` / `innerHeight` are accessor properties in jsdom, so 
they
+// are staged with `defineProperty` and restored from the captured descriptor.
+function withViewport<T>(width: number, height: number, run: () => T): T {
+  const originalWidth = Object.getOwnPropertyDescriptor(window, "innerWidth");
+  const originalHeight = Object.getOwnPropertyDescriptor(window, 
"innerHeight");
+  Object.defineProperty(window, "innerWidth", { value: width, configurable: 
true });
+  Object.defineProperty(window, "innerHeight", { value: height, configurable: 
true });
+  try {
+    return run();
+  } finally {
+    if (originalWidth) Object.defineProperty(window, "innerWidth", 
originalWidth);
+    if (originalHeight) Object.defineProperty(window, "innerHeight", 
originalHeight);
+  }
+}
+
 describe("CodeEditorComponent", () => {
   let workflowActionService: WorkflowActionService;
   let uiParametersParseErrors: Subject<{ operatorId: string; message?: string 
}>;
   let notificationServiceMock: { error: ReturnType<typeof vi.fn> };
+  let aiEnabled$: BehaviorSubject<string>;
+  let getTypeAnnotationsSpy: ReturnType<typeof vi.fn>;
+  let locateUnannotatedSpy: ReturnType<typeof vi.fn>;
 
   beforeEach(async () => {
+    // The component persists the dialog geometry per operator id in
+    // localStorage and restores it in ngAfterViewInit, so a test that resizes
+    // the container would otherwise leak that geometry into every later
+    // fixture built from the same predicate.
+    localStorage.clear();
     uiParametersParseErrors = new Subject();
     notificationServiceMock = { error: vi.fn() };
+    aiEnabled$ = new BehaviorSubject<string>("OpenAI");
+    getTypeAnnotationsSpy = vi.fn().mockReturnValue(of({ choices: [{ message: 
{ content: ": int" } }] }));
+    locateUnannotatedSpy = vi.fn().mockReturnValue(of([]));
     await TestBed.configureTestingModule({
       providers: [
         WorkflowActionService,
@@ -111,6 +203,17 @@ describe("CodeEditorComponent", () => {
           },
         },
         { provide: NotificationService, useValue: notificationServiceMock },
+        // The real service resolves its gate over HTTP, which
+        // HttpClientTestingModule never answers, so `setupAIAssistantActions`
+        // would hang before registering anything.
+        {
+          provide: AIAssistantService,
+          useValue: {
+            checkAIAssistantEnabled: () => aiEnabled$,
+            getTypeAnnotations: getTypeAnnotationsSpy,
+            locateUnannotated: locateUnannotatedSpy,
+          },
+        },
         ...commonTestProviders,
       ],
       imports: [CodeEditorComponent, HttpClientTestingModule],
@@ -361,4 +464,491 @@ describe("CodeEditorComponent", () => {
       expect(checked).toBe("OpenAI");
     });
   });
+
+  // `getCoeditorCursorStyles` interpolates two awareness-sourced values into a
+  // `<style>` tag that is handed to `bypassSecurityTrustHtml`, so the 
allow-list
+  // in front of it is the only thing standing between a peer-controlled string
+  // and raw HTML in the page. `DomSanitizer.sanitize` unwraps the bypassed 
value
+  // back to the exact string the component produced, which is what lets these
+  // tests assert on the CSS rather than just "something truthy came back".
+  describe("getCoeditorCursorStyles", () => {
+    const unwrap = (value: SafeHtml): string =>
+      TestBed.inject(DomSanitizer).sanitize(SecurityContext.HTML, value) ?? "";
+    const stylesFor = (clientId: string, color: string): string => {
+      const fixture = makeFixture(mockJavaUDFPredicate);
+      return unwrap(fixture.componentInstance.getCoeditorCursorStyles({ 
clientId, color } as unknown as Coeditor));
+    };
+
+    it("scopes every rule to the coeditor id and dims only the selection 
background", () => {
+      // The alpha in the source colour is 0.8 and the selection background is
+      // rewritten to 0.5, so the two values are distinguishable — a colour
+      // without an alpha would make the rewrite invisible.
+      const css = stylesFor("12345", "rgba(1, 2, 3, 0.8)");
+      expect(css).toContain(".yRemoteSelection-12345 { background-color: 
rgba(1, 2, 3, 0.5)}");
+      expect(css).toContain(".yRemoteSelectionHead-12345::after { 
border-color: rgba(1, 2, 3, 0.8)}");
+      expect(css).toContain(".yRemoteSelectionHead-12345 { border-color: 
rgba(1, 2, 3, 0.8)}");
+      expect(css.startsWith("<style>")).toBe(true);
+      expect(css.endsWith("</style>")).toBe(true);
+    });
+
+    // One case per alternation in SAFE_CSS_COLOR: short hex, hex-with-alpha,
+    // and the percent-bearing hsl() form.
+    (["#abc", "#aabbccdd", "hsl(120, 50%, 50%)"] as const).forEach(color => {
+      it(`accepts the ${color} colour notation`, () => {
+        expect(stylesFor("42", color)).toContain(`.yRemoteSelectionHead-42 { 
border-color: ${color}}`);
+      });
+    });
+
+    const REJECTED: ReadonlyArray<readonly [string, string, string]> = [
+      ["a clientId that is not all digits", "12; 
}</style><script>alert(1)</script>", "#ff00aa"],
+      ["a clientId longer than ten digits", "123456789012", "#ff00aa"],
+      ["an empty clientId", "", "#ff00aa"],
+      ["a missing colour", "12345", ""],
+      ["a named colour outside the allow-list", "12345", "red"],
+      ["a url() colour", "12345", "url(https://evil.example/x)"],
+      ["a colour carrying a style-tag escape", "12345", 
"#fff}</style><script>alert(1)</script>"],
+    ];
+    REJECTED.forEach(([label, clientId, color]) => {
+      it(`emits no CSS at all for ${label}`, () => {
+        expect(stylesFor(clientId, color)).toBe("");
+      });
+    });
+  });
+
+  describe("template", () => {
+    it("the close button destroys the host component reference", () => {
+      const fixture = makeFixture(mockJavaUDFPredicate);
+      const destroy = vi.fn();
+      fixture.componentInstance.componentRef = { destroy } as unknown as 
ComponentRef<CodeEditorComponent>;
+
+      (fixture.nativeElement as 
HTMLElement).querySelector<HTMLButtonElement>("#close-button")!.click();
+
+      expect(destroy).toHaveBeenCalledOnce();
+    });
+
+    it("the close button is inert when the dialog was created without a 
component reference", () => {
+      const fixture = makeFixture(mockJavaUDFPredicate);
+      expect(fixture.componentInstance.componentRef).toBeUndefined();
+
+      expect(() =>
+        (fixture.nativeElement as 
HTMLElement).querySelector<HTMLButtonElement>("#close-button")!.click()
+      ).not.toThrow();
+    });
+
+    it("renders one scoped cursor-style block per coeditor", () => {
+      const fixture = makeFixture(mockJavaUDFPredicate);
+      fixture.componentInstance.coeditorPresenceService.coeditors = [
+        { clientId: "777", color: "rgba(9, 8, 7, 0.8)" } as unknown as 
Coeditor,
+        { clientId: "888", color: "#abcdef" } as unknown as Coeditor,
+      ];
+      fixture.detectChanges();
+
+      const html = (fixture.nativeElement as HTMLElement).innerHTML;
+      expect(html).toContain("yRemoteSelection-777");
+      expect(html).toContain("yRemoteSelection-888");
+    });
+
+    it("renders nothing for a coeditor whose awareness values fail the 
allow-list", () => {
+      const fixture = makeFixture(mockJavaUDFPredicate);
+      fixture.componentInstance.coeditorPresenceService.coeditors = [
+        { clientId: "not-a-number", color: "#abcdef" } as unknown as Coeditor,
+      ];
+      fixture.detectChanges();
+
+      expect((fixture.nativeElement as 
HTMLElement).innerHTML).not.toContain("yRemoteSelection");
+    });
+
+    it("keeps the suggestion panel out of the DOM until a suggestion is 
staged", () => {
+      const fixture = makeFixture(mockJavaUDFPredicate);
+      expect((fixture.nativeElement as 
HTMLElement).querySelector("texera-annotation-suggestion")).toBeNull();
+
+      fixture.componentInstance.showAnnotationSuggestion = true;
+      fixture.detectChanges();
+
+      expect((fixture.nativeElement as 
HTMLElement).querySelector("texera-annotation-suggestion")).not.toBeNull();
+    });
+
+    it("routes the suggestion panel's accept and decline to the matching 
handlers", () => {
+      const fixture = makeFixture(mockJavaUDFPredicate);
+      const component = fixture.componentInstance;
+      component.showAnnotationSuggestion = true;
+      fixture.detectChanges();
+      const accepted = vi.spyOn(component, "acceptCurrentAnnotation");
+      const declined = vi.spyOn(component, "rejectCurrentAnnotation");
+
+      component.annotationSuggestion.onAccept();
+      expect(accepted).toHaveBeenCalledOnce();
+      expect(declined).not.toHaveBeenCalled();
+
+      component.annotationSuggestion.onDecline();
+      expect(declined).toHaveBeenCalledOnce();
+      expect(accepted).toHaveBeenCalledOnce();
+    });
+  });
+
+  // The two context-menu actions are registered against a live Monaco editor,
+  // which jsdom cannot host. Their `run` callbacks are plain closures over the
+  // component though, so `setupAIAssistantActions` is driven directly with a
+  // recording stand-in and the callbacks are invoked off the registration 
list.
+  describe("AI assistant actions", () => {
+    function registerOn(editor: FakeEditor): CodeEditorComponent {
+      const fixture = makeFixture(mockJavaUDFPredicate);
+      (fixture.componentInstance as any).setupAIAssistantActions(editor);
+      return fixture.componentInstance;
+    }
+    const runAction = (editor: FakeEditor, id: string) =>
+      editor.actions.find(action => action.id === id)!.run(asEditor(editor));
+
+    it("registers the per-selection action ahead of the bulk action when the 
gate reports OpenAI", () => {
+      const editor = makeFakeEditor();
+      registerOn(editor);
+      expect(editor.actions.map(action => 
action.id)).toEqual(["type-annotation-action", "all-type-annotation-action"]);
+    });
+
+    it("registers only the bulk action when the gate reports a different 
provider", () => {
+      aiEnabled$.next("NoAiAssistant");
+      const editor = makeFakeEditor();
+      registerOn(editor);
+      expect(editor.actions.map(action => 
action.id)).toEqual(["all-type-annotation-action"]);
+    });
+
+    it("the per-selection action forwards the selected text, its start line, 
and the whole file", () => {
+      const editor = makeFakeEditor();
+      editor.getSelection.mockReturnValue(new monaco.Selection(3, 2, 4, 9));
+      registerOn(editor);
+
+      runAction(editor, "type-annotation-action");
+
+      // Three distinct arguments — selected text, start line, whole document —
+      // so transposing any pair changes the call.
+      expect(getTypeAnnotationsSpy).toHaveBeenCalledWith("a, b", 3, "def f(a, 
b):\n  return a\n");
+    });
+
+    it("the bulk action forwards the selected text and its start line", () => {
+      const editor = makeFakeEditor();
+      editor.getSelection.mockReturnValue(new monaco.Selection(3, 2, 4, 9));
+      registerOn(editor);
+
+      runAction(editor, "all-type-annotation-action");
+
+      expect(locateUnannotatedSpy).toHaveBeenCalledWith("a, b", 3);
+    });
+
+    it("both actions bail out when the editor has no model", () => {
+      const editor = makeFakeEditor();
+      editor.getModel.mockReturnValue(null);
+      registerOn(editor);
+
+      runAction(editor, "type-annotation-action");
+      runAction(editor, "all-type-annotation-action");
+
+      expect(getTypeAnnotationsSpy).not.toHaveBeenCalled();
+      expect(locateUnannotatedSpy).not.toHaveBeenCalled();
+    });
+
+    it("both actions bail out when there is no selection", () => {
+      const editor = makeFakeEditor();
+      editor.getSelection.mockReturnValue(null);
+      registerOn(editor);
+
+      runAction(editor, "type-annotation-action");
+      runAction(editor, "all-type-annotation-action");
+
+      expect(getTypeAnnotationsSpy).not.toHaveBeenCalled();
+      expect(locateUnannotatedSpy).not.toHaveBeenCalled();
+    });
+
+    it("the bulk action leaves the multi-variable state untouched when nothing 
is unannotated", () => {
+      const editor = makeFakeEditor();
+      const component = registerOn(editor);
+
+      runAction(editor, "all-type-annotation-action");
+
+      expect(locateUnannotatedSpy).toHaveBeenCalledOnce();
+      expect((component as any).isMultipleVariables).toBe(false);
+      expect((component as any).userResponseSubject).toBeUndefined();
+      expect(editor.createDecorationsCollection).not.toHaveBeenCalled();
+    });
+  });
+
+  // "Add All Type Annotations" walks the unannotated arguments one at a time,
+  // pausing on each until the user accepts or declines. Every variable 
inserted
+  // on a line shifts the columns of the ones after it on that same line, which
+  // the walker compensates for with a running offset.
+  describe("bulk annotation walk", () => {
+    // Two arguments share line 3 (so the second one is offset by the length of
+    // the accepted suggestion) and a third sits on line 7 (so the offset
+    // resets). Every column is distinct, so a mistaken offset is visible.
+    const UNANNOTATED: ReadonlyArray<UnannotatedArgument> = [
+      { name: "alpha", startLine: 3, startColumn: 5, endLine: 3, endColumn: 6 
},
+      { name: "beta", startLine: 3, startColumn: 10, endLine: 3, endColumn: 11 
},
+      { name: "gamma", startLine: 7, startColumn: 2, endLine: 7, endColumn: 3 
},
+    ];
+    const rangeTuple = (range: monaco.Range | undefined) => [
+      range!.startLineNumber,
+      range!.startColumn,
+      range!.endLineNumber,
+      range!.endColumn,
+    ];
+
+    // `acceptCurrentAnnotation` emits the advance and only then clears
+    // `showAnnotationSuggestion`, so it relies on the annotation lookup being
+    // asynchronous: the next argument's suggestion has to land after that
+    // clear, not before it. The real service answers over HTTP, so the stub
+    // resolves off a promise rather than emitting inline — a synchronous
+    // `of(...)` here would stall the walk after the first argument.
+    const respondAsync = () =>
+      defer(() => Promise.resolve({ choices: [{ message: { content: ": int" } 
}] } as TypeAnnotationResponse));
+    const settle = () => new Promise(resolve => setTimeout(resolve, 0));
+
+    async function startWalk() {
+      locateUnannotatedSpy.mockReturnValue(of(UNANNOTATED));
+      getTypeAnnotationsSpy.mockImplementation(respondAsync);
+      const fixture = makeFixture(mockJavaUDFPredicate);
+      const component = fixture.componentInstance;
+      const editor = makeFakeEditor();
+      const insert = vi.fn();
+      // `acceptCurrentAnnotation` reaches through the EditorApp for the editor
+      // and through the YText for the insertion point; both are staged so that
+      // accepting exercises the real path rather than poking the private
+      // response subject directly.
+      const stageEditorApp = () => {
+        (component as any).editorApp = {
+          getEditor: () => editor,
+          dispose: vi.fn().mockResolvedValue(undefined),
+        };
+      };
+      stageEditorApp();
+      (component as any).code = { insert };
+      (component as any).setupAIAssistantActions(editor);
+      editor.actions.find(action => action.id === 
"all-type-annotation-action")!.run(asEditor(editor));
+      await settle();
+      const accept = async () => {
+        // ngAfterViewInit's own editor bring-up resolves on a later tick and
+        // reassigns `editorApp`, so it is re-staged immediately before each
+        // accept (synchronously, so nothing can interleave) to keep the
+        // insertion point coming from the stand-in editor.
+        stageEditorApp();
+        component.acceptCurrentAnnotation();
+        await settle();
+      };
+      return { component, editor, insert, accept };
+    }
+
+    it("offsets a same-line argument by the accepted suggestion and resets on 
a new line", async () => {
+      const { component, editor, accept } = await startWalk();
+
+      // First argument: untouched columns, and the walk is now in flight.
+      expect((component as any).isMultipleVariables).toBe(true);
+      expect(component.currentCode).toBe("alpha");
+      expect(rangeTuple(component.currentRange)).toEqual([3, 5, 3, 6]);
+
+      await accept();
+
+      // Second argument shares line 3, so both columns shift by the length of
+      // the ": int" suggestion accepted a moment ago.
+      expect(component.currentCode).toBe("beta");
+      expect(rangeTuple(component.currentRange)).toEqual([3, 15, 3, 16]);
+
+      await accept();
+
+      // Third argument is on line 7, so the running offset resets to zero.
+      expect(component.currentCode).toBe("gamma");
+      expect(rangeTuple(component.currentRange)).toEqual([7, 2, 7, 3]);
+
+      expect(editor.createDecorationsCollection).toHaveBeenCalledTimes(3);
+    });
+
+    it("tears the walk down once the last argument has been answered", async 
() => {
+      const { component, editor, accept } = await startWalk();
+
+      await accept();
+      await accept();
+      expect((component as any).isMultipleVariables).toBe(true);
+
+      await accept();
+
+      expect((component as any).isMultipleVariables).toBe(false);
+      expect((component as any).userResponseSubject).toBeUndefined();
+      // No fourth argument, so no fourth highlight.
+      expect(editor.createDecorationsCollection).toHaveBeenCalledTimes(3);
+    });
+
+    it("highlights each argument as it comes up and clears that highlight on 
answer", async () => {
+      const { editor, accept } = await startWalk();
+      const highlightOf = (index: number) =>
+        editor.createDecorationsCollection.mock.results[index].value as { 
clear: ReturnType<typeof vi.fn> };
+
+      const decoration = 
editor.createDecorationsCollection.mock.calls[0][0][0];
+      expect(rangeTuple(decoration.range)).toEqual([3, 5, 3, 6]);
+      expect(decoration.options.className).toBe("annotation-highlight");
+      expect(decoration.options.hoverMessage.value).toBe("Argument without 
Annotation");
+      expect(highlightOf(0).clear).not.toHaveBeenCalled();
+
+      await accept();
+
+      expect(highlightOf(0).clear).toHaveBeenCalledOnce();
+      expect(highlightOf(1).clear).not.toHaveBeenCalled();
+    });
+
+    it("advances on decline without shifting the next same-line argument", 
async () => {
+      const { component } = await startWalk();
+      expect(component.currentCode).toBe("alpha");
+
+      component.rejectCurrentAnnotation();
+      await settle();
+
+      // Nothing was inserted, so the second argument on line 3 keeps its
+      // original columns — the offset only compensates for accepted text.
+      expect(component.currentCode).toBe("beta");
+      expect(rangeTuple(component.currentRange)).toEqual([3, 10, 3, 11]);
+    });
+
+    it("inserts the accepted suggestion at the model offset for the range 
end", async () => {
+      const { insert, accept } = await startWalk();
+
+      await accept();
+
+      // 42 is the stand-in model's offset; the `?? 0` fallback would give 0.
+      expect(insert).toHaveBeenCalledWith(42, ": int");
+    });
+  });
+
+  describe("handleTypeAnnotation", () => {
+    it("stages the trimmed suggestion and pushes it into the rendered 
suggestion panel", () => {
+      getTypeAnnotationsSpy.mockReturnValue(of({ choices: [{ message: { 
content: "\n  : int  \n" } }] }));
+      const fixture = makeFixture(mockJavaUDFPredicate);
+      const component = fixture.componentInstance;
+      // The panel is behind an *ngIf, so it has to be rendered before the
+      // ViewChild that the component writes into resolves.
+      component.showAnnotationSuggestion = true;
+      fixture.detectChanges();
+      expect(component.annotationSuggestion).toBeDefined();
+
+      (component as any).handleTypeAnnotation("a, b", new monaco.Range(2, 3, 
2, 8), makeFakeEditor(), 2, "all code");
+
+      // 50 + 100 and 100 + 100 — top and left stay distinguishable.
+      expect(component.suggestionTop).toBe(150);
+      expect(component.suggestionLeft).toBe(200);
+      expect(component.showAnnotationSuggestion).toBe(true);
+      expect(component.annotationSuggestion.code).toBe("a, b");
+      expect(component.annotationSuggestion.suggestion).toBe(": int");
+      expect(component.annotationSuggestion.top).toBe(150);
+      expect(component.annotationSuggestion.left).toBe(200);
+    });
+
+    it("leaves the suggestion offsets alone when the range is scrolled out of 
view", () => {
+      const fixture = makeFixture(mockJavaUDFPredicate);
+      const component = fixture.componentInstance;
+      const editor = makeFakeEditor();
+      editor.getScrolledVisiblePosition.mockReturnValue(null);
+
+      (component as any).handleTypeAnnotation("a, b", new monaco.Range(2, 3, 
2, 8), editor, 2, "all code");
+
+      expect(component.suggestionTop).toBe(0);
+      expect(component.suggestionLeft).toBe(0);
+      expect(component.showAnnotationSuggestion).toBe(true);
+    });
+
+    const INVALID_RESPONSES: ReadonlyArray<readonly [string, unknown]> = [
+      ["carries no choices field", {}],
+      ["carries an empty choices array", { choices: [] }],
+      ["carries a choice with no message", { choices: [{}] }],
+      ["carries a message with empty content", { choices: [{ message: { 
content: "" } }] }],
+    ];
+    INVALID_RESPONSES.forEach(([label, response]) => {
+      it(`raises and stages nothing when the AI response ${label}`, async () 
=> {
+        getTypeAnnotationsSpy.mockReturnValue(of(response as 
TypeAnnotationResponse));
+        const fixture = makeFixture(mockJavaUDFPredicate);
+        const component = fixture.componentInstance;
+
+        // The throw happens inside an RxJS `next` handler, which RxJS reports
+        // out-of-band on a timer rather than rethrowing to the caller. Route
+        // that report into a local sink so the assertion can see it and it
+        // cannot surface as an unhandled error later in the run.
+        const reported: unknown[] = [];
+        const previousHandler = rxjsConfig.onUnhandledError;
+        rxjsConfig.onUnhandledError = error => reported.push(error);
+        try {
+          (component as any).handleTypeAnnotation("a, b", new monaco.Range(2, 
3, 2, 8), makeFakeEditor(), 2, "all");
+          await new Promise(resolve => setTimeout(resolve, 0));
+        } finally {
+          rxjsConfig.onUnhandledError = previousHandler;
+        }
+
+        expect(reported.some(error => String(error).includes("does not contain 
valid message content"))).toBe(true);
+        expect(component.showAnnotationSuggestion).toBe(false);
+        expect(component.currentSuggestion).toBe("");
+        expect(component.currentRange).toBeUndefined();
+      });
+    });
+  });
+
+  describe("container geometry", () => {
+    const containerOf = (fixture: ComponentFixture<CodeEditorComponent>) =>
+      (fixture.componentInstance as any).containerElement.nativeElement as 
HTMLElement;
+
+    it("restores the geometry saved under this operator id", () => {
+      localStorage.setItem(mockJavaUDFPredicate.operatorID, "width: 640px; 
height: 480px;");
+
+      const container = containerOf(makeFixture(mockJavaUDFPredicate));
+
+      expect(container.style.width).toBe("640px");
+      expect(container.style.height).toBe("480px");
+    });
+
+    it("writes the geometry back under this operator id on destroy", () => {
+      const fixture = makeFixture(mockJavaUDFPredicate);
+      containerOf(fixture).style.cssText = "width: 111px; height: 222px;";
+
+      fixture.destroy();
+
+      const saved = localStorage.getItem(mockJavaUDFPredicate.operatorID) ?? 
"";
+      expect(saved).toContain("width: 111px");
+      expect(saved).toContain("height: 222px");
+    });
+
+    it("clamps a container that overflows the viewport on both axes", () => {
+      const fixture = makeFixture(mockJavaUDFPredicate);
+      const component = fixture.componentInstance;
+      const container = containerOf(fixture);
+      container.getBoundingClientRect = () => stubRect(10, 20, 5000, 5000);
+      const layout = vi.fn();
+      (component as any).editorApp = { getEditor: () => ({ layout }), dispose: 
vi.fn().mockResolvedValue(undefined) };
+
+      withViewport(800, 600, () => component.onWindowResize());
+
+      // Width comes off the LEFT edge and the viewport WIDTH, height off the
+      // TOP edge and the viewport HEIGHT; all four inputs differ, so swapping
+      // any pair changes the result.
+      expect(container.style.width).toBe("790px");
+      expect(container.style.height).toBe("580px");
+      expect(layout).toHaveBeenCalledOnce();
+    });
+
+    it("leaves a container that already fits the viewport untouched", () => {
+      const fixture = makeFixture(mockJavaUDFPredicate);
+      const component = fixture.componentInstance;
+      const container = containerOf(fixture);
+      container.getBoundingClientRect = () => stubRect(10, 20, 300, 400);
+      const layout = vi.fn();
+      (component as any).editorApp = { getEditor: () => ({ layout }), dispose: 
vi.fn().mockResolvedValue(undefined) };
+
+      withViewport(800, 600, () => component.onWindowResize());
+
+      expect(container.style.width).toBe("");
+      expect(container.style.height).toBe("");
+      // The relayout is unconditional even when no clamping was needed.
+      expect(layout).toHaveBeenCalledOnce();
+    });
+
+    it("survives a resize before any editor has been mounted", () => {
+      const fixture = makeFixture(mockJavaUDFPredicate);
+      const container = containerOf(fixture);
+      container.getBoundingClientRect = () => stubRect(10, 20, 5000, 5000);
+
+      expect(() => withViewport(800, 600, () => 
fixture.componentInstance.onWindowResize())).not.toThrow();
+      expect(container.style.width).toBe("790px");
+    });
+  });
 });
diff --git 
a/frontend/src/app/workspace/component/result-panel/console-frame/console-frame.component.spec.ts
 
b/frontend/src/app/workspace/component/result-panel/console-frame/console-frame.component.spec.ts
index af17ba018c..82f8789be1 100644
--- 
a/frontend/src/app/workspace/component/result-panel/console-frame/console-frame.component.spec.ts
+++ 
b/frontend/src/app/workspace/component/result-panel/console-frame/console-frame.component.spec.ts
@@ -445,7 +445,13 @@ describe("ConsoleFrameComponent", () => {
       // ignored the selection would still be sitting on W7 / All Workers.
       const select = 
fixture.debugElement.query(By.css("nz-select")).nativeElement as HTMLElement;
       select.click();
-      tick(500);
+      // The option list renders into a CDK overlay, i.e. a view that hangs off
+      // ApplicationRef rather than off this fixture. Render the open state 
first, then
+      // flush, so the options are there whether or not Angular's automatic 
tick has
+      // run — it stops firing for the rest of a Vitest worker once a spec 
that mounts
+      // Monaco has gone before this one, and the suite runs with isolate: 
false.
+      fixture.detectChanges();
+      flush();
       fixture.detectChanges();
 
       const options = Array.from(document.querySelectorAll("nz-option-item"));

Reply via email to