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
}
// ...
}
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
}
},
})
// ...
},
})
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:
- Code reuse-ability. No need to reinvent the wheel. You know it exists because this was already baked in before you got here.
- 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
}
}
}
`
$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)
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)
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
}
}
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
}
}
}
`
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' })
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
}
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()
})
We simply call launchIfBrowserSetInCli
when the component mounts and let the function do what is needs.
Putting it all together
- When a user passes
--browser chrome
to the cypress cli (assuming chrome is installed), the user will be prompted to select an existing project. - Once the user chooses the project, another screen renders to allow the user to select the testing type (e2e or component).
- On the final screen for browser selection during component mount, the
lauchIfBrowserSetInCli
function is executed based on the callback toonMounted
. - A query to the
localSettings
object type is executed for the fieldshouldLaunchBrowserFromOpenBrowser
.- 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 alonelaunchProject
should not have been called, so thelaunchCount
should evaluate to 0 and the resolver should returntrue
- The query value is received and the
if
block is executed inside oflaunchIfBrowserSetInCli
and thelaunch
function is called. - The launch function updates the
launching
ref and executes a mutation tolaunchOpenProject
to open the browser. - The browser is launched and the
launching
ref is flipped tofalse
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
.
- The user passes all flags
--project ./some/path --e2e --browser chrome
- We have all the information we need to launch a project, so a mutation is sent to launch the project from the main view.
- Once the mutation is executed, the loading state is stopped and we are brought to the browser selection screen.
- Since the
launchProject
method has been called,launchCount
has been incremented evaluating to 1. The query forshouldLaunchBrowserFromOpenBrowser
returns false, skipping theif
block inlaunchIfBrowserSetInCli
and returning. - The UI for an already opened browser is shown.
Lets assume that a user passes only the browser flag but it is incorrect.
- User passes
--browser doesntExist
to the cli. - After going through project and testing type selection the
launchIfBrowserSetInCli
function is called. - A query is made to the browser.
- A "browser" was passed to the cli so we can move on and pass it to
ensureAndGetByNameOrPath
in the resolver. - Since the browser doesn't exist, an error is throw from
ensureAndGetByNameOrPath
and we return false. - 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!
Top comments (0)