monaco-react icon indicating copy to clipboard operation
monaco-react copied to clipboard

How to test Editor

Open jquintozamora opened this issue 5 years ago • 24 comments

I'm trying to test components using ControlledEditor, but I'm using react testing library and I only can see "Loading..." but never gets the editor working in the tests.

Is there a way to get given value / autocomplete items in the tests ?

jquintozamora avatar Jun 30 '20 07:06 jquintozamora

I didn't find a universal way to do it. Depends on the test and testing tool, there can be different problems. First, by default it tries to load sources by injecting a script into the body, it can cause problems, plus it will try to set up workers and etc. So, unfortunately we haven't a normal way to do it yet.

suren-atoyan avatar Jul 03 '20 10:07 suren-atoyan

Thanks for this component! I am using rollup so not having to have a compile step was huge. I am trying to test a component using Jest and either react testing library or enzyme. I am having issues with both. Do you have any tips @suren-atoyan?

davidicus avatar Sep 24 '20 14:09 davidicus

Hi @davidicus. I am sorry for the inconvenience regarding testing. Currently, I have no tips. As the initialization process is supposed to load some scripts from CDN, there is a problem with Jest (and other testing tools). I'll try to find the best way to test it and will come back to you.

suren-atoyan avatar Sep 24 '20 21:09 suren-atoyan

I have faced the same issue. 😭

As-is, when I print dom, it shows... Screenshot 2021-02-16 at 13 41 33

sujinleeme avatar Feb 16 '21 12:02 sujinleeme

Hi everyone! @suren-atoyan thank you for your great work on this project :+1: Recently we started using monaco-react in our web app, where unit testing is very important. All attempts to run them using JSDOM(default jest environment) failed, but we discovered a new Cypress feature called component testing. We just started using it, but it looks very promising, monaco-react component fully renders.

przlada avatar Apr 22 '21 11:04 przlada

Hey @przlada, thank you for sharing this 🙂 It's really promising

suren-atoyan avatar Apr 22 '21 11:04 suren-atoyan

Hi Everyone,

I mocked out a "fake" Editor using Jest. I used this example as a guide. My implementation of the Editor component is pretty simple, therefore my mock is also pretty simple. The main behavior I needed to test was the onChange function I wrote for my Editor. I realize mocking a component is not ideal, but I was finally able to test how other elements on my page are affected by the onChange.

As seen on the react-testing-library FAQ:

In general, you should avoid mocking out components (see the Guiding Principles section). However if you need to, then it's pretty trivial using Jest's mocking feature.

import * from "@testing-library/react";
import Tabs from ".";

jest.mock("@monaco-editor/react", () => {
  const FakeEditor = jest.fn(props => {
    return (
      <textarea
        data-auto={props.wrapperClassName}
        onChange={e => props.onChange(e.target.value)}
        value={props.value}
      ></textarea>
    );
  });
  return FakeEditor;
});

it("can save blob", async () => {
  render(<Tabs />);
  let saveChangesButton = screen.getByTestId("save-changes");
  
  expect(saveChangesButton).toBeDisabled();
  
  let editor = screen.getByTestId("monaco-editor");
  let newValue = JSON.stringify([{ key: false }]);

  act(() => {
    fireEvent.change(editor, {
      target: { value: newValue },
    });
  });
  
  expect(saveChangesButton).toBeEnabled();
  
  act(() => {
    fireEvent.click(saveChangesButton);
  });
  await waitFor(() => {
    expect(screen.getByTestId("success-notification")).toBeInTheDocument();
  });
});

Looking forward to a more robust solution in the future...

kevinpowell1 avatar Jul 26 '21 22:07 kevinpowell1

Any updates on that?? Would be great to test using RTL the real component instead of a mock.

fboechats avatar Mar 30 '22 19:03 fboechats

Hey everyone 👋 could you please try to use monaco-editor as an npm package during the testing? It's available in v4.4.1

suren-atoyan avatar Apr 01 '22 12:04 suren-atoyan

