Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions frontend/angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,8 @@
"include": ["**/*.spec.ts"],
"setupFiles": ["src/jsdom-svg-polyfill.ts"],
"exclude": [
"**/app/workspace/component/workflow-editor/workflow-editor.component.spec.ts"
"**/app/workspace/component/workflow-editor/workflow-editor.component.spec.ts",
"**/*.browser.spec.ts"
]
}
},
Expand All @@ -105,7 +106,10 @@
"runner": "vitest",
"runnerConfig": "vitest.browser.config.ts",
"tsConfig": "src/tsconfig.spec.json",
"include": ["**/app/workspace/component/workflow-editor/workflow-editor.component.spec.ts"]
"include": [
"**/app/workspace/component/workflow-editor/workflow-editor.component.spec.ts",
"**/*.browser.spec.ts"
]
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,323 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

// Browser-mode companion to code-editor.component.spec.ts. The sibling jsdom
// spec covers the constructor, language detection, getFileSuffixByLanguage,
// onFocus, getCoeditorCursorStyles, and the accept/reject annotation paths,
// but cannot reach anything gated on a real Monaco editor — the
// `initAndStart` subscribe body, `initializeDiffEditor`, AI-action run
// callbacks, `handleTypeAnnotation`'s position branch, and the resize
// handler. This spec drives those by swapping the component's editorWrapper
// for a fake-with-real-DOM and running in vitest's Playwright/Chromium
// browser mode, where monaco-editor's codingame fork can be imported without
// jsdom's missing-canvas / Node-Buffer-allocation tripwires (see the
// nodePolyfills entry in vitest.browser.config.ts).

import { ComponentFixture, TestBed } from "@angular/core/testing";
import { HttpClientTestingModule } from "@angular/common/http/testing";
import { FormControl } from "@angular/forms";
import { BehaviorSubject, of } from "rxjs";
import * as Y from "yjs";

import { CodeEditorComponent } from "./code-editor.component";
import { WorkflowActionService } from "../../service/workflow-graph/model/workflow-action.service";
import { WorkflowVersionService } from "../../../dashboard/service/user/workflow-version/workflow-version.service";
import { AIAssistantService } from "../../service/ai-assistant/ai-assistant.service";
import { OperatorMetadataService } from "../../service/operator-metadata/operator-metadata.service";
import { StubOperatorMetadataService } from "../../service/operator-metadata/stub-operator-metadata.service";
import { mockOperatorMetaData } from "../../service/operator-metadata/mock-operator-metadata.data";
import { mockJavaUDFPredicate, mockPoint } from "../../service/workflow-graph/model/mock-workflow-data";
import { OperatorSchema } from "../../types/operator-schema.interface";
import { commonTestProviders } from "../../../common/testing/test-utils";
import * as monaco from "monaco-editor";

// y-monaco's MonacoBinding wires real listeners against the YText and a real
// monaco TextModel. Our fake editor returns a stub model, so the binding's
// constructor would throw on `model.onDidChangeContent(...)`. The component
// only depends on the binding's `destroy()` (called in ngOnDestroy) and the
// fact that the constructor was called with the right shape of args, so a
// recording stub is sufficient.
// `vi.mock` is hoisted to the top of the file, so any closure variables it
// references must be declared inside `vi.hoisted` — a plain top-level `const`
// is evaluated AFTER the mock factory runs, leaving `monacoBindingCalls`
// undefined at the moment MonacoBinding's constructor would try to push.
const { monacoBindingCalls } = vi.hoisted(() => ({
monacoBindingCalls: [] as unknown[][],
}));
vi.mock("y-monaco", () => ({
MonacoBinding: class {
constructor(...args: unknown[]) {
monacoBindingCalls.push(args);
}
destroy = vi.fn();
},
}));

