Repository: bahmutov/cypress-map
Pull request #14 (2023-01-20 14:20:42)

feat: add cy.invokeOnce


Patch:
diff --git a/README.md b/README.md
index 8cace9e..8a53d28 100644
--- a/README.md
+++ b/README.md
@@ -215,6 +215,20 @@ cy.get('table')
   .should('deep.equal', ['Dave', 'Cary', 'Joe', 'Anna'])
 ```
 
+### invokeOnce
+
+In Cypress v12 `cy.invoke` became a query, which made working with asynchronous methods really unwieldy. The `cy.invokeOnce` is a return the old way of calling the method and yielding the resolved value.
+
+```js
+cy.wrap(app)
+  // app.fetchName is an asynchronous method
+  // that returns a Promise
+  .invokeOnce('fetchName')
+  .should('equal', 'My App')
+```
+
+See the spec [invoke-once.cy.js](./cypress/e2e/invoke-once.cy.js) for more examples.
+
 ## cy.invoke vs cy.map vs cy.mapInvoke
 
 Here are a few examples to clarify the different between the `cy.invoke`, `cy.map`, and `cy.mapInvoke` query commands, see [diff.cy.js](./cypress/e2e/diff.cy.js)
diff --git a/commands/index.d.ts b/commands/index.d.ts
index e7d9c79..8ca9a1a 100644
--- a/commands/index.d.ts
+++ b/commands/index.d.ts
@@ -120,5 +120,11 @@ declare namespace Cypress {
      * @example cy.get('table').table(0, 0, 2, 2)
      */
     table(x?: number, y?: number, w?: number, h?: number): Chainable<string[][]>
+
+    /**
+     * Invokes the method on the current subject.
+     * This is a COMMAND, not a query, so it won't retry, unlike the stock `cy.invoke`
+     */
+    invokeOnce(methodName: string, ...args: unknown[]): Chainable<any>
   }
 }
diff --git a/commands/index.js b/commands/index.js
index 766345b..5183bc8 100644
--- a/commands/index.js
+++ b/commands/index.js
@@ -11,3 +11,4 @@ import './prop'
 import './at'
 import './print'
 import './table'
+import './invoke-once'
diff --git a/commands/invoke-once.js b/commands/invoke-once.js
new file mode 100644
index 0000000..79cf3c9
--- /dev/null
+++ b/commands/invoke-once.js
@@ -0,0 +1,20 @@
+/// <reference types="cypress" />
+
+Cypress.Commands.add(
+  'invokeOnce',
+  { prevSubject: true },
+  (subject, methodName, ...args) => {
+    let message = methodName
+    if (args.length) {
+      message += ' ' + args.map(JSON.stringify).join(',')
+    }
+
+    const log = Cypress.log({ name: 'invokeOnce', message })
+
+    if (typeof subject[methodName] !== 'function') {
+      throw new Error(`Cannot find method ${methodName} on the current subject`)
+    }
+
+    return subject[methodName](...args)
+  },
+)


Test patch:
diff --git a/cypress/e2e/invoke-once.cy.js b/cypress/e2e/invoke-once.cy.js
new file mode 100644
index 0000000..be5347b
--- /dev/null
+++ b/cypress/e2e/invoke-once.cy.js
@@ -0,0 +1,51 @@
+/// <reference types="cypress" />
+// @ts-check
+
+// import cypress-map plugin
+import '../../commands'
+
+describe('invoke vs invokeOnce', () => {
+  const app = {
+    fetchName() {
+      return new Promise((resolve) => {
+        setTimeout(() => {
+          resolve('My App')
+        }, 1000)
+      })
+    },
+
+    add(a, b) {
+      return Promise.resolve(a + b)
+    },
+  }
+
+  it('cy.then yields the resolved value', () => {
+    // call the method ourselves from "cy.then"
+    cy.wrap(app)
+      .then((app) => app.fetchName())
+      .should('equal', 'My App')
+  })
+
+  it('cy.invoke yields the promise', () => {
+    cy.wrap(app)
+      .invoke('fetchName')
+      .should('satisfy', (x) => typeof x.then === 'function')
+      // then we can check the resolved value
+      .then((s) => expect(s).to.equal('My App'))
+
+    // we can use a truthy assertion to "wait" for the promise
+    cy.wrap(app)
+      .invoke('fetchName')
+      .should('be.ok')
+      // and then we can check the resolved value
+      .then((s) => expect(s).to.equal('My App'))
+  })
+
+  it('calls the method and yields the resolved value', () => {
+    cy.wrap(app).invokeOnce('fetchName').should('equal', 'My App')
+  })
+
+  it('passes method arguments', () => {
+    cy.wrap(app).invokeOnce('add', 2, 3).should('equal', 5)
+  })
+})