Repository: unexpectedjs/unexpected
Pull request #476 (2018-05-20 18:19:46)

Implement Unexpected#freeze / expect.freeze
As discussed on gitter a while ago -- will force the consumer to `.clone()` before adding assertions, types, styles etc. so that nothing is leaked into instances that are used elsewhere.

The idea is to eventually freeze the "main" instance exported from [lib/index.js](https://github.com/unexpectedjs/unexpected/blob/f42805661ac8f0d396aae5ad9adec3f51767a9f5/lib/index.js#L5), but that's semver-major.


Patch:
diff --git a/lib/Unexpected.js b/lib/Unexpected.js
index e11645dd..bef52f2d 100644
--- a/lib/Unexpected.js
+++ b/lib/Unexpected.js
@@ -642,6 +642,11 @@ Unexpected.prototype.addAssertion = function(
   handler,
   childUnexpected
 ) {
+  if (this._frozen) {
+    throw new Error(
+      'Cannot add an assertion to a frozen instance, please run .clone() first'
+    );
+  }
   let maxArguments;
   if (typeof childUnexpected === 'object') {
     maxArguments = 3;
@@ -754,6 +759,11 @@ Unexpected.prototype.addAssertion = function(
 };
 
 Unexpected.prototype.addType = function(type, childUnexpected) {
+  if (this._frozen) {
+    throw new Error(
+      'Cannot add a type to a frozen instance, please run .clone() first'
+    );
+  }
   const that = this;
   let baseType;
   if (
@@ -883,11 +893,21 @@ Unexpected.prototype.addType = function(type, childUnexpected) {
 };
 
 Unexpected.prototype.addStyle = function(...args) {
+  if (this._frozen) {
+    throw new Error(
+      'Cannot add a style to a frozen instance, please run .clone() first'
+    );
+  }
   this.output.addStyle(...args);
   return this.expect;
 };
 
 Unexpected.prototype.installTheme = function(...args) {
+  if (this._frozen) {
+    throw new Error(
+      'Cannot install a theme into a frozen instance, please run .clone() first'
+    );
+  }
   this.output.installTheme(...args);
   return this.expect;
 };
@@ -901,6 +921,11 @@ function getPluginName(plugin) {
 }
 
 Unexpected.prototype.use = function(plugin) {
+  if (this._frozen) {
+    throw new Error(
+      'Cannot install a plugin into a frozen instance, please run .clone() first'
+    );
+  }
   if (
     (typeof plugin !== 'function' &&
       (typeof plugin !== 'object' ||
@@ -1011,6 +1036,7 @@ function installExpectMethods(unexpected) {
   expect.getType = unexpected.getType;
   expect.clone = unexpected.clone.bind(unexpected);
   expect.child = unexpected.child.bind(unexpected);
+  expect.freeze = unexpected.freeze.bind(unexpected);
   expect.toString = unexpected.toString.bind(unexpected);
   expect.assertions = unexpected.assertions;
   expect.use = expect.installPlugin = unexpected.use.bind(unexpected);
@@ -1648,6 +1674,11 @@ Unexpected.prototype.child = function() {
   return childExpect;
 };
 
+Unexpected.prototype.freeze = function() {
+  this._frozen = true;
+  return this.expect;
+};
+
 Unexpected.prototype.outputFormat = function(format) {
   if (typeof format === 'undefined') {
     return this._outputFormat;


Test patch:
diff --git a/test/api/freeze.spec.js b/test/api/freeze.spec.js
new file mode 100644
index 00000000..553829af
--- /dev/null
+++ b/test/api/freeze.spec.js
@@ -0,0 +1,169 @@
+/*global expect*/
+describe('freeze', function() {
+  it('is chainable', function() {
+    const clonedExpect = expect.clone();
+
+    clonedExpect.freeze()('foo', 'to equal', 'foo');
+  });
+
+  // Debatable? Seems nice for forwards compatibility if we freeze
+  // the default instance in Unexpected 11.
+  it('does not throw if the instance is already frozen', function() {
+    expect
+      .clone()
+      .freeze()
+      .freeze();
+  });
+
+  it('makes .use(...) throw', function() {
+    expect(
+      function() {
+        expect
+          .clone()
+          .freeze()
+          .use(function() {});
+      },
+      'to throw',
+      'Cannot install a plugin into a frozen instance, please run .clone() first'
+    );
+  });
+
+  it('should allow cloning, and the clone should not be frozen', function() {
+    expect
+      .clone()
+      .freeze()
+      .clone()
+      .use(function() {});
+  });
+
+  it('makes .addAssertion(...) throw', function() {
+    expect(
+      function() {
+        expect
+          .clone()
+          .freeze()
+          .addAssertion('<string> to foo', function(expect, subject) {
+            expect(subject, 'to equal', 'foo');
+          });
+      },
+      'to throw',
+      'Cannot add an assertion to a frozen instance, please run .clone() first'
+    );
+  });
+
+  it('makes .addType(...) throw', function() {
+    expect(
+      function() {
+        expect
+          .clone()
+          .freeze()
+          .addType({ name: 'foo', identify: false });
+      },
+      'to throw',
+      'Cannot add a type to a frozen instance, please run .clone() first'
+    );
+  });
+
+  it('makes .addStyle(...) throw', function() {
+    expect(
+      function() {
+        expect
+          .clone()
+          .freeze()
+          .addStyle('smiley', function() {
+            this.red('\u263a');
+          });
+      },
+      'to throw',
+      'Cannot add a style to a frozen instance, please run .clone() first'
+    );
+  });
+
+  it('makes .installTheme(...) throw', function() {
+    expect(
+      function() {
+        expect
+          .clone()
+          .freeze()
+          .installTheme('html', { comment: 'gray' });
+      },
+      'to throw',
+      'Cannot install a theme into a frozen instance, please run .clone() first'
+    );
+  });
+
+  describe('with .child()', function() {
+    it('does not throw', function() {
+      expect
+        .clone()
+        .freeze()
+        .child();
+    });
+
+    it('allows addAssertion', function() {
+      expect
+        .clone()
+        .freeze()
+        .child()
+        .addAssertion('<string> to foo', function(expect, subject) {
+          expect(subject, 'to equal', 'foo');
+        });
+    });
+
+    it('throws on exportAssertion', function() {
+      expect(
+        function() {
+          expect
+            .clone()
+            .freeze()
+            .child()
+            .exportAssertion('<string> to foo', function(expect, subject) {
+              expect(subject, 'to equal', 'foo');
+            });
+        },
+        'to throw',
+        'Cannot add an assertion to a frozen instance, please run .clone() first'
+      );
+    });
+
+    it('throws on exportType', function() {
+      expect(
+        function() {
+          expect
+            .clone()
+            .freeze()
+            .child()
+            .exportType({ name: 'foo', identify: false });
+        },
+        'to throw',
+        'Cannot add a type to a frozen instance, please run .clone() first'
+      );
+    });
+
+    it('allows .addStyle(...)', function() {
+      expect
+        .clone()
+        .freeze()
+        .child()
+        .addStyle('smiley', function() {
+          this.red('\u263a');
+        });
+    });
+
+    it('throws on exportStyle', function() {
+      expect(
+        function() {
+          expect
+            .clone()
+            .freeze()
+            .child()
+            .exportStyle('smiley', function() {
+              this.red('\u263a');
+            });
+        },
+        'to throw',
+        'Cannot add a style to a frozen instance, please run .clone() first'
+      );
+    });
+  });
+});