// Re-use the augmented stub from the jsdom spec so the component constructor
// can resolve its highlighted operator regardless of operatorType.
const baseSchema = mockOperatorMetaData.operators.find(op => op.operatorType === "PythonUDF");
if (!baseSchema) {
throw new Error(
"CodeEditorComponent browser spec setup expected a PythonUDF schema in mockOperatorMetaData — fixture has drifted."
);
}
const synthesizeSchema = (operatorType: string): OperatorSchema => ({ ...baseSchema, operatorType });
const augmentedSchemas: OperatorSchema[] = [...mockOperatorMetaData.operators, synthesizeSchema("PythonUDFV2")];
class AugmentedStubMetadataService extends StubOperatorMetadataService {
private readonly augmentedMetadata = of({ ...mockOperatorMetaData, operators: augmentedSchemas });
override getOperatorMetadata(): typeof this.augmentedMetadata {
return this.augmentedMetadata;
}
override getOperatorSchema(operatorType: string): OperatorSchema {
const schema = augmentedSchemas.find(op => op.operatorType === operatorType);
if (!schema) throw new Error(`unknown operatorType ${operatorType}`);
return schema;
}
override operatorTypeExists(operatorType: string): boolean {
return augmentedSchemas.some(op => op.operatorType === operatorType);
}
}

// A minimal, recording Monaco editor stand-in. Returns realistic values
// where the component reads from it (`getScrolledVisiblePosition`,
// `getSelection`, `getModel`) and records what the component asks it to do
// (`addAction`, `updateOptions`, `layout`). The component does not introspect
// any of these beyond truthiness, so the stub does not need to be a real
// IStandaloneCodeEditor — TypeScript's structural check is the only gate.
interface FakeEditor {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are we testing a fake editor?

addAction: ReturnType<typeof vi.fn>;
updateOptions: ReturnType<typeof vi.fn>;
layout: ReturnType<typeof vi.fn>;
getSelection: ReturnType<typeof vi.fn>;
getModel: ReturnType<typeof vi.fn>;
getScrolledVisiblePosition: ReturnType<typeof vi.fn>;
createDecorationsCollection: ReturnType<typeof vi.fn>;
actions: monaco.editor.IActionDescriptor[];
}

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 };
}),
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,
})),
getScrolledVisiblePosition: vi.fn(() => ({ top: 50, left: 100, height: 18 })),
createDecorationsCollection: vi.fn(() => ({ clear: vi.fn() })),
};
}

function makeFakeWrapper(editor: FakeEditor) {
return {
initAndStart: vi.fn().mockResolvedValue(undefined),
getEditor: vi.fn(() => editor),
dispose: vi.fn(),
};
}

