New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding custom commands with typescript definitions leads to error #1065

Open
brian-mann opened this Issue Dec 13, 2017 · 34 comments

Comments

9 participants
@brian-mann
Member

brian-mann commented Dec 13, 2017

Cypress.Commands.add('myCustomCommand', '...')

Using TypeScript now results in...

Property ‘myCustomCommand’ does not exist on type ‘Chainable’)

How do we fix this @NicholasBoll @bahmutov

@bahmutov

This comment has been minimized.

Collaborator

bahmutov commented Dec 13, 2017

Good concrete example where we could use it today is https://github.com/cypress-io/snapshot

User adds custom command (in cypress/support/commands.js

require('@cypress/snapshot')()

and now has commanf

cy.wrap(42).snapshot()
// or
cy.wrap(42).snapshot({name: 'meaning of life'})

It would be nice to add this command to the cy type after https://github.com/cypress-io/snapshot/blob/master/src/index.js#L155

Cypress.Commands.add('snapshot', { prevSubject: true }, snapshot)
@shcallaway

This comment has been minimized.

shcallaway commented Dec 14, 2017

One way to do this would be to export the Cypress namespace and allow TypeScript users to extend the Chainable interface.

@NicholasBoll

This comment has been minimized.

Contributor

NicholasBoll commented Dec 14, 2017

@shcallaway is exactly right. Chainable interfaces have to be extended:

declare namespace Cypress {
  interface Chainable<Subject> {
    myCustomCommand(value: string): Chainable<Subject>
  }
}

Cypress.Commands.add('myCustomCommand', { prevSubject: true}, snapshot)

Later:

cy.get('foo').myCustomCommand('bar')
@brian-mann

This comment has been minimized.

Member

brian-mann commented Dec 14, 2017

So we don't have to make any changes on our end right? We just need to update the Typescript recipes to show this... @bahmutov ?

@NicholasBoll

This comment has been minimized.

Contributor

NicholasBoll commented Dec 14, 2017

I'll just note there are some subtleties to TypeScript modules. TypeScript defines global declarations as a namespace and anything with an require/import/export as a module. A module has it's own local scope. A namespace participates in a global scope. Since Cypress has no exports, it is a namespace.

You can actually mix them for UMD modules.

Example:

// module
export default function foo() { return 'foo' }

// in another file:
import foo from './foo'
// global namespace
namespace Foo {
  interface foo {
    (): string
}

declare const foo: Foo.foo

// in another file
foo()
// UMD
// global namespace
namespace Foo {
  interface foo {
    (): string
}

declare const foo: Foo.foo

export default foo
export as namespace Foo

// in another file
foo()

// also works
import foo from './foo'
foo()

Chai exports a namespace because their API is chainable and you'll need access to that namespace in order to extend the Assertion interface. But they also export a const called chai: https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/chai/index.d.ts#L1616

More info about TypeScript modules can be found here: https://www.typescriptlang.org/docs/handbook/modules.html

@NicholasBoll

This comment has been minimized.

Contributor

NicholasBoll commented Dec 14, 2017

I was looking at the examples preprocessors__typescript-webpack and preprocessors__typescript-browserify. TypeScript will not allow you to extend a namespace if the file is a module (has import/export/require). To get around this in the example, the command.ts file(s) cannot import/export anything. They simple contain the following code:

commands.ts:

declare namespace Cypress {
  interface Chainable<Subject> {
    myCustomCommand: typeof myCustomCommand // more DRY than the following:
    // myCustomCommand(value: string): Cypress.Chainable<JQuery>
  }
}

function myCustomCommand(value: string): Cypress.Chainable<JQuery> {
  return cy.get('foo')
}

Cypress.Commands.add('myCustomCommand', myCustomCommand)

Some other file:

cy.myCustomCommand('foo')

And the index.ts simply imports it: import './command'. I'll also note the tsconfig.json needs to include all files in support for this to work (it looks like it does).

@shcallaway

This comment has been minimized.

shcallaway commented Dec 14, 2017

What about commands that are supposed to return a Chainable? For example:

function login(email: string, password: string): Cypress.Chainable<Chainable> {
  cy.request(options);
});

Cypress.Commands.add("login", login);

TypeScript won't allow us to not return a value in command, and yet this is how most commands work.

@brian-mann

This comment has been minimized.

Member

brian-mann commented Dec 14, 2017

Custom commands like this should always return a value. You should return cy.request(...) here.

@shcallaway

This comment has been minimized.

shcallaway commented Dec 14, 2017

What about this example, then?

@brian-mann

This comment has been minimized.

Member

brian-mann commented Dec 14, 2017

It would technically work if all you're doing is enqueing more Cypress commands, but it would not work in other situations like if you return a regular Promise. So best practice would be to always return something.

We'll update the examples. It's not going to hurt anything by always returning from Custom Commands.

@shcallaway

This comment has been minimized.

shcallaway commented Dec 14, 2017

So in general I should return the last cy command in a custom command? Cypress will correctly enqueue all the commands that come before it?

@brian-mann

This comment has been minimized.

Member

brian-mann commented Dec 14, 2017

Right so this is where it gets weird which is why we've opted not to return some things from the examples. It looks good when everything is chained together but then looks really weird if they are not...

All that's happening when you do cy.whatever is that its literally enqueuing a command to run. IT doesn't actually do anything at that moment. No work is kicked off.

So the return value is only applicable do the current custom command. What I mean is, the login command resolves whenever the function finishes - or its awaited if a Promise is returned. Since cy commands return synchronous chainer objects then the command would finish immediately.

The work of the other commands has just been enqueued, not run yet.

What happens is that it just looks weird...

Cypress.Commands.add('foo', () => {
  cy.get(...)
  cy.get(...)
  cy.visit(...)

  // super weird
  return cy.request(...)
})

But returning a synchronous chainer isn't actually doing anything. So you could instead just return null to indicate you're done your promise chain to make Typescript happy.

Cypress.Commands.add('foo', () => {
  cy.get(...)
  cy.get(...)
  cy.visit(...)
  cy.request(...)

  return null // we are done but still weird
})
Cypress.Commands.add('foo', () => {
  cy.get(...)
  cy.get(...)
  cy.visit(...)
  cy.request(...)
  // this is why we don't return anything in the examples
  // because it looks most familiar to the way you write your normal test code
})
@shcallaway

This comment has been minimized.

shcallaway commented Dec 14, 2017

Given that the actual return value of the custom command has no purpose (outside of the custom command itself), why would I declare the return type to begin with? I could just do this:

function login(email: string, password: string): void {
  cy.request(options);
});

Cypress.Commands.add('login', login);
@brian-mann

This comment has been minimized.

Member

brian-mann commented Dec 14, 2017

Yes, I suppose that is all you need. That should work fine. Unless the custom command returns things other than more cypress commands, it can return void.

@shcallaway

This comment has been minimized.

shcallaway commented Dec 14, 2017

For future readers, this is the TypeScript concept that makes it possible to re-declare the Cypress namespace: declaration merging.

Also, @NicholasBoll , regarding your example, what is the purpose of adding the generic?

@bahmutov

This comment has been minimized.

Collaborator

bahmutov commented Dec 14, 2017

I tried it out with Cypress 1.1.4 type definitions and this approach of merging Cypress interface works. https://github.com/cypress-io/snapshot/pull/11/files The new method becomes available on the interface, making TS and VSCode happy

@shcallaway

This comment has been minimized.

shcallaway commented Dec 14, 2017

Hmm I'm still getting errors (e.g. Property 'goToPage' does not exist on type 'Chainable'.). I don't think declaration merging is working — possibly because of these two lines in cypress/index.d.ts?

// global variables added by Cypress when it runs
declare const cy: Cypress.Chainable;
declare const Cypress: Cypress.Cypress;

Here is my cypress/support/commands.ts:

declare namespace Cypress {
  interface Chainable {
    goToPage: typeof goToPage;
    login: typeof login;
    getGridHeader: typeof getGridHeader;
    clearSession: typeof clearSession;
    logout: typeof logout;
    // ...
  }
}

Cypress.Commands.add("goToPage", goToPage);
Cypress.Commands.add("login", login);
Cypress.Commands.add("getGridHeader", getGridHeader);
Cypress.Commands.add("clearSession", clearSession);
Cypress.Commands.add("logout", logout);
// ...

function goToPage(path: string): void {
// ...

Here is my cypress/tsconfig.cypress.json:

{
  "compilerOptions": {
    "target": "es5",
    "module": "commonjs"
  },
  "include": [
    "./*/*.ts",
    // explicitly include cypress types from node module
    "../node_modules/cypress"
  ]
}

And I don't think it matters, but here is my cypress/plugins/index.js:

const webpack = require("@cypress/webpack-preprocessor");
module.exports = (on, config) => {
  const webpackOptions = {
    resolve: {
      extensions: [".ts", ".js"]
    },
    module: {
      rules: [
        {
          test: /\.ts$/,
          loader: "ts-loader",
          options: {
            configFile: "tsconfig.cypress.json"
          }
        }
      ]
    }
  };

  on("file:preprocessor", webpack({
    webpackOptions: webpackOptions
  }));
};
@NicholasBoll

This comment has been minimized.

Contributor

NicholasBoll commented Dec 14, 2017

Also, @NicholasBoll , regarding your example, what is the purpose of adding the generic?

@shcallaway I added the Generic (should be available on Cypress 1.2.0, but not on Cypress 1.1.4) to allow they subject to be passed around so that the .then type would be known. For backwards compatibility, the Subject is defaulted to any. For example:

cy.wrap({ foo: 'bar' })
  .then(subject => {
    subject; // $ExpectType { foo: string }
  })
  .its('foo') // keyof type checked - 'foo1' would be a type error
  .then(subject => {
    subject; // $ExpectType string
  })

Without the generic, the subject would always be any

@NicholasBoll

This comment has been minimized.

Contributor

NicholasBoll commented Dec 14, 2017

@shcallaway I knew that TypeScript merged declarations, but I didn't know they documented it. Thanks!

@shcallaway

This comment has been minimized.

shcallaway commented Dec 14, 2017

@bahmutov I don't think your PR on snapshot fully demonstrates how to appropriately use custom commands with TypeScript. Specifically, it does not address:

  1. The extremely common use case of using cy in commands.ts. The TS compiler insists that myCustomCommand does not exist on type Chainable, even after re-declaring the namespace and interface.

image

I believe this is due to the declare const cy: Cypress.Chainable in index.d.ts.

  1. The fact that, as we determined earlier, most custom commands don't return anything. And yet users will naturally expect to be able to do something like this:

image

For this behavior to work, all custom commands should return something like Chainable<void>. (It sounds like this is being fixed in the upcoming 1.2.0 release.)

@NicholasBoll

This comment has been minimized.

Contributor

NicholasBoll commented Dec 15, 2017

A suggestion and I don't know how the Cypress team feels about it, but we don't use Cypress.Commands.add for actions we want to complete that don't return anything (void). It is a bit odd to type and if we don't return chainable methods it might not make sense. We just use function calls:

export function login(
  name: string,
  password?: string,
): void {
  cy.log(`Logging in as ${name}`);

  cy.request({
    method: 'POST',
    url: '/api/webconsole/login',
    body: {
      username: name,
      password: 'logrhythm!1',
    },
  }).then(response => {
    console.log(response);
    return response;
  });
}

In some other file:

import { login } from '../helpers/login'

describe('Login', () => {
  it('should allow the user to enter credentials', () => {
    login('username', 'password');

    cy.title().should('contain', 'Dashboard');
  });
});
@NicholasBoll

This comment has been minimized.

Contributor

NicholasBoll commented Dec 15, 2017

@shcallaway, I just checkout out the branch that @bahmutov provided: https://github.com/cypress-io/snapshot/pull/11/files and changed a few things to verify:

// commands.ts
// register .snapshot() command
require('../..').register()

// merge Cypress interface with new command "snapshot()"
declare namespace Cypress {
  interface Chainable {
    snapshot(): void
  }
}

declare namespace Cypress {
  interface Chainable {
    getBody: typeof getBody
  }
}

function getBody(): Cypress.Chainable {
  return cy.get('body')
}

Cypress.Commands.add('getBody', getBody)
// spec-typescript.ts
describe('@cypress/snapshot', () => {
  beforeEach(() => {
    cy.visit('https://example.cypress.io')
  })

  context('simple types', () => {
    it('works with objects', () => {
      cy.wrap({ foo: 42 }).snapshot()
    })

    it('works with numbers', () => {
      cy.wrap(42).snapshot()
    })

    it('works with strings', () => {
      cy.wrap('foo-bar').snapshot()
    })

    it('works with arrays', () => {
      cy.wrap([1, 2, 3]).snapshot()
    })

    it('should get the body tag', () => {
      cy.getBody().should('contain', 'Kitchen Sink')
    })
  })
})

That is working for me. getBody is known and can be chained.

@shcallaway

This comment has been minimized.

shcallaway commented Dec 15, 2017

😕 I must be doing something wrong then.

@NicholasBoll It's good that it works as expected, though.

@NicholasBoll

This comment has been minimized.

Contributor

NicholasBoll commented Dec 15, 2017

@shcallaway Note the snapshot repo is using Cypress 1.1.4 which has no generic. 1.2.0 adds the Subject generic. This shouldn't effect any implementation, but if you are creating your own commands in TypeScript the interface declaration will change slightly:

declare namespace Cypress {
  interface Chainable<Subject> {
    getBody: typeof getBody
  }
}

// can omit return type - it is inferred - will be `Cypress.Chainable<JQuery<HTMLBodyElement>>`
function getBody() {
  return cy.get('body')
}
@NicholasBoll

This comment has been minimized.

Contributor

NicholasBoll commented Dec 15, 2017

My steps were checking out the snapshot repo, checking out the branch and running npm install. I'm using VS Code. I'm curious to know why it isn't working for you.

Are you trying the snapshot repo?

Something to watch out for is your tsconfig.json needs to include files from ./cypress/support (or where ever you've configured that folder). Here's the tsconfig.json for the snapshot repo:

{
  "include": [
    "node_modules/cypress",
    "cypress/*/*.ts"
  ]
}
@shcallaway

This comment has been minimized.

shcallaway commented Dec 15, 2017

@NicholasBoll I'm working off my own app — not snapshot. It appears to be configured correctly though. You can see my files here.

Just in case, I checked out the snapshot TypeScript branch and ran the tests (with your changes). They pass.

@shcallaway

This comment has been minimized.

shcallaway commented Dec 15, 2017

Note that I have tried forcibly importing the Cypress types into my commands.ts by doing:

import "../../node_modules/cypress/index.d.ts";

Which results in a lot of these:

TS90010: Type 'Cypress.Chainable' is not assignable to type 'Cypress.Chainable'. Two different types with this name exist, but they are unrelated.��

I believe these errors occur because the return type for custom command is Cypress.Chainable but the return value is cy. Even though cy is of type Cypress.Chainable, TS is not correctly merging my definition of Cypress.Chainable with the existing definition.

@NicholasBoll

This comment has been minimized.

Contributor

NicholasBoll commented Dec 15, 2017

I got that same error when I used an import in commands.ts. I had to not have any import/export in that file (makes TypeScript treat as a module). Not sure why TS thinks they are unrelated in that case.

The tsconfig was a little tricky, but I think I'm doing it the same as you. My tsconfig.json looks like this:

./cypress/tsconfig.json

{
  "compilerOptions": {
    "baseUrl": "../node_modules",
    "sourceMap": true,
    "target": "es2017",
    "strict": true,
    "moduleResolution": "node",
    "types": [
      "mocha"
    ],
    "lib": [
      "dom",
      "es2017"
    ]
  },
  "include": [
    "../node_modules",
    "**/*.ts"
  ]
}

Note the baseUrl and the include with ../node_modules.

Also make sure you don't have @types/cypress lurking in your node_modules. I was getting duplicate and conflicting definitions. Those types are pre-1.0.

@NicholasBoll

This comment has been minimized.

Contributor

NicholasBoll commented Dec 15, 2017

Normally TypeScript includes node_modules, but if the file isn't defined in the same directory as node_modules, I've had missing types.

I just noticed your tsconfig.json has ./*/*.ts. I have **/*.ts. I don't know the subtleties of glob, but the globstar should include any number of directories. I consider that safer.

@bahmutov

This comment has been minimized.

Collaborator

bahmutov commented Jan 2, 2018

I just tried it from scratch in TS 2.6.2 and Cypress 1.4.1 and it seems to work, adding new commands https://github.com/cypress-io/add-cypress-custom-command-in-typescript

I suspect there are other TS definitions in node_modules that somehow conflict or break TS compiler :(

@jogelin

This comment has been minimized.

jogelin commented Apr 23, 2018

Any update with the version 2.x of cypress ?
I have the same issue:

Property login does not exist on type Chainable<undefined>

I am using cypress 2.1.0 and ts 2.7.1

Is https://github.com/bahmutov/add-typescript-to-cypress still necessary ? Ts seems to work without webpack preprocessor ....

@krzysztof-grzybek

This comment has been minimized.

krzysztof-grzybek commented Aug 3, 2018

I fixed it by adding index.d.ts file in my commands folder. In this file I added something like this:

import { MyCustomType } from '../Types';

declare global {
  namespace Cypress {
    interface Chainable<Subject = any> {
      login(): Chainable<MyCustomType>;
    }
  }
}

If You don't import or export anything, just omit global namespace declaration:

declare namespace Cypress {
  interface Chainable<Subject = any> {
    login(): Chainable<MyCustomType>;
  }
}

Keep in mind that it won't work with Typesciprt < 2.3, becuase default generics type has to be supported.

@brandonmp-cw

This comment has been minimized.

brandonmp-cw commented Sep 12, 2018

I was having this problem. TS and custom Cypress commands were working fine, but I after I added another custom command, everything broke.

I think it was b/c I was importing something into my cypress.d.ts in the wrong spot.

//** I had been importing something here

declare namespace Cypress {
  import { SalesforceGetAccountArgs } from "../cypress/support/sf";
//** Moving it to here (within the declaration) fixed things, apparently
  import { CustomerIoMessage } from "../lib/customer-io";

  export interface Chainable<Subject> {
    sfGetAccount: (
      getAccountArgs: SalesforceGetAccountArgs
    ) => Chainable<Partial<SalesforceAccount>>;
   getSentEmails: (email: string) => Chainable<Array<CustomerIoMessage>>;
  }
}
@andezzat

This comment has been minimized.

Contributor

andezzat commented Dec 11, 2018

I've just had success extending the Cypress namespace declaration by simply including a neat index.d.ts file in the root of my cypress/support/commands folder. I (already) import that folder's cypress/support/commands/index.js file into my cypress/support/index.js and voila!

Would you guys be interested in adding these simple steps as an example in your docs to show users how they can extend type declarations for when they add custom commands?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment