From eae319407755a92c868f541a0ef77d2284f55244 Mon Sep 17 00:00:00 2001 From: Matthew Ball Date: Tue, 26 May 2026 12:29:04 -0700 Subject: [PATCH] test(frontend): browser-mode spec for code-editor.component --- frontend/angular.json | 8 +- .../code-editor.component.browser.spec.ts | 323 ++++++++++++++++++ frontend/src/browser-buffer-polyfill.ts | 44 +++ frontend/vitest.browser.config.ts | 17 +- 4 files changed, 389 insertions(+), 3 deletions(-) create mode 100644 frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.browser.spec.ts create mode 100644 frontend/src/browser-buffer-polyfill.ts diff --git a/frontend/angular.json b/frontend/angular.json index b9e9961d027..01b2efdd6ff 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -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" ] } }, @@ -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" + ] } } } 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 new file mode 100644 index 00000000000..757c21b8d47 --- /dev/null +++ b/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.browser.spec.ts @@ -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 { + addAction: ReturnType; + updateOptions: ReturnType; + layout: ReturnType; + getSelection: ReturnType; + getModel: ReturnType; + getScrolledVisiblePosition: ReturnType; + createDecorationsCollection: ReturnType; + 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; + let aiEnabled$: BehaviorSubject; + let getTypeAnnotationsSpy: ReturnType; + let locateUnannotatedSpy: ReturnType; + let workflowActionService: WorkflowActionService; + + beforeEach(async () => { + monacoBindingCalls.length = 0; + displayVersionStream$ = new BehaviorSubject(false); + aiEnabled$ = new BehaviorSubject("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 { + 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(); + }); +}); diff --git a/frontend/src/browser-buffer-polyfill.ts b/frontend/src/browser-buffer-polyfill.ts new file mode 100644 index 00000000000..981615df248 --- /dev/null +++ b/frontend/src/browser-buffer-polyfill.ts @@ -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"; + +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 } }).process) { + (globalThis as unknown as { process: { env: Record } }).process = { env: {} }; +} diff --git a/frontend/vitest.browser.config.ts b/frontend/vitest.browser.config.ts index eee0624ff1e..eb609d1cdae 100644 --- a/frontend/vitest.browser.config.ts +++ b/frontend/vitest.browser.config.ts @@ -41,6 +41,15 @@ export default defineConfig({ { find: /^lib0\/webcrypto$/, replacement: lib0Webcrypto }, ], }, + // `buffer` is imported only by the test setup file (src/browser-buffer- + // polyfill.ts), which Vite's import-scan via the @angular/build:unit-test + // builder does NOT crawl — without this hint the dep is served raw and + // `require()` calls inside the CJS package crash on first import. + // Explicit-include forces esbuild to pre-bundle it (alongside its + // `base64-js` + `ieee754` transitive deps) into a browser-runnable ESM. + optimizeDeps: { + include: ["buffer"], + }, test: { // Emit a JUnit-XML report alongside the default console reporter so // Codecov Test Analytics can ingest browser-mode failures and detect @@ -48,7 +57,13 @@ export default defineConfig({ // can disambiguate it from the unit-test report. reporters: ["default", ["junit", { outputFile: "junit-browser.xml" }]], globals: true, - setupFiles: ["src/test-zone-setup.ts"], + // browser-buffer-polyfill must run FIRST: it puts Buffer/process on + // globalThis before any test module loads, which is required for + // monaco-editor-wrapper's eager Buffer.allocUnsafe references (under + // jsdom-mode Vitest these are satisfied by Node's built-in Buffer; in + // browser-mode the runtime has neither, so we install the `buffer` npm + // package as a shim). + setupFiles: ["src/browser-buffer-polyfill.ts", "src/test-zone-setup.ts"], browser: { enabled: true, provider: playwright(),