Repository: bahmutov/cypress-map (js)

Issue:
Add an assertion for checking a property without changing the subject
```js
cy.wrap(S)
  .should('possess', 'property name', 'optional value')
// yields the original subject S
```

Pull request description:
feat: add should possess assertion
- closes #244 

Patch:
diff --git a/README.md b/README.md
index 3160db7..9d6265b 100644
--- a/README.md
+++ b/README.md
@@ -653,6 +653,40 @@ cy.get('#name').should('read', /\sSmith$/)
 cy.get('#ages').should('read', [/^\d+$/, '35', '15'])
 ```
 
+### possess
+
+**Assertion**
+
+Checks if the subject has the given property. Can check the property value. Yields the original subject
+
+```js
+// check if the subject has the "name" property
+cy.wrap({ name: 'Joe' }).should('possess', 'name')
+// yields the { name: 'Joe' } object
+
+// check if the subject has the "name" property with the value "Joe"
+cy.wrap({ name: 'Joe' }).should('possess', 'name', 'Joe')
+// yields the { name: 'Joe' } object
+```
+
+The assertion supports deep object access using the dot notation
+
+```js
+cy.wrap({ user: { name: 'Joe' } }).should(
+  'possess',
+  'user.name',
+  'Joe',
+)
+```
+
+The assertion supports arrays using `[ ]` or simple dot notation
+
+```js
+cy.wrap({ users: [{ name: 'Joe' }, { name: 'Jane' }] })
+  .should('possess', 'users[0].name', 'Joe') // [] notation
+  .and('possess', 'users.1.name', 'Jane') // simple dot notation
+```
+
 ## 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/package-lock.json b/package-lock.json
index 5de3c2f..daa67fa 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,7 +13,7 @@
       },
       "devDependencies": {
         "cy-spok": "^1.5.2",
-        "cypress": "^13.6.4",
+        "cypress": "^13.17.0",
         "cypress-split": "^1.19.0",
         "prettier": "^3.0.0",
         "semantic-release": "^23.0.0",
diff --git a/package.json b/package.json
index 31527be..891aa22 100644
--- a/package.json
+++ b/package.json
@@ -24,7 +24,7 @@
   "license": "MIT",
   "devDependencies": {
     "cy-spok": "^1.5.2",
-    "cypress": "^13.6.4",
+    "cypress": "^13.17.0",
     "cypress-split": "^1.19.0",
     "prettier": "^3.0.0",
     "semantic-release": "^23.0.0",
diff --git a/src/commands/assertions.js b/src/commands/assertions.js
index beada7f..4f05c10 100644
--- a/src/commands/assertions.js
+++ b/src/commands/assertions.js
@@ -38,4 +38,38 @@ chai.use((_chai) => {
     )
   }
   _chai.Assertion.addMethod('read', readAssertion)
+
+  function possesAssertion(propertyName, maybeValue) {
+    if (typeof propertyName !== 'string') {
+      throw new Error(
+        `possess assertion: Expected a string, but got ${typeof propertyName}`,
+      )
+    }
+
+    const subjectText = JSON.stringify(this._obj)
+    const subjectShort =
+      subjectText.length > 40
+        ? `${subjectText.slice(0, 40)}...`
+        : subjectText
+
+    if (arguments.length === 1) {
+      return this.assert(
+        propertyName in this._obj,
+        `expected ${subjectShort} to possess property "${propertyName}"`,
+        `expected ${subjectShort} to not possess property "${propertyName}"`,
+      )
+    }
+    if (arguments.length === 2) {
+      return this.assert(
+        Cypress._.get(this._obj, propertyName) === maybeValue,
+        `expected ${subjectShort} to possess property ${propertyName}=${maybeValue}`,
+        `expected ${subjectShort} to not possess property ${propertyName}=${maybeValue}`,
+      )
+    }
+
+    throw new Error(
+      `Unexpected arguments to the "possess" assertion ${[...arguments].join(', ')}`,
+    )
+  }
+  _chai.Assertion.addMethod('possess', possesAssertion)
 })
diff --git a/src/commands/index.d.ts b/src/commands/index.d.ts
index a20050c..6763315 100644
--- a/src/commands/index.d.ts
+++ b/src/commands/index.d.ts
@@ -385,6 +385,7 @@ declare namespace Cypress {
      * and compares it to the given text value or against a regular expression.
      * @example cy.get('#name').should('read', 'Joe Smith')
      * @example cy.get('#name').should('read', /Joe/)
+     * @see https://github.com/bahmutov/cypress-map
      */
     (chainer: 'read', text: string | RegExp): Chainable<Subject>
 
@@ -393,7 +394,50 @@ declare namespace Cypress {
      * and compares them to the given text values or regular expressions.
      * @example cy.get('#ages').should('read', ['20', '35', '15'])
      * @example cy.get('#ages').should('read', ['20', /^\d+$/, '15'])
+     * @see https://github.com/bahmutov/cypress-map
      */
     (chainer: 'read', texts: (string | RegExp)[]): Chainable<Subject>
+
+    /**
+     * Checks the presence of the given property in the current subject object.
+     * Yields the original subject.
+     * Supports deep nested properties using dot notation.
+     * Supports arrays and array indexes.
+     *
+     * @example cy.wrap({ name: 'Joe' }).should('possess', 'name')
+     * @example
+     *  cy.wrap({ user: { name: 'Joe' } })
+     *    .should('possess', 'user.name')
+     * @example
+     *  cy.wrap({ users: ['Joe', 'Jane'] })
+     *    .should('possess', 'users[0]')
+     *
+     * @see https://github.com/bahmutov/cypress-map
+     */
+    (chainer: 'possess', propertyName: string): Chainable<Subject>
+
+    /**
+     * Checks the presence of the given property with the given value
+     * in the current subject object.
+     * Yields the original subject.
+     * Supports deep nested properties using dot notation.
+     * Supports arrays and array indexes, brackets are optional
+     *
+     * @example cy.wrap({ name: 'Joe' }).should('possess', 'name', 'Joe')
+     * @example
+     *  cy.wrap({ user: { name: 'Joe' } })
+     *    .should('possess', 'user.name', 'Joe')
+     * @example
+     *  cy.wrap({ users: [{ name: 'Joe' }, { name: 'Jane' }] })
+     *    .should('possess', 'users[0].name', 'Joe')
+     *    .and('possess', 'users.1.name', 'Jane')
+     *
+     * @see https://github.com/bahmutov/cypress-map
+     */
+    (
+      chainer: 'possess',
+      propertyName: string,
+      value: unknown,
+    ): Chainable<Subject>
   }
 }


Test patch:
diff --git a/cypress/e2e/assertions/possess.cy.js b/cypress/e2e/assertions/possess.cy.js
new file mode 100644
index 0000000..a9e5865
--- /dev/null
+++ b/cypress/e2e/assertions/possess.cy.js
@@ -0,0 +1,88 @@
+/// <reference types="cypress" />
+// @ts-check
+
+import '../../../src/commands'
+
+describe('have.property assertion', () => {
+  it('yields the property value', () => {
+    cy.wrap({ foo: 'bar' })
+      .should('have.property', 'foo')
+      .should('equal', 'bar')
+  })
+})
+
+describe('should possess assertion', () => {
+  it('checks if the subject possesses a property', () => {
+    cy.wrap({ foo: 'bar' })
+      .should('possess', 'foo')
+      .and('not.possess', 'fooz')
+  })
+
+  it('yields the subject', () => {
+    cy.wrap({ foo: 'bar' })
+      .should('possess', 'foo')
+      .should('deep.equal', { foo: 'bar' })
+  })
+
+  it('checks the property value', () => {
+    cy.wrap({ foo: 'bar' })
+      .should('possess', 'foo', 'bar')
+      // still yields the original subject
+      .should('deep.equal', { foo: 'bar' })
+
+    cy.wrap({ foo: 42 }).should('possess', 'foo', 42)
+  })
+
+  it('checks the property value using not', () => {
+    cy.wrap({ foo: 'bar' }).should('not.possess', 'foo', 'BAR')
+  })
+
+  it('checks the property presence using not and value', () => {
+    cy.wrap({ foo: 'bar' }).should(
+      'not.possess',
+      'some-other-name',
+      'BAR',
+    )
+  })
+
+  it('supports nested properties', () => {
+    const person = { name: { first: 'Joe' } }
+    cy.wrap(person)
+      .should('possess', 'name.first', 'Joe')
+      // still yields the original subject
+      .should('deep.equal', person)
+  })
+
+  it('supports arrays', () => {
+    const counts = [1, 2, 3]
+    cy.wrap(counts)
+      .should('possess', '1', 2)
+      // still yields the original subject
+      .should('deep.equal', counts)
+      .should('possess', 'length', 3)
+  })
+
+  it('supports mixing arrays and objects', () => {
+    cy.wrap([
+      { sum: 42 },
+      { sum: 101 },
+      { sum: { errors: ['Invalid operation'] } },
+    ]).should('possess', '2.sum.errors.0', 'Invalid operation')
+  })
+
+  it('supports array bracket notation', () => {
+    cy.wrap([
+      { sum: 42 },
+      { sum: 101 },
+      { sum: { errors: ['Invalid operation'] } },
+    ]).should('possess', '[2].sum.errors[0]', 'Invalid operation')
+  })
+
+  // unskip to see the thrown error
+  it.skip('throws an error', () => {
+    cy.wrap({ foo: 'bar' })
+      // @ts-ignore
+      .should('possess', 'foo', 'bar', 123)
+      .should('deep.equal', { foo: 'bar' })
+  })
+})
diff --git a/cypress/e2e/assertions.cy.js b/cypress/e2e/assertions/read.cy.js
similarity index 95%
rename from cypress/e2e/assertions.cy.js
rename to cypress/e2e/assertions/read.cy.js
index c2e272e..e2b4501 100644
--- a/cypress/e2e/assertions.cy.js
+++ b/cypress/e2e/assertions/read.cy.js
@@ -1,7 +1,7 @@
 /// <reference types="cypress" />
 // @ts-check
 
-import '../../src/commands'
+import '../../../src/commands'
 
 it('compares text', () => {
   cy.visit('cypress/index.html')