DEV Community

Raymond
Raymond

Posted on

Adding A Feature to Cypress.JS

Cypress.js

Cypress now has a new feature for automatic browser launch after passing the --browser flag alone with a good browser name or path! This is a breakdown of the contribution that brought it to life!

Cypress is a testing framework used to perform end to end (e2e) testing and component testing. It provides a wide range of support for multiple frontend frameworks including Vue, React, and Angular. It has a healthy ecosystem and has over five million downloads a week. Multiple businesses use it in their CI pipeline for test automation.

The Cypress repo is a package-based mono repo that is handled by Lerna. Lerna is powered by nx under the hood. This allows you to manage workspaces in the root of the repo from the command line and install dependencies that are shared between packages. yarn is used as the package manager for dependencies.

Cypress uses Javascript and Typescript as a full stack language. Their frontend components are shared between their launchPad and another frontend application. The launchpad is used with Electron which is a tool for creating desktop applications in Javascript. They use node to set up a server and add a series of caching mechanisms utilizing the file system on the host machine for user preferences, projects, etc. Their frontend is in Vue.js and uses Urql for their GraphQL client. They utilize a series of tools for GraphQL to create GraphQL object types, queries, mutations, and subscriptions.

Data Context

The data-context workspace is the core of the application. It is the single source of truth for the data floating around. It uses some Typescript decorators to cache values. It's also used to inject it's this value in a host of other APIs. It utilizes a singleton pattern to be consumed by the application. This data context is used as the main GraphQL context for resolvers in the app.

The issue

As the application stood, you could pass --project <project-path>, --<testing-type> (component or e2e), and --browser <browser-name-or-path> flags and have the application automatically launch the browser. The caveat was that you had to pass all of those flags together from the cli and could not pass the --browser flag alone. If passing that flag alone, you would reach the view for browser selection. The browser would be highlighted showing the passed browser, but would not automatically launch it.

Specs

The use case for the issue was to:

  • have a browser launch automatically after selection of project and testing type in the application when passing --browser <name-or-path> alone.
  • If name or path was invalid it would render the already created error message without automatically launching the last cached browser.
    • the user would have to dismiss the warning and select the browser from the GUI
    • or the user would have to start the process with a good name or path from the cli
  • If the name or path was correct, it would only launch once.

Making Some Changes

ProjectActions.ts

The data-context utilizes several different APIs to handle different function calls. These APIs are referred to as actions. They are class based and provide functionality based on data received from other APIs. The ProjectActions class is responsible for (given a browser, project, and testing type) launching the browser and given project in its respective test mode.

export class ProjectActions {
  // keep track of launchProject calls
  private globalLaunchCount = 0
  constructor (private ctx: DataContext) {}

  private get api () {
    return this.ctx._apis.projectApi
  }
  // allow the launch count to be read
  get launchCount() {
    return this.globalLaunchCount
  }
  // ...
  async launchProject (testingType: Cypress.TestingType | null, options?: Partial<OpenProjectLaunchOpts>, specPath?: string | null) {
    // code omitted for brevity

    await this.api.launchProject(browser, activeSpec ?? emptySpec, options)
    this.globalLaunchCount++

    return
  }
  // ...
}
Enter fullscreen mode Exit fullscreen mode

The main focus here was to have a way to track when launchProject was called. Since there will be two different scenarios for the browser to launch (i.e. passing 3 flags or passing 1 flag), globalLaunchCount will be the differentiator. In the event that all 3 flags have been passed by the time we land on the browser selection screen we don't want the function to launch again. So we increment the value after the this.api.launchProject call just before the return. If this doesn't make sense just yet, that's ok, it will soon.

LocalSettings.ts

The GraphQL layer uses a tool called GraphQL Nexus. It provides a declarative way to create schemas, among other things, in GraphQL. When you define a type with nexus, it auto-generates type-documents for Typescript type-safety (super nice feature).

export const LocalSettingsPreferences = objectType({
  name: 'LocalSettingsPreferences',
  description: 'local setting preferences',
  definition (t) {
   // ...
    t.boolean('shouldLaunchBrowserFromOpenBrowser', {
      description: 'Determine if the browser should launch when the browser flag is passed alone',
      resolve: async (_source, _args, ctx) => {
        try {
          const cliBrowser = ctx.coreData.cliBrowser

          if (!cliBrowser) {
            return false
          }

          const browser = await ctx._apis.browserApi.ensureAndGetByNameOrPath(cliBrowser)
          const shouldLaunch = Boolean(browser) && (ctx.actions.project.launchCount === 0)

          return shouldLaunch
        } catch (e) {
          // if error is thrown, browser doesn't exist
          return false
        }
      },
    })
   // ...
  },
})
Enter fullscreen mode Exit fullscreen mode

Let's cover what's going on here. objectType is a function coming from nexus. It allows you to create a GraphQL object type with a name and a definition and optionally a description. The argument to the definition method is an object (t) that provides helper methods to define fields, scalars, and resolvers. t.boolean indicates a boolean scalar. This helper method takes one required argument (a field name) and an optional second argument (an object). Here, shouldLaunchBrowserFromOpenBrowser (I know, specific) is the field name for the LocalSettingsPreferences object type and is intended to resolve to a boolean value.

