Repository: unexpectedjs/unexpected
Pull request #783 (2020-12-20 11:42:44)

Absorb the unexpected-set plugin
SLUUUURRRP! 🤤

Excluding the `<array-like> with set semantics <assertion?>` assertion which I think is a bit too opinionated/obscure for core.

TODO:

- [x] Maybe enhance `<Set> [not] to contain <any>` to take varargs like `<array-like> [not] to contain <any+>` and `<string> [not] to contain <string+>`. (Or remove the ability from the other ones, it might be a bit obscure)
- [x] Figure out the equality semantics. `Set#has` uses the SameValueZero algorithm, but we should probably plug in unexpected's own `equal` in some cases, such as `to contain`. Right now this fails: `expect(new Set([new Date('2020-01-01T12:34:56.789Z')]), 'to contain', new Date('2020-01-01T12:34:56.789Z'));`

sunesimonsen: > Or remove the ability from the other ones, it might be a bit obscure

I think varargs is more okay now that you can do array splatting, so I don't think we should remove it. I also think it would break a lot of my tests :-)

Patch:
diff --git a/documentation/assertions/Set/to-be-empty.md b/documentation/assertions/Set/to-be-empty.md
new file mode 100644
index 00000000..bb85a652
--- /dev/null
+++ b/documentation/assertions/Set/to-be-empty.md
@@ -0,0 +1,31 @@
+Asserts that a Set is empty.
+
+```js
+expect(new Set(), 'to be empty');
+```
+
+In the case of a failing expectation you get the following output:
+
+```js
+expect(new Set([1, 2, 3]), 'to be empty');
+```
+
+```output
+expected new Set([ 1, 2, 3 ]) to be empty
+```
+
+The assertion can be negated using the `not` flag:
+
+```js
+expect(new Set([1, 2, 3]), 'not to be empty');
+```
+
+And with a failing expectation:
+
+```js
+expect(new Set(), 'not to be empty');
+```
+
+```output
+expected new Set([]) not to be empty
+```
diff --git a/documentation/assertions/Set/to-contain.md b/documentation/assertions/Set/to-contain.md
new file mode 100644
index 00000000..b8b7471c
--- /dev/null
+++ b/documentation/assertions/Set/to-contain.md
@@ -0,0 +1,42 @@
+Asserts that a Set instance contains a given element.
+
+```js
+expect(new Set([1, 2, 3]), 'to contain', 3);
+```
+
+You get a diff when the assertion fails:
+
+```js
+expect(new Set([1, 2, 3]), 'to contain', 4);
+```
+
+```output
+expected new Set([ 1, 2, 3 ]) to contain 4
+
+new Set([
+  1,
+  2,
+  3
+  // missing 4
+])
+```
+
+The assertion can be negated using the `not` flag:
+
+```js
+expect(new Set([1, 2, 3]), 'not to contain', 4);
+```
+
+```js
+expect(new Set([1, 2, 3]), 'not to contain', 3);
+```
+
+```output
+expected new Set([ 1, 2, 3 ]) not to contain 3
+
+new Set([
+  1,
+  2,
+  3 // should be removed
+])
+```
diff --git a/documentation/assertions/Set/to-have-an-item-satisfying.md b/documentation/assertions/Set/to-have-an-item-satisfying.md
new file mode 100644
index 00000000..014eac74
--- /dev/null
+++ b/documentation/assertions/Set/to-have-an-item-satisfying.md
@@ -0,0 +1,37 @@
+Asserts that at least one item contained in a Set satisfies a given value, function or other assertion.
+
+```js
+expect(new Set([1, 2, 3]), 'to have an item satisfying', 4);
+```
+
+```output
+expected new Set([ 1, 2, 3 ]) to have an item satisfying 4
+
+new Set([
+  1, // should equal 4
+  2, // should equal 4
+  3 // should equal 4
+])
+```
+
+In order to check a property holds for at least one item, an assertion can be
+passed as the argument – in this example we assert that one of the items in
+the set is a number:
+
+```js
+expect(
+  new Set(['a', false, []]),
+  'to have an item satisfying',
+  'to be a number'
+);
+```
+
+```output
+expected new Set([ 'a', false, [] ]) to have an item satisfying to be a number
+
+new Set([
+  'a', // should be a number
+  false, // should be a number
+  [] // should be a number
+])
+```
diff --git a/documentation/assertions/Set/to-have-items-satisfying.md b/documentation/assertions/Set/to-have-items-satisfying.md
new file mode 100644
index 00000000..9404fa88
--- /dev/null
+++ b/documentation/assertions/Set/to-have-items-satisfying.md
@@ -0,0 +1,55 @@
+Asserts the items contained by a Set satisfy a particular list of items.
+
+```js
+expect(new Set([1, 2, 3]), 'to have items satisfying', [1, { foo: 'bar' }, 3]);
+```
+
+```output
+expected new Set([ 1, 2, 3 ]) to have items satisfying [ 1, { foo: 'bar' }, 3 ]
+
+new Set([
+  1, // should equal [ 1, { foo: 'bar' }, 3 ]
+  2, // should equal [ 1, { foo: 'bar' }, 3 ]
+  3 // should equal [ 1, { foo: 'bar' }, 3 ]
+])
+```
+
+In order to check a property holds for all the items, an assertion can be
+passed as the argument – in this example we assert that all the items in
+the set are numbers:
+
+```js
+expect(new Set([1, 2, []]), 'to have items satisfying', 'to be a number');
+```
+
+```output
+expected new Set([ 1, 2, [] ]) to have items satisfying to be a number
+
+new Set([
+  1,
+  2,
+  [] // should be a number
+])
+```
+
+The exact number of elements in a Set must always be matched. However, nested
+objects are, be default, compared using "satisfy" semantics which allow missing
+properties. In order to enforce that all properties are present, the `exhaustively`
+flag can be used:
+
+```js
+expect(new Set([[{ foo: true, bar: true }], [1]]), 'to have items satisfying', [
+  expect.it('to be an object'),
+]);
+```
+
+```output
+expected Set to have items satisfying [ expect.it('to be an object') ]
+
+new Set([
+  [ { foo: true, bar: true } ],
+  [
+    1 // should be an object
+  ]
+])
+```
diff --git a/documentation/assertions/Set/to-have-size.md b/documentation/assertions/Set/to-have-size.md
new file mode 100644
index 00000000..74d39994
--- /dev/null
+++ b/documentation/assertions/Set/to-have-size.md
@@ -0,0 +1,31 @@
+Asserts that a Set has a certain size.
+
+```js
+expect(new Set([1, 2, 3]), 'to have size', 3);
+```
+
+In the case of a failing expectation you get the following output:
+
+```js
+expect(new Set([1, 2, 3]), 'to have size', 4);
+```
+
+```output
+expected new Set([ 1, 2, 3 ]) to have size 4
+```
+
+The assertion can be negated using the `not` flag:
+
+```js
+expect(new Set([1, 2, 3]), 'not to have size', 4);
+```
+
+And with a failing expectation:
+
+```js
+expect(new Set([1, 2, 3]), 'not to have size', 3);
+```
+
+```output
+expected new Set([ 1, 2, 3 ]) not to have size 3
+```
diff --git a/documentation/assertions/Set/to-satisfy.md b/documentation/assertions/Set/to-satisfy.md
new file mode 100644
index 00000000..9dc26e73
--- /dev/null
+++ b/documentation/assertions/Set/to-satisfy.md
@@ -0,0 +1,54 @@
+Asserts that a Set instance has at least one element satisfying each given
+spec.
+
+```js
+expect(
+  new Set([1, 2, 3]),
+  'to satisfy',
+  new Set([
+    1,
+    expect.it('to be less than or equal to', 1),
+    expect.it('to be greater than', 10),
+  ])
+);
+```
+
+```output
+expected new Set([ 1, 2, 3 ]) to satisfy
+new Set([
+  1,
+  expect.it('to be less than or equal to', 1),
+  expect.it('to be greater than', 10)
+])
+
+new Set([
+  1,
+  2, // should be removed
+  3 // should be removed
+  // missing: should be greater than 10
+])
+```
+
+The exact number of elements in a Set must always be matched. However, nested
+objects are, be default, compared using "satisfy" semantics which allow missing
+properties. In order to enforce that all properties are present, the `exhaustively`
+flag can be used:
+
+```js
+expect(
+  new Set([1, { foo: true, bar: false }]),
+  'to exhaustively satisfy',
+  new Set([1, { foo: true }])
+);
+```
+
+```output
+expected new Set([ 1, { foo: true, bar: false } ])
+to exhaustively satisfy new Set([ 1, { foo: true } ])
+
+new Set([
+  1,
+  { foo: true, bar: false } // should be removed
+  // missing { foo: true }
+])
+```
diff --git a/lib/assertions.js b/lib/assertions.js
index fb1f0bac..427a70ea 100644
--- a/lib/assertions.js
+++ b/lib/assertions.js
@@ -2635,4 +2635,389 @@ module.exports = (expect) => {
         expect(err, 'to satisfy', value);
       })
   );