Not sure if using a 70mb lib just for testing is a good approach.

fboechats avatar Apr 01 '22 19:04 fboechats

@fboechats It shouldn't be 70mb. Have you already tested it?

suren-atoyan avatar Apr 04 '22 09:04 suren-atoyan

@suren-atoyan Npm says it's around that: https://www.npmjs.com/package/monaco-editor

fboechats avatar Apr 04 '22 12:04 fboechats

@fboechats the number you've seen on the npm website includes everything in the repo - README, images, etc. The real size of the source is incomparably less than that. In fact, it is just 81.4kB minified and 13.7 KB min+gziped. Check it here.

suren-atoyan avatar Sep 28 '22 02:09 suren-atoyan

@suren-atoyan Having this issue as well. I'm not sure I understand what you mean by using the npm package during the testing? Am I to mock out the import in Jest or actually change my component (thin wrapper for Editor) to use the NPM package altogether?

vicky-carbon avatar Oct 11 '22 03:10 vicky-carbon

@vicky-carbon please check this for more information. Let me know if it helps.

By default you do not use monaco-editor package from node_modules, instead, you import it from CDN.

suren-atoyan avatar Oct 11 '22 04:10 suren-atoyan

Can anyone post a working example of this in a codepen? It seems the response from @suren-atoyan results in a number of follow-on questions, and I haven't been able to find any way to implement the advice or the reference given in any test of my own.

When I attempt to implement any of the the solutions I've been able to find, either:

  1. Changing this config: loader.config({ paths: { vs: "node_modules/monaco-editor/esm/vs/editor/editor.api.js" } });

as in:

import Editor, {loader} from "@monaco-editor/react";

loader.config({ paths: { vs: "node_modules/monaco-editor/esm/vs/editor/editor.api.js" } });

function App() {
  return (
      <div className="App">
        <Editor height={"90vh"} defaultLanguage={"javascript"} defaultValue={"const bubble = () => {console.log('yeet')}"}/>
      </div>
  );
}

export default App;

this does not work

  1. Loading the package locally using this method:
import * as monaco from 'monaco-editor';
import Editor, {loader} from "@monaco-editor/react";

loader.config({monaco});

as in...

import * as monaco from 'monaco-editor';
import Editor, {loader} from "@monaco-editor/react";

loader.config({monaco});

function App() {
  return (
      <div className="App">
        <Editor height={"90vh"} defaultLanguage={"javascript"} defaultValue={"const bubble = () => {console.log('yeet')}"}/>
      </div>
  );
}

export default App;

This also does not work. Depending on the environment I'm creating in I get a number of errors. Either to do with the module 'monaco-editor' not being found or with babel, some other issues.

I've done just about everything I've seen suggested. Installed the 'monaco-editor-webpack-plugin' followed a number of threads on stack overflow to change settings in webpack.config.js, the jest settings, etc.

I'm just trying to run a simple test with React Testing Library and Jest that gets past that initially rendered "Loading...". Does anyone have a working example which they can share on codepen or some other way they can share similar @kevinpowell1's super helpful post? Much appreciated. And btw, thank you @suren-atoyan for this great project and all your help.

nikiforos-dev avatar Dec 30 '22 21:12 nikiforos-dev

@suren-atoyan I am facing the same problem. Is there any working example with a test setup for @monaco-editor/react? It is highly appreciated.

Nishchit14 avatar Jul 25 '23 11:07 Nishchit14

@Nishchit14 Unfortunately, I do not have a working example, but it's supposed to be working with this setup. It's the second option of @nikiforos-dev comment.

This also does not work. Depending on the environment I'm creating in I get a number of errors. Either to do with the module 'monaco-editor' not being found or with babel, some other issues.

I am wondering what issues he faced here

suren-atoyan avatar Jul 25 '23 12:07 suren-atoyan

I tried the first option and I was also getting the error something like locader_default.loader.init is not a function @suren-atoyan

I am using this solution for now https://github.com/suren-atoyan/monaco-react/issues/88#issuecomment-887055307 but this is not perfect for our usecase.

