You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@texera.apache.org by gi...@apache.org on 2026/05/31 23:48:42 UTC
(texera) 01/01: test(frontend): browser-mode spec for code-editor.component (#5241)
This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-5241-2ebef323605b156bcaaeb9eb02454797059758a2
in repository https://gitbox.apache.org/repos/asf/texera.git
commit e25c28225f25990a4c149b85fa48f8090900cf85
Author: Matthew B. <mg...@uci.edu>
AuthorDate: Sun May 31 16:48:20 2026 -0700
test(frontend): browser-mode spec for code-editor.component (#5241)
### What changes were proposed in this PR?
- Adds a browser-mode companion spec,
code-editor.component.browser.spec.ts, that covers the four paths the
jsdom spec cannot reach: the initializeMonacoEditor subscribe body
(readOnly toggle, MonacoBinding wiring, AI-action registration),
initializeDiffEditor's diff config path, the OpenAI gate in
setupAIAssistantActions, the type-annotation action's
getScrolledVisiblePosition branch, and the onWindowResize ->
editor.layout() handler. y-monaco's MonacoBinding is mocked via
vi.hoisted so the constructor records its args without needing a real
Y.Text + monaco TextModel.
- Adds src/browser-buffer-polyfill.ts and wires it as the first
setupFile in vitest.browser.config.ts so monaco-editor-wrapper and
monaco-vscode-files-service-override can be imported under package by
listing it in optimizeDeps.include so Vite's import-scan picks it up
despite the setup-file indirection.
- Broadens the gui:test-browser target's include glob to
**/*.browser.spec.ts in angular.json, and adds the same pattern to
gui:test's exclude list so the two runners do not double-execute.
### Any related issues, documentation, or discussions?
Closes: #5192
### How was this PR tested?
- yarn nx run gui:test-browser: 29/29 passing (7 new code-editor + 22
existing workflow-editor).
- yarn nx run gui:test: 623 passed, 2 skipped, 1 todo (no regression
from the exclude change).
### Was this PR authored or co-authored using generative AI tooling?
Co-authored with Claude Opus 4.7 in compliance with ASF
---
frontend/angular.json | 8 +-
frontend/package.json | 1 +
.../code-editor.component.browser.spec.ts | 324 +++++++++++++++++++++
frontend/src/browser-buffer-polyfill.ts | 44 +++
frontend/vitest.browser.config.ts | 17 +-
frontend/yarn.lock | 1 +
6 files changed, 392 insertions(+), 3 deletions(-)
diff --git a/frontend/angular.json b/frontend/angular.json
index b9e9961d02..01b2efdd6f 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/package.json b/frontend/package.json
index 481e720d97..265b0bbe4f 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -122,6 +122,7 @@
"@vitest/browser": "4.1.5",
"@vitest/browser-playwright": "4.1.5",
"@vitest/coverage-v8": "4.1.5",
+ "buffer": "5.7.1",
"concurrently": "7.4.0",
"eslint": "8.57.0",
"eslint-plugin-rxjs": "5.0.3",
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 0000000000..d6a3266fd1
--- /dev/null
+++ b/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.browser.spec.ts
@@ -0,0 +1,324 @@
+/**
+ * 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 (the Buffer/process
+// shim is wired as the first setupFile in vitest.browser.config.ts — see
+// src/browser-buffer-polyfill.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<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();
+ });
+});
diff --git a/frontend/src/browser-buffer-polyfill.ts b/frontend/src/browser-buffer-polyfill.ts
new file mode 100644
index 0000000000..981615df24
--- /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<string, string> } }).process) {
+ (globalThis as unknown as { process: { env: Record<string, string> } }).process = { env: {} };
+}
diff --git a/frontend/vitest.browser.config.ts b/frontend/vitest.browser.config.ts
index eee0624ff1..eb609d1cda 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(),
diff --git a/frontend/yarn.lock b/frontend/yarn.lock
index b929da0c62..70c89eb2ef 100644
--- a/frontend/yarn.lock
+++ b/frontend/yarn.lock
@@ -11094,6 +11094,7 @@ __metadata:
"@vitest/coverage-v8": "npm:4.1.5"
ai: "npm:5.0.93"
ajv: "npm:8.10.0"
+ buffer: "npm:5.7.1"
concaveman: "npm:2.0.0"
concurrently: "npm:7.4.0"
d3-shape: "npm:2.1.0"