+
+  expect.addAssertion(
+    '<Set> [not] to contain <any+>',
+    (expect, subject, ...values) => {
+      const subjectElements = [];
+      subject.forEach((element) => {
+        subjectElements.push(element);
+      });
+
+      expect.withError(
+        () => {
+          values.forEach((value) => {
+            expect(
+              subject.has(value) ||
+                subjectElements.some((element) => expect.equal(element, value)),
+              '[not] to be true'
+            );
+          });
+        },
+        () => {
+          expect.fail({
+            diff(output, diff, inspect, equal) {
+              output.inline = true;
+              expect.subjectType.prefix(output, subject);
+              output.nl().indentLines();
+
+              subjectElements.forEach((subjectElement, subjectIndex) => {
+                output
+                  .i()
+                  .block(function () {
+                    this.appendInspected(subjectElement);
+                    expect.subjectType.delimiter(
+                      this,
+                      subjectIndex,
+                      subjectElements.length
+                    );
+                    if (
+                      expect.flags.not &&
+                      values.some((value) => equal(subjectElement, value))
+                    ) {
+                      this.sp().annotationBlock(function () {
+                        this.error('should be removed');
+                      });
+                    }
+                  })
+                  .nl();
+              });
+              if (!expect.flags.not) {
+                values.forEach((value) => {
+                  if (
+                    !subject.has(value) &&
+                    !subjectElements.some((element) => equal(element, value))
+                  ) {
+                    output
+                      .i()
+                      .block(function () {
+                        this.annotationBlock(function () {
+                          this.error('missing').sp().appendInspected(value);
+                        });
+                      })
+                      .nl();
+                  }
+                });
+              }
+
+              output.outdentLines();
+              expect.subjectType.suffix(output, subject);
+
+              return output;
+            },
+          });
+        }
+      );
+    }
+  );
+
+  expect.addAssertion('<Set> [not] to be empty', (expect, subject) => {
+    expect(subject.size, '[not] to equal', 0);
+  });
+
+  expect.addAssertion(
+    [
+      '<Set> to have items [exhaustively] satisfying <any>',
+      '<Set> to have items [exhaustively] satisfying <assertion>',
+    ],
+    (expect, subject, nextArg) => {
+      expect.errorMode = 'nested';
+      expect(subject, 'not to be empty');
+      expect.errorMode = 'bubble';
+
+      const subjectElements = [];
+      subject.forEach((element) => {
+        subjectElements.push(element);
+      });
+
+      const expected = [];
+      subjectElements.forEach((subjectElement) => {
+        if (typeof nextArg === 'string') {
+          expected.push(expect.it((s) => expect.shift(s, 0)));
+        } else {
+          expected.push(nextArg);
+        }
+      });
+
+      return expect.withError(
+        () => expect(subjectElements, 'to [exhaustively] satisfy', expected),
+        (err) => {
+          expect.fail({
+            message(output) {
+              output.append(
+                expect.standardErrorMessage(output.clone(), { compact: true })
+              );
+            },
+            diff(output) {
+              output.inline = true;
+              return output
+                .jsKeyword('new')
+                .sp()
+                .jsKeyword('Set')
+                .text('(')
+                .append(err.getDiff({ output }))
+                .text(')');
+            },
+          });
+        }
+      );
+    }
+  );
+
+  expect.addAssertion(
+    [
+      '<Set> to have an item [exhaustively] satisfying <any>',
+      '<Set> to have an item [exhaustively] satisfying <assertion>',
+    ],
+    (expect, subject, nextArg) => {
+      expect.errorMode = 'nested';
+      expect(subject, 'not to be empty');
+      expect.errorMode = 'bubble';
+
+      const subjectElements = [];
+      subject.forEach((element) => {
+        subjectElements.push(element);
+      });
+
+      const expected =
+        typeof nextArg === 'string'
+          ? expect.it((s) => expect.shift(s, 0))
+          : nextArg;
+      const expectedType = expect.findTypeOf(expected);
+      const results = new Array(subjectElements.length);
+      subjectElements.forEach((subjectElement, subjectIndex) => {
+        results[subjectIndex] = expect.promise(() => {
+          if (expectedType.is('expect.it')) {
+            expect.context.thisObject = subject;
+            return expected(subjectElement, expect.context);
+          } else {
+            return expect(subjectElement, 'to [exhaustively] satisfy', nextArg);
+          }
+        });
+      });
+
+      return expect.promise.settle(results).then(function () {
+        if (results.every((result) => !result.isFulfilled())) {
+          expect.fail({
+            message(output) {
+              output.append(
+                expect.standardErrorMessage(output.clone(), { compact: true })
+              );
+            },
+            diff(output, diff, inspect, equal) {
+              output.inline = true;
+              const prefixOutput = expect.subjectType.prefix(
+                output.clone(),
+                subject
+              );
+              const suffixOutput = expect.subjectType.suffix(
+                output.clone(),
+                subject
+              );
+
+              output.append(prefixOutput);
+              if (!prefixOutput.isEmpty()) {
+                output.nl();
+              }
+              if (expect.subjectType.indent) {
+                output.indentLines();
+              }
+
+              let index = 0;
+              subjectElements.forEach((subjectElement, subjectIndex) => {
+                output.omitSubject = subject;
+                output
+                  .nl(index > 0 ? 1 : 0)
+                  .i()
+                  .block(function () {
+                    const delimiterOutput = expect.subjectType.delimiter(
+                      output.clone(),
+                      subjectIndex,
+                      subjectElements.length
+                    );
+
+                    const err = results[subjectIndex].reason();
+                    const diff = err.getDiff({ output: output.clone() });
+
+                    if (diff && diff.inline) {
+                      this.append(diff).amend(delimiterOutput);
+                    } else {
+                      this.appendInspected(subjectElement)
+                        .amend(delimiterOutput)
+                        .sp()
+                        .annotationBlock(function () {
+                          this.omitSubject = subjectElement;
+                          this.appendErrorMessage(err);
+                        });
+                    }
+                  });
+                index += 1;
+              });
+
+              if (expect.subjectType.indent) {
+                output.outdentLines();
+              }
+              if (!suffixOutput.isEmpty()) {
+                output.nl();
+              }
+              output.append(suffixOutput);
+
+              return output;
+            },
+          });
+        }
+      });
+    }
+  );
+
+  expect.addAssertion(
+    '<Set> to [exhaustively] satisfy <Set>',
+    (expect, subject, value) => {
+      const subjectElements = [];
+      subject.forEach((element) => {
+        subjectElements.push(element);
+      });
+      const valueElements = [];
+      value.forEach((element) => {
+        valueElements.push(element);
+      });
+
+      const promiseBySubjectIndexAndValueIndex = subjectElements.map(
+        () => new Array(valueElements.length)
+      );
+
+      const promiseByValueIndexAndSubjectIndex = valueElements.map(
+        (valueElement, valueIndex) =>
+          subjectElements.map((subjectElement, subjectIndex) => {
+            const promise = expect.promise(() => {
+              const valueElementType = expect.findTypeOf(valueElement);
+              if (valueElementType.is('function')) {
+                return valueElement(subjectElement);
+              } else {
+                return expect(
+                  subjectElement,
+                  'to [exhaustively] satisfy',
+                  valueElement
+                );
+              }
+            });
+            promiseBySubjectIndexAndValueIndex[subjectIndex][
+              valueIndex
+            ] = promise;
+            return promise;
+          })
+      );
+
+      return expect.promise
+        .settle(promiseByValueIndexAndSubjectIndex)
+        .then(() => {
+          if (
+            !promiseByValueIndexAndSubjectIndex.every((row) =>
+              row.some((promise) => promise.isFulfilled())
+            ) ||
+            !subjectElements.every((subjectElement, i) =>
+              promiseByValueIndexAndSubjectIndex.some((row) =>
+                row[i].isFulfilled()
+              )
+            )
+          ) {
+            expect.fail({
+              diff(output, diff, inspect, equal) {
+                output.inline = true;
+                const prefixOutput = expect.subjectType.prefix(
+                  output.clone(),
+                  subject
+                );
+                const suffixOutput = expect.subjectType.suffix(
+                  output.clone(),
+                  subject
+                );
+
+                output.append(prefixOutput);
+                if (!prefixOutput.isEmpty()) {
+                  output.nl();
+                }
+                if (expect.subjectType.indent) {
+                  output.indentLines();
+                }
+
+                let index = 0;
+                subjectElements.forEach((subjectElement, subjectIndex) => {
+                  output
+                    .nl(index > 0 ? 1 : 0)
+                    .i()
+                    .block(function () {
+                      this.appendInspected(subjectElement);
+                      expect.subjectType.delimiter(
+                        this,
+                        subjectIndex,
+                        subjectElements.length
+                      );
+                      if (
+                        !promiseBySubjectIndexAndValueIndex[
+                          subjectIndex
+                        ].some((promise, valueIndex) =>
+                          promiseBySubjectIndexAndValueIndex[subjectIndex][
+                            valueIndex
+                          ].isFulfilled()
+                        )
+                      ) {
+                        this.sp().annotationBlock(function () {
+                          this.error('should be removed');
+                        });
+                      }
+                    });
+                  index += 1;
+                });
+                valueElements.forEach((valueElement, valueIndex) => {
+                  if (
+                    promiseByValueIndexAndSubjectIndex[
+                      valueIndex
+                    ].every((promise) => promise.isRejected())
+                  ) {
+                    output
+                      .nl(index > 0 ? 1 : 0)
+                      .i()
+                      .annotationBlock(function () {
+                        if (expect.findTypeOf(valueElement).is('function')) {
+                          this.omitSubject = subjectElements[0];
+                          this.error('missing:')
+                            .sp()
+                            .appendErrorMessage(
+                              promiseByValueIndexAndSubjectIndex[
+                                valueIndex
+                              ][0].reason()
+                            );
+                        } else {
+                          this.error('missing')
+                            .sp()
+                            .appendInspected(valueElement);
+                        }
+                      });
+                    index += 1;
+                  }
+                });
+
+                if (expect.subjectType.indent) {
+                  output.outdentLines();
+                }
+                if (!suffixOutput.isEmpty()) {
+                  output.nl();
+                }
+                output.append(suffixOutput);
+
+                return output;
+              },
+            });
+          }
+        });
+    }
+  );
+
+  expect.addAssertion(
+    '<Set> [not] to have size <number>',
+    (expect, subject, value) => {
+      expect(subject.size, '[not] to equal', value);
+    }
+  );
 };
diff --git a/lib/types.js b/lib/types.js
index dacca368..50ccef47 100644
--- a/lib/types.js
+++ b/lib/types.js
@@ -1028,6 +1028,177 @@ module.exports = function (expect) {
     },
   });
 
+  expect.addType({
+    name: 'Set',
+    base: 'object',
+    indent: true,
+    identify(obj) {
+      return obj instanceof Set;
+    },
+    equal(a, b, equal) {
+      if (a.size !== b.size) {
+        return false;
+      }
+      let unequal = false;
+      a.forEach((value) => {
+        unequal = unequal || !b.has(value);
+      });
+      if (unequal) {
+        // Slow path
+        const bElements = [];
+        b.forEach((element) => {
+          bElements.push(element);
+        });
+        unequal = false;
+        a.forEach((value) => {
+          unequal =
+            unequal ||
+            (!b.has(value) &&
+              !bElements.some((bElement) => equal(value, bElement)));
+        });
+      }
+      return !unequal;
+    },
+    prefix(output) {
+      return output.jsKeyword('new').sp().jsKeyword('Set').text('([');
+    },
+    suffix(output) {
+      return output.text('])');
+    },
+    inspect(set, depth, output, inspect) {
+      // Mostly copied from array-like's inspect:
+      const prefixOutput = this.prefix(output.clone(), set);
+      const suffixOutput = this.suffix(output.clone(), set);
+      if (set.size === 0) {
+        return output.append(prefixOutput).append(suffixOutput);
+      }
+
+      if (depth === 1 && set.size > 10) {
+        return output.append(prefixOutput).text('...').append(suffixOutput);
+      }
+
+      const inspectedItems = [];
+      set.forEach((item) => {
+        inspectedItems.push(inspect(item));
+      });
+
+      const currentDepth = defaultDepth - Math.min(defaultDepth, depth);
+      const maxLineLength =
+        output.preferredWidth - 20 - currentDepth * output.indentationWidth - 2;
+      let width = 0;
+      const multipleLines = inspectedItems.some((o) => {
+        if (o.isMultiline()) {
+          return true;
+        }
+
+        const size = o.size();
+        width += size.width;
+        return width > maxLineLength;
+      });
+
+      const type = this;
+      inspectedItems.forEach((inspectedItem, index) => {
+        inspectedItem.amend(
+          type.delimiter(output.clone(), index, inspectedItems.length)
+        );
+      });
+
+      output.append(prefixOutput);
+      if (this.forceMultipleLines || multipleLines) {
+        if (!prefixOutput.isEmpty()) {
+          output.nl();
+        }
+        if (this.indent) {
+          output.indentLines();
+        }
+        inspectedItems.forEach((inspectedItem, index) => {
+          output
+            .nl(index > 0 ? 1 : 0)
+            .i()
+            .block(inspectedItem);
+        });
+        if (this.indent) {
+          output.outdentLines();
+        }
+        if (!suffixOutput.isEmpty()) {
+          output.nl();
+        }
+      } else {
+        output.sp(prefixOutput.isEmpty() ? 0 : 1);
+        inspectedItems.forEach((inspectedItem, index) => {
+          output.append(inspectedItem);
+          const lastIndex = index === inspectedItems.length - 1;
+          if (!lastIndex) {
+            output.sp();
+          }
+        });
+        output.sp(suffixOutput.isEmpty() ? 0 : 1);
+      }
+      output.append(suffixOutput);
+    },
+    diff(actual, expected, output, diff, inspect, equal) {
+      output.inline = true;
+      const prefixOutput = this.prefix(output.clone(), actual);
+      const suffixOutput = this.suffix(output.clone(), actual);
+
+      output.append(prefixOutput).nl(prefixOutput.isEmpty() ? 0 : 1);
+      if (this.indent) {
+        output.indentLines();
+      }
+      const type = this;
+      let index = 0;
+
+      const actualElements = [];
+      actual.forEach((element) => {
+        actualElements.push(element);
+      });
+
+      const expectedElements = [];
+      expected.forEach((element) => {
+        expectedElements.push(element);
+      });
+
+      actual.forEach((actualElement) => {
+        output
+          .nl(index > 0 ? 1 : 0)
+          .i()
+          .block(function () {
+            this.appendInspected(actualElement);
+            type.delimiter(this, index, actual.size);
+            if (
+              !expected.has(actualElement) &&
+              !expectedElements.some((element) => equal(element, actualElement))
+            ) {
+              this.sp().annotationBlock(function () {
+                this.error('should be removed');
+              });
+            }
+          });
+        index += 1;
+      });
+      expected.forEach((expectedElement) => {
+        if (
+          !actual.has(expectedElement) &&
+          !actualElements.some((element) => equal(element, expectedElement))
+        ) {
+          output
+            .nl(index > 0 ? 1 : 0)
+            .i()
+            .annotationBlock(function () {
+              this.error('missing').sp().appendInspected(expectedElement);
+            });
+          index += 1;
+        }
+      });
+      if (this.indent) {
+        output.outdentLines();
+      }
+      output.nl(suffixOutput.isEmpty() ? 0 : 1).append(suffixOutput);
+
+      return output;
+    },
+  });
+
   expect.addType({
     base: 'function',
     name: 'expect.it',
diff --git a/package.json b/package.json
index d6eb2f2c..b087fa02 100644
--- a/package.json
+++ b/package.json
@@ -62,7 +62,7 @@
     "node-version-check": "^2.2.0",
     "nyc": "^15.0.0",
     "offline-github-changelog": "^2.0.0",
-    "prettier": "~2.2.0",
+    "prettier": "~2.2.1",
     "rollup": "^2.0.3",
     "rollup-plugin-commonjs": "^10.0.0",
     "rollup-plugin-multi-entry": "^2.1.0",


Test patch:
diff --git a/test/assertions/to-be-empty.spec.js b/test/assertions/to-be-empty.spec.js
index 785d5a11..f5a67ae4 100644
--- a/test/assertions/to-be-empty.spec.js
+++ b/test/assertions/to-be-empty.spec.js
@@ -39,6 +39,7 @@ describe('empty assertion', () => {
         '  The assertion does not have a matching signature for:\n' +
         '    <null> to be empty\n' +
         '  did you mean:\n' +
+        '    <Set> [not] to be empty\n' +
         '    <object> [not] to be empty\n' +
         '    <string|array-like> [not] to be empty'
     );
@@ -64,4 +65,36 @@ describe('empty assertion', () => {
       'expected {} not to be empty'
     );
   });
+
+  describe('with a Set instance', () => {
+    it('should succeed', () => {
+      expect(new Set(), 'to be empty');
+    });
+
+    it('should fail', () => {
+      expect(
+        () => {
+          expect(new Set([1, 2, 3]), 'to be empty');
+        },
+        'to throw',
+        'expected new Set([ 1, 2, 3 ]) to be empty'
+      );
+    });
+
+    describe('with the not flag', () => {
+      it('should succeed', () => {
+        expect(new Set([1, 2, 3]), 'not to be empty');
+      });
+
+      it('should fail', () => {
+        expect(
+          () => {
+            expect(new Set(), 'not to be empty');
+          },
+          'to throw',
+          'expected new Set([]) not to be empty'
+        );
+      });
+    });
+  });
 });
diff --git a/test/assertions/to-contain.spec.js b/test/assertions/to-contain.spec.js
index 7464b9ed..cba38ae7 100644
--- a/test/assertions/to-contain.spec.js
+++ b/test/assertions/to-contain.spec.js
@@ -32,6 +32,7 @@ describe('to contain assertion', () => {
         '  The assertion does not have a matching signature for:\n' +
         '    <null> not to contain <string>\n' +
         '  did you mean:\n' +
+        '    <Set> [not] to contain <any+>\n' +
         '    <array-like> [not] to contain <any+>\n' +
         '    <string> [not] to contain <string+>'
     );
@@ -80,6 +81,7 @@ describe('to contain assertion', () => {
         '  The assertion does not have a matching signature for:\n' +
         '    <number> to contain <number>\n' +
         '  did you mean:\n' +
+        '    <Set> [not] to contain <any+>\n' +
         '    <array-like> [not] to contain <any+>\n' +
         '    <array-like> to [only] contain <any+>\n' +
         '    <string> [not] to contain <string+>'
@@ -263,4 +265,125 @@ describe('to contain assertion', () => {
         '^^^^      ^^^^'
     );
   });
+
+  describe('with a Set instance', () => {
+    it('should succeed', () => {
+      expect(new Set([1, 2, 3]), 'to contain', 3);
+    });
+
+    it('should fail with a diff', () => {
+      expect(
+        () => {
+          expect(new Set([1, 2, 3]), 'to contain', 4);
+        },
+        'to throw',
+        'expected new Set([ 1, 2, 3 ]) to contain 4\n' +
+          '\n' +
+          'new Set([\n' +
+          '  1,\n' +
+          '  2,\n' +
+          '  3\n' +
+          '  // missing 4\n' +
+          '])'
+      );
+    });
+
+    describe('with a value that is equal according to unexpected, but not the SameValueZero algorithm', function () {
+      it('should succeed', () => {
+        expect(
+          new Set([new Date('2020-01-01T12:34:56.789Z')]),
+          'to contain',
+          new Date('2020-01-01T12:34:56.789Z')
+        );
+      });
+
+      it('not render such an item as different in a diff', () => {
+        expect(
+          () => {
+            expect(
+              new Set([new Date('2020-01-01T12:34:56.789Z')]),
+              'to contain',
+              new Date('2020-01-01T12:34:56.789Z'),
+              1
+            );
+          },
+          'to throw',
+          "expected new Set([ new Date('2020-01-01T12:34:56.789Z') ])\n" +
+            "to contain new Date('2020-01-01T12:34:56.789Z'), 1\n" +
+            '\n' +
+            'new Set([\n' +
+            "  new Date('2020-01-01T12:34:56.789Z')\n" +
+            '  // missing 1\n' +
+            '])'
+        );
+      });
+    });
+
+    describe('with multiple expected values', function () {
+      it('should succeed', () => {
+        expect(new Set([1, 2, 3]), 'to contain', 1, 2);
+      });
+
+      it('should fail with a diff', () => {
+        expect(
+          () => {
+            expect(new Set([1, 2, 3]), 'to contain', 1, 4);
+          },
+          'to throw',
+          'expected new Set([ 1, 2, 3 ]) to contain 1, 4\n' +
+            '\n' +
+            'new Set([\n' +
+            '  1,\n' +
+            '  2,\n' +
+            '  3\n' +
+            '  // missing 4\n' +
+            '])'
+        );
+      });
+    });
+
+    describe('with the not flag', () => {
+      it('should succeed', () => {
+        expect(new Set([1, 2, 3]), 'not to contain', 4);
+      });
+
+      it('should fail with a diff', () => {
+        expect(
+          () => {
+            expect(new Set([1, 2, 3]), 'not to contain', 2);
+          },
+          'to throw',
+          'expected new Set([ 1, 2, 3 ]) not to contain 2\n' +
+            '\n' +
+            'new Set([\n' +
+            '  1,\n' +
+            '  2, // should be removed\n' +
+            '  3\n' +
+            '])'
+        );
+      });
+
+      describe('with multiple expected values', function () {
+        it('should succeed', () => {
+          expect(new Set([1, 2, 3]), 'not to contain', 4, 5);
+        });
+
+        it('should fail with a diff', () => {
+          expect(
+            () => {
+              expect(new Set([1, 2, 3]), 'not to contain', 3, 4, 1);
+            },
+            'to throw',
+            'expected new Set([ 1, 2, 3 ]) not to contain 3, 4, 1\n' +
+              '\n' +
+              'new Set([\n' +
+              '  1, // should be removed\n' +
+              '  2,\n' +
+              '  3 // should be removed\n' +
+              '])'
+          );
+        });
+      });
+    });
+  });
 });
diff --git a/test/assertions/to-have-an-item-satisfying.spec.js b/test/assertions/to-have-an-item-satisfying.spec.js
index b1f1d001..aee5127c 100644
--- a/test/assertions/to-have-an-item-satisfying.spec.js
+++ b/test/assertions/to-have-an-item-satisfying.spec.js
@@ -10,6 +10,8 @@ describe('to have an item satisfying assertion', () => {
         '  The assertion does not have a matching signature for:\n' +
         '    <array> to have an item satisfying\n' +
         '  did you mean:\n' +
+        '    <Set> to have an item [exhaustively] satisfying <any>\n' +
+        '    <Set> to have an item [exhaustively] satisfying <assertion>\n' +
         '    <array-like> [not] to have an item [exhaustively] satisfying <any>\n' +
         '    <array-like> [not] to have an item [exhaustively] satisfying <assertion>'
     );
@@ -25,6 +27,8 @@ describe('to have an item satisfying assertion', () => {
         '  The assertion does not have a matching signature for:\n' +
         '    <number> to have an item satisfying <string>\n' +
         '  did you mean:\n' +
+        '    <Set> to have an item [exhaustively] satisfying <any>\n' +
+        '    <Set> to have an item [exhaustively] satisfying <assertion>\n' +
         '    <array-like> [not] to have an item [exhaustively] satisfying <any>\n' +
         '    <array-like> [not] to have an item [exhaustively] satisfying <assertion>'
     );
@@ -220,4 +224,86 @@ describe('to have an item satisfying assertion', () => {
       );
     });
   });
