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"));