The resolve method takes in 4 arguments. If you would like to have a quick rundown of them, you can find a quick diagram here. For the sake of this, we are just concerned about the 3rd argument... the context. This is the data-context passed in on the server during initialization and becomes available to all resolvers. The cliBrowser is the browser (name or path) that was passed in the cli. If nothing was passed we can return false right away indicating we should not launch the browser. If it does exist, the data context provides a nice async function from the browser API, ensureAndGetByNameOrPath, that can attempt to find the browser on the machine. This helps with two things:

  1. Code reuse-ability. No need to reinvent the wheel. You know it exists because this was already baked in before you got here.
  2. Provides a way to tell if the actual browser (name or path) is valid.

If the browser does exist, we can convert the value to a boolean and also check that the launchProject function hasn't been called based on the launchCount being strictly equal to 0 allowing it to resolve to true other wise it resolves to false. If the browser doesn't exist, then the function throws an error. We can catch it and return false.

OpenBrowser.vue

Let's understand some code that was already present.

gql

This function is supplied by Urql to generate DocumentNodes. Here, this function is creating a document node called OpenBrowser_LaunchProject which is a mutation.

gql`
mutation OpenBrowser_LaunchProject ($testingType: TestingTypeEnum!)  {
  launchOpenProject {
    id
  }
  setProjectPreferencesInGlobalCache(testingType: $testingType) {
    currentProject {
      id
      title
    }
  }
}
`
Enter fullscreen mode Exit fullscreen mode

$testingType is an argument to the OpenBrowser_LaunchProject document node that is an enum. The mutation to launchOpenProject corresponds to a server mutation of the same name and is intended to launch the current project with the current testing type. The other mutation is to set the current testing type in a global cache.

launchOpenProject

This will be the mutation used to launch the project

const launchOpenProject = useMutation(OpenBrowser_LaunchProjectDocument)
Enter fullscreen mode Exit fullscreen mode

useMutation is a hook from Urql that abstracts away some of the common needs of creating mutations. We pass the OpenBrowser_LaunchProjectDocument that is imported from generated document nodes based on the gql definition of OpenBrowser_LaunchProject.

launching

This is a Vue ref.

const launching = ref(false)
Enter fullscreen mode Exit fullscreen mode

refs are values to be directly mutated. Unlike React when setting or updating state, the value can be directly updated or mutated to trigger component updates based on reassignment of the launching ref's value property. The value property is the current value of the ref. In this case it is initialized to false.

launch

This function was already present. It was used to emit an event called @launch from a child Vue component form submission to launch the project. The syntax @launch is a custom event defined as a v-on handler. You can learn more about events in Vue here

const launch = async () => {
  const testingType = query.data.value?.currentProject?.currentTestingType

  if (testingType && !launching.value) {
    launching.value = true
    await launchOpenProject.executeMutation({
      testingType,
    })

    launching.value = false
  }
}
Enter fullscreen mode Exit fullscreen mode

The launch function finds the current testingType based on a query. If there is a testing type selected, and the launching ref's value is false, we set the launching.value to true which triggers a component update for a loading state for the UI. It executes the launchProject.executeMutation method of the useMutation hook created earlier and takes in the required testing type as the argument for the mutation. If that launch occurs or not, the component state needs to be updated to reflect the function has completed and set launching.value to false.

OpenBrowser_LocalSettings

This is a newly created document node

gql`
query OpenBrowser_LocalSettings {
  localSettings {
    preferences {
      shouldLaunchBrowserFromOpenBrowser
    }
  }
}
`
Enter fullscreen mode Exit fullscreen mode

This document node's sole responsibility is to grab the value for shouldLaunchBrowserFromOpenBrowser which will either be true or false.

lsQuery

lsQuery uses another hook from Urql. This was a newly created query. As for the naming, I could have been a little more explicit to state that this was localSettingsQuery and probably should have done so.

const lsQuery = useQuery({ query: OpenBrowser_LocalSettingsDocument, requestPolicy: 'network-only' })
Enter fullscreen mode Exit fullscreen mode

Like the mutation, we pass in a document node for the query. The thing to note here is the requestPolicy property. During initialization of the app and the Urql client, there is an in-memory caching policy set up to prevent unnecessary network requests. It provides a quicker lookup for data already received and reduces api calls for commonly used data. When setting the caching policy it applies to all queries that the client performs by default. Since the first run of this function grabbed stale data out of the cache, it would call the launch function again based on the shouldLaunchBrowserFromOpenBrowser always evaluating to true from the cache if all the criteria were good for launching initially.

Fortunately, there is a way to change the caching policy per query. By adding the requestPolicy with network-only it will ensure that the value for shouldLaunchBrowserFromOpenBrowser will be the most up-to-date and, subsequently, only call the function once.