+
+  describe('with a Set instance', () => {
+    it('should succeed with a primitive as the expected value', () => {
+      expect(new Set([1, 2, 3]), 'to have an item satisfying', 3);
+    });
+
+    it('should succeed with an expect.it as the expected value', () => {
+      expect(
+        new Set([1, 2, 3]),
+        'to have an item satisfying',
+        expect.it('to be a number').and('to be greater than', 2)
+      );
+    });
+
+    it('should succeed with an assertion as the expected value', () => {
+      expect(
+        new Set([1, 2, 3]),
+        'to have an item satisfying',
+        'to be a number'
+      );
+    });
+
+    it('should succeed with an array as the expected value', () => {
+      expect(new Set([[1], [2], [3]]), 'to have an item satisfying', [
+        expect.it('to be a number').and('to be greater than', 2),
+      ]);
+    });
+
+    it('should fail with a diff', () => {
+      expect(
+        () => {
+          expect(new Set([[2], ['foo']]), 'to have an item satisfying', [
+            expect.it('to be a number').and('to be greater than', 2),
+          ]);
+        },
+        'to throw',
+        "expected new Set([ [ 2 ], [ 'foo' ] ]) to have an item satisfying\n" +
+          '[\n' +
+          "  expect.it('to be a number')\n" +
+          "          .and('to be greater than', 2)\n" +
+          ']\n' +
+          '\n' +
+          'new Set([\n' +
+          '  [\n' +
+          '    2 // ✓ should be a number and\n' +
+          '      // ⨯ should be greater than 2\n' +
+          '  ],\n' +
+          '  [\n' +
+          "    'foo' // ⨯ should be a number and\n" +
+          '          // ⨯ should be greater than 2\n' +
+          '  ]\n' +
+          '])'
+      );
+    });
+
+    it('should fail with a non-inline diff', () => {
+      expect(
+        () => {
+          expect(
+            new Set(['foo']),
+            'to have an item satisfying',
+            expect.it('to equal', 'bar')
+          );
+        },
+        'to throw',
+        "expected new Set([ 'foo' ]) to have an item satisfying expect.it('to equal', 'bar')\n" +
+          '\n' +
+          'new Set([\n' +
+          "  'foo' // should equal 'bar'\n" +
+          '        //\n' +
+          '        // -foo\n' +
+          '        // +bar\n' +
+          '])'
+      );
+    });
+
+    it('should fail for the empty set', () => {
+      expect(() => {
+        expect(new Set([]), 'to have an item satisfying to be a number');
+      }, 'to throw');
+    });
+  });
 });
diff --git a/test/assertions/to-have-items-satisfying.spec.js b/test/assertions/to-have-items-satisfying.spec.js
index 6f0fbd27..a4f54aeb 100644
--- a/test/assertions/to-have-items-satisfying.spec.js
+++ b/test/assertions/to-have-items-satisfying.spec.js
@@ -10,6 +10,8 @@ describe('to have items satisfying assertion', () => {
         '  The assertion does not have a matching signature for:\n' +
         '    <array> to have items satisfying\n' +
         '  did you mean:\n' +
+        '    <Set> to have items [exhaustively] satisfying <any>\n' +
+        '    <Set> to have items [exhaustively] satisfying <assertion>\n' +
         '    <array-like> to have items [exhaustively] satisfying <any>\n' +
         '    <array-like> to have items [exhaustively] satisfying <assertion>'
     );
@@ -25,6 +27,8 @@ describe('to have items satisfying assertion', () => {
         '  The assertion does not have a matching signature for:\n' +
         '    <array> to have items satisfying <number> <number>\n' +
         '  did you mean:\n' +
+        '    <Set> to have items [exhaustively] satisfying <any>\n' +
+        '    <Set> to have items [exhaustively] satisfying <assertion>\n' +
         '    <array-like> to have items [exhaustively] satisfying <any>\n' +
         '    <array-like> to have items [exhaustively] satisfying <assertion>'
     );
@@ -40,6 +44,8 @@ describe('to have items satisfying assertion', () => {
         '  The assertion does not have a matching signature for:\n' +
         '    <number> to have items satisfying <function>\n' +
         '  did you mean:\n' +
+        '    <Set> to have items [exhaustively] satisfying <any>\n' +
+        '    <Set> to have items [exhaustively] satisfying <assertion>\n' +
         '    <array-like> to have items [exhaustively] satisfying <any>\n' +
         '    <array-like> to have items [exhaustively] satisfying <assertion>'
     );
@@ -383,4 +389,71 @@ describe('to have items satisfying assertion', () => {
       );
     });
   });