Nishchit14 avatar Jul 25 '23 12:07 Nishchit14

I am facing a similar issue, using

import * as monaco from 'monaco-editor';
import Editor, {loader} from "@monaco-editor/react";

loader.config({monaco});

I get this error when running the test suite:

TypeError: _react.loader.config is not a function

matias-lg avatar Sep 05 '23 23:09 matias-lg

Hi Everyone,

I spent some time trying to figure it out, and finally, I can make it work, at least to pass the 'Loading...' state and a little bit more in Jest + JSDOM enviroment. I want to share some ideas of how I solved this so someone using different tools and environments can use it as a guide for what they should look for.

If you just want to see the final solution, check this codesandbox.

Long story short, you need resources: "usable" and runScripts: "dangerously" of JSDOM options, mocking missing stuff, installing canvas package, and using loader.config if you want to use monaco-editor from your local node_modules.

Loading the scripts

As @suren-atoyan explained that the Monaco tries to load sources by injecting a script into the HTML, by default it will load those scripts from CDN as configured here. However, where they are loaded from isn't a problem, the problem is how can we load them. In JSDOM environment, it will not load any subresources such as scripts, stylesheets, images, or iframes, unless we explicitly tell it to do so. That why we keep struck at 'Loading...' state. We can change this behavior by providing the option resources: "usable". When we use JSDOM with Jest runner, we can set the test environment options by providing it either in the configuration file (jest.config.js) that apply to all tests, or in a docblock at the top of a test file. I prefer the last one since we don't need to load any subresources in general cases.

You can paste the following docblock at the top of your test file

/**
 * @jest-environment-options { "resources": "usable" }
 */
import { render } from "@testing-library/react"
import userEvnet from '@testing-library/user-event'
import Monaco from "./Monaco";
...
it("Test monaco editor",async () => {
    render(<Monaco/>)

    const editor = await screen.findByRole('textbox')
    expect(editor).toBeInTheDocument()
})

Executing the scripts

jsdom's most powerful ability is that it can execute scripts inside the jsdom. These scripts can modify the content of the page and access all the web platform APIs jsdom implements.

However, this is also highly dangerous when dealing with untrusted content. The jsdom sandbox is not foolproof, and code running inside the DOM's

Executing external (untrusted) scripts is very dangerous so JSDOM disables that by default. Again, we can override this using the runScripts: "dangerously" of JSDOM options. If you are using jest-environment-jsdom, you don't need to do anything because it is already enabled for you.

At this point, we can load and execute scripts for initiating the Monaco (even from CDN, of course).

Mocking

If you try to initiate the Monaco in an environment like JSDOM, you will get multiple errors. This is because JSDOM is just a browser emulator, not a real one. It is implemented only fundamental features to make it works like a browser. Those errors complained about missing features that the Monaco is using, so you need to mock them one-by-one. You can mock them any way you like, you see my way in the codesandbox link above.

The list of errors that I fixed in the example is the following:

  • TypeError: document.queryCommandSupported is not a function
  • TypeError: window.matchMedia is not a function
  • Not implemented: HTMLCanvasElement.prototype.getContext ...
  • ReferenceError: ResizeObserver is not defined
  • ReferenceError: TextDecoder is not defined

The special one is Not implemented: HTMLCanvasElement.prototype.getContext ... error that can be resolved by installing the canvas package (npm i -D canvas).