describe("CodeEditorComponent (browser)", () => {
let displayVersionStream$: BehaviorSubject<boolean>;
let aiEnabled$: BehaviorSubject<string>;
let getTypeAnnotationsSpy: ReturnType<typeof vi.fn>;
let locateUnannotatedSpy: ReturnType<typeof vi.fn>;
let workflowActionService: WorkflowActionService;

beforeEach(async () => {
monacoBindingCalls.length = 0;
displayVersionStream$ = new BehaviorSubject<boolean>(false);
aiEnabled$ = new BehaviorSubject<string>("OpenAI");
getTypeAnnotationsSpy = vi.fn().mockReturnValue(of({ choices: [{ message: { content: ": int" } }] }));
locateUnannotatedSpy = vi.fn().mockReturnValue(of([]));

await TestBed.configureTestingModule({
providers: [
WorkflowActionService,
{ provide: OperatorMetadataService, useClass: AugmentedStubMetadataService },
{
provide: AIAssistantService,
useValue: {
checkAIAssistantEnabled: () => aiEnabled$,
getTypeAnnotations: getTypeAnnotationsSpy,
locateUnannotated: locateUnannotatedSpy,
},
},
{
provide: WorkflowVersionService,
useValue: {
getDisplayParticularVersionStream: () => displayVersionStream$,
},
},
...commonTestProviders,
],
imports: [CodeEditorComponent, HttpClientTestingModule],
}).compileComponents();

workflowActionService = TestBed.inject(WorkflowActionService);
});

// Builds a fixture for the highlighted operator, but defers the
// detectChanges/ngAfterViewInit step so the caller can swap in the fake
// editor wrapper and stage `code` / `formControl` before the subscribe body
// fires. Returns the fixture, the fake wrapper, and the fake editor.
function makeFixtureWithFakes() {
const predicate = { ...mockJavaUDFPredicate };
workflowActionService.addOperator(predicate, mockPoint);
workflowActionService.getJointGraphWrapper().highlightOperators(predicate.operatorID);

const fixture = TestBed.createComponent(CodeEditorComponent);
const editor = makeFakeEditor();
const wrapper = makeFakeWrapper(editor);
const c = fixture.componentInstance as any;
c.editorWrapper = wrapper;
c.formControl = new FormControl({ value: "", disabled: false });
// A YText must live inside a Y.Doc to be useful; the binding stub doesn't
// care, but we stage it as if it came from the shared model so the
// subscribe body crosses the `if (!this.code) return;` gate.
c.code = new Y.Doc().getText("code");
return { fixture, wrapper, editor, c: c as CodeEditorComponent };
}

// The component's subscribe path runs `from(initAndStart(...)).pipe(...)`,
// which is microtask-async. One macrotask flush after detectChanges is
// enough for the RxJS chain to deliver the editor into the subscribe body.
async function flush(): Promise<void> {
await Promise.resolve();
await new Promise(r => setTimeout(r, 0));
}

it("initializeMonacoEditor: wires the editor + MonacoBinding + AI actions when code exists", async () => {
const { fixture, wrapper, editor } = makeFixtureWithFakes();

fixture.detectChanges();
await flush();

// The non-diff config branch should fire with the editor host element.
expect(wrapper.initAndStart).toHaveBeenCalledOnce();
const [userConfig, host] = wrapper.initAndStart.mock.calls[0];
expect((userConfig as any).wrapperConfig.editorAppConfig.useDiffEditor).toBeUndefined();
expect((userConfig as any).wrapperConfig.editorAppConfig.codeResources.main.uri).toMatch(/^in-memory-.*\.\.java$/);
expect(host).toBeInstanceOf(HTMLElement);

// The subscribe body should: push readOnly via updateOptions, construct
// MonacoBinding, and register the two AI actions.
expect(editor.updateOptions).toHaveBeenCalledWith({ readOnly: false });
expect(monacoBindingCalls.length).toBe(1);
expect(editor.addAction).toHaveBeenCalledTimes(2);
});

it("initializeMonacoEditor: respects formControl.disabled when toggling readOnly", async () => {
const { fixture, editor, c } = makeFixtureWithFakes();
(c as any).formControl = new FormControl({ value: "", disabled: true });

fixture.detectChanges();
await flush();

expect(editor.updateOptions).toHaveBeenCalledWith({ readOnly: true });
});

it("initializeDiffEditor: when displayParticularVersion is true, runs the diff config path", async () => {
const { fixture, wrapper } = makeFixtureWithFakes();
// Seed the stream BEFORE detectChanges so the subscribe in ngAfterViewInit
// picks `true` on first emission and takes the diff branch.
displayVersionStream$.next(true);

fixture.detectChanges();
await flush();

expect(wrapper.initAndStart).toHaveBeenCalledOnce();
const [userConfig] = wrapper.initAndStart.mock.calls[0];
expect((userConfig as any).wrapperConfig.editorAppConfig.useDiffEditor).toBe(true);
// `original` is the previous-version source, only set on the diff path.
expect((userConfig as any).wrapperConfig.editorAppConfig.codeResources.original).toBeDefined();
});

it("setupAIAssistantActions: registers only the 'all' action when the gate is not OpenAI", async () => {
aiEnabled$.next("none");
const { fixture, editor } = makeFixtureWithFakes();

fixture.detectChanges();
await flush();

expect(editor.addAction).toHaveBeenCalledTimes(1);
expect(editor.addAction.mock.calls[0][0].id).toBe("all-type-annotation-action");
});

it("type-annotation-action: invoking the run callback populates suggestion state from position", async () => {
const { fixture, editor } = makeFixtureWithFakes();
fixture.detectChanges();
await flush();

// The first registered action is "type-annotation-action" (the OpenAI
// gate fires). Pull its `run` callback and invoke it directly — the
// alternative is dispatching via the Monaco command palette, which our
// fake editor doesn't implement.
const typeAction = editor.actions.find(a => a.id === "type-annotation-action");
expect(typeAction).toBeDefined();
typeAction!.run(editor as unknown as monaco.editor.ICodeEditor);

await flush();

expect(getTypeAnnotationsSpy).toHaveBeenCalledOnce();
// getScrolledVisiblePosition returns {top: 50, left: 100}; the component
// adds +100 on each axis when staging the suggestion.
expect(fixture.componentInstance.suggestionTop).toBe(150);
expect(fixture.componentInstance.suggestionLeft).toBe(200);
expect(fixture.componentInstance.showAnnotationSuggestion).toBe(true);
expect(fixture.componentInstance.currentSuggestion).toBe(": int");
});

it("all-type-annotation-action: locateUnannotated returns empty list, action no-ops cleanly", async () => {
const { fixture, editor } = makeFixtureWithFakes();
fixture.detectChanges();
await flush();

const allAction = editor.actions.find(a => a.id === "all-type-annotation-action");
expect(allAction).toBeDefined();
allAction!.run(editor as unknown as monaco.editor.ICodeEditor);

await flush();

expect(locateUnannotatedSpy).toHaveBeenCalledOnce();
// Empty unannotated list -> processNextVariable never invoked; the
// multi-variable state stays in its initial shape.
expect((fixture.componentInstance as any).isMultipleVariables).toBe(false);
});

it("onWindowResize: calls editor.layout() through adjustEditorSize", async () => {
const { fixture, editor } = makeFixtureWithFakes();
fixture.detectChanges();
await flush();

// Reset layout's call count so we don't pick up Monaco's own init layout.
editor.layout.mockClear();
fixture.componentInstance.onWindowResize();

expect(editor.layout).toHaveBeenCalledOnce();
});
});
44 changes: 44 additions & 0 deletions frontend/src/browser-buffer-polyfill.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