+
+  describe('with a Set instance', () => {
+    it('should succeed', () => {
+      expect(new Set([[1], [2], [3]]), 'to have items satisfying', [
+        expect.it('to be a number').and('to be greater than', 0),
+      ]);
+    });
+
+    it('should fail with a diff', () => {
+      expect(
+        () => {
+          expect(new Set([[2], ['foo']]), 'to have items satisfying', [
+            expect.it('to be a number').and('to be greater than', 0),
+          ]);
+        },
+        'to throw',
+        "expected new Set([ [ 2 ], [ 'foo' ] ]) to have items satisfying\n" +
+          '[\n' +
+          "  expect.it('to be a number')\n" +
+          "          .and('to be greater than', 0)\n" +
+          ']\n' +
+          '\n' +
+          'new Set([\n' +
+          '  [ 2 ],\n' +
+          '  [\n' +
+          "    'foo' // ⨯ should be a number and\n" +
+          '          // ⨯ should be greater than 0\n' +
+          '  ]\n' +
+          '])'
+      );
+    });
+
+    it('should fail for the empty set', () => {
+      expect(() => {
+        expect(new Set([]), 'to have items satisfying to be a number');
+      }, 'to throw');
+    });
+
+    describe('with a compound assertion on the RHS', () => {
+      it('should succeed', () => {
+        expect(
+          new Set([1, 2, 3]),
+          'to have items satisfying',
+          'to be a number'
+        );
+      });
+
+      it('should fail with a diff', () => {
+        expect(
+          () => {
+            expect(
+              new Set([1, 2, 'foo']),
+              'to have items satisfying to be a number'
+            );
+          },
+          'to throw',
+          "expected new Set([ 1, 2, 'foo' ]) to have items satisfying to be a number\n" +
+            '\n' +
+            'new Set([\n' +
+            '  1,\n' +
+            '  2,\n' +
+            "  'foo' // should be a number\n" +
+            '])'
+        );
+      });
+    });
+  });
 });
diff --git a/test/assertions/to-have-size.spec.js b/test/assertions/to-have-size.spec.js
new file mode 100644
index 00000000..e78cdb1f
--- /dev/null
+++ b/test/assertions/to-have-size.spec.js
@@ -0,0 +1,32 @@
+/* global expect */
+describe('to have size assertion', () => {
+  it('should succeed', () => {
+    expect(new Set([1, 2, 3]), 'to have size', 3);
+  });
+
+  it('should fail', () => {
+    expect(
+      () => {
+        expect(new Set([1, 2, 3]), 'to have size', 2);
+      },
+      'to throw',
+      'expected new Set([ 1, 2, 3 ]) to have size 2'
+    );
+  });
+
+  describe('with the not flag', () => {
+    it('should succeed', () => {
+      expect(new Set([1, 2, 3]), 'not to have size', 2);
+    });
+
+    it('should fail', () => {
+      expect(
+        () => {
+          expect(new Set([1, 2, 3]), 'not to have size', 3);
+        },
+        'to throw',
+        'expected new Set([ 1, 2, 3 ]) not to have size 3'
+      );
+    });
+  });
+});
diff --git a/test/assertions/to-satisfy.spec.js b/test/assertions/to-satisfy.spec.js
index 0261e7c0..74c4e193 100644
--- a/test/assertions/to-satisfy.spec.js
+++ b/test/assertions/to-satisfy.spec.js
@@ -2759,4 +2759,95 @@ describe('to satisfy assertion', () => {
       { foo: 123 }
     );
   });
+
+  describe('with a Set instance', () => {
+    describe('against another Set instance', () => {
+      it('should succeed', () => {
+        expect(new Set([1, 2]), 'to satisfy', new Set([2, 1]));
+      });
+
+      it('should succeed with partial object matches', () => {
+        expect(
+          new Set([{ foo: true, bar: 1 }]),
+          'to satisfy',
+          new Set([{ foo: true }])
+        );
+      });
+
+      it('should fail with a diff', () => {
+        expect(
+          () => {
+            expect(new Set([1, 2]), 'to satisfy', new Set([3]));
+          },
+          'to throw',
+          'expected new Set([ 1, 2 ]) to satisfy new Set([ 3 ])\n' +
+            '\n' +
+            'new Set([\n' +
+            '  1, // should be removed\n' +
+            '  2 // should be removed\n' +
+            '  // missing 3\n' +
+            '])'
+        );
+      });
+
+      it('should not accept extraneous items', () => {
+        expect(
+          () => {
+            expect(new Set([1, 2]), 'to exhaustively satisfy', new Set([1]));
+          },
+          'to throw',
+          'expected new Set([ 1, 2 ]) to exhaustively satisfy new Set([ 1 ])\n' +
+            '\n' +
+            'new Set([\n' +
+            '  1,\n' +
+            '  2 // should be removed\n' +
+            '])'
+        );
+      });
+
+      describe('with the exhaustively flag', () => {
+        it('should succeed', () => {
+          expect(
+            new Set([{ foo: true, bar: 1 }]),
+            'to exhaustively satisfy',
+            new Set([{ foo: true, bar: 1 }])
+          );
+        });
+      });
+    });
+
+    it('should point out missing items', () => {
+      expect(
+        () => {
+          expect(new Set([1]), 'to satisfy', new Set([1, 2]));
+        },
+        'to throw',
+        'expected new Set([ 1 ]) to satisfy new Set([ 1, 2 ])\n' +
+          '\n' +
+          'new Set([\n' +
+          '  1\n' +
+          '  // missing 2\n' +
+          '])'
+      );
+    });
+
+    it('should point out items that were supposed to satisfy an expect.it', () => {
+      expect(
+        () => {
+          expect(
+            new Set([1]),
+            'to satisfy',
+            new Set([1, expect.it('to equal', 2)])
+          );
+        },
+        'to throw',
+        "expected new Set([ 1 ]) to satisfy new Set([ 1, expect.it('to equal', 2) ])\n" +
+          '\n' +
+          'new Set([\n' +
+          '  1\n' +
+          '  // missing: should equal 2\n' +
+          '])'
+      );
+    });
+  });
 });