import { TextDecoder } from 'util';
...
beforeAll(() => {
    // Resolve "TypeError: document.queryCommandSupported is not a function"
    global.document.queryCommandSupported = () => true;
    // Resolve "TypeError: window.matchMedia is not a function"
    Object.defineProperty(window, 'matchMedia', {
        writable: true,
        value: jest.fn().mockImplementation(query => ({
           matches: false,
           media: query,
           onchange: null,
           addListener: jest.fn(),
           removeListener: jest.fn(),
           addEventListener: jest.fn(),
           removeEventListener: jest.fn(),
           dispatchEvent: jest.fn(),
        })),
    });
    // Resolve "ReferenceError: ResizeObserver is not defined"
    Object.defineProperty(window, 'ResizeObserver', {
        value: class ResizeObserver {
           observe() {}
           unobserve() {}
           disconnect() {} 
        },
    });
    // Resolve "ReferenceError: TextDecoder is not defined"
    Object.defineProperty(window, 'TextDecoder', { value: TextDecoder });
})
...
it("Test monaco editor",async () => {
    render(<Monaco/>)

    const editor = await screen.findByRole('textbox')
    expect(editor).toBeInTheDocument()
})

When you run your test now, you can pass the 'Loading...' state. Congratulation, if this is you goal, you can stop here.

I want a little bit more so I tried to type something into the Monaco and it started to complain again (TypeError: performance.mark is not a function). No worries, just mock another stuff.

import { performance } from 'perf_hooks';
...
beforeAll(() => {
   ...
    // Resolve "TypeError: performance.mark is not a function" and other properties of performance
    Object.defineProperty(window, 'performance', { value: performance });
})
...
it("Test monaco editor",async () => {
    render(<Monaco/>)

    const editor = await screen.findByRole('textbox')
    expect(editor).toBeInTheDocument()
    
    const user = userEvnet.setup()
    await user.type(editor, 'console.log("Hello");');

    await expect(screen.findByDisplayValue('console.log("Hello");')).resolves.toBeInTheDocument()
})

Using your local files instead of CDN

This is the easy part. We can change the source where the Monaco will load the scripts using loader.config. We use this as a setup in the test file so the Monaco still load the scripts from CDN when running in production.

import { loader } from '@monaco-editor/react';
import path from 'path';
...
function ensureFirstBackSlash(str) {
  return str.length > 0 && !str.startsWith('/') ? '/' + str : str;
}
function uriFromPath(_path) {
  const pathName = path.resolve(_path).replace(/\\/g, '/');
  return encodeURI('file://' + ensureFirstBackSlash(pathName));
}

const appPath = path.join(__dirname, '../');

beforeAll(() => {
    loader.config({ paths: { vs: uriFromPath(path.resolve(appPath, 'node_modules/monaco-editor/min/vs')) }})
    ...
});
...

Notes

  • Error: Could not load script .... Error: request canceled by user ... Loading scripts takes some time. If your test exits before the process has been completed, it will show the above error telling you that loading scripts was interupted.
  • Could not create web worker(s). and URL.createObjectURL is not a function warning I don't know if these will cause any issues, but since I don't need to test any advance stuffs, I decided to ignore them. If you need to solve it, maybe find some library like jsdom-worker that may work.
  • Note that the version of monaco-editor that you use from CDN and your local may be different. For example, @monaco-editor/[email protected] specifies [email protected] as the source version for CDN in configuration, but when you install @monaco-editor/react in your project it may install newer version of monaco-editor into your local node_modules according to the peerDependecies of @monaco-editor/react.
   "peerDependencies": {
      "monaco-editor": ">= 0.25.0 < 1",
       ...
   },
  • userEvent.clear doesn't work. userEvent.type and userEvent.keyboard using some special keys i.e. {Delete}, {Control}, and {End} also don't work. I don't know if I've done something wrong.

pasinJ avatar Sep 08 '23 15:09 pasinJ

@pasinJ your codesandbox link doesn't seem to have the code anymore. Can you please share an updated sandbox link or a link to a repo?

macintushar avatar Apr 22 '24 08:04 macintushar

@macintushar Please check this repo. I hope it could help.

Testing Monaco in a JSDOM environment is pretty complicated and has limited features because we need to mock many things. This method may be suitable only for testing whether it can display something correctly. For more realistic testing, I recommend using a real browser with a framework like Cypress.

pasinJ avatar Apr 28 '24 06:04 pasinJ

Thanks for the help @pasinJ! :raised_hands:

macintushar avatar May 13 '24 05:05 macintushar