launchIfBrowserSetInCli

This newly created function, like the resolver for shouldLaunchBrowserFromOpenBrowser, reuses functionality.

const launchIfBrowserSetInCli = async () => {
  const shouldLaunchBrowser = (await lsQuery).data.value?.localSettings?.preferences?.shouldLaunchBrowserFromOpenBrowser

  if (shouldLaunchBrowser) {
    await launch()
  }

  return
}
Enter fullscreen mode Exit fullscreen mode

We grab the shouldLaunchBrowserFromOpenBrowser field. We then check to see if its truthy, or rather that it's true, and launch the browser. Either way, we return. Since the launch function already triggers component updates and UI updates, there is no need to create that logic.

onMounted

This is a lifecycle hook from Vue. The anonymous callback function will be executed when the component is mounted (rendered) to the screen.

onMounted(() => {
  launchIfBrowserSetInCli()
})
Enter fullscreen mode Exit fullscreen mode

We simply call launchIfBrowserSetInCli when the component mounts and let the function do what is needs.

Putting it all together

  1. When a user passes --browser chrome to the cypress cli (assuming chrome is installed), the user will be prompted to select an existing project.
  2. Once the user chooses the project, another screen renders to allow the user to select the testing type (e2e or component).
  3. On the final screen for browser selection during component mount, the lauchIfBrowserSetInCli function is executed based on the callback to onMounted.
  4. A query to the localSettings object type is executed for the field shouldLaunchBrowserFromOpenBrowser.
    • The resolver runs, and finds that the browser was passed to the cli, so it attempts to look it up.
    • In this case we are successful and need to check if launchCount has been called.
    • Since --browser was passed alone launchProject should not have been called, so the launchCount should evaluate to 0 and the resolver should return true
  5. The query value is received and the if block is executed inside of launchIfBrowserSetInCli and the launch function is called.
  6. The launch function updates the launching ref and executes a mutation to launchOpenProject to open the browser.
  7. The browser is launched and the launching ref is flipped to false to show the updated opened browser state.

Since it is possible that a user can pass all flags and launch directly from the main page view, we can differentiate the calls to launchProject in ProjectActions.

  1. The user passes all flags --project ./some/path --e2e --browser chrome
  2. We have all the information we need to launch a project, so a mutation is sent to launch the project from the main view.
  3. Once the mutation is executed, the loading state is stopped and we are brought to the browser selection screen.
  4. Since the launchProject method has been called, launchCount has been incremented evaluating to 1. The query for shouldLaunchBrowserFromOpenBrowser returns false, skipping the if block in launchIfBrowserSetInCli and returning.
  5. The UI for an already opened browser is shown.

Lets assume that a user passes only the browser flag but it is incorrect.

  1. User passes --browser doesntExist to the cli.
  2. After going through project and testing type selection the launchIfBrowserSetInCli function is called.
  3. A query is made to the browser.
  4. A "browser" was passed to the cli so we can move on and pass it to ensureAndGetByNameOrPath in the resolver.
  5. Since the browser doesn't exist, an error is throw from ensureAndGetByNameOrPath and we return false.
  6. The browser is not launched and a dismissible error is presented to the user letting them know that the browser wasn't found on the system.

During testing of this flow, I found that the launchProject method was being called when it shouldn't have been when reaching the browser selection screen in some instances. I inadvertently stumbled across a silent bug. There was a variable wasBrowserSetInCli which was a boolean that indicated if something was passed as a browser to the cli specifically. It did not perform checks on if it was a valid browser. So it would attempt to launch and fail. After some banging around and getting some much needed help from a maintainer, the issue was narrowed down to that variable. Swapping it out for the more precise shouldLaunchBrowserFromOpenBrowser field solved the issue.

Recap

Now users can pass a --browser flag alone with a name or path and select project and testing type options in the application before the browser is automatically launched. This helps to improve UX/DX by allowing a user to set up a script to run in a default browser and to be able to select between multiple projects and testing types in their local environment. This request has been ongoing since v10 and is now available in v13.4.0 and up!

This was a challenging PR to make. I ended up learning a lot. Before making this contribution, I knew nothing about GraphQL and the tooling around it. I knew of Typescript, but ended up taking a deeper dive since it was needed to navigate the codebase. I learned a little about Vue and the things I could and couldn't do. I was able to keep some code DRY as well. This was a pretty large and extensive code base and ended up reading a lot of it to fit the pieces together. In the end, this was a great opportunity to play with production code, give back to the development community, and to push the limits of what I know to add to the tool belt.

Thank you to the maintainers for their reviews and help along the way!

Image of Datadog

The Essential Toolkit for Front-end Developers

Take a user-centric approach to front-end monitoring that evolves alongside increasingly complex frameworks and single-page applications.

Get The Kit

Top comments (0)

A Workflow Copilot. Tailored to You.

Pieces.app image

Our desktop app, with its intelligent copilot, streamlines coding by generating snippets, extracting code from screenshots, and accelerating problem-solving.

Read the docs