// monaco-editor-wrapper + monaco-vscode-files-service-override dereference
// Node's Buffer (including Buffer.allocUnsafe) at module-evaluation time.
// Under jsdom-mode Vitest, the test runs on Node and Buffer is built-in. In
// browser-mode (Playwright/Chromium), Buffer doesn't exist, so importing
// monaco-editor-wrapper crashes at load. The `buffer` npm package is the
// browser polyfill of Node's API; expose its `Buffer` (and a minimal
// `process` shim) on the global so monaco's internals find them at the same
// names they'd find Node's. Has to run before any monaco import is
// evaluated, which is why this is wired into vitest.browser.config.ts as the
// FIRST setupFile — setupFiles run in order, before any test module loads.
//
// We use a namespace import (rather than a named or default import) because
// Vite's dep-optimizer rewrites `buffer@5`'s CJS exports in a way that
// exposes neither a `Buffer` named export nor a `default` export at
// module-eval time. The namespace object always has the optimizer-injected
// shape, so we read `Buffer` off it dynamically.
import * as bufferModule from "buffer";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also have this concern. In addition, why do we need to have a polyfill file for buffer?


const { Buffer } = bufferModule as unknown as { Buffer: typeof globalThis.Buffer };

(globalThis as unknown as { Buffer: typeof Buffer }).Buffer = Buffer;
(globalThis as unknown as { global: typeof globalThis }).global = globalThis;
if (!(globalThis as unknown as { process?: { env: Record<string, string> } }).process) {
(globalThis as unknown as { process: { env: Record<string, string> } }).process = { env: {} };
}
Loading
Loading