diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ef3965..e6e2e46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# HEAD + +- **js:** Add optional check for experiment cookie to persist previously seen variations (#93). + # 3.0.0 - **js:** Remove use of cookies (#56). diff --git a/README.md b/README.md index c185cb2..94e3083 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Most of the content experiments on [mozilla.org](https://www.mozilla.org) simply In contrast to third-party options (e.g. [Optimizely](https://www.optimizely.com/)), Traffic Cop offers: 1. **Security** — Many third-party options require loading JS from their site, which is a potential [XSS](https://en.wikipedia.org/wiki/Cross-site_scripting) vector. Traffic Cop can (and should) be served from your site/CDN. -2. **Privacy** - Traffic Cop does not use cookies of any kind (unlike most third-party solutions), nor does it store or send any experiment data itself (that part is up to you and your consent management solution). +2. **Privacy** - Traffic Cop does not set cookies of any kind (unlike most third-party solutions), nor does it store or send any experiment data itself (that part is up to you and your consent management solution). 3. **Performance** — Traffic Cop is light and has zero dependencies, resulting in less than 2KB of JS when minified. (In our experience, Optimizely's JS bundle was regularly above 200KB.) 4. **Your workflow** — Traffic Cop offers great flexibility in when and how you write and load variation code. No need to type jQuery in a text box on a third-party web page. 5. **Savings** — No need to pay for a third-party service. diff --git a/demo/package-lock.json b/demo/package-lock.json index 8e576f7..e60e513 100644 --- a/demo/package-lock.json +++ b/demo/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "MPL-2.0", "dependencies": { + "@mozmeao/cookie-helper": "^1.1.0", "express": "^4.19.2", "nunjucks": "^3.2.4", "path": "^0.12.7", @@ -18,6 +19,11 @@ "nodemon": "^3.1.4" } }, + "node_modules/@mozmeao/cookie-helper": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mozmeao/cookie-helper/-/cookie-helper-1.1.0.tgz", + "integrity": "sha512-9eF1kckwlNw28pJhMcheE0jdMOJXgRnDOYkp0q86I16uoeyR5jhCT7B68EXWW1JFJILsX7tY0snZlAksTCp8hw==" + }, "node_modules/a-sync-waterfall": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/a-sync-waterfall/-/a-sync-waterfall-1.0.1.tgz", @@ -1183,6 +1189,11 @@ } }, "dependencies": { + "@mozmeao/cookie-helper": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mozmeao/cookie-helper/-/cookie-helper-1.1.0.tgz", + "integrity": "sha512-9eF1kckwlNw28pJhMcheE0jdMOJXgRnDOYkp0q86I16uoeyR5jhCT7B68EXWW1JFJILsX7tY0snZlAksTCp8hw==" + }, "a-sync-waterfall": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/a-sync-waterfall/-/a-sync-waterfall-1.0.1.tgz", diff --git a/demo/package.json b/demo/package.json index 2bcadec..b351e35 100644 --- a/demo/package.json +++ b/demo/package.json @@ -9,6 +9,7 @@ "author": "Mozilla", "license": "MPL-2.0", "dependencies": { + "@mozmeao/cookie-helper": "^1.1.0", "express": "^4.19.2", "nunjucks": "^3.2.4", "path": "^0.12.7", diff --git a/demo/src/index.js b/demo/src/index.js index 078da1d..bf27491 100644 --- a/demo/src/index.js +++ b/demo/src/index.js @@ -16,6 +16,7 @@ app.use( '/uncompressed-src', express.static(path.join(__dirname, '/../../src/')) ); +app.use('/libs', express.static(path.join(__dirname, '/../libs/'))); nunjucks.configure('src/views', { autoescape: true, diff --git a/demo/src/libs.js b/demo/src/libs.js new file mode 100644 index 0000000..d5b93c5 --- /dev/null +++ b/demo/src/libs.js @@ -0,0 +1,8 @@ +const CookieHelper = require('@mozmeao/cookie-helper'); + +// create namespace +if (typeof window.Mozilla === 'undefined') { + window.Mozilla = {}; +} + +window.Mozilla.Cookies = CookieHelper; diff --git a/demo/src/public/experiment-page1.js b/demo/src/public/experiment-page1.js index 6f37735..1d0822e 100644 --- a/demo/src/public/experiment-page1.js +++ b/demo/src/public/experiment-page1.js @@ -1,7 +1,25 @@ (function () { 'use strict'; - var eddie = new window.TrafficCop({ + function setVariationCookie(exp) { + // set cookie to expire in 24 hours + var date = new Date(); + date.setTime(date.getTime() + 1 * 24 * 60 * 60 * 1000); + var expires = date.toUTCString(); + + window.Mozilla.Cookies.setItem( + exp.id, + exp.chosenVariation, + expires, + undefined, + undefined, + false, + 'lax' + ); + } + + var cop = new window.TrafficCop({ + id: 'my-experiment-id-1', variations: { 'v=1': 40.5, 'v=2': 20.3, @@ -11,6 +29,7 @@ 'v=6': 0.1 } }); + cop.init(); - eddie.init(); + setVariationCookie(cop); })(); diff --git a/demo/src/public/experiment-page2.js b/demo/src/public/experiment-page2.js index 688eebc..1df1508 100644 --- a/demo/src/public/experiment-page2.js +++ b/demo/src/public/experiment-page2.js @@ -7,6 +7,23 @@ c: 30 }; + function setVariationCookie(exp) { + // set cookie to expire in 24 hours + var date = new Date(); + date.setTime(date.getTime() + 1 * 24 * 60 * 60 * 1000); + var expires = date.toUTCString(); + + window.Mozilla.Cookies.setItem( + exp.id, + exp.chosenVariation, + expires, + undefined, + undefined, + false, + 'lax' + ); + } + function handleVariation(variation) { // wait until DOM is ready to be manipulated... domReady(function () { @@ -28,9 +45,12 @@ } var wiggum = new window.TrafficCop({ + id: 'my-experiment-id-2', customCallback: handleVariation, variations: variants }); wiggum.init(); + + setVariationCookie(wiggum); })(); diff --git a/demo/src/public/experiment-page3-customcallback.js b/demo/src/public/experiment-page3-customcallback.js index b81aa38..fb583dd 100644 --- a/demo/src/public/experiment-page3-customcallback.js +++ b/demo/src/public/experiment-page3-customcallback.js @@ -6,6 +6,23 @@ b: 40 }; + function setVariationCookie(exp) { + // set cookie to expire in 24 hours + var date = new Date(); + date.setTime(date.getTime() + 1 * 24 * 60 * 60 * 1000); + var expires = date.toUTCString(); + + window.Mozilla.Cookies.setItem( + exp.id, + exp.chosenVariation, + expires, + undefined, + undefined, + false, + 'lax' + ); + } + function handleVariation(variation) { if (Object.prototype.hasOwnProperty.call(variants, variation)) { var target = document.getElementById('var-' + variation); @@ -14,9 +31,12 @@ } var wiggum = new window.TrafficCop({ + id: 'my-experiment-id-3', customCallback: handleVariation, variations: variants }); wiggum.init(); + + setVariationCookie(wiggum); })(); diff --git a/demo/src/public/experiment-page3-redirect.js b/demo/src/public/experiment-page3-redirect.js index d16019b..9da67a7 100644 --- a/demo/src/public/experiment-page3-redirect.js +++ b/demo/src/public/experiment-page3-redirect.js @@ -1,7 +1,25 @@ (function () { 'use strict'; + function setVariationCookie(exp) { + // set cookie to expire in 24 hours + var date = new Date(); + date.setTime(date.getTime() + 1 * 24 * 60 * 60 * 1000); + var expires = date.toUTCString(); + + window.Mozilla.Cookies.setItem( + exp.id, + exp.chosenVariation, + expires, + undefined, + undefined, + false, + 'lax' + ); + } + var lou = new window.TrafficCop({ + id: 'my-experiment-id-4', variations: { 'v=a': 40, 'v=b': 40 @@ -9,4 +27,6 @@ }); lou.init(); + + setVariationCookie(lou); })(); diff --git a/demo/src/views/pages/page1.njk b/demo/src/views/pages/page1.njk index 0f63c86..646ae74 100644 --- a/demo/src/views/pages/page1.njk +++ b/demo/src/views/pages/page1.njk @@ -1,6 +1,7 @@ {% extends "../index.njk" %} {% block experiment_js %} + {% endblock %} @@ -61,5 +62,9 @@ this line. If yes, do. I love you all.
+ + {% endblock %} diff --git a/demo/src/views/pages/page2.njk b/demo/src/views/pages/page2.njk index 8ca9e9e..ab6a4be 100644 --- a/demo/src/views/pages/page2.njk +++ b/demo/src/views/pages/page2.njk @@ -1,6 +1,7 @@ {% extends "../index.njk" %} {% block experiment_js %} + {% endblock %} @@ -39,5 +40,9 @@ No. + + {% endblock %} diff --git a/demo/src/views/pages/page3.njk b/demo/src/views/pages/page3.njk index 842de38..1fc0f4d 100644 --- a/demo/src/views/pages/page3.njk +++ b/demo/src/views/pages/page3.njk @@ -1,6 +1,7 @@ {% extends "../index.njk" %} {% block experiment_js %} + {% endblock %} @@ -45,6 +46,10 @@ Herman Blume (customCallback b) + + {% endblock %} diff --git a/documentation.md b/documentation.md index df218e3..9d201ae 100644 --- a/documentation.md +++ b/documentation.md @@ -6,9 +6,7 @@ Traffic Cop places visitors into A/B/x cohorts, and either performs a redirect o After verifying the supplied configuration, Traffic Cop then generates a random number to choose a variation based on the supplied cohort percentages. If the supplied variations do not target 100% of visitors, the `no-variation` value may be chosen. -Traffic Cop does not use cookies to record or store data of any kind. It also does not store, send or transmit any kind of experiment data for analysis. It simply performs the task of displaying different experiment variations upon page load. It is up to you to record experiment data using whatever your standard website analytics tools may be. - -**Note that because Traffic Cop does not use cookies, this does mean that repeat visits to the canonical URL might see a different variation to the one they saw previously during the course of an experiment running.** +Traffic Cop does not set cookies to record or store data of any kind. It also does not store, send or transmit any kind of experiment data for analysis. It simply performs the task of displaying different experiment variations upon page load. It is up to you to record experiment data using whatever your standard website analytics tools may be. Likewise, if you want to set a cookie on redirect to remember which variation a website visitor has seen, it is up to you to handle cookie consent appropriately before doing so. ## Type A: Callback @@ -121,6 +119,29 @@ eddie.init(); In the above example, the test will have 3 variations and will target a total of 23.78% of visitors. There will also be a 76.22% chance that `no-variation` is chosen. +### Remembering which variation a visitor has seen previously. + +If you would like to try and ensure website visitors see the same experiment variation on repeat page visits, you can pass an optional experiment cookie ID to Traffic Cop when initializing an experiment: + +```javascript +var eddie = new TrafficCop({ + id: 'my-experiment-cookie-id', + variations: { + 'v=1': 12.2, + 'v=2': 0.13, + 'v=3': 11.45 + } +}); + +eddie.init(); +``` + +Once Traffic Cop is initialized with an `id`, you can then set a cookie in your website code to store which variation was chosen. Traffic Cop will then check for existence of this cookie before deciding which variation to show on repeat visits. + +The cookie ID should match the value of `eddie.id`, and the cookie value should match the value of `eddie.chosenVariation`. If you need help setting cookies, see [https://github.com/mozmeao/cookie-helper](https://github.com/mozmeao/cookie-helper). + +Note: it is a website's responsibility to check for cookie consent before setting non-essential cookies! + ## Implementation Traffic Cop requires two JavaScript files: diff --git a/eslint.config.js b/eslint.config.js index 528b3e7..7f97d3f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -29,7 +29,17 @@ const rules = { // Disallow the use of `console` // https://eslint.org/docs/rules/no-console - 'no-console': 'error' + 'no-console': 'error', + + // Allow unused vars in caught errors for older + // browsers that don't support optional catch binding + // https://eslint.org/docs/latest/rules/no-unused-vars#options + 'no-unused-vars': [ + 'error', + { + caughtErrors: 'none' + } + ] }; module.exports = [ diff --git a/package-lock.json b/package-lock.json index a15985c..ae393f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "devDependencies": { "@babel/core": "^7.25.2", "@babel/preset-env": "^7.25.3", + "@mozmeao/cookie-helper": "^1.1.0", "babel-loader": "^9.1.3", "clean-webpack-plugin": "^4.0.0", "copy-webpack-plugin": "^12.0.2", @@ -2152,6 +2153,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mozmeao/cookie-helper": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mozmeao/cookie-helper/-/cookie-helper-1.1.0.tgz", + "integrity": "sha512-9eF1kckwlNw28pJhMcheE0jdMOJXgRnDOYkp0q86I16uoeyR5jhCT7B68EXWW1JFJILsX7tY0snZlAksTCp8hw==", + "dev": true + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", diff --git a/package.json b/package.json index d999b35..ae536ff 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "devDependencies": { "@babel/core": "^7.25.2", "@babel/preset-env": "^7.25.3", + "@mozmeao/cookie-helper": "^1.1.0", "babel-loader": "^9.1.3", "clean-webpack-plugin": "^4.0.0", "copy-webpack-plugin": "^12.0.2", diff --git a/src/mozilla-traffic-cop.js b/src/mozilla-traffic-cop.js index 906e9bf..066570d 100644 --- a/src/mozilla-traffic-cop.js +++ b/src/mozilla-traffic-cop.js @@ -8,6 +8,7 @@ * Example usage: * * const cop = new TrafficCop({ + * id: 'my-experiment-cookie-id', * variations: { * 'v=1': 25, * 'v=2': 25, @@ -19,6 +20,8 @@ * * * @param Object config: Object literal containing the following: + * [String] id (optional): Unique string ID for cookie identification. + * Only needs to be unique to other currently running tests. * [Function] customCallback (optional): Arbitrary function to run when * a variation (or lack thereof) is chosen. This function will be * passed the variation value (if chosen), or the value of @@ -39,6 +42,9 @@ const TrafficCop = function (config) { // make sure config is an object this.config = typeof config === 'object' ? config : {}; + // store cookie id + this.id = this.config.id; + // store variations this.variations = this.config.variations; @@ -81,6 +87,7 @@ TrafficCop.prototype.init = function () { if (this.verifyConfig()) { // determine which (if any) variation to choose for this user/experiment this.chosenVariation = TrafficCop.chooseVariation( + this.id, this.variations, this.totalPercentage ); @@ -158,33 +165,81 @@ TrafficCop.isRedirectVariation = function (variations, queryString) { return isVariation; }; +/** + * Get the cookie value for a given ID. + * Hat tip to https://github.com/mozmeao/cookie-helper + * @param {String} cookie id + * @returns {String|null} cookie value + */ +TrafficCop.getCookie = function (id) { + if (typeof id !== 'string') { + return null; + } + + try { + return ( + decodeURIComponent( + document.cookie.replace( + new RegExp( + '(?:(?:^|.*;)\\s*' + + encodeURIComponent(id).replace(/[-.+*]/g, '\\$&') + + '\\s*\\=\\s*([^;]*).*$)|^.*$' + ), + '$1' + ) + ) || null + ); + } catch (e) { + return null; + } +}; + +TrafficCop.hasVariationCookie = function (id, variations) { + const cookie = TrafficCop.getCookie(id); + + if ( + cookie && + (variations[cookie] || cookie === TrafficCop.noVariationValue) + ) { + return true; + } else { + return false; + } +}; + /* * Returns the variation chosen for the current user/experiment. */ -TrafficCop.chooseVariation = function (variations, totalPercentage) { +TrafficCop.chooseVariation = function (id, variations, totalPercentage) { let random; let runningTotal; let choice = TrafficCop.noVariationValue; - // conjure a random float between 1 and 100 (inclusive) - random = Math.floor(Math.random() * 10000) + 1; - random = random / 100; - - // make sure random number falls in the distribution range - if (random <= totalPercentage) { - runningTotal = 0; - - // loop through all variations - for (const v in variations) { - // check if random number falls within current variation range - if (random <= variations[v] + runningTotal) { - // if so, we have found our variation - choice = v; - break; + // check to see if user has a cookie from a previously visited variation + // also make sure variation in cookie is still valid (you never know) + if (TrafficCop.hasVariationCookie(id, variations)) { + choice = TrafficCop.getCookie(id); + } else { + // conjure a random float between 1 and 100 (inclusive) + random = Math.floor(Math.random() * 10000) + 1; + random = random / 100; + + // make sure random number falls in the distribution range + if (random <= totalPercentage) { + runningTotal = 0; + + // loop through all variations + for (const v in variations) { + // check if random number falls within current variation range + if (random <= variations[v] + runningTotal) { + // if so, we have found our variation + choice = v; + break; + } + + // tally variation percentages for the next loop iteration + runningTotal += variations[v]; } - - // tally variation percentages for the next loop iteration - runningTotal += variations[v]; } } diff --git a/tests/test-traffic-cop.js b/tests/test-traffic-cop.js index 2f16a05..5cb200d 100644 --- a/tests/test-traffic-cop.js +++ b/tests/test-traffic-cop.js @@ -6,6 +6,7 @@ /* global sinon */ import TrafficCop from '../dist/index'; +import CookieHelper from '@mozmeao/cookie-helper'; describe('mozilla-traffic-cop.js', function () { 'use strict'; @@ -200,7 +201,7 @@ describe('mozilla-traffic-cop.js', function () { }); }); - describe('TrafficCop.chooseVariation', function () { + describe('TrafficCop.chooseVariation (no cookie)', function () { const cop = new TrafficCop({ variations: { 'v=3': 30, @@ -211,11 +212,19 @@ describe('mozilla-traffic-cop.js', function () { } }); + beforeEach(function () { + spyOn(TrafficCop, 'getCookie').and.returnValue(null); + }); + it('should return noVariationValue if random number is greater than total percentages', function () { // random number >= 80 is greater than percentage total above (75.55) spyOn(window.Math, 'random').and.returnValue(0.7556); expect( - TrafficCop.chooseVariation(cop.variations, cop.totalPercentage) + TrafficCop.chooseVariation( + cop.id, + cop.variations, + cop.totalPercentage + ) ).toEqual(TrafficCop.noVariationValue); }); @@ -223,7 +232,11 @@ describe('mozilla-traffic-cop.js', function () { // first variation is 30%, so 1-30 spyOn(window.Math, 'random').and.returnValue(0.01); expect( - TrafficCop.chooseVariation(cop.variations, cop.totalPercentage) + TrafficCop.chooseVariation( + cop.id, + cop.variations, + cop.totalPercentage + ) ).toEqual('v=3'); }); @@ -231,7 +244,11 @@ describe('mozilla-traffic-cop.js', function () { // first variation is 30%, so 1-30 spyOn(window.Math, 'random').and.returnValue(0.29); expect( - TrafficCop.chooseVariation(cop.variations, cop.totalPercentage) + TrafficCop.chooseVariation( + cop.id, + cop.variations, + cop.totalPercentage + ) ).toEqual('v=3'); }); @@ -239,7 +256,11 @@ describe('mozilla-traffic-cop.js', function () { // second variation is 20%, so 31-50 spyOn(window.Math, 'random').and.returnValue(0.3); expect( - TrafficCop.chooseVariation(cop.variations, cop.totalPercentage) + TrafficCop.chooseVariation( + cop.id, + cop.variations, + cop.totalPercentage + ) ).toEqual('v=1'); }); @@ -247,7 +268,11 @@ describe('mozilla-traffic-cop.js', function () { // second variation is 20%, so 31-50 spyOn(window.Math, 'random').and.returnValue(0.49); expect( - TrafficCop.chooseVariation(cop.variations, cop.totalPercentage) + TrafficCop.chooseVariation( + cop.id, + cop.variations, + cop.totalPercentage + ) ).toEqual('v=1'); }); @@ -255,7 +280,11 @@ describe('mozilla-traffic-cop.js', function () { // third variation is 25.25%, so 51-75.25 spyOn(window.Math, 'random').and.returnValue(0.5); expect( - TrafficCop.chooseVariation(cop.variations, cop.totalPercentage) + TrafficCop.chooseVariation( + cop.id, + cop.variations, + cop.totalPercentage + ) ).toEqual('v=2'); }); @@ -263,7 +292,11 @@ describe('mozilla-traffic-cop.js', function () { // third variation is 25.25%, so 51-75.25 spyOn(window.Math, 'random').and.returnValue(0.7525); expect( - TrafficCop.chooseVariation(cop.variations, cop.totalPercentage) + TrafficCop.chooseVariation( + cop.id, + cop.variations, + cop.totalPercentage + ) ).toEqual('v=2'); }); @@ -271,7 +304,11 @@ describe('mozilla-traffic-cop.js', function () { // fourth variation is 0.2%, so 75.26-75.45 spyOn(window.Math, 'random').and.returnValue(0.7526); expect( - TrafficCop.chooseVariation(cop.variations, cop.totalPercentage) + TrafficCop.chooseVariation( + cop.id, + cop.variations, + cop.totalPercentage + ) ).toEqual('v=4'); }); @@ -279,7 +316,11 @@ describe('mozilla-traffic-cop.js', function () { // fourth variation is 0.2%, so 75.26-75.45 spyOn(window.Math, 'random').and.returnValue(0.7545); expect( - TrafficCop.chooseVariation(cop.variations, cop.totalPercentage) + TrafficCop.chooseVariation( + cop.id, + cop.variations, + cop.totalPercentage + ) ).toEqual('v=4'); }); @@ -287,11 +328,77 @@ describe('mozilla-traffic-cop.js', function () { // fifth variation is 0.1%, so 75.46 spyOn(window.Math, 'random').and.returnValue(0.7546); expect( - TrafficCop.chooseVariation(cop.variations, cop.totalPercentage) + TrafficCop.chooseVariation( + cop.id, + cop.variations, + cop.totalPercentage + ) ).toEqual('v=5'); }); }); + describe('TrafficCop.chooseVariation (cookie)', function () { + const cop = new TrafficCop({ + id: 'my-experiment-cookie-id', + variations: { + 'v=1': 20, + 'v=2': 25.25, + 'v=3': 30 + } + }); + + it('should return previously seen variation if an experiment cookie exists', function () { + spyOn(TrafficCop, 'getCookie').and.returnValue('v=3'); + expect( + TrafficCop.chooseVariation( + cop.id, + cop.variations, + cop.totalPercentage + ) + ).toEqual('v=3'); + }); + + it('should return no-variation if an experiment cookie exists', function () { + spyOn(TrafficCop, 'getCookie').and.returnValue( + TrafficCop.noVariationValue + ); + expect( + TrafficCop.chooseVariation( + cop.id, + cop.variations, + cop.totalPercentage + ) + ).toEqual(TrafficCop.noVariationValue); + }); + }); + + describe('TrafficCop.getCookie', function () { + const cookieId = 'test-cookie'; + var date = new Date(); + date.setHours(date.getHours() + 48); + + beforeEach(function () { + document.cookie = ''; // clear cookies + CookieHelper.setItem(cookieId, 'test', date, '/'); + }); + + afterEach(function () { + document.cookie = ''; // clear cookies + }); + + it('should return the value of the cookie that is passed to the getItem method', function () { + expect(TrafficCop.getCookie(cookieId)).toBe('test'); + }); + + it('should return null if no cookie with that name is found', function () { + expect(TrafficCop.getCookie('oatmeal-raisin')).toBeNull(); + }); + + it('should return null if no id is passed', function () { + expect(TrafficCop.getCookie()).toBeNull(); + }); + }); + describe('TrafficCop.generateRedirectUrl', function () { it('should generate a redirect retaining the original querystring when present', function () { expect(