diff --git a/test/common.js b/test/common.js
index 24d9c86f..dd489be3 100644
--- a/test/common.js
+++ b/test/common.js
@@ -14,6 +14,13 @@ unexpected
       expect(expect.inspect(subject).toString(), 'to equal', value);
     }
   )
+  .addAssertion(
+    '<array> to produce a diff of <string>',
+    (expect, subject, value) => {
+      expect.errorMode = 'bubble';
+      expect(expect.diff(subject[0], subject[1]).toString(), 'to equal', value);
+    }
+  )
   .addAssertion(
     '<any> when delayed a little bit <assertion>',
     function (expect, subject) {
diff --git a/test/types/Set-type.spec.js b/test/types/Set-type.spec.js
new file mode 100644
index 00000000..c4fe2c06
--- /dev/null
+++ b/test/types/Set-type.spec.js
@@ -0,0 +1,121 @@
+/* global expect */
+describe('Set type', () => {
+  it('should inspect a Set instance correctly', () => {
+    expect(new Set([1, 2]), 'to inspect as', 'new Set([ 1, 2 ])');
+  });
+
+  it('should diff two Set instances correctly', () => {
+    expect(
+      [new Set([1, 2]), new Set([2, 3])],
+      'to produce a diff of',
+      'new Set([\n' +
+        '  1, // should be removed\n' +
+        '  2\n' +
+        '  // missing 3\n' +
+        '])'
+    );
+  });
+
+  it('should diff two Set instances with complex items correctly', () => {
+    expect(
+      [
+        new Set([1, new Date('2020-01-01T12:34:56.789Z')]),
+        new Set([new Date('2020-01-01T12:34:56.789Z'), 3]),
+      ],
+      'to produce a diff of',
+      'new Set([\n' +
+        '  1, // should be removed\n' +
+        "  new Date('2020-01-01T12:34:56.789Z')\n" +
+        '  // missing 3\n' +
+        '])'
+    );
+  });
+
+  describe('equality', () => {
+    it('should consider two empty sets equal', () => {
+      expect(new Set(), 'to equal', new Set());
+    });
+
+    it('should consider two sets with the same primitive item equal', () => {
+      expect(new Set(['abc']), 'to equal', new Set(['abc']));
+    });
+
+    it('should consider two sets with the same complex item equal', () => {
+      expect(
+        new Set([new Date('2020-01-01T12:34:56.789Z')]),
+        'to equal',
+        new Set([new Date('2020-01-01T12:34:56.789Z')])
+      );
+    });
+
+    it('should consider two sets with a different item unequal', () => {
+      expect(new Set(['abc']), 'not to equal', new Set(['def']));
+    });
+
+    it('should consider two sets with a common and a different item unequal', () => {
+      expect(new Set(['abc', 'def']), 'not to equal', new Set(['abc', 'ghi']));
+    });
+
+    it('should consider two sets with a different number of items unequal', () => {
+      expect(new Set(['abc']), 'not to equal', new Set(['abc', 'def']));
+      expect(new Set(['abc', 'def']), 'not to equal', new Set(['abc']));
+    });
+  });
+
+  describe('with a subtype that disables indentation', () => {
+    const clonedExpect = expect.clone();
+
+    clonedExpect.addType({
+      base: 'Set',
+      name: 'bogusSet',
+      identify(obj) {
+        return obj && obj.constructor && obj.constructor.name === 'Set';
+      },
+      prefix(output) {
+        return output;
+      },
+      suffix(output) {
+        return output;
+      },
+      indent: false,
+    });
+
+    it('should not render the indentation when an instance is inspected in a multi-line context', () => {
+      expect(
+        clonedExpect
+          .inspect(
+            new Set([
+              'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
+              'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
+            ])
+          )
+          .toString(),
+        'to equal',
+        "'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',\n" +
+          "'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'"
+      );
+    });
+
+    it('should not render the indentation when an instance is diffed', () => {
+      expect(
+        clonedExpect.diff(new Set(['a', 'b']), new Set(['b', 'c'])).toString(),
+        'to equal',
+        "'a', // should be removed\n" + "'b'\n" + "// missing 'c'"
+      );
+    });
+
+    it('should not render the indentation when an instance participates in a "to satisfy" diff', () => {
+      expect(
+        () => {
+          clonedExpect(new Set(['aaa', 'bbb']), 'to satisfy', new Set(['foo']));
+        },
+        'to throw',
+        "expected 'aaa', 'bbb' to satisfy 'foo'\n" +
+          '\n' +
+          "'aaa', // should be removed\n" +
+          "'bbb' // should be removed\n" +
+          "// missing 'foo'"
+      );
+    });
+  });
+});
diff --git a/test/unexpected.spec.js b/test/unexpected.spec.js
index ba717ac7..d5e3fd64 100644
--- a/test/unexpected.spec.js
+++ b/test/unexpected.spec.js
@@ -82,6 +82,8 @@ describe('unexpected', () => {
           '  The assertion does not have a matching signature for:\n' +
           '    <string> to have items satisfying <expect.it>\n' +
           '  did you mean:\n' +
+          '    <Set> to have items [exhaustively] satisfying <any>\n' +
+          '    <Set> to have items [exhaustively] satisfying <assertion>\n' +
           '    <array-like> to have items [exhaustively] satisfying <any>\n' +
           '    <array-like> to have items [exhaustively] satisfying <assertion>'
       );