├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── client.js ├── collection.js ├── domready.js ├── examples └── static-pages │ ├── .gitignore │ ├── README.md │ ├── client.js │ ├── components │ ├── navigation.jsx │ ├── not-found.jsx │ └── page.jsx │ ├── config │ ├── api-browser.js │ └── api.js │ ├── options.js │ ├── package.json │ ├── public │ ├── css │ │ ├── bootstrap.min.css │ │ └── styles.css │ ├── favicon.ico │ ├── index.html │ ├── js │ │ └── .gitkeep │ └── pages │ │ ├── client.json │ │ ├── index.json │ │ ├── routes.json │ │ ├── server.json │ │ └── stores.json │ ├── routes.js │ ├── server.js │ └── stores │ └── page.js ├── index.js ├── model.js ├── package.json ├── server.js ├── src ├── client.js ├── collection.js ├── domready.js ├── model.js ├── server-utils.js ├── server.js ├── store.js ├── store │ └── create-cache-key.js ├── utils.js └── validate-options.js ├── store.js └── test ├── client ├── client.js ├── index.html └── vendor │ ├── mocha.css │ ├── mocha.js │ ├── npo.js │ └── should.js └── server ├── constructor.js ├── public └── index.html ├── server.js ├── store.js └── utils.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | test/client/cerebellum_test_build.js 4 | TODO 5 | npm-debug.log 6 | lib 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | examples 2 | test 3 | src 4 | TODO 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## master 4 | 5 | ## Breaking changes 6 | 7 | - Changed store's event signature, from now on store events will be returned with signature `event, error, options`. 8 | Options will always include `store` key that has the related storeId as value. 9 | This change will make listening changes much easier as you can now re-render by just listening `expire` instead of adding separate `expire:storeId` listeners for all stores. 10 | Events are still dispatched with previous signature `event:storeId, error, extra` but that format will be removed in the future. 11 | 12 | ## Other changes 13 | 14 | - Fixed array merging in store's fetch 15 | 16 | ## Version `0.10.0` 17 | 18 | ## Breaking changes 19 | 20 | - Dropped immstruct, Cerebellum's store now uses vanilla Immutable.js 21 | 22 | ## Other changes 23 | 24 | - Added `identifier` option, by default Store assumes that `id` field defines the identity of model. It's currently only used in Store's fetch when merging changes. 25 | - Store's fetch now properly merges Immutable.Lists, only changed items will be re-rendered when using pure render mixin with React. 26 | 27 | ## Version `0.9.0` 28 | 29 | ### Breaking changes 30 | 31 | - Client's render now has a matching signature to server's render, they both 32 | call the given render method with document, options & params/query. 33 | 34 | ## Version `0.8.0` 35 | 36 | ### Breaking changes 37 | 38 | - Store's dispatch now returns promise, allows for easy error handling in view components 39 | - Store's `trigger` is now deprecated, use `dispatch` instead. 40 | 41 | ### Other changes 42 | 43 | - You can now define entry .html files per route pattern, e.g. /admin can use admin.html 44 | 45 | ## Version `0.7.0` 46 | 47 | ### Breaking changes 48 | - Store's state is now held in [immstruct](https://github.com/omniscientjs/immstruct) 49 | - Store's autoToJSON option was deprecated, all stores are now automatically converted to JSON for immstruct 50 | - Store's autoClearCaches option is now enabled by default 51 | - Store now marks caches as stale instead of immediately expiring them 52 | - Store now performs optimistic creates, updates & deletes and automatically rolls back in case of API errors 53 | 54 | ### Other changes 55 | - Store now tracks ongoing fetch requests to prevent multiple identical API calls 56 | - Collection & model are now exported as standalone modules 57 | - Server's fallback error handler now prints the stack trace 58 | - Now written in ES6, build with `npm run build` & watch changes with `npm run watch` 59 | - Fixed client side tests 60 | 61 | ## Version `0.6.0` 62 | 63 | - Now uses [vertebrae](https://www.npmjs.com/package/vertebrae) instead of exoskeleton. 64 | - Moved main modules from lib/ to root. It's now easier to `require('cerebellum/server')` 65 | 66 | ## Version `0.5.0` 67 | 68 | - Easier cache clearing 69 | - Added **relatedCaches** 70 | - Added query string as last argument for route handlers 71 | - Route context can also be a promise that resolves with object 72 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Lari Hoppula, SC5 Online 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Cerebellum.js 2 | 3 | [![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/SC5/cerebellum?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 4 | 5 | Cerebellum.js is a powerful set of tools that help you structure your isomorphic apps, just add your preferred view engine. Cerebellum works great in conjunction with [React](http://facebook.github.io/react/). 6 | 7 | Cerebellum is designed for single-page apps that need search engine visibility. Same code works on server and client. 8 | 9 | [Introductory blog post, published on February 5, 2015.](http://sc5.io/posts/cerebellum-js-uncomplicated-isomorphic-javascript-apps) 10 | 11 | [Cerebellum React helpers](https://github.com/hoppula/cerebellum-react) 12 | 13 | ### What does it do? 14 | 15 | * Fully shared GET routes between server and client 16 | * Fully shared data stores between server and client, uses [Vertebrae's](https://github.com/hoppula/vertebrae/) Collection & Model with [Axios](https://github.com/mzabriskie/axios) adapter, so you can use the same REST APIs everywhere. 17 | * Stores the server state snapshot to JSON. Browser client will automatically bootstrap from snapshot, you don't need to do any extra requests on client side. 18 | * Uses [express.js](http://expressjs.com/) router on server and [page.js](https://github.com/visionmedia/page.js) router on browser. Both use same [route format](https://github.com/pillarjs/path-to-regexp), so you can use named parameters, optional parameters and regular expressions in your routes. 19 | * Data flows from models/collections to views and views can dispatch changes with change events. All rendering happens through router. 20 | * Automatic SEO, no hacks needed for server side rendering. 21 | * You can easily make apps that work even when JavaScript is disabled in browser 22 | * Fast initial load for mobile clients, browser bootstraps from server state and continues as a single-page app without any extra configuration. 23 | * Store state is maintained in [Immutable.js](http://facebook.github.io/immutable-js/) 24 | * Can be used with any framework that provides rendering for both server and client. [React.js](http://facebook.github.io/react/) recommended, see [examples/static-pages](https://github.com/SC5/cerebellum/tree/master/examples/static-pages). 25 | 26 | ## Data flow 27 | 28 | Cerebellum's data flow is similar to [Flux architecture](https://facebook.github.io/flux/) in many ways, but it has some key differences. 29 | 30 | Diagram below shows the data flow for client side. Server side is identical, except that there are naturally no interaction triggered updates (green arrows). 31 | 32 |   33 | 34 | ![Cerebellum data flow](http://i.imgur.com/fuxe9Sw.png "Cerebellum data flow") 35 | 36 |   37 | 38 | In a nutshell, route handler asks stores for data and renders a view with the response. 39 | 40 | When you want to change things, you send a change event to central Store instance. Store will perform the API call and trigger a success event when it's ready. You can then act on that event by invoking a route handler again. 41 | 42 | **All rendering happens through route handlers in Cerebellum.** 43 | 44 | ### Server and client data flow example 45 | 46 | 1) User requests a page at **/posts/1** 47 | 48 | 2) Server or client will ask from router to check for a route that matches "/posts/1". 49 | 50 | 2) Router finds a matching route handler at "/posts/:id" and queries Store's post store (which is a model) with parameter `{id: id}`. 51 | 52 | 3) Store will either invoke `GET /api/posts/1` call or use cached post data (if available). 53 | 54 | 4) Store returns post data to route handler 55 | 56 | 5) Route handler passes data to Post view component 57 | 58 | 6) Server or client renders the returned view component 59 | 60 | ### Triggering changes with client side change events (green arrows) 61 | 62 | Views can trigger change events (`create`, `update`, `delete` or `expire`) with Store's `dispatch` method. Store delegates change event to corresponding store and invokes API request. When request is completed, Store triggers completion event (**create:storeId**, **update:storeName**, **delete:storeName** or **expire:storeName**). 63 | 64 | Client can listen for these events. In store event callbacks you can clear caches and re-render current route (or invoke another route handler). There's also an option to automatically clear caches for stores. 65 | 66 | ### Change events example 67 | 68 | Let's say that reader wants to add a comment to a blog post. We want to persist that comment to server and re-render the blog post. 69 | 70 | 1) When our avid reader clicks "Send comment" button, view triggers change event with 71 | ```javascript 72 | store.dispatch("create", "comments", {id: this.props.id}, {name: "Hater", comment: "This example sucks!"}) 73 | ``` 74 | 75 | 2) Store makes a API call `POST /api/posts/1/comments` to create a new comment with our data 76 | 77 | 3) Store automatically clears the client side cache for this particular post's comments and triggers `create:comments` event 78 | 79 | 4) We have a event handler in `client.js` which invokes the **/posts/1** route handler 80 | 81 | ```javascript 82 | client.store.on("create:comments", function(err, data) { 83 | // document.location.pathname is the current url, 84 | // you could also use "/posts/" + data.options.id; 85 | client.router.replace(document.location.pathname); 86 | }); 87 | ``` 88 | 5) Route handler re-fetches comments collection for this post as its cache was cleared 89 | 90 | 6) When comments collection has been fetched, the blog post gets re-rendered with the new comment 91 | 92 | ## Store 93 | 94 | Store is responsible for handling all data operations in Cerebellum. It receives change events for individual stores, performs changes and notifies client by triggering success events. 95 | 96 | You register your collections and models (stores) to Store by passing them to server and client constructors in **options.stores** (see **"Stores (stores.js)"** section below for more details). 97 | 98 | Store will automatically snapshot its state on server and client will bootstrap Store from that state. Client will also cache all additional API requests, but you can easily clear caches when fresh data is needed. 99 | 100 | ### Models and Collections are immutable 101 | 102 | All your stores are read only, state is stored in [Immutable.js](http://facebook.github.io/immutable-js/). All mutations are handled by Store with **create**, **update**, **delete** and **expire** events. 103 | 104 | ### Fetching data inside routes 105 | 106 | You can retrieve data from a store using **fetch(storeId, options)**. Route's **this** context includes Store instance (**this.store**) that is used for all data retrieval. 107 | 108 | When fetching collections, you don't usually need any parameters, so you can do: 109 | 110 | ```javascript 111 | this.store.fetch("posts").then(...); 112 | ``` 113 | 114 | If your store needs to fetch dynamic data (models usually do), pass options to **fetch** as second parameter. For example, if you need to fetch model by id, your options would be `{id: id}`. 115 | 116 | ```javascript 117 | this.store.fetch("post", {id: id}).then(...); 118 | ``` 119 | 120 | You can also fetch multiple stores at once with **fetchAll**: 121 | 122 | ```javascript 123 | this.store.fetchAll({"post": {id: id}, "comments": {id: id}}).then(...); 124 | ``` 125 | 126 | **fetch** returns [Immutable.js Cursors](https://github.com/facebook/immutable-js/tree/master/contrib/cursor). If you're using React, [Omniscient's shouldUpdate mixin](https://github.com/omniscientjs/omniscient/blob/master/shouldupdate.js) works great with these cursors. 127 | 128 | ### Caches and cacheKeys 129 | 130 | Store will populate its internal cache when calling **fetch()**. So when you request same data in different route on client, Store will return the cached data. 131 | 132 | Your models and collections can have `cacheKey` method, it defines the path where data will be cached in Store's cache. Store will automatically generate the `cacheKey` for collections and models if you don't provide one. Note that the model needs to be fetched with `id` parameter for automatic `cacheKey` generation. 133 | 134 | If you want to use **fetch** options as part of `cacheKey`, you can access them using `this.storeOptions`. 135 | 136 | ```javascript 137 | var Model = require('cerebellum').Model; 138 | 139 | var Post = Model.extend({ 140 | cacheKey: function() { 141 | return "posts/" + this.storeOptions.id; 142 | }, 143 | url: function() { 144 | return "/posts/" + this.storeOptions.id + ".json"; 145 | } 146 | }); 147 | ``` 148 | 149 | ### Triggering changes 150 | 151 | Pass router's store instance to your view components and 152 | call `store.dispatch` with **create**, **update**, **delete** or **expire**. 153 | 154 | For example, you would create a new post to "posts" collection by calling: 155 | 156 | ```javascript 157 | store.dispatch("create", "posts", {title: "New post", body: "Body text"}); 158 | ``` 159 | 160 | You can update a model with: 161 | 162 | ```javascript 163 | store.dispatch("update", "post", {id: id}, { 164 | title: "New post", 165 | body: "New body text" 166 | }); 167 | ``` 168 | 169 | Store will then execute the API call to url defined in given store and fire an appropriate callback when it finishes. 170 | 171 | ### Expiring caches and re-rendering routes 172 | 173 | You can listen for store events in your client.js. Make sure to wait for client's initialize callback to finish before placing event handlers. 174 | 175 | ```javascript 176 | options.initialize = function(client) { 177 | var store = client.store; 178 | var router = client.router; 179 | store.on("create:posts", function(err, data) { 180 | console.log(data.store) // => posts 181 | console.log(data.result); // => {id: 3423, title: "New post", body: "Body text"} 182 | router("/posts"); // navigate to posts index, will re-fetch posts from API as cache was automatically cleared 183 | }); 184 | 185 | store.on("update:post", function(err, data) { 186 | // explicitly clear posts collection in addition to automatically cleared post model 187 | // you could also handle this in post model with relatedCaches method 188 | store.clearCache("posts"); 189 | // re-render route, posts collection & post model will be re-fetched 190 | router.replace("/posts/" + data.options.id); 191 | }); 192 | 193 | }; 194 | 195 | cerebellum.client(options); 196 | ``` 197 | 198 | ## Options 199 | 200 | Options below are processed by both server.js & client.js, it usually makes sense to create a shared `options.js` for these shared options. 201 | Server and Client specific options are documented in their respective sections in documentation. 202 | 203 | ### Options (options.js) 204 | 205 | example `options.js` with default values, these options are shared with client & server constructors. 206 | 207 | ```javascript 208 | var stores = require('./stores'); 209 | var routes = require('./routes'); 210 | 211 | module.exports = { 212 | routes: routes, 213 | storeId: "store_state_from_server", 214 | stores: stores, 215 | initStore: true 216 | }; 217 | ``` 218 | 219 | #### routes 220 | 221 | Object of route paths and route handlers. Best practice is to put these to their own file instead of bloating options.js, see **"Routes (routes.js)"** documentation below. 222 | 223 | #### storeId 224 | 225 | DOM ID in index.html where server stores the JSON snapshot that client will use for bootstrapping Store. 226 | 227 | #### stores 228 | 229 | Object containining store ids and stores. Best practice is to put these to their own file as well, see **"Stores (stores.js)"** documentation below. 230 | 231 | #### initStore 232 | 233 | Initialize store for route handlers (**this.store**). Defaults to true. Disable if you want to perform the data retrieval elsewhere. For example, when using framework like [Omniscient](https://github.com/omniscientjs/omniscient) you would perform the data fetching in server.js & client.js and pass cursor to immutable data structure in routeContext. 234 | 235 | #### routeHandler 236 | 237 | Method that decides how route handlers are being called. Default behaviour is that route handler gets applied with route params. With React, you can use `cerebellum-react/route-handler`. 238 | 239 | 240 | ### Routes (routes.js) 241 | 242 | Example `routes.js` 243 | 244 | ```javascript 245 | var Index = React.createFactory(require('./components/index')); 246 | var Post = React.createFactory(require('./components/post')); 247 | 248 | module.exports = { 249 | '/': function() { 250 | return this.store.fetch("posts").then(function(posts) { 251 | return {title: "Front page", component: Index({posts: posts})}; 252 | }); 253 | }, 254 | '/posts/:id': function(id) { 255 | return this.store.fetch("post", {id: id}).then(function(post) { 256 | return {title: post.get("title"), component: Post({post: post})}; 257 | }); 258 | } 259 | }; 260 | ``` 261 | 262 | Your routes will get picked by **client.js** and **server.js** and generate exactly same response in both environments (provided you implement your **render** functions in that manner). 263 | 264 | Your route handlers can return either promises or strings, cerebellum will handle both use cases. 265 | 266 | In route handler's **this** scope you have **this.store** which is the reference to Store instance. It contains all your stores and **fetch** for getting the data. 267 | 268 | On the server Store is initialized for every request and on the client it's created only once, in the application's initialization phase. 269 | 270 | Server serializes all Store content to a JSON snapshot at the end of a request and client then deserializes that JSON and bootstraps itself. 271 | 272 | ### Stores (stores.js) 273 | 274 | Example `stores.js` 275 | 276 | ```javascript 277 | var PostsCollection = require('./stores/posts'); 278 | var AuthorModel = require('stores/author'); 279 | 280 | module.exports = { 281 | posts: PostsCollection, 282 | author: AuthorModel 283 | }; 284 | ``` 285 | 286 | Return an object of store ids and stores. These will be registered to be used with Store. 287 | 288 | ### Server (server.js) 289 | 290 | Server is responsible for rendering the first page for the user. Under the hood server creates an express.js app and server constructor returns reference to that express app instance. 291 | 292 | Server is initialized by calling: 293 | 294 | ```javascript 295 | var app = cerebellum.server(options, routeContext); 296 | ``` 297 | 298 | If you want to customize route handler's **this** context, pass your own context as **routeContext**. routeContext must be an object or a promise that resolves with an object. Cerebellum will automatically add **store** to the context. If you don't want this, use the **initStore: false** option. 299 | 300 | See **"Options (options.js)"** section for shared options **(routes, storeId, stores ...)**, options below are server only. 301 | 302 | #### options.render(document, options={}, request={params: {}, query: {}}) 303 | 304 | Route handler will call server's render with document, its options and request object. document is a cheerio instance containing the `index.html` content. 305 | 306 | Render method is invoked with route handler's this context. 307 | 308 | Render method can return either a string or a promise resolving with string. 309 | 310 | Example server render function: 311 | 312 | ```javascript 313 | options.render = function(document, options) { 314 | document("title").html(options.title); 315 | document("#app").html(React.renderToString(options.component)); 316 | return document.html(); 317 | } 318 | ``` 319 | 320 | #### options.staticFiles 321 | 322 | Path to static files, `index.html` will be served from there. 323 | 324 | ```javascript 325 | options.staticFiles = __dirname + "/public" 326 | ``` 327 | 328 | #### options.middleware 329 | 330 | Array of middleware functions, each of them will be passed to express.use(). 331 | You can also include array with route & function. 332 | 333 | ```javascript 334 | var compress = require('compression'); 335 | var auth = require('./lib/auth'); 336 | options.middleware = [ 337 | compress(), 338 | ["/admin", auth()] 339 | ]; 340 | ``` 341 | 342 | #### options.entries 343 | 344 | You can define entry files per route pattern, e.g. you want to include different .js bundle & .css in admin section. 345 | If entries option is not defined, server will default to `path.join(options.staticFiles, "index.html")`. 346 | If routes object is empty or any route pattern does not match, server will default to `path.join(options.entries.path, "index.html")`. 347 | 348 | ```javascript 349 | options.entries = { 350 | path: "assets/entries", 351 | routes: { 352 | "/admin": "admin.html" 353 | } 354 | }; 355 | ``` 356 | 357 | #### useStatic 358 | 359 | Instance method for cerebellum.server instance. Registers express.js static file handling, you usually want to call this after executing cerebellum.server constructor, so Cerebellum routes take precedence over static files. 360 | 361 | ```javascript 362 | var app = cerebellum.server(options); 363 | app.useStatic(); 364 | ``` 365 | 366 | ### Client (client.js) 367 | 368 | Client is responsible for managing the application after getting the initial state from server. 369 | 370 | Client is initialized by calling: 371 | 372 | ```javascript 373 | cerebellum.client(options, routeContext); 374 | ``` 375 | 376 | If you want to customize route handler's **this** context, pass your own context as **routeContext**. routeContext must be an object or a promise that resolves with an object. Cerebellum will automatically add **store** to the context. If you don't want this, use the **initStore: false** option. 377 | 378 | See **"Options (options.js)"** section for shared options **(routes, storeId, stores ...)**, options below are client only. 379 | 380 | #### options.render(options={}, request={params:{}, query:{}}) 381 | 382 | Route handler will call client's render with its options and request object when it gets resolved. 383 | 384 | Render method is invoked with route handler's this context. 385 | 386 | ```javascript 387 | options.render = function(options) { 388 | document.getElementsByTagName("title")[0].innerHTML = options.title; 389 | return React.render(options.component, document.getElementById("app")); 390 | }; 391 | ``` 392 | 393 | #### options.initialize(client) 394 | 395 | This callback will be executed after client bootstrap is done. 396 | Returns client object with **router** and **store** instances. 397 | 398 | You can listen for store events, expire store caches and render routes here. 399 | 400 | ```javascript 401 | options.initialize = function(client) { 402 | React.initializeTouchEvents(true); 403 | }; 404 | ``` 405 | 406 | #### options.autoClearCaches 407 | 408 | With this option Store will automatically clear cache for matching cacheKey after **create**, **update** or **delete**. Defaults to true. 409 | 410 | ```javascript 411 | options.autoClearCaches = true; 412 | ``` 413 | 414 | #### options.instantResolve 415 | 416 | With instantResolve you can make the **fetch** promises to resolve immediately with empty data. When **fetch** calls actually finish, they will fire **fetch:storeId** events that you can use to re-render the routes. This is really useful when you want to render the view skeleton immediately and show some loading spinners while the data retrieval is ongoing. instantResolve will only affect client side **fetch** calls, it has no effect on server side. 417 | 418 | ```javascript 419 | options.instantResolve = true; 420 | ``` 421 | 422 | ## Models & Collections 423 | 424 | Cerebellum comes with models & collections from CommonJS version of Backbone, [Vertebrae](https://www.npmjs.com/package/vertebrae). 425 | However, you could roll your own implementations as Cerebellum's Store has no dependencies to any model or collection libraries. 426 | 427 | ### Model options 428 | 429 | [Model documentation for Backbone](http://backbonejs.org/#Model) applies to Cerebellum models, but there are some extra options that can be utilized. 430 | 431 | #### cacheKey 432 | 433 | Optional property or method, should return the cache key for model. This will be generated automatically if not provided (you must provide storeOptions.id for automatic generation). 434 | 435 | ```javascript 436 | cacheKey: function() { 437 | return "myCustomPrefix:"+this.storeOptions.id; // defaults to this.storeOptions.id 438 | } 439 | ``` 440 | 441 | #### relatedCaches 442 | 443 | With **relatedCaches** method you can define additional cache sweeps that happen when model's cache gets cleared. 444 | 445 | ```javascript 446 | relatedCaches: function() { 447 | return {"comments": this.storeOptions.id}; 448 | } 449 | ``` 450 | 451 | ### Collection options 452 | 453 | [Collection documentation for Backbone](http://backbonejs.org/#Collection) applies to Cerebellum collections, but the are some extra options that can be utilized. 454 | 455 | #### cacheKey 456 | 457 | Optional property or method, should return the cache key for collection. This will be generated automatically if not provided. 458 | 459 | ```javascript 460 | cacheKey: function() { 461 | return "myCustomPrefix:"+this.storeOptions.id; // defaults to this.storeOptions.id 462 | } 463 | ``` 464 | 465 | #### relatedCaches 466 | 467 | With **relatedCaches** method you can define additional cache sweeps that happen when collection's cache gets cleared. 468 | 469 | ```javascript 470 | relatedCaches: function() { 471 | return {"posts": "/"}; 472 | } 473 | ``` 474 | 475 | ## Usage with React 476 | 477 | Cerebellum works best with [React](http://facebook.github.io/react/). 478 | 479 | React makes server side rendering easy with **React.renderToString** and it can easily initialize client from server state. All code examples in this documentation use React for view generation. 480 | 481 | ## Running tests 482 | 483 | Start test server for client tests 484 | 485 | npm start 486 | 487 | Running client tests (requires test server to be up and running) 488 | 489 | npm run test_client 490 | 491 | Running server tests 492 | 493 | npm run test_server 494 | 495 | Running all tests (server & client) 496 | 497 | npm run test 498 | 499 | ## Browser support 500 | 501 | Internet Explorer 9 and newer, uses ES5 and needs pushState. 502 | 503 | ## Apps using Cerebellum 504 | 505 | ### [urls](https://github.com/SC5/cerebellum-urls) 506 | Sample app for saving & tagging urls, demonstrates CRUD & authorization 507 | 508 | ### [Cereboard](https://github.com/hoppula/cereboard) 509 | Sample app on how to declare needed data directly in view components and keep the router clear of store.fetch calls. 510 | 511 | ### [LiigaOpas](http://liiga.pw) 512 | Stats site for Finnish hockey league (Liiga) 513 | 514 | Source available at: [https://github.com/hoppula/liiga](https://github.com/hoppula/liiga) 515 | 516 | ## License 517 | MIT, see LICENSE. 518 | 519 | Copyright (c) 2014-2015 Lari Hoppula, [SC5 Online](http://sc5.io) 520 | -------------------------------------------------------------------------------- /client.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/client'); -------------------------------------------------------------------------------- /collection.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/collection'); -------------------------------------------------------------------------------- /domready.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/domready'); -------------------------------------------------------------------------------- /examples/static-pages/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | public/js -------------------------------------------------------------------------------- /examples/static-pages/README.md: -------------------------------------------------------------------------------- 1 | # Static pages with cerebellum 2 | 3 | Example implementation of static pages single-page app with cerebellum & React.js. 4 | 5 | Acts as a single-page app after initial bootstrap from server, all routes are shared between server & client. 6 | 7 | As server will always render the same thing as client and all the links are regular anchors, pages will work even with JavaScript disabled, try it :) 8 | 9 | ## Initial steps 10 | Install dependencies: 11 | 12 | npm install 13 | 14 | Build: 15 | 16 | npm run build 17 | 18 | Start (& reload server with nodemon on changes): 19 | 20 | npm start 21 | 22 | Watch for changes (and rebuild public/js/app.js with source maps): 23 | 24 | npm run watch -------------------------------------------------------------------------------- /examples/static-pages/client.js: -------------------------------------------------------------------------------- 1 | var React = require('react/addons'); 2 | var cerebellum = require('../../index'); 3 | var options = require('./options'); 4 | 5 | options.render = function render(opts) { 6 | if (opts == null) { 7 | opts = {}; 8 | } 9 | window.scrollTo(0, 0); 10 | document.getElementsByTagName("title")[0].innerHTML = opts.title; 11 | React.render(opts.component, document.getElementById(options.appId)); 12 | }; 13 | 14 | options.initialize = function(client) { 15 | React.initializeTouchEvents(true); 16 | }; 17 | 18 | var app = new cerebellum.client(options); -------------------------------------------------------------------------------- /examples/static-pages/components/navigation.jsx: -------------------------------------------------------------------------------- 1 | var React = require('react/addons'); 2 | var Navbar = require('react-bootstrap/Navbar'); 3 | var NavItem = require('react-bootstrap/NavItem'); 4 | var Nav = require('react-bootstrap/Nav'); 5 | 6 | var Navigation = React.createClass({ 7 | render: function() { 8 | var brand = Cerebellum.js; 9 | return ( 10 | 11 | 17 | 18 | ); 19 | } 20 | }); 21 | 22 | module.exports = Navigation; 23 | -------------------------------------------------------------------------------- /examples/static-pages/components/not-found.jsx: -------------------------------------------------------------------------------- 1 | var React = require('react/addons'); 2 | var Jumbotron = require('react-bootstrap/Jumbotron'); 3 | var Panel = require('react-bootstrap/Panel'); 4 | var Navigation = require('./navigation.jsx'); 5 | 6 | var NotFound = React.createClass({ 7 | render: function() { 8 | var content = this.props.content; 9 | return ( 10 |
11 | 12 | 13 | 14 |

{this.props.title}

15 |
16 | 17 | 18 | {content} 19 | 20 |
21 | ); 22 | } 23 | }); 24 | 25 | module.exports = NotFound; -------------------------------------------------------------------------------- /examples/static-pages/components/page.jsx: -------------------------------------------------------------------------------- 1 | var React = require('react/addons'); 2 | var Jumbotron = require('react-bootstrap/Jumbotron'); 3 | var Panel = require('react-bootstrap/Panel'); 4 | var Navigation = require('./navigation.jsx'); 5 | 6 | var Page = React.createClass({ 7 | render: function() { 8 | var content = this.props.content; 9 | return ( 10 |
11 | 12 | 13 | 14 |

{this.props.title}

15 |

{content.get("subTitle")}

16 |
17 | 18 | 19 | {content.get("body")} 20 | 21 |
22 | ); 23 | } 24 | }); 25 | 26 | module.exports = Page; -------------------------------------------------------------------------------- /examples/static-pages/config/api-browser.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | url: document.location.origin 3 | }; -------------------------------------------------------------------------------- /examples/static-pages/config/api.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | url: "http://localhost:"+ Number(process.env.PORT || 4000) 3 | }; -------------------------------------------------------------------------------- /examples/static-pages/options.js: -------------------------------------------------------------------------------- 1 | var routes = require('./routes'); 2 | var page = require('./stores/page'); 3 | 4 | module.exports = { 5 | staticFiles: __dirname+"/public", 6 | storeId: "store_state_from_server", 7 | appId: "app", 8 | routes: routes, // shared routes required from routes.js 9 | stores: { 10 | page: page 11 | } 12 | }; -------------------------------------------------------------------------------- /examples/static-pages/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "static-pages", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "NODE_ENV=production browserify client.js -u jquery -u underscore -t reactify -t envify -o public/js/app.js", 8 | "build_min": "uglifyjs public/js/app.js -o public/js/app.min.js", 9 | "watch": "watchify client.js -u jquery -u underscore -v -d -t reactify -o public/js/app.js", 10 | "start": "nodemon server.js" 11 | }, 12 | "author": "", 13 | "license": "MIT", 14 | "dependencies": { 15 | "react": "^0.12.1", 16 | "react-bootstrap": "^0.13.0", 17 | "serve-favicon": "^2.1.7" 18 | }, 19 | "devDependencies": { 20 | "browserify": "^6.3.3", 21 | "compression": "^1.2.1", 22 | "envify": "^3.2.0", 23 | "native-promise-only": "^0.7.6-a", 24 | "node-jsx": "^0.12.4", 25 | "nodemon": "^1.2.1", 26 | "reactify": "^0.17.1", 27 | "uglify-js": "^2.4.15", 28 | "watchify": "^2.1.1" 29 | }, 30 | "browser": { 31 | "./config/api.js": "./config/api-browser.js" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /examples/static-pages/public/css/styles.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SC5/cerebellum/6e4747f3031fd325663fa302d8f80290d4afc25f/examples/static-pages/public/css/styles.css -------------------------------------------------------------------------------- /examples/static-pages/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SC5/cerebellum/6e4747f3031fd325663fa302d8f80290d4afc25f/examples/static-pages/public/favicon.ico -------------------------------------------------------------------------------- /examples/static-pages/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Cerebellum static pages example 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /examples/static-pages/public/js/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SC5/cerebellum/6e4747f3031fd325663fa302d8f80290d4afc25f/examples/static-pages/public/js/.gitkeep -------------------------------------------------------------------------------- /examples/static-pages/public/pages/client.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Client", 3 | "content": { 4 | "subTitle": "client.js controls your app in the browser", 5 | "body": "Client page content" 6 | } 7 | } -------------------------------------------------------------------------------- /examples/static-pages/public/pages/index.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Cerebellum.js", 3 | "content": { 4 | "subTitle": "Cerebellum.js is a powerful set of tools that help you structure your isomorphic apps, just add your preferred view engine.", 5 | "body": "Index page content" 6 | } 7 | } -------------------------------------------------------------------------------- /examples/static-pages/public/pages/routes.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Routes", 3 | "content": { 4 | "subTitle": "routes.js includes your shared routes for server and client", 5 | "body": "Routes page content" 6 | } 7 | } -------------------------------------------------------------------------------- /examples/static-pages/public/pages/server.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Server", 3 | "content": { 4 | "subTitle": "server.js controls your app on the server", 5 | "body": "Server page content" 6 | } 7 | } -------------------------------------------------------------------------------- /examples/static-pages/public/pages/stores.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Stores", 3 | "content": { 4 | "subTitle": "stores.js has all your stores listed", 5 | "body": "Stores page content" 6 | } 7 | } -------------------------------------------------------------------------------- /examples/static-pages/routes.js: -------------------------------------------------------------------------------- 1 | var React = require('react/addons'); 2 | var NotFound = require('./components/not-found.jsx'); 3 | var Page = require('./components/page.jsx'); 4 | 5 | module.exports = { 6 | "/:page?": function(page) { 7 | page = page || "index"; 8 | 9 | return this.store.fetch("page", {id: page}).then(function(pageStore) { 10 | return { 11 | title: pageStore.get("title"), 12 | component: React.createElement(Page, { 13 | title: pageStore.get("title"), 14 | content: pageStore.get("content") || {} 15 | }) 16 | }; 17 | }).catch(function(err) { 18 | // we could render different error message based on 19 | // err.status, but let's stick to "Not found" here 20 | return { 21 | title: "Not found", 22 | component: React.createElement(NotFound, { 23 | title: "Not found", 24 | content: "Page you requested was not found" 25 | }) 26 | }; 27 | }); 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /examples/static-pages/server.js: -------------------------------------------------------------------------------- 1 | // with node-jsx we can require our .jsx from components/*.jsx 2 | require('node-jsx').install({extension: ".jsx"}); 3 | 4 | var React = require('react/addons'); 5 | var compress = require('compression'); 6 | var favicon = require('serve-favicon'); 7 | 8 | var cerebellum = require('../../index'); 9 | var options = require('./options'); 10 | 11 | // document is a cheerio instance with public/index.html content 12 | options.render = function render(document, opts) { 13 | if (opts == null) { 14 | opts = {}; 15 | } 16 | document("title").html(opts.title); 17 | document("#"+options.appId).html( React.renderToString(opts.component) ); 18 | return document.html(); 19 | }; 20 | 21 | // pass your middleware to express with options.middleware 22 | options.middleware = [ 23 | favicon(options.staticFiles + '/favicon.ico'), 24 | compress() 25 | ]; 26 | 27 | var app = cerebellum.server(options); 28 | 29 | // always register static files middleware after defining routes 30 | app.useStatic(); 31 | 32 | app.listen(Number(process.env.PORT || 4000), function() { 33 | console.log("static-pages development server listening on port "+ (this.address().port)); 34 | }); -------------------------------------------------------------------------------- /examples/static-pages/stores/page.js: -------------------------------------------------------------------------------- 1 | var Model = require('../../../index').Model; 2 | var apiConfig = require("../config/api"); 3 | 4 | var Page = Model.extend({ 5 | cacheKey: function() { 6 | return this.storeOptions.id; 7 | }, 8 | url: function() { 9 | return apiConfig.url +"/pages/"+ this.storeOptions.id +".json" 10 | } 11 | }); 12 | 13 | module.exports = Page; -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var ajax = require('vertebrae/adapters/axios'); 2 | var Sync = require('vertebrae/sync')({ajax: ajax}); 3 | var Model = require('vertebrae/model')({sync: Sync}); 4 | var Collection = require('vertebrae/collection')({sync: Sync}, Model); 5 | 6 | module.exports.server = function server(options, routeContext) { 7 | return require("./lib/server")(options, routeContext); 8 | }; 9 | 10 | module.exports.client = function(options, routeContext) { 11 | return require("./lib/client")(options, routeContext); 12 | }; 13 | 14 | module.exports.Store = require('./store'); 15 | module.exports.Collection = Collection; 16 | module.exports.Model = Model; 17 | module.exports.DOMReady = function() { 18 | return require('./lib/domready'); 19 | } -------------------------------------------------------------------------------- /model.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/model'); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cerebellum", 3 | "version": "0.10.0", 4 | "description": "Controls your isomorphic apps", 5 | "scripts": { 6 | "build": "babel src --out-dir lib --stage 1", 7 | "watch": "babel src --watch --out-dir lib --stage 1", 8 | "test_build": "browserify -s Cerebellum index.js -o test/client/cerebellum_test_build.js", 9 | "test": "npm run test_server && npm run test_client", 10 | "test_server": "mocha test/server", 11 | "test_client": "npm run test_build && mocha-phantomjs -s loadImages=false http://localhost:8000/", 12 | "prepublish": "npm run build", 13 | "start": "http-server test/client -p 8000" 14 | }, 15 | "author": "Lari Hoppula", 16 | "license": "MIT", 17 | "devDependencies": { 18 | "babel": "^5.8.35", 19 | "browserify": "^13.0.0", 20 | "http-server": "^0.8.0", 21 | "mocha": "^2.2.4", 22 | "mocha-phantomjs": "^4.0.2", 23 | "nock": "^5.2.1", 24 | "phantomjs": "^1.9.16", 25 | "should": "^8.1.1", 26 | "supertest": "^1.1.0" 27 | }, 28 | "dependencies": { 29 | "array.prototype.find": "^1.0.0", 30 | "cheerio": "^0.19.0", 31 | "express": "^4.12.3", 32 | "html5-history-api": "^4.2.0", 33 | "immutable": "^3.7.6", 34 | "native-promise-only": "^0.8.1", 35 | "page": "^1.6.2", 36 | "path-to-regexp": "^1.0.3", 37 | "qs": "^6.0.2", 38 | "vertebrae": "^1.1.2" 39 | }, 40 | "main": "index.js", 41 | "browser": { 42 | "./lib/server": false 43 | }, 44 | "repository": { 45 | "type": "git", 46 | "url": "https://github.com/SC5/cerebellum.git" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/server'); -------------------------------------------------------------------------------- /src/client.js: -------------------------------------------------------------------------------- 1 | import 'html5-history-api'; 2 | import 'native-promise-only'; 3 | import DOMReady from './domready'; 4 | import Events from 'vertebrae/events'; 5 | import page from 'page'; 6 | import qs from 'qs'; 7 | import Store from './store'; 8 | import utils from './utils'; 9 | import validateOptions from './validate-options'; 10 | import {extend} from 'vertebrae/utils'; 11 | 12 | function defaultRouteHandler(handler, params) { 13 | return handler.apply(this, params); 14 | } 15 | 16 | function createStoreOptions(options={}) { 17 | const storeOptions = {}; 18 | 19 | if (options.autoClearCaches) { 20 | storeOptions.autoClearCaches = true; 21 | } 22 | 23 | if (typeof options.autoToJSON !== "undefined") { 24 | storeOptions.autoToJSON = options.autoToJSON; 25 | } 26 | 27 | if (typeof options.instantResolve !== "undefined") { 28 | storeOptions.instantResolve = options.instantResolve; 29 | } 30 | 31 | if (typeof options.allowedStatusCodes !== "undefined") { 32 | storeOptions.allowedStatusCodes = options.allowedStatusCodes; 33 | } 34 | 35 | if (typeof options.identifier !== "undefined") { 36 | storeOptions.identifier = options.identifier; 37 | } 38 | 39 | return storeOptions; 40 | } 41 | 42 | function Client(options={}, routeContext={}) { 43 | validateOptions(options); 44 | 45 | // ensure proper initial state for page.js 46 | page.stop(); 47 | page.callbacks = []; 48 | page.exits = []; 49 | 50 | const { 51 | initialize: initializeCallback, 52 | initStore = true, 53 | initialUrl, 54 | render, 55 | routes, 56 | routeHandler = defaultRouteHandler, 57 | storeId, 58 | stores, 59 | identifier 60 | } = options; 61 | 62 | const clientEvents = extend({}, Events); 63 | const store = initStore ? new Store(stores, createStoreOptions(options)) : null; 64 | 65 | DOMReady.then(() => { 66 | 67 | if (initStore) { 68 | const storeState = document.getElementById(storeId); 69 | store.bootstrap( storeState.innerHTML ); 70 | storeState.innerHTML = ""; 71 | } 72 | 73 | // register page.js handler for each route 74 | Object.keys(routes).forEach(route => { 75 | 76 | page(route, ctx => { 77 | const context = (typeof routeContext === "function") 78 | ? routeContext.call({}) 79 | : routeContext; 80 | 81 | // return array of params in proper order 82 | const params = utils.extractParams(route, ctx.params); 83 | 84 | // add parsed query string object as last parameter for route handler 85 | const query = qs.parse(ctx.querystring); 86 | params.push(query); 87 | 88 | Promise.resolve(context).then(context => { 89 | if (initStore) { 90 | context.store = store; 91 | } 92 | 93 | return Promise.resolve( 94 | routeHandler.call(context, routes[route], params) 95 | ).then(options => { 96 | return Promise.resolve( 97 | render.call(context, document, options, {params: ctx.params, query: query}) 98 | ).then(result => { 99 | clientEvents.trigger("render", route); 100 | return result; 101 | }); 102 | }).catch(error => { 103 | // log error as user hasn't handled it 104 | console.error(`Render error while processing route ${route}:, ${error}`); 105 | }); 106 | }); 107 | 108 | }); 109 | }); 110 | 111 | // initialize page.js route handling 112 | page(initialUrl); 113 | 114 | // invoke initialize callback if it was provided in options 115 | if (initializeCallback && typeof initializeCallback === "function") { 116 | initializeCallback.call(null, { 117 | router: page, 118 | store: store 119 | }); 120 | } 121 | 122 | }); 123 | 124 | return clientEvents; 125 | }; 126 | 127 | export default Client; 128 | -------------------------------------------------------------------------------- /src/collection.js: -------------------------------------------------------------------------------- 1 | import ajax from 'vertebrae/adapters/axios'; 2 | import Sync from 'vertebrae/sync'; 3 | import Model from 'vertebrae/model'; 4 | import Collection from 'vertebrae/collection'; 5 | 6 | export default Collection( 7 | { 8 | sync: Sync({ 9 | ajax: ajax 10 | }) 11 | }, 12 | Model({ 13 | sync: Sync({ 14 | ajax: ajax 15 | }) 16 | }) 17 | ); 18 | -------------------------------------------------------------------------------- /src/domready.js: -------------------------------------------------------------------------------- 1 | import 'native-promise-only'; 2 | 3 | export default new Promise((resolve, reject) => { 4 | if (document.readyState === 'complete') { 5 | resolve(); 6 | } else { 7 | function onReady() { 8 | resolve(); 9 | document.removeEventListener('DOMContentLoaded', onReady, true); 10 | } 11 | document.addEventListener('DOMContentLoaded', onReady, true); 12 | } 13 | }); 14 | -------------------------------------------------------------------------------- /src/model.js: -------------------------------------------------------------------------------- 1 | import ajax from 'vertebrae/adapters/axios'; 2 | import Sync from 'vertebrae/sync'; 3 | import Model from 'vertebrae/model'; 4 | 5 | export default Model({ 6 | sync: Sync({ 7 | ajax: ajax 8 | }) 9 | }); 10 | -------------------------------------------------------------------------------- /src/server-utils.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import cheerio from 'cheerio'; 4 | import pathToRegexp from 'path-to-regexp'; 5 | import 'array.prototype.find'; 6 | 7 | function loadHTML(path) { 8 | return cheerio.load(fs.readFileSync(path, {encoding: "UTF-8"})).html(); 9 | } 10 | 11 | const ServerUtils = { 12 | 13 | loadEntries(entries, staticFiles) { 14 | const entryPath = entries.path ? entries.path : staticFiles; 15 | 16 | // always fallback to index.html 17 | const defaultEntry = { 18 | "*": { 19 | regExp: null, 20 | html: loadHTML(path.join(entryPath, "index.html")) 21 | } 22 | }; 23 | 24 | const routeEntries = Object.keys(entries.routes).reduce((result, route) => { 25 | const entry = path.join(entryPath, entries.routes[route]); 26 | result[route] = { 27 | regExp: pathToRegexp(route), 28 | html: loadHTML(entry) 29 | }; 30 | return result; 31 | }, {}); 32 | 33 | return {...defaultEntry, ...routeEntries}; 34 | }, 35 | 36 | entryHTML(entryFiles, req) { 37 | const entryPath = Object.keys(entryFiles).find(path => { 38 | if (path === "*") { 39 | return false; 40 | } 41 | return entryFiles[path].regExp.test(req.path); 42 | }); 43 | 44 | // always fallback to index.html if route not matched 45 | if (entryPath) { 46 | return entryFiles[entryPath].html; 47 | } else { 48 | return entryFiles["*"].html; 49 | } 50 | } 51 | 52 | }; 53 | 54 | export default ServerUtils; 55 | -------------------------------------------------------------------------------- /src/server.js: -------------------------------------------------------------------------------- 1 | import 'native-promise-only'; 2 | import cheerio from 'cheerio'; 3 | import express from 'express'; 4 | import Store from './store'; 5 | import utils from './utils'; 6 | import serverUtils from './server-utils'; 7 | import validateOptions from './validate-options'; 8 | 9 | function defaultRouteHandler(handler, params) { 10 | return handler.apply(this, params); 11 | } 12 | 13 | function Server(options={}, routeContext={}) { 14 | validateOptions(options); 15 | 16 | const { 17 | app: appSettings = [], 18 | entries = { 19 | path: null, 20 | routes: {} 21 | }, 22 | middleware = [], 23 | render, 24 | routes, 25 | routeHandler = defaultRouteHandler, 26 | staticFiles, 27 | storeId, 28 | stores, 29 | identifier, 30 | autoToJSON = true, 31 | initStore = true, 32 | allowedStatusCodes = null 33 | } = options; 34 | 35 | if (!staticFiles || typeof staticFiles !== "string") { 36 | throw new Error("You must define staticFiles path for index.html"); 37 | } 38 | 39 | // preload all entry files on startup 40 | const entryFiles = serverUtils.loadEntries(entries, staticFiles); 41 | const app = express(); 42 | 43 | // useful for delaying the static middleware injection 44 | app.useStatic = () => { 45 | app.use( express.static(staticFiles) ); 46 | }; 47 | 48 | appSettings.forEach(key => { 49 | app.set(key, appSettings[key]); 50 | }); 51 | 52 | middleware.forEach(mw => { 53 | if (mw.constructor === Array) { 54 | app.use.apply(app, mw); 55 | } else { 56 | app.use(mw); 57 | } 58 | }); 59 | 60 | Object.keys(routes).forEach(route => { 61 | 62 | app.get(route, (req, res) => { 63 | const context = (typeof routeContext === "function") 64 | ? routeContext.call({}, req) 65 | : routeContext; 66 | 67 | // return array of params in proper order 68 | const params = utils.extractParams(route, req.params); 69 | 70 | // add parsed query string object as last parameter for route handler 71 | params.push(req.query); 72 | 73 | Promise.resolve(context).then(context => { 74 | if (initStore) { 75 | const options = { 76 | autoToJSON: autoToJSON, 77 | allowedStatusCodes: allowedStatusCodes, 78 | identifier: identifier 79 | }; 80 | 81 | if (req.headers.cookie) { 82 | options.cookie = req.headers.cookie; 83 | } 84 | 85 | context.store = new Store(stores, options); 86 | } 87 | 88 | return Promise.resolve( 89 | routeHandler.call(context, routes[route], params) 90 | ).then(options => { 91 | const document = cheerio.load(serverUtils.entryHTML(entryFiles, req)); 92 | 93 | // store state snapshot to HTML document 94 | if (context.store) { 95 | document(`#${storeId}`).text(context.store.snapshot()); 96 | } 97 | 98 | return Promise.resolve( 99 | render.call(context, document, options, { 100 | params: req.params, 101 | query: req.query 102 | }) 103 | ).then(response => { 104 | return res.send(response); 105 | }); 106 | }).catch(error => { 107 | if (app.routeError && typeof app.routeError === "function") { 108 | app.routeError(error); 109 | } 110 | if (error.status && error.stack) { 111 | return res.send(`Error ${error.status}: ${error.stack}`); 112 | } else { 113 | return res.send(`Error: ${error.stack} ${JSON.stringify(error)}`); 114 | } 115 | }); 116 | }); 117 | 118 | }); 119 | }); 120 | 121 | return app; 122 | }; 123 | 124 | export default Server; 125 | -------------------------------------------------------------------------------- /src/store.js: -------------------------------------------------------------------------------- 1 | import 'native-promise-only'; 2 | import Events from 'vertebrae/events'; 3 | import Immutable from 'immutable'; 4 | import {extend} from 'vertebrae/utils'; 5 | import createCacheKey from './store/create-cache-key'; 6 | 7 | class Store { 8 | constructor(stores={}, options={}) { 9 | // store cookie for server side API request authentication 10 | if (options.cookie) { 11 | this.cookie = options.cookie; 12 | } 13 | 14 | // instantly resolve "fetch" promise, for non-blocking rendering on client side 15 | // use "fetch" event to re-render when this is enabled 16 | this.instantResolve = false; 17 | if (typeof options.instantResolve !== "undefined") { 18 | this.instantResolve = options.instantResolve; 19 | } 20 | 21 | // automatically clear caches after mutation operations, default true 22 | this.autoClearCaches = true; 23 | if (typeof options.autoClearCaches !== "undefined") { 24 | this.autoClearCaches = options.autoClearCaches; 25 | } 26 | 27 | // which key should be considered as model identity when merging collections 28 | this.identifier = "id"; 29 | if (typeof options.identifier !== "undefined") { 30 | this.identifier = options.identifier; 31 | } 32 | 33 | // allowed status codes 34 | // return empty store with these status codes 35 | // this allows us to show proper data for logged-in users 36 | // and prevents error for users who are not logged-in 37 | this.allowedStatusCodes = [401, 403]; 38 | if (Array.isArray(options.allowedStatusCodes)) { 39 | this.allowedStatusCodes = options.allowedStatusCodes; 40 | } 41 | 42 | // used for storing all collection/model data, fetch returns 43 | // fresh cursors to this structure 44 | this.cached = Immutable.Map(); 45 | 46 | // used for tracking stale caches, it's better to mark caches stale 47 | // and re-fetch than mutating the cache and causing glitchy re-renders 48 | this.staleCaches = []; 49 | 50 | // used for tracking ongoing fetch requests to prevent multiple 51 | // concurrent fetch calls to same API 52 | this.ongoingFetches = []; 53 | 54 | // used for tracking temporarily disabled caches, failing fetch request 55 | // with non-allowed status code gets cached disabled for 60 seconds 56 | this.temporarilyDisabledCaches = []; 57 | 58 | // empty instances of stores 59 | // clone actual instances from these 60 | this.stores = {}; 61 | 62 | Object.keys(stores).forEach(storeId => { 63 | const store = stores[storeId]; 64 | this.stores[storeId] = new store(); 65 | this.cached = this.cached.set(storeId, Immutable.fromJS({})); 66 | }); 67 | 68 | // allows Store to be used as event bus 69 | extend(this, Events); 70 | } 71 | 72 | dispatch(action, storeId, storeOptions, attrs) { 73 | return this[action].call(this, storeId, storeOptions, attrs); 74 | } 75 | 76 | create(storeId, storeOptions, attrs) { 77 | return new Promise((resolve, reject) => { 78 | if (!attrs) { 79 | attrs = storeOptions; 80 | storeOptions = {}; 81 | } 82 | 83 | const store = this.get(storeId); 84 | store.storeOptions = extend({}, storeOptions); 85 | const cacheKey = createCacheKey(store); 86 | 87 | if (typeof store.create !== "function") { 88 | // DEPRECATED 89 | this.trigger(`create:${storeId}`, new Error("You can call create only for collections!")); 90 | this.trigger("create", new Error("You can call create only for collections!"), {store: storeId}); 91 | return; 92 | } 93 | 94 | // optimistic create, save previous value for error scenario 95 | const previousCollection = this.cached.getIn([storeId, cacheKey]); 96 | this.cached = this.cached.updateIn([storeId, cacheKey], collection => { 97 | const newItem = Immutable.fromJS(attrs); 98 | if (collection) { 99 | return collection.push(newItem); 100 | } else { 101 | return Immutable.List([newItem]); 102 | } 103 | }); 104 | 105 | store.create(attrs, { 106 | success: (model, response) => { 107 | if (this.autoClearCaches) { 108 | this.clearCache(storeId, store.storeOptions); 109 | } 110 | // DEPRECATED 111 | this.trigger(`create:${storeId}`, null, { 112 | cacheKey: cacheKey, 113 | store: storeId, 114 | options: storeOptions, 115 | result: model 116 | }); 117 | this.trigger("create", null, { 118 | cacheKey: cacheKey, 119 | store: storeId, 120 | options: storeOptions, 121 | result: model 122 | }); 123 | resolve(model); 124 | }, 125 | error: (model, response) => { 126 | const error = new Error(`Creating new item to store '${storeId}' failed`); 127 | error.store = storeId; 128 | error.result = response; 129 | error.options = storeOptions; 130 | this.cached = this.cached.updateIn([storeId, cacheKey], () => previousCollection); 131 | // DEPRECATED 132 | this.trigger(`create:${storeId}`, error); 133 | this.trigger("create", error, {store: storeId}); 134 | reject(error); 135 | } 136 | }); 137 | }); 138 | } 139 | 140 | update(storeId, storeOptions, attrs) { 141 | return new Promise((resolve, reject) => { 142 | const store = this.get(storeId); 143 | store.storeOptions = extend({}, storeOptions); 144 | const cacheKey = createCacheKey(store); 145 | 146 | if (typeof store.save !== "function") { 147 | // DEPRECATED 148 | this.trigger(`update:${storeId}`, new Error("You can call update only for models!")); 149 | this.trigger("update", new Error("You can call update only for models!"), {store: storeId}); 150 | return; 151 | } 152 | 153 | // optimistic update, save previous value for error scenario 154 | const previousModel = this.cached.getIn([storeId, cacheKey]); 155 | if (previousModel) { 156 | this.cached = this.cached.updateIn([storeId, cacheKey], previous => { 157 | return previous.merge(attrs); 158 | }); 159 | } 160 | 161 | store.save(attrs, { 162 | success: (model, response) => { 163 | if (this.autoClearCaches) { 164 | this.clearCache(storeId, store.storeOptions); 165 | } 166 | // DEPRECATED 167 | this.trigger(`update:${storeId}`, null, { 168 | cacheKey: createCacheKey(store), 169 | store: storeId, 170 | options: storeOptions, 171 | result: model 172 | }); 173 | this.trigger("update", null, { 174 | cacheKey: createCacheKey(store), 175 | store: storeId, 176 | options: storeOptions, 177 | result: model 178 | }); 179 | resolve(model); 180 | }, 181 | error: (model, response) => { 182 | const error = new Error(`Updating '${storeId}' failed`); 183 | error.store = storeId; 184 | error.result = response; 185 | error.options = storeOptions; 186 | if (previousModel) { 187 | this.cached = this.cached.updateIn([storeId, cacheKey], () => previousModel); 188 | } 189 | // DEPRECATED 190 | this.trigger(`update:${storeId}`, error); 191 | this.trigger("update", error, {store: storeId}); 192 | reject(error); 193 | } 194 | }); 195 | }); 196 | } 197 | 198 | delete(storeId, storeOptions) { 199 | return new Promise((resolve, reject) => { 200 | const store = this.get(storeId); 201 | store.storeOptions = extend({}, storeOptions); 202 | // Model#destroy needs id attribute or it considers model new and triggers success callback straight away 203 | store.set("id", store.storeOptions.id); 204 | const cacheKey = createCacheKey(store); 205 | 206 | if (typeof store.destroy !== "function") { 207 | // DEPRECATED 208 | this.trigger(`delete:${storeId}`, new Error("You can call destroy only for models!")); 209 | this.trigger("delete", new Error("You can call destroy only for models!"), {store: storeId}); 210 | return; 211 | } 212 | 213 | // optimistic delete, save previous value for error scenario 214 | const previousModel = this.cached.getIn([storeId, cacheKey]); 215 | this.cached = this.cached.deleteIn([storeId, cacheKey]); 216 | 217 | store.destroy({ 218 | success: (model, response) => { 219 | if (this.autoClearCaches) { 220 | this.clearCache(storeId, store.storeOptions); 221 | } 222 | // DEPRECATED 223 | this.trigger(`delete:${storeId}`, null, { 224 | cacheKey: createCacheKey(store), 225 | store: storeId, 226 | options: storeOptions, 227 | result: model 228 | }); 229 | this.trigger("delete", null, { 230 | cacheKey: createCacheKey(store), 231 | store: storeId, 232 | options: storeOptions, 233 | result: model 234 | }); 235 | resolve(model); 236 | }, 237 | error: (model, response) => { 238 | const error = new Error(`Deleting '${storeId}' failed`); 239 | error.store = storeId; 240 | error.result = response; 241 | error.options = storeOptions; 242 | this.cached = this.cached.setIn([storeId, cacheKey], previousModel); 243 | // DEPRECATED 244 | this.trigger(`delete:${storeId}`, error); 245 | this.trigger("delete", error, {store: storeId}); 246 | reject(error); 247 | } 248 | }); 249 | }); 250 | } 251 | 252 | expire(storeId, storeOptions) { 253 | return new Promise((resolve, reject) => { 254 | const store = this.get(storeId); 255 | store.storeOptions = extend({}, storeOptions); 256 | this.clearCache(storeId, store.storeOptions); 257 | // DEPRECATED 258 | this.trigger(`expire:${storeId}`, null, { 259 | cacheKey: createCacheKey(store), 260 | store: storeId, 261 | options: storeOptions 262 | }); 263 | this.trigger("expire", null, { 264 | cacheKey: createCacheKey(store), 265 | store: storeId, 266 | options: storeOptions 267 | }); 268 | return resolve(storeId); 269 | }); 270 | } 271 | 272 | clearCache(storeId, storeOptions) { 273 | let relatedCaches = {}; 274 | let relatedCacheKeys = []; 275 | 276 | const store = this.get(storeId); 277 | if (!store) { 278 | return; 279 | } 280 | store.storeOptions = storeOptions; 281 | const cacheKey = createCacheKey(store); 282 | 283 | if (cacheKey) { 284 | // mark cache as stale 285 | if (this.cached.getIn([storeId, cacheKey])) { 286 | this.staleCaches.push({id: storeId, key: cacheKey}); 287 | } 288 | } else { 289 | // mark all caches for store stale if cacheKey doesn't exist 290 | Object.keys(this.cached.getIn([storeId]).toJS()).forEach(key => { 291 | this.staleCaches.push({id: storeId, key: key}); 292 | }); 293 | } 294 | 295 | // mark related caches as stale 296 | if (typeof store.relatedCaches === "function") { 297 | relatedCaches = store.relatedCaches(); 298 | relatedCacheKeys = Object.keys(relatedCaches); 299 | 300 | if (relatedCacheKeys.length) { 301 | relatedCacheKeys.forEach(id => { 302 | const key = relatedCaches[id]; 303 | if (this.cached.getIn([id, key])) { 304 | this.staleCaches.push({id: id, key: key}); 305 | } 306 | }); 307 | } 308 | } 309 | } 310 | 311 | clearCookie() { 312 | this.cookie = null; 313 | } 314 | 315 | // bootstrap caches from JSON snapshot 316 | bootstrap(json) { 317 | if (!json) { 318 | return; 319 | } 320 | 321 | this.cached = this.cached.update(() => { 322 | return Immutable.fromJS(JSON.parse(json)); 323 | }); 324 | return this.cached; 325 | } 326 | 327 | // export snapshot of current caches to JSON 328 | snapshot() { 329 | return JSON.stringify( 330 | this.cached.toJSON() 331 | ); 332 | } 333 | 334 | // returns cloned empty store instance 335 | get(storeId) { 336 | const store = this.stores[storeId]; 337 | return (store ? store.clone() : null); 338 | } 339 | 340 | // checks if cache has gone stale 341 | isCacheStale(id, key) { 342 | return this.staleCaches.some(cache => { 343 | return cache.id === id && cache.key === key; 344 | }); 345 | } 346 | 347 | // mark cache as not stale 348 | markCacheFresh(id, key) { 349 | this.staleCaches = this.staleCaches.filter(cache => { 350 | return cache.id !== id && cache.key !== key; 351 | }); 352 | } 353 | 354 | ongoingFetch(id, key) { 355 | return this.ongoingFetches.filter(fetch => { 356 | return fetch.id === id && fetch.key === key.toString(); 357 | })[0]; 358 | } 359 | 360 | markFetchOngoing(id, key, promise) { 361 | this.ongoingFetches.push({ 362 | id: id, 363 | key: key, 364 | promise: promise 365 | }); 366 | } 367 | 368 | markFetchCompleted(id, key) { 369 | this.ongoingFetches = this.ongoingFetches.filter(fetch => { 370 | return fetch.id !== id && fetch.key !== key; 371 | }); 372 | } 373 | 374 | disableCache(id, key) { 375 | this.temporarilyDisabledCaches.push({ 376 | id: id, 377 | key: key, 378 | disabledUntil: (new Date()).getTime() + 60000 // disable for 60 seconds 379 | }); 380 | } 381 | 382 | temporarilyDisabledCache(id, key) { 383 | return this.temporarilyDisabledCaches.filter(cache => { 384 | return ( 385 | cache.disabledUntil > (new Date()).getTime() && 386 | cache.id === id && 387 | cache.key === key 388 | ); 389 | })[0]; 390 | } 391 | 392 | createFetchOptions() { 393 | const fetchOptions = {}; 394 | if (this.cookie) { 395 | fetchOptions.headers = {'cookie': this.cookie}; 396 | } 397 | return fetchOptions; 398 | } 399 | 400 | createStoreInstance(storeId, options) { 401 | const store = this.get(storeId); 402 | if (!store) { 403 | throw new Error(`Store ${storeId} not registered.`); 404 | } 405 | store.storeOptions = extend({}, options); 406 | return store; 407 | } 408 | 409 | // get store from cache or fetch from server 410 | // TODO: split to smaller parts, this is really complicated to comprehend 411 | fetch(storeId, options) { 412 | return new Promise((resolve, reject) => { 413 | const fetchOptions = this.createFetchOptions(); 414 | const instantResolve = this.instantResolve; 415 | const store = this.createStoreInstance(storeId, options); 416 | const cacheKey = createCacheKey(store); 417 | if (!cacheKey) { 418 | reject(new Error(`Store ${storeId} has no cacheKey method.`)); 419 | } 420 | const cachedStore = this.cached.getIn([storeId, cacheKey]); 421 | const ongoingFetch = this.ongoingFetch(storeId, cacheKey); 422 | const temporarilyDisabledCache = this.temporarilyDisabledCache(storeId, cacheKey); 423 | 424 | if ( 425 | (!this.isCacheStale(storeId, cacheKey) && cachedStore && cachedStore.size) || temporarilyDisabledCache 426 | ) { 427 | return resolve(cachedStore); 428 | } else { 429 | if (instantResolve) { 430 | resolve(Immutable.fromJS(store.toJSON())); 431 | } 432 | if (ongoingFetch) { 433 | return ongoingFetch.promise.then(() => { 434 | if (instantResolve) { 435 | this.trigger("fetch", null, {store: storeId, value: this.cached.getIn([storeId, cacheKey])}); 436 | // DEPRECATED 437 | return this.trigger(`fetch:${storeId}`, null, this.cached.getIn([storeId, cacheKey])); 438 | } else { 439 | return resolve(this.cached.getIn([storeId, cacheKey])); 440 | } 441 | }); 442 | } else { 443 | const fetchPromise = store.fetch(fetchOptions).catch(err => { 444 | const allowedStatus = this.allowedStatusCodes.some(statusCode => { 445 | return statusCode === err.status; 446 | }); 447 | if (allowedStatus) { 448 | const result = Immutable.fromJS(store.toJSON()); 449 | if (instantResolve) { 450 | this.trigger("fetch", null, {store: storeId, value: result}); 451 | // DEPRECATED 452 | return this.trigger(`fetch:${storeId}`, null, result); 453 | } else { 454 | return resolve(result); 455 | } 456 | } else { 457 | // reject for other statuses so error can be catched in router 458 | 459 | // disable cache for 60 seconds so we don't get in re-render loop 460 | this.disableCache(storeId, cacheKey); 461 | 462 | if (instantResolve) { 463 | // DEPRECATED 464 | this.trigger(`fetch:${storeId}`, err); 465 | this.trigger("fetch", err, {store: storeId}); 466 | } 467 | return reject(err); 468 | } 469 | }); 470 | 471 | this.markFetchOngoing(storeId, cacheKey, fetchPromise); 472 | 473 | return fetchPromise.then(() => { 474 | this.cached = this.cached.updateIn([storeId, cacheKey], previousStore => { 475 | // TODO: optimize 476 | if (previousStore) { 477 | const nextStore = Immutable.fromJS(store.toJSON()); 478 | if (previousStore.findIndex) { 479 | const identifier = this.identifier; 480 | const mergedStore = previousStore.reduce((result, prevItem) => { 481 | const index = nextStore.findIndex((item) => { 482 | return item.get(identifier) === prevItem.get(identifier); 483 | }); 484 | if (index !== -1) { 485 | return result.set(result.indexOf(prevItem), prevItem.merge(nextStore.get(index))); 486 | } else { 487 | return result.delete(result.indexOf(prevItem)); 488 | } 489 | }, previousStore).concat( 490 | nextStore.filterNot((nextItem) => { 491 | return previousStore.find((item) => { 492 | return item.get(identifier) === nextItem.get(identifier); 493 | }); 494 | }) 495 | ); 496 | return mergedStore; 497 | } else { 498 | return previousStore.mergeDeep(nextStore); 499 | } 500 | } else { 501 | return Immutable.fromJS(store.toJSON()); 502 | } 503 | }); 504 | 505 | if (instantResolve) { 506 | // DEPRECATED 507 | this.trigger(`fetch:${storeId}`, null, this.cached.getIn([storeId, cacheKey])); 508 | this.trigger("fetch", null, {store: storeId, value: this.cached.getIn([storeId, cacheKey])}); 509 | } else { 510 | resolve(this.cached.getIn([storeId, cacheKey])); 511 | } 512 | 513 | this.markCacheFresh(storeId, cacheKey); 514 | // wait 50ms before marking fetch completed to prevent multiple fetches 515 | // within small time window 516 | setTimeout(() => { 517 | this.markFetchCompleted(storeId, cacheKey); 518 | }, 50); 519 | }); 520 | } 521 | 522 | } 523 | }); 524 | } 525 | 526 | fetchAll(options={}) { 527 | const storeIds = Object.keys(options); 528 | return Promise.all(storeIds.map(storeId => { 529 | return this.fetch(storeId, options[storeId]); 530 | })).then(results => { 531 | return results.reduce((result, store, i) => { 532 | result[storeIds[i]] = store; 533 | return result; 534 | }, {}); 535 | }); 536 | } 537 | 538 | } 539 | 540 | export default Store; 541 | -------------------------------------------------------------------------------- /src/store/create-cache-key.js: -------------------------------------------------------------------------------- 1 | export default function createCacheKey(store) { 2 | const storeOptions = store.storeOptions || {}; 3 | 4 | // if store is collection 5 | if (typeof store.create === "function") { 6 | 7 | if (typeof store.cacheKey === "function") { 8 | return store.cacheKey(); 9 | } else { 10 | // for collection empty cache key is ok when there's only a single collection 11 | return store.cacheKey ? store.cacheKey : (storeOptions.id || "/"); 12 | } 13 | 14 | } else { // store is model 15 | 16 | if (typeof store.cacheKey === "function") { 17 | return store.cacheKey(); 18 | } else { 19 | // fallback to model's id as cache key, fetch will reject if it does not exist 20 | return store.cacheKey ? store.cacheKey : storeOptions.id; 21 | } 22 | 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import pathToRegexp from 'path-to-regexp'; 2 | 3 | const Utils = { 4 | 5 | extractParams(route, params) { 6 | return pathToRegexp(route).keys.map(key => { 7 | return params[key.name]; 8 | }); 9 | } 10 | 11 | }; 12 | 13 | export default Utils; 14 | -------------------------------------------------------------------------------- /src/validate-options.js: -------------------------------------------------------------------------------- 1 | export default function validateOptions(options) { 2 | if (!options.storeId || typeof options.storeId !== "string") { 3 | throw new Error("You must define storeId option, it's used for collections cache, e.g. 'store'"); 4 | } 5 | 6 | if (!options.render || typeof options.render !== "function") { 7 | throw new Error("You must define render option, it will be called when route handler finishes."); 8 | } 9 | 10 | if (!options.routes || typeof options.routes !== "object") { 11 | throw new Error("You must define routes option or your app won't respond to anything"); 12 | } 13 | 14 | if (options.initStore && (!options.stores || typeof options.stores !== "object")) { 15 | console.warn("You won't be able to use this.store in router without defining any stores"); 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /store.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/store'); -------------------------------------------------------------------------------- /test/client/client.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 3 | var cerebellum = window.Cerebellum; 4 | var Collection = cerebellum.Collection; 5 | var appId = "app"; 6 | var storeId = "store_state_from_server"; 7 | var appContainer = document.getElementById(appId); 8 | var options; 9 | 10 | describe('Client', function() { 11 | 12 | beforeEach(function() { 13 | 14 | document.getElementById(storeId).innerHTML = '{"movies": {"/": [{"name": "Interstellar"}, {"name": "Inception"}, {"name": "Insomnia"}]}}'; 15 | 16 | options = { 17 | appId: appId, 18 | storeId: storeId, 19 | initialize: function(client) {}, 20 | render: function(document, options) { 21 | appContainer.innerHTML = options.value; 22 | }, 23 | routes: { 24 | "/": function() { 25 | return new Promise(function(resolve, reject) { 26 | resolve({value: "index content"}); 27 | }); 28 | }, 29 | "/second_route": function() { 30 | return {value: "second view"}; 31 | }, 32 | "/third_route/:category_id/:id": function(categoryId, id) { 33 | return {value: [categoryId, id]}; 34 | }, 35 | "/fourth/:optional?": function(optional) { 36 | return {value: optional}; 37 | }, 38 | "/movies": function() { 39 | return this.store.fetch("movies").then(function(movies) { 40 | return {value: movies.map(function(movie) { return movie.get("name"); }).join(",")}; 41 | }); 42 | } 43 | }, 44 | stores: { 45 | movies: Collection.extend({}) 46 | } 47 | }; 48 | }); 49 | 50 | it('should render index route handler\'s response to #app after initialization', function(done) { 51 | var clientEvents = cerebellum.client(options); 52 | clientEvents.on("render", function(route) { 53 | route.should.equal("/"); 54 | appContainer.innerHTML.should.equal("index content"); 55 | clientEvents.off(); 56 | done(); 57 | }); 58 | }); 59 | 60 | it('should route to another url and render it\'s content', function(done) { 61 | options.initialUrl = "/second_route"; 62 | var clientEvents = cerebellum.client(options); 63 | 64 | clientEvents.on("render", function(route) { 65 | route.should.equal("/second_route"); 66 | appContainer.innerHTML.should.equal("second view"); 67 | clientEvents.off(); 68 | done(); 69 | }); 70 | 71 | }); 72 | 73 | it('should handle route parameters', function(done) { 74 | options.initialUrl = "/third_route/1/2"; 75 | var clientEvents = cerebellum.client(options); 76 | 77 | clientEvents.on("render", function(route) { 78 | route.should.equal("/third_route/:category_id/:id"); 79 | appContainer.innerHTML.should.equal("1,2"); 80 | clientEvents.off(); 81 | done(); 82 | }); 83 | 84 | }); 85 | 86 | it('should render view without optional route param', function(done) { 87 | options.initialUrl = "/fourth"; 88 | var clientEvents = cerebellum.client(options); 89 | 90 | clientEvents.on("render", function(route) { 91 | route.should.equal("/fourth/:optional?"); 92 | appContainer.innerHTML.should.equal("undefined"); 93 | clientEvents.off(); 94 | done(); 95 | }); 96 | 97 | }); 98 | 99 | it('should render view with optional route param', function(done) { 100 | options.initialUrl = "/fourth/123"; 101 | var clientEvents = cerebellum.client(options); 102 | 103 | clientEvents.on("render", function(route) { 104 | route.should.equal("/fourth/:optional?"); 105 | appContainer.innerHTML.should.equal("123"); 106 | clientEvents.off(); 107 | done(); 108 | }); 109 | 110 | }); 111 | 112 | it('should bootstrap from initial JSON', function(done) { 113 | options.initialUrl = "/movies"; 114 | var clientEvents = cerebellum.client(options); 115 | 116 | clientEvents.on("render", function(route) { 117 | route.should.equal("/movies"); 118 | appContainer.innerHTML.should.equal("Interstellar,Inception,Insomnia"); 119 | clientEvents.off(); 120 | done(); 121 | }); 122 | 123 | }); 124 | 125 | it('should be possible to render asynchronously with context and query params', function(done) { 126 | 127 | var asyncOptions = { 128 | appId: appId, 129 | storeId: storeId, 130 | initialize: function(client) {}, 131 | render: function(document, response, request) { 132 | var context = this; 133 | return new Promise(function(resolve, reject) { 134 | setTimeout(function() { 135 | appContainer.innerHTML = JSON.stringify({ 136 | response: response, 137 | request: request, 138 | storeExists: !!context.store 139 | }); 140 | resolve(response); 141 | }, 10); 142 | }); 143 | }, 144 | routes: { 145 | "/async/:id": function() { 146 | return "async render"; 147 | } 148 | }, 149 | stores: {}, 150 | initialUrl: "/async/1?q=hello" 151 | }; 152 | 153 | var clientEvents = cerebellum.client(asyncOptions); 154 | 155 | clientEvents.on("render", function(route) { 156 | route.should.equal("/async/:id"); 157 | var result = JSON.parse(appContainer.innerHTML); 158 | result.response.should.equal("async render"); 159 | result.request.params.should.eql({id: "1"}); 160 | result.request.query.should.eql({q: "hello"}); 161 | result.storeExists.should.equal(true); 162 | clientEvents.off(); 163 | done(); 164 | }); 165 | }); 166 | 167 | }); 168 | 169 | })(); -------------------------------------------------------------------------------- /test/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Mocha Tests 7 | 8 | 13 | 14 | 15 |
16 | 17 |
18 | 19 | 20 | 47 | 48 | 49 | 50 | 51 | 52 | 55 | 56 | 57 | 58 | 59 | 66 | 67 | -------------------------------------------------------------------------------- /test/client/vendor/mocha.css: -------------------------------------------------------------------------------- 1 | @charset "utf-8"; 2 | 3 | body { 4 | margin:0; 5 | } 6 | 7 | #mocha { 8 | font: 20px/1.5 "Helvetica Neue", Helvetica, Arial, sans-serif; 9 | margin: 60px 50px; 10 | } 11 | 12 | #mocha ul, 13 | #mocha li { 14 | margin: 0; 15 | padding: 0; 16 | } 17 | 18 | #mocha ul { 19 | list-style: none; 20 | } 21 | 22 | #mocha h1, 23 | #mocha h2 { 24 | margin: 0; 25 | } 26 | 27 | #mocha h1 { 28 | margin-top: 15px; 29 | font-size: 1em; 30 | font-weight: 200; 31 | } 32 | 33 | #mocha h1 a { 34 | text-decoration: none; 35 | color: inherit; 36 | } 37 | 38 | #mocha h1 a:hover { 39 | text-decoration: underline; 40 | } 41 | 42 | #mocha .suite .suite h1 { 43 | margin-top: 0; 44 | font-size: .8em; 45 | } 46 | 47 | #mocha .hidden { 48 | display: none; 49 | } 50 | 51 | #mocha h2 { 52 | font-size: 12px; 53 | font-weight: normal; 54 | cursor: pointer; 55 | } 56 | 57 | #mocha .suite { 58 | margin-left: 15px; 59 | } 60 | 61 | #mocha .test { 62 | margin-left: 15px; 63 | overflow: hidden; 64 | } 65 | 66 | #mocha .test.pending:hover h2::after { 67 | content: '(pending)'; 68 | font-family: arial, sans-serif; 69 | } 70 | 71 | #mocha .test.pass.medium .duration { 72 | background: #c09853; 73 | } 74 | 75 | #mocha .test.pass.slow .duration { 76 | background: #b94a48; 77 | } 78 | 79 | #mocha .test.pass::before { 80 | content: '✓'; 81 | font-size: 12px; 82 | display: block; 83 | float: left; 84 | margin-right: 5px; 85 | color: #00d6b2; 86 | } 87 | 88 | #mocha .test.pass .duration { 89 | font-size: 9px; 90 | margin-left: 5px; 91 | padding: 2px 5px; 92 | color: #fff; 93 | -webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,.2); 94 | -moz-box-shadow: inset 0 1px 1px rgba(0,0,0,.2); 95 | box-shadow: inset 0 1px 1px rgba(0,0,0,.2); 96 | -webkit-border-radius: 5px; 97 | -moz-border-radius: 5px; 98 | -ms-border-radius: 5px; 99 | -o-border-radius: 5px; 100 | border-radius: 5px; 101 | } 102 | 103 | #mocha .test.pass.fast .duration { 104 | display: none; 105 | } 106 | 107 | #mocha .test.pending { 108 | color: #0b97c4; 109 | } 110 | 111 | #mocha .test.pending::before { 112 | content: '◦'; 113 | color: #0b97c4; 114 | } 115 | 116 | #mocha .test.fail { 117 | color: #c00; 118 | } 119 | 120 | #mocha .test.fail pre { 121 | color: black; 122 | } 123 | 124 | #mocha .test.fail::before { 125 | content: '✖'; 126 | font-size: 12px; 127 | display: block; 128 | float: left; 129 | margin-right: 5px; 130 | color: #c00; 131 | } 132 | 133 | #mocha .test pre.error { 134 | color: #c00; 135 | max-height: 300px; 136 | overflow: auto; 137 | } 138 | 139 | /** 140 | * (1): approximate for browsers not supporting calc 141 | * (2): 42 = 2*15 + 2*10 + 2*1 (padding + margin + border) 142 | * ^^ seriously 143 | */ 144 | #mocha .test pre { 145 | display: block; 146 | float: left; 147 | clear: left; 148 | font: 12px/1.5 monaco, monospace; 149 | margin: 5px; 150 | padding: 15px; 151 | border: 1px solid #eee; 152 | max-width: 85%; /*(1)*/ 153 | max-width: calc(100% - 42px); /*(2)*/ 154 | word-wrap: break-word; 155 | border-bottom-color: #ddd; 156 | -webkit-border-radius: 3px; 157 | -webkit-box-shadow: 0 1px 3px #eee; 158 | -moz-border-radius: 3px; 159 | -moz-box-shadow: 0 1px 3px #eee; 160 | border-radius: 3px; 161 | } 162 | 163 | #mocha .test h2 { 164 | position: relative; 165 | } 166 | 167 | #mocha .test a.replay { 168 | position: absolute; 169 | top: 3px; 170 | right: 0; 171 | text-decoration: none; 172 | vertical-align: middle; 173 | display: block; 174 | width: 15px; 175 | height: 15px; 176 | line-height: 15px; 177 | text-align: center; 178 | background: #eee; 179 | font-size: 15px; 180 | -moz-border-radius: 15px; 181 | border-radius: 15px; 182 | -webkit-transition: opacity 200ms; 183 | -moz-transition: opacity 200ms; 184 | transition: opacity 200ms; 185 | opacity: 0.3; 186 | color: #888; 187 | } 188 | 189 | #mocha .test:hover a.replay { 190 | opacity: 1; 191 | } 192 | 193 | #mocha-report.pass .test.fail { 194 | display: none; 195 | } 196 | 197 | #mocha-report.fail .test.pass { 198 | display: none; 199 | } 200 | 201 | #mocha-report.pending .test.pass, 202 | #mocha-report.pending .test.fail { 203 | display: none; 204 | } 205 | #mocha-report.pending .test.pass.pending { 206 | display: block; 207 | } 208 | 209 | #mocha-error { 210 | color: #c00; 211 | font-size: 1.5em; 212 | font-weight: 100; 213 | letter-spacing: 1px; 214 | } 215 | 216 | #mocha-stats { 217 | position: fixed; 218 | top: 15px; 219 | right: 10px; 220 | font-size: 12px; 221 | margin: 0; 222 | color: #888; 223 | z-index: 1; 224 | } 225 | 226 | #mocha-stats .progress { 227 | float: right; 228 | padding-top: 0; 229 | } 230 | 231 | #mocha-stats em { 232 | color: black; 233 | } 234 | 235 | #mocha-stats a { 236 | text-decoration: none; 237 | color: inherit; 238 | } 239 | 240 | #mocha-stats a:hover { 241 | border-bottom: 1px solid #eee; 242 | } 243 | 244 | #mocha-stats li { 245 | display: inline-block; 246 | margin: 0 5px; 247 | list-style: none; 248 | padding-top: 11px; 249 | } 250 | 251 | #mocha-stats canvas { 252 | width: 40px; 253 | height: 40px; 254 | } 255 | 256 | #mocha code .comment { color: #ddd; } 257 | #mocha code .init { color: #2f6fad; } 258 | #mocha code .string { color: #5890ad; } 259 | #mocha code .keyword { color: #8a6343; } 260 | #mocha code .number { color: #2f6fad; } 261 | 262 | @media screen and (max-device-width: 480px) { 263 | #mocha { 264 | margin: 60px 0px; 265 | } 266 | 267 | #mocha #stats { 268 | position: absolute; 269 | } 270 | } 271 | -------------------------------------------------------------------------------- /test/client/vendor/npo.js: -------------------------------------------------------------------------------- 1 | /*! Native Promise Only 2 | v0.7.6-a (c) Kyle Simpson 3 | MIT License: http://getify.mit-license.org 4 | */ 5 | !function(t,n,e){n[t]=n[t]||e(),"undefined"!=typeof module&&module.exports?module.exports=n[t]:"function"==typeof define&&define.amd&&define(function(){return n[t]})}("Promise","undefined"!=typeof global?global:this,function(){"use strict";function t(t,n){l.add(t,n),h||(h=y(l.drain))}function n(t){var n,e=typeof t;return null==t||"object"!=e&&"function"!=e||(n=t.then),"function"==typeof n?n:!1}function e(){for(var t=0;t0&&t(e,a))}catch(s){i.call(u||new f(a),s)}}}function i(n){var o=this;o.triggered||(o.triggered=!0,o.def&&(o=o.def),o.msg=n,o.state=2,o.chain.length>0&&t(e,o))}function c(t,n,e,o){for(var r=0;r and contributors 5 | * @link https://github.com/shouldjs/should.js 6 | * @license MIT 7 | */ 8 | !function(e){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=e();else if("function"==typeof define&&define.amd)define([],e);else{var f;"undefined"!=typeof window?f=window:"undefined"!=typeof global?f=global:"undefined"!=typeof self&&(f=self),f.Should=e()}}(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);throw new Error("Cannot find module '"+o+"'")}var f=n[o]={exports:{}};t[o][0].call(f.exports,function(e){var n=t[o][1][e];return s(n?n:e)},f,f.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o 12 | * MIT Licensed 13 | */ 14 | 15 | // Taken from node's assert module, because it sucks 16 | // and exposes next to nothing useful. 17 | var util = _dereq_('./util'); 18 | 19 | module.exports = _deepEqual; 20 | 21 | var pSlice = Array.prototype.slice; 22 | 23 | function _deepEqual(actual, expected) { 24 | // 7.1. All identical values are equivalent, as determined by ===. 25 | if (actual === expected) { 26 | return true; 27 | 28 | } else if (util.isBuffer(actual) && util.isBuffer(expected)) { 29 | if (actual.length != expected.length) return false; 30 | 31 | for (var i = 0; i < actual.length; i++) { 32 | if (actual[i] !== expected[i]) return false; 33 | } 34 | 35 | return true; 36 | 37 | // 7.2. If the expected value is a Date object, the actual value is 38 | // equivalent if it is also a Date object that refers to the same time. 39 | } else if (util.isDate(actual) && util.isDate(expected)) { 40 | return actual.getTime() === expected.getTime(); 41 | 42 | // 7.3 If the expected value is a RegExp object, the actual value is 43 | // equivalent if it is also a RegExp object with the same source and 44 | // properties (`global`, `multiline`, `lastIndex`, `ignoreCase`). 45 | } else if (util.isRegExp(actual) && util.isRegExp(expected)) { 46 | return actual.source === expected.source && 47 | actual.global === expected.global && 48 | actual.multiline === expected.multiline && 49 | actual.lastIndex === expected.lastIndex && 50 | actual.ignoreCase === expected.ignoreCase; 51 | 52 | // 7.4. Other pairs that do not both pass typeof value == 'object', 53 | // equivalence is determined by ==. 54 | } else if (!util.isObject(actual) && !util.isObject(expected)) { 55 | return actual == expected; 56 | 57 | // 7.5 For all other Object pairs, including Array objects, equivalence is 58 | // determined by having the same number of owned properties (as verified 59 | // with Object.prototype.hasOwnProperty.call), the same set of keys 60 | // (although not necessarily the same order), equivalent values for every 61 | // corresponding key, and an identical 'prototype' property. Note: this 62 | // accounts for both named and indexed properties on Arrays. 63 | } else { 64 | return objEquiv(actual, expected); 65 | } 66 | } 67 | 68 | 69 | function objEquiv (a, b) { 70 | if (util.isNullOrUndefined(a) || util.isNullOrUndefined(b)) 71 | return false; 72 | // an identical 'prototype' property. 73 | if (a.prototype !== b.prototype) return false; 74 | //~~~I've managed to break Object.keys through screwy arguments passing. 75 | // Converting to array solves the problem. 76 | if (util.isArguments(a)) { 77 | if (!util.isArguments(b)) { 78 | return false; 79 | } 80 | a = pSlice.call(a); 81 | b = pSlice.call(b); 82 | return _deepEqual(a, b); 83 | } 84 | try{ 85 | var ka = Object.keys(a), 86 | kb = Object.keys(b), 87 | key, i; 88 | } catch (e) {//happens when one is a string literal and the other isn't 89 | return false; 90 | } 91 | // having the same number of owned properties (keys incorporates 92 | // hasOwnProperty) 93 | if (ka.length != kb.length) 94 | return false; 95 | //the same set of keys (although not necessarily the same order), 96 | ka.sort(); 97 | kb.sort(); 98 | //~~~cheap key test 99 | for (i = ka.length - 1; i >= 0; i--) { 100 | if (ka[i] != kb[i]) 101 | return false; 102 | } 103 | //equivalent values for every corresponding key, and 104 | //~~~possibly expensive deep test 105 | for (i = ka.length - 1; i >= 0; i--) { 106 | key = ka[i]; 107 | if (!_deepEqual(a[key], b[key])) return false; 108 | } 109 | return true; 110 | } 111 | 112 | },{"./util":15}],2:[function(_dereq_,module,exports){ 113 | /*! 114 | * Should 115 | * Copyright(c) 2010-2014 TJ Holowaychuk 116 | * MIT Licensed 117 | */ 118 | 119 | var util = _dereq_('../util') 120 | , assert = _dereq_('assert') 121 | , AssertionError = assert.AssertionError; 122 | 123 | module.exports = function(should) { 124 | var i = should.format; 125 | 126 | /** 127 | * Expose assert to should 128 | * 129 | * This allows you to do things like below 130 | * without require()ing the assert module. 131 | * 132 | * should.equal(foo.bar, undefined); 133 | * 134 | */ 135 | util.merge(should, assert); 136 | 137 | /** 138 | * Assert _obj_ exists, with optional message. 139 | * 140 | * @param {*} obj 141 | * @param {String} [msg] 142 | * @api public 143 | */ 144 | should.exist = should.exists = function(obj, msg) { 145 | if(null == obj) { 146 | throw new AssertionError({ 147 | message: msg || ('expected ' + i(obj) + ' to exist'), stackStartFunction: should.exist 148 | }); 149 | } 150 | }; 151 | 152 | /** 153 | * Asserts _obj_ does not exist, with optional message. 154 | * 155 | * @param {*} obj 156 | * @param {String} [msg] 157 | * @api public 158 | */ 159 | 160 | should.not = {}; 161 | should.not.exist = should.not.exists = function(obj, msg) { 162 | if(null != obj) { 163 | throw new AssertionError({ 164 | message: msg || ('expected ' + i(obj) + ' to not exist'), stackStartFunction: should.not.exist 165 | }); 166 | } 167 | }; 168 | }; 169 | },{"../util":15,"assert":16}],3:[function(_dereq_,module,exports){ 170 | /*! 171 | * Should 172 | * Copyright(c) 2010-2014 TJ Holowaychuk 173 | * MIT Licensed 174 | */ 175 | 176 | module.exports = function(should, Assertion) { 177 | Assertion.add('true', function() { 178 | this.is.exactly(true); 179 | }, true); 180 | 181 | Assertion.alias('true', 'True'); 182 | 183 | Assertion.add('false', function() { 184 | this.is.exactly(false); 185 | }, true); 186 | 187 | Assertion.alias('false', 'False'); 188 | 189 | Assertion.add('ok', function() { 190 | this.params = { operator: 'to be truthy' }; 191 | 192 | this.assert(this.obj); 193 | }, true); 194 | }; 195 | },{}],4:[function(_dereq_,module,exports){ 196 | /*! 197 | * Should 198 | * Copyright(c) 2010-2014 TJ Holowaychuk 199 | * MIT Licensed 200 | */ 201 | 202 | module.exports = function(should, Assertion) { 203 | 204 | function addLink(name) { 205 | Object.defineProperty(Assertion.prototype, name, { 206 | get: function() { 207 | return this; 208 | }, 209 | enumerable: true 210 | }); 211 | } 212 | 213 | ['an', 'of', 'a', 'and', 'be', 'have', 'with', 'is', 'which', 'the'].forEach(addLink); 214 | }; 215 | },{}],5:[function(_dereq_,module,exports){ 216 | /*! 217 | * Should 218 | * Copyright(c) 2010-2014 TJ Holowaychuk 219 | * MIT Licensed 220 | */ 221 | 222 | var util = _dereq_('../util'), 223 | eql = _dereq_('../eql'); 224 | 225 | module.exports = function(should, Assertion) { 226 | var i = should.format; 227 | 228 | Assertion.add('containEql', function(other) { 229 | this.params = { operator: 'to contain ' + i(other) }; 230 | var obj = this.obj; 231 | if(util.isArray(obj)) { 232 | this.assert(obj.some(function(item) { 233 | return eql(item, other); 234 | })); 235 | } else if(util.isString(obj)) { 236 | // expect obj to be string 237 | this.assert(obj.indexOf(String(other)) >= 0); 238 | } else if(util.isObject(obj)) { 239 | // object contains object case 240 | util.forOwn(other, function(value, key) { 241 | obj.should.have.property(key, value); 242 | }); 243 | } else { 244 | //other uncovered cases 245 | this.assert(false); 246 | } 247 | }); 248 | 249 | Assertion.add('containDeepOrdered', function(other) { 250 | this.params = { operator: 'to contain ' + i(other) }; 251 | 252 | var obj = this.obj; 253 | if(util.isArray(obj)) { 254 | if(util.isArray(other)) { 255 | var otherIdx = 0; 256 | obj.forEach(function(item) { 257 | try { 258 | should(item).not.be.Null.and.containDeep(other[otherIdx]); 259 | otherIdx++; 260 | } catch(e) { 261 | if(e instanceof should.AssertionError) { 262 | return; 263 | } 264 | throw e; 265 | } 266 | }, this); 267 | 268 | this.assert(otherIdx == other.length); 269 | //search array contain other as sub sequence 270 | } else { 271 | this.assert(false); 272 | } 273 | } else if(util.isString(obj)) {// expect other to be string 274 | this.assert(obj.indexOf(String(other)) >= 0); 275 | } else if(util.isObject(obj)) {// object contains object case 276 | if(util.isObject(other)) { 277 | util.forOwn(other, function(value, key) { 278 | should(obj[key]).not.be.Null.and.containDeep(value); 279 | }); 280 | } else {//one of the properties contain value 281 | this.assert(false); 282 | } 283 | } else { 284 | this.eql(other); 285 | } 286 | }); 287 | 288 | Assertion.add('containDeep', function(other) { 289 | this.params = { operator: 'to contain ' + i(other) }; 290 | 291 | var obj = this.obj; 292 | if(util.isArray(obj)) { 293 | if(util.isArray(other)) { 294 | var usedKeys = {}; 295 | other.forEach(function(otherItem) { 296 | this.assert(obj.some(function(item, index) { 297 | if(index in usedKeys) return false; 298 | 299 | try { 300 | should(item).not.be.Null.and.containDeep(otherItem); 301 | usedKeys[index] = true; 302 | return true; 303 | } catch(e) { 304 | if(e instanceof should.AssertionError) { 305 | return false; 306 | } 307 | throw e; 308 | } 309 | })); 310 | }, this); 311 | 312 | } else { 313 | this.assert(false); 314 | } 315 | } else if(util.isString(obj)) {// expect other to be string 316 | this.assert(obj.indexOf(String(other)) >= 0); 317 | } else if(util.isObject(obj)) {// object contains object case 318 | if(util.isObject(other)) { 319 | util.forOwn(other, function(value, key) { 320 | should(obj[key]).not.be.Null.and.containDeep(value); 321 | }); 322 | } else {//one of the properties contain value 323 | this.assert(false); 324 | } 325 | } else { 326 | this.eql(other); 327 | } 328 | }); 329 | 330 | }; 331 | 332 | },{"../eql":1,"../util":15}],6:[function(_dereq_,module,exports){ 333 | /*! 334 | * Should 335 | * Copyright(c) 2010-2014 TJ Holowaychuk 336 | * MIT Licensed 337 | */ 338 | 339 | var eql = _dereq_('../eql'); 340 | 341 | module.exports = function(should, Assertion) { 342 | Assertion.add('eql', function(val, description) { 343 | this.params = { operator: 'to equal', expected: val, showDiff: true, message: description }; 344 | 345 | this.assert(eql(val, this.obj)); 346 | }); 347 | 348 | Assertion.add('equal', function(val, description) { 349 | this.params = { operator: 'to be', expected: val, showDiff: true, message: description }; 350 | 351 | this.assert(val === this.obj); 352 | }); 353 | 354 | Assertion.alias('equal', 'exactly'); 355 | }; 356 | },{"../eql":1}],7:[function(_dereq_,module,exports){ 357 | /*! 358 | * Should 359 | * Copyright(c) 2010-2014 TJ Holowaychuk 360 | * MIT Licensed 361 | */ 362 | var util = _dereq_('../util'); 363 | 364 | module.exports = function(should, Assertion) { 365 | var i = should.format; 366 | 367 | Assertion.add('throw', function(message, properties) { 368 | var fn = this.obj 369 | , err = {} 370 | , errorInfo = '' 371 | , thrown = false; 372 | 373 | var errorMatched = true; 374 | 375 | try { 376 | fn(); 377 | } catch(e) { 378 | thrown = true; 379 | err = e; 380 | } 381 | 382 | if(thrown) { 383 | if(message) { 384 | if('string' == typeof message) { 385 | errorMatched = message == err.message; 386 | } else if(message instanceof RegExp) { 387 | errorMatched = message.test(err.message); 388 | } else if('function' == typeof message) { 389 | errorMatched = err instanceof message; 390 | } else if(util.isObject(message)) { 391 | try { 392 | err.should.match(message); 393 | } catch(e) { 394 | if(e instanceof should.AssertionError) { 395 | errorInfo = ": " + e.message; 396 | errorMatched = false; 397 | } else { 398 | throw e; 399 | } 400 | } 401 | } 402 | 403 | if(!errorMatched) { 404 | if('string' == typeof message || message instanceof RegExp) { 405 | errorInfo = " with a message matching " + i(message) + ", but got '" + err.message + "'"; 406 | } else if('function' == typeof message) { 407 | errorInfo = " of type " + util.functionName(message) + ", but got " + util.functionName(err.constructor); 408 | } 409 | } else if('function' == typeof message && properties) { 410 | try { 411 | err.should.match(properties); 412 | } catch(e) { 413 | if(e instanceof should.AssertionError) { 414 | errorInfo = ": " + e.message; 415 | errorMatched = false; 416 | } else { 417 | throw e; 418 | } 419 | } 420 | } 421 | } else { 422 | errorInfo = " (got " + i(err) + ")"; 423 | } 424 | } 425 | 426 | this.params = { operator: 'to throw exception' + errorInfo }; 427 | 428 | this.assert(thrown); 429 | this.assert(errorMatched); 430 | }); 431 | 432 | Assertion.alias('throw', 'throwError'); 433 | }; 434 | },{"../util":15}],8:[function(_dereq_,module,exports){ 435 | /*! 436 | * Should 437 | * Copyright(c) 2010-2014 TJ Holowaychuk 438 | * MIT Licensed 439 | */ 440 | 441 | var util = _dereq_('../util'), 442 | eql = _dereq_('../eql'); 443 | 444 | module.exports = function(should, Assertion) { 445 | var i = should.format; 446 | 447 | Assertion.add('match', function(other, description) { 448 | this.params = { operator: 'to match ' + i(other), message: description }; 449 | 450 | if(!eql(this.obj, other)) { 451 | if(util.isRegExp(other)) { // something - regex 452 | 453 | if(util.isString(this.obj)) { 454 | 455 | this.assert(other.exec(this.obj)); 456 | } else if(util.isArray(this.obj)) { 457 | 458 | this.obj.forEach(function(item) { 459 | this.assert(other.exec(item));// should we try to convert to String and exec? 460 | }, this); 461 | } else if(util.isObject(this.obj)) { 462 | 463 | var notMatchedProps = [], matchedProps = []; 464 | util.forOwn(this.obj, function(value, name) { 465 | if(other.exec(value)) matchedProps.push(util.formatProp(name)); 466 | else notMatchedProps.push(util.formatProp(name) + ' (' + i(value) +')'); 467 | }, this); 468 | 469 | if(notMatchedProps.length) 470 | this.params.operator += '\n\tnot matched properties: ' + notMatchedProps.join(', '); 471 | if(matchedProps.length) 472 | this.params.operator += '\n\tmatched properties: ' + matchedProps.join(', '); 473 | 474 | this.assert(notMatchedProps.length == 0); 475 | } // should we try to convert to String and exec? 476 | } else if(util.isFunction(other)) { 477 | var res; 478 | try { 479 | res = other(this.obj); 480 | } catch(e) { 481 | if(e instanceof should.AssertionError) { 482 | this.params.operator += '\n\t' + e.message; 483 | } 484 | throw e; 485 | } 486 | 487 | if(res instanceof Assertion) { 488 | this.params.operator += '\n\t' + res.getMessage(); 489 | } 490 | 491 | //if we throw exception ok - it is used .should inside 492 | if(util.isBoolean(res)) { 493 | this.assert(res); // if it is just boolean function assert on it 494 | } 495 | } else if(util.isObject(other)) { // try to match properties (for Object and Array) 496 | notMatchedProps = []; matchedProps = []; 497 | 498 | util.forOwn(other, function(value, key) { 499 | try { 500 | should(this.obj[key]).match(value); 501 | matchedProps.push(util.formatProp(key)); 502 | } catch(e) { 503 | if(e instanceof should.AssertionError) { 504 | notMatchedProps.push(util.formatProp(key) + ' (' + i(this.obj[key]) + ')'); 505 | } else { 506 | throw e; 507 | } 508 | } 509 | }, this); 510 | 511 | if(notMatchedProps.length) 512 | this.params.operator += '\n\tnot matched properties: ' + notMatchedProps.join(', '); 513 | if(matchedProps.length) 514 | this.params.operator += '\n\tmatched properties: ' + matchedProps.join(', '); 515 | 516 | this.assert(notMatchedProps.length == 0); 517 | } else { 518 | this.assert(false); 519 | } 520 | } 521 | }); 522 | 523 | Assertion.add('matchEach', function(other, description) { 524 | this.params = { operator: 'to match each ' + i(other), message: description }; 525 | 526 | var f = other; 527 | 528 | if(util.isRegExp(other)) 529 | f = function(it) { 530 | return !!other.exec(it); 531 | }; 532 | else if(!util.isFunction(other)) 533 | f = function(it) { 534 | return eql(it, other); 535 | }; 536 | 537 | util.forOwn(this.obj, function(value, key) { 538 | var res = f(value, key); 539 | 540 | //if we throw exception ok - it is used .should inside 541 | if(util.isBoolean(res)) { 542 | this.assert(res); // if it is just boolean function assert on it 543 | } 544 | }, this); 545 | }); 546 | }; 547 | },{"../eql":1,"../util":15}],9:[function(_dereq_,module,exports){ 548 | /*! 549 | * Should 550 | * Copyright(c) 2010-2014 TJ Holowaychuk 551 | * MIT Licensed 552 | */ 553 | 554 | module.exports = function(should, Assertion) { 555 | Assertion.add('NaN', function() { 556 | this.params = { operator: 'to be NaN' }; 557 | 558 | this.assert(this.obj !== this.obj); 559 | }, true); 560 | 561 | Assertion.add('Infinity', function() { 562 | this.params = { operator: 'to be Infinity' }; 563 | 564 | this.is.a.Number 565 | .and.not.a.NaN 566 | .and.assert(!isFinite(this.obj)); 567 | }, true); 568 | 569 | Assertion.add('within', function(start, finish, description) { 570 | this.params = { operator: 'to be within ' + start + '..' + finish, message: description }; 571 | 572 | this.assert(this.obj >= start && this.obj <= finish); 573 | }); 574 | 575 | Assertion.add('approximately', function(value, delta, description) { 576 | this.params = { operator: 'to be approximately ' + value + " ±" + delta, message: description }; 577 | 578 | this.assert(Math.abs(this.obj - value) <= delta); 579 | }); 580 | 581 | Assertion.add('above', function(n, description) { 582 | this.params = { operator: 'to be above ' + n, message: description }; 583 | 584 | this.assert(this.obj > n); 585 | }); 586 | 587 | Assertion.add('below', function(n, description) { 588 | this.params = { operator: 'to be below ' + n, message: description }; 589 | 590 | this.assert(this.obj < n); 591 | }); 592 | 593 | Assertion.alias('above', 'greaterThan'); 594 | Assertion.alias('below', 'lessThan'); 595 | 596 | }; 597 | 598 | },{}],10:[function(_dereq_,module,exports){ 599 | /*! 600 | * Should 601 | * Copyright(c) 2010-2014 TJ Holowaychuk 602 | * MIT Licensed 603 | */ 604 | 605 | var util = _dereq_('../util'), 606 | eql = _dereq_('../eql'); 607 | 608 | var aSlice = Array.prototype.slice; 609 | 610 | module.exports = function(should, Assertion) { 611 | var i = should.format; 612 | 613 | Assertion.add('enumerable', function(name, val) { 614 | name = String(name); 615 | 616 | this.params = { 617 | operator:"to have enumerable property " + util.formatProp(name) 618 | }; 619 | 620 | this.assert(this.obj.propertyIsEnumerable(name)); 621 | 622 | if(arguments.length > 1){ 623 | this.params.operator += " equal to "+i(val); 624 | this.assert(eql(val, this.obj[name])); 625 | } 626 | }); 627 | 628 | Assertion.add('property', function(name, val) { 629 | name = String(name); 630 | if(arguments.length > 1) { 631 | var p = {}; 632 | p[name] = val; 633 | this.have.properties(p); 634 | } else { 635 | this.have.properties(name); 636 | } 637 | this.obj = this.obj[name]; 638 | }); 639 | 640 | Assertion.add('properties', function(names) { 641 | var values = {}; 642 | if(arguments.length > 1) { 643 | names = aSlice.call(arguments); 644 | } else if(!util.isArray(names)) { 645 | if(util.isString(names)) { 646 | names = [names]; 647 | } else { 648 | values = names; 649 | names = Object.keys(names); 650 | } 651 | } 652 | 653 | var obj = Object(this.obj), missingProperties = []; 654 | 655 | //just enumerate properties and check if they all present 656 | names.forEach(function(name) { 657 | if(!(name in obj)) missingProperties.push(util.formatProp(name)); 658 | }); 659 | 660 | var props = missingProperties; 661 | if(props.length === 0) { 662 | props = names.map(util.formatProp); 663 | } else if(this.anyOne) { 664 | props = names.filter(function(name) { 665 | return missingProperties.indexOf(util.formatProp(name)) < 0; 666 | }).map(util.formatProp); 667 | } 668 | 669 | var operator = (props.length === 1 ? 670 | 'to have property ' : 'to have '+(this.anyOne? 'any of ' : '')+'properties ') + props.join(', '); 671 | 672 | this.params = { operator: operator }; 673 | 674 | //check that all properties presented 675 | //or if we request one of them that at least one them presented 676 | this.assert(missingProperties.length === 0 || (this.anyOne && missingProperties.length != names.length)); 677 | 678 | // check if values in object matched expected 679 | var valueCheckNames = Object.keys(values); 680 | if(valueCheckNames.length) { 681 | var wrongValues = []; 682 | props = []; 683 | 684 | // now check values, as there we have all properties 685 | valueCheckNames.forEach(function(name) { 686 | var value = values[name]; 687 | if(!eql(obj[name], value)) { 688 | wrongValues.push(util.formatProp(name) + ' of ' + i(value) + ' (got ' + i(obj[name]) + ')'); 689 | } else { 690 | props.push(util.formatProp(name) + ' of ' + i(value)); 691 | } 692 | }); 693 | 694 | if((wrongValues.length !== 0 && !this.anyOne) || (this.anyOne && props.length === 0)) { 695 | props = wrongValues; 696 | } 697 | 698 | operator = (props.length === 1 ? 699 | 'to have property ' : 'to have '+(this.anyOne? 'any of ' : '')+'properties ') + props.join(', '); 700 | 701 | this.params = { operator: operator }; 702 | 703 | //if there is no not matched values 704 | //or there is at least one matched 705 | this.assert(wrongValues.length === 0 || (this.anyOne && wrongValues.length != valueCheckNames.length)); 706 | } 707 | }); 708 | 709 | Assertion.add('length', function(n, description) { 710 | this.have.property('length', n, description); 711 | }); 712 | 713 | Assertion.alias('length', 'lengthOf'); 714 | 715 | var hasOwnProperty = Object.prototype.hasOwnProperty; 716 | 717 | Assertion.add('ownProperty', function(name, description) { 718 | name = String(name); 719 | this.params = { operator: 'to have own property ' + util.formatProp(name), message: description }; 720 | 721 | this.assert(hasOwnProperty.call(this.obj, name)); 722 | 723 | this.obj = this.obj[name]; 724 | }); 725 | 726 | Assertion.alias('ownProperty', 'hasOwnProperty'); 727 | 728 | Assertion.add('empty', function() { 729 | this.params = { operator: 'to be empty' }; 730 | 731 | if(util.isString(this.obj) || util.isArray(this.obj) || util.isArguments(this.obj)) { 732 | this.have.property('length', 0); 733 | } else { 734 | var obj = Object(this.obj); // wrap to reference for booleans and numbers 735 | for(var prop in obj) { 736 | this.have.not.ownProperty(prop); 737 | } 738 | } 739 | }, true); 740 | 741 | Assertion.add('keys', function(keys) { 742 | if(arguments.length > 1) keys = aSlice.call(arguments); 743 | else if(arguments.length === 1 && util.isString(keys)) keys = [ keys ]; 744 | else if(arguments.length === 0) keys = []; 745 | 746 | keys = keys.map(String); 747 | 748 | var obj = Object(this.obj); 749 | 750 | // first check if some keys are missing 751 | var missingKeys = []; 752 | keys.forEach(function(key) { 753 | if(!hasOwnProperty.call(this.obj, key)) 754 | missingKeys.push(util.formatProp(key)); 755 | }, this); 756 | 757 | // second check for extra keys 758 | var extraKeys = []; 759 | Object.keys(obj).forEach(function(key) { 760 | if(keys.indexOf(key) < 0) { 761 | extraKeys.push(util.formatProp(key)); 762 | } 763 | }); 764 | 765 | var verb = keys.length === 0 ? 'to be empty' : 766 | 'to have ' + (keys.length === 1 ? 'key ' : 'keys '); 767 | 768 | this.params = { operator: verb + keys.map(util.formatProp).join(', ')}; 769 | 770 | if(missingKeys.length > 0) 771 | this.params.operator += '\n\tmissing keys: ' + missingKeys.join(', '); 772 | 773 | if(extraKeys.length > 0) 774 | this.params.operator += '\n\textra keys: ' + extraKeys.join(', '); 775 | 776 | this.assert(missingKeys.length === 0 && extraKeys.length === 0); 777 | }); 778 | 779 | Assertion.alias("keys", "key"); 780 | 781 | Assertion.add('propertyByPath', function(properties) { 782 | if(arguments.length > 1) properties = aSlice.call(arguments); 783 | else if(arguments.length === 1 && util.isString(properties)) properties = [ properties ]; 784 | else if(arguments.length === 0) properties = []; 785 | 786 | var allProps = properties.map(util.formatProp); 787 | 788 | properties = properties.map(String); 789 | 790 | var obj = should(Object(this.obj)); 791 | 792 | var foundProperties = []; 793 | 794 | var currentProperty; 795 | while(currentProperty = properties.shift()) { 796 | this.params = { operator: 'to have property by path ' + allProps.join(', ') + ' - failed on ' + util.formatProp(currentProperty) }; 797 | obj = obj.have.property(currentProperty); 798 | foundProperties.push(currentProperty); 799 | } 800 | 801 | this.params = { operator: 'to have property by path ' + allProps.join(', ') }; 802 | 803 | this.obj = obj.obj; 804 | }); 805 | }; 806 | 807 | },{"../eql":1,"../util":15}],11:[function(_dereq_,module,exports){ 808 | /*! 809 | * Should 810 | * Copyright(c) 2010-2014 TJ Holowaychuk 811 | * MIT Licensed 812 | */ 813 | 814 | module.exports = function(should, Assertion) { 815 | Assertion.add('startWith', function(str, description) { 816 | this.params = { operator: 'to start with ' + should.format(str), message: description }; 817 | 818 | this.assert(0 === this.obj.indexOf(str)); 819 | }); 820 | 821 | Assertion.add('endWith', function(str, description) { 822 | this.params = { operator: 'to end with ' + should.format(str), message: description }; 823 | 824 | this.assert(this.obj.indexOf(str, this.obj.length - str.length) >= 0); 825 | }); 826 | }; 827 | },{}],12:[function(_dereq_,module,exports){ 828 | /*! 829 | * Should 830 | * Copyright(c) 2010-2014 TJ Holowaychuk 831 | * MIT Licensed 832 | */ 833 | 834 | var util = _dereq_('../util'); 835 | 836 | module.exports = function(should, Assertion) { 837 | Assertion.add('Number', function() { 838 | this.params = { operator: 'to be a number' }; 839 | 840 | this.assert(util.isNumber(this.obj)); 841 | }, true); 842 | 843 | Assertion.add('arguments', function() { 844 | this.params = { operator: 'to be arguments' }; 845 | 846 | this.assert(util.isArguments(this.obj)); 847 | }, true); 848 | 849 | Assertion.add('type', function(type, description) { 850 | this.params = { operator: 'to have type ' + type, message: description }; 851 | 852 | (typeof this.obj).should.be.exactly(type, description); 853 | }); 854 | 855 | Assertion.add('instanceof', function(constructor, description) { 856 | this.params = { operator: 'to be an instance of ' + util.functionName(constructor), message: description }; 857 | 858 | this.assert(Object(this.obj) instanceof constructor); 859 | }); 860 | 861 | Assertion.add('Function', function() { 862 | this.params = { operator: 'to be a function' }; 863 | 864 | this.assert(util.isFunction(this.obj)); 865 | }, true); 866 | 867 | Assertion.add('Object', function() { 868 | this.params = { operator: 'to be an object' }; 869 | 870 | this.assert(util.isObject(this.obj)); 871 | }, true); 872 | 873 | Assertion.add('String', function() { 874 | this.params = { operator: 'to be a string' }; 875 | 876 | this.assert(util.isString(this.obj)); 877 | }, true); 878 | 879 | Assertion.add('Array', function() { 880 | this.params = { operator: 'to be an array' }; 881 | 882 | this.assert(util.isArray(this.obj)); 883 | }, true); 884 | 885 | Assertion.add('Boolean', function() { 886 | this.params = { operator: 'to be a boolean' }; 887 | 888 | this.assert(util.isBoolean(this.obj)); 889 | }, true); 890 | 891 | Assertion.add('Error', function() { 892 | this.params = { operator: 'to be an error' }; 893 | 894 | this.assert(util.isError(this.obj)); 895 | }, true); 896 | 897 | Assertion.add('null', function() { 898 | this.params = { operator: 'to be null' }; 899 | 900 | this.assert(this.obj === null); 901 | }, true); 902 | 903 | Assertion.alias('null', 'Null'); 904 | 905 | Assertion.alias('instanceof', 'instanceOf'); 906 | }; 907 | 908 | },{"../util":15}],13:[function(_dereq_,module,exports){ 909 | // Copyright Joyent, Inc. and other Node contributors. 910 | // 911 | // Permission is hereby granted, free of charge, to any person obtaining a 912 | // copy of this software and associated documentation files (the 913 | // "Software"), to deal in the Software without restriction, including 914 | // without limitation the rights to use, copy, modify, merge, publish, 915 | // distribute, sublicense, and/or sell copies of the Software, and to permit 916 | // persons to whom the Software is furnished to do so, subject to the 917 | // following conditions: 918 | // 919 | // The above copyright notice and this permission notice shall be included 920 | // in all copies or substantial portions of the Software. 921 | // 922 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 923 | // OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 924 | // MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN 925 | // NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 926 | // DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 927 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE 928 | // USE OR OTHER DEALINGS IN THE SOFTWARE. 929 | 930 | var util = _dereq_('./util'); 931 | var isBoolean = util.isBoolean; 932 | var isObject = util.isObject; 933 | var isUndefined = util.isUndefined; 934 | var isFunction = util.isFunction; 935 | var isString = util.isString; 936 | var isNumber = util.isNumber; 937 | var isNull = util.isNull; 938 | var isRegExp = util.isRegExp; 939 | var isDate = util.isDate; 940 | var isError = util.isError; 941 | var isArray = util.isArray; 942 | 943 | /** 944 | * Echos the value of a value. Trys to print the value out 945 | * in the best way possible given the different types. 946 | * 947 | * @param {Object} obj The object to print out. 948 | * @param {Object} opts Optional options object that alters the output. 949 | */ 950 | /* legacy: obj, showHidden, depth, colors*/ 951 | function inspect(obj, opts) { 952 | // default options 953 | var ctx = { 954 | seen: [], 955 | stylize: stylizeNoColor 956 | }; 957 | // legacy... 958 | if (arguments.length >= 3) ctx.depth = arguments[2]; 959 | if (arguments.length >= 4) ctx.colors = arguments[3]; 960 | if (isBoolean(opts)) { 961 | // legacy... 962 | ctx.showHidden = opts; 963 | } else if (opts) { 964 | // got an "options" object 965 | exports._extend(ctx, opts); 966 | } 967 | // set default options 968 | if (isUndefined(ctx.showHidden)) ctx.showHidden = false; 969 | if (isUndefined(ctx.depth)) ctx.depth = 2; 970 | if (isUndefined(ctx.colors)) ctx.colors = false; 971 | if (isUndefined(ctx.customInspect)) ctx.customInspect = true; 972 | if (ctx.colors) ctx.stylize = stylizeWithColor; 973 | return formatValue(ctx, obj, ctx.depth); 974 | } 975 | exports.inspect = inspect; 976 | 977 | 978 | // http://en.wikipedia.org/wiki/ANSI_escape_code#graphics 979 | inspect.colors = { 980 | 'bold' : [1, 22], 981 | 'italic' : [3, 23], 982 | 'underline' : [4, 24], 983 | 'inverse' : [7, 27], 984 | 'white' : [37, 39], 985 | 'grey' : [90, 39], 986 | 'black' : [30, 39], 987 | 'blue' : [34, 39], 988 | 'cyan' : [36, 39], 989 | 'green' : [32, 39], 990 | 'magenta' : [35, 39], 991 | 'red' : [31, 39], 992 | 'yellow' : [33, 39] 993 | }; 994 | 995 | // Don't use 'blue' not visible on cmd.exe 996 | inspect.styles = { 997 | 'special': 'cyan', 998 | 'number': 'yellow', 999 | 'boolean': 'yellow', 1000 | 'undefined': 'grey', 1001 | 'null': 'bold', 1002 | 'string': 'green', 1003 | 'date': 'magenta', 1004 | // "name": intentionally not styling 1005 | 'regexp': 'red' 1006 | }; 1007 | 1008 | 1009 | function stylizeWithColor(str, styleType) { 1010 | var style = inspect.styles[styleType]; 1011 | 1012 | if (style) { 1013 | return '\u001b[' + inspect.colors[style][0] + 'm' + str + 1014 | '\u001b[' + inspect.colors[style][1] + 'm'; 1015 | } else { 1016 | return str; 1017 | } 1018 | } 1019 | 1020 | 1021 | function stylizeNoColor(str, styleType) { 1022 | return str; 1023 | } 1024 | 1025 | 1026 | function arrayToHash(array) { 1027 | var hash = {}; 1028 | 1029 | array.forEach(function(val, idx) { 1030 | hash[val] = true; 1031 | }); 1032 | 1033 | return hash; 1034 | } 1035 | 1036 | 1037 | function formatValue(ctx, value, recurseTimes) { 1038 | // Provide a hook for user-specified inspect functions. 1039 | // Check that value is an object with an inspect function on it 1040 | if (ctx.customInspect && 1041 | value && 1042 | isFunction(value.inspect) && 1043 | // Filter out the util module, it's inspect function is special 1044 | value.inspect !== exports.inspect && 1045 | // Also filter out any prototype objects using the circular check. 1046 | !(value.constructor && value.constructor.prototype === value)) { 1047 | var ret = value.inspect(recurseTimes, ctx); 1048 | if (!isString(ret)) { 1049 | ret = formatValue(ctx, ret, recurseTimes); 1050 | } 1051 | return ret; 1052 | } 1053 | 1054 | // Primitive types cannot have properties 1055 | var primitive = formatPrimitive(ctx, value); 1056 | if (primitive) { 1057 | return primitive; 1058 | } 1059 | 1060 | // Look up the keys of the object. 1061 | var keys = Object.keys(value); 1062 | var visibleKeys = arrayToHash(keys); 1063 | 1064 | if (ctx.showHidden) { 1065 | keys = Object.getOwnPropertyNames(value); 1066 | } 1067 | 1068 | // This could be a boxed primitive (new String(), etc.), check valueOf() 1069 | // NOTE: Avoid calling `valueOf` on `Date` instance because it will return 1070 | // a number which, when object has some additional user-stored `keys`, 1071 | // will be printed out. 1072 | var formatted; 1073 | var raw = value; 1074 | try { 1075 | // the .valueOf() call can fail for a multitude of reasons 1076 | if (!isDate(value)) 1077 | raw = value.valueOf(); 1078 | } catch (e) { 1079 | // ignore... 1080 | } 1081 | 1082 | if (isString(raw)) { 1083 | // for boxed Strings, we have to remove the 0-n indexed entries, 1084 | // since they just noisey up the output and are redundant 1085 | keys = keys.filter(function(key) { 1086 | return !(key >= 0 && key < raw.length); 1087 | }); 1088 | } 1089 | 1090 | if (isError(value)) { 1091 | return formatError(value); 1092 | } 1093 | 1094 | // Some type of object without properties can be shortcutted. 1095 | if (keys.length === 0) { 1096 | if (isFunction(value)) { 1097 | var name = value.name ? ': ' + value.name : ''; 1098 | return ctx.stylize('[Function' + name + ']', 'special'); 1099 | } 1100 | if (isRegExp(value)) { 1101 | return ctx.stylize(RegExp.prototype.toString.call(value), 'regexp'); 1102 | } 1103 | if (isDate(value)) { 1104 | return ctx.stylize(Date.prototype.toString.call(value), 'date'); 1105 | } 1106 | // now check the `raw` value to handle boxed primitives 1107 | if (isString(raw)) { 1108 | formatted = formatPrimitiveNoColor(ctx, raw); 1109 | return ctx.stylize('[String: ' + formatted + ']', 'string'); 1110 | } 1111 | if (isNumber(raw)) { 1112 | formatted = formatPrimitiveNoColor(ctx, raw); 1113 | return ctx.stylize('[Number: ' + formatted + ']', 'number'); 1114 | } 1115 | if (isBoolean(raw)) { 1116 | formatted = formatPrimitiveNoColor(ctx, raw); 1117 | return ctx.stylize('[Boolean: ' + formatted + ']', 'boolean'); 1118 | } 1119 | } 1120 | 1121 | var base = '', array = false, braces = ['{', '}']; 1122 | 1123 | // Make Array say that they are Array 1124 | if (isArray(value)) { 1125 | array = true; 1126 | braces = ['[', ']']; 1127 | } 1128 | 1129 | // Make functions say that they are functions 1130 | if (isFunction(value)) { 1131 | var n = value.name ? ': ' + value.name : ''; 1132 | base = ' [Function' + n + ']'; 1133 | } 1134 | 1135 | // Make RegExps say that they are RegExps 1136 | if (isRegExp(value)) { 1137 | base = ' ' + RegExp.prototype.toString.call(value); 1138 | } 1139 | 1140 | // Make dates with properties first say the date 1141 | if (isDate(value)) { 1142 | base = ' ' + Date.prototype.toUTCString.call(value); 1143 | } 1144 | 1145 | // Make error with message first say the error 1146 | if (isError(value)) { 1147 | base = ' ' + formatError(value); 1148 | } 1149 | 1150 | // Make boxed primitive Strings look like such 1151 | if (isString(raw)) { 1152 | formatted = formatPrimitiveNoColor(ctx, raw); 1153 | base = ' ' + '[String: ' + formatted + ']'; 1154 | } 1155 | 1156 | // Make boxed primitive Numbers look like such 1157 | if (isNumber(raw)) { 1158 | formatted = formatPrimitiveNoColor(ctx, raw); 1159 | base = ' ' + '[Number: ' + formatted + ']'; 1160 | } 1161 | 1162 | // Make boxed primitive Booleans look like such 1163 | if (isBoolean(raw)) { 1164 | formatted = formatPrimitiveNoColor(ctx, raw); 1165 | base = ' ' + '[Boolean: ' + formatted + ']'; 1166 | } 1167 | 1168 | if (keys.length === 0 && (!array || value.length === 0)) { 1169 | return braces[0] + base + braces[1]; 1170 | } 1171 | 1172 | if (recurseTimes < 0) { 1173 | if (isRegExp(value)) { 1174 | return ctx.stylize(RegExp.prototype.toString.call(value), 'regexp'); 1175 | } else { 1176 | return ctx.stylize('[Object]', 'special'); 1177 | } 1178 | } 1179 | 1180 | ctx.seen.push(value); 1181 | 1182 | var output; 1183 | if (array) { 1184 | output = formatArray(ctx, value, recurseTimes, visibleKeys, keys); 1185 | } else { 1186 | output = keys.map(function(key) { 1187 | return formatProperty(ctx, value, recurseTimes, visibleKeys, key, array); 1188 | }); 1189 | } 1190 | 1191 | ctx.seen.pop(); 1192 | 1193 | return reduceToSingleString(output, base, braces); 1194 | } 1195 | 1196 | 1197 | function formatPrimitive(ctx, value) { 1198 | if (isUndefined(value)) 1199 | return ctx.stylize('undefined', 'undefined'); 1200 | if (isString(value)) { 1201 | var simple = '\'' + JSON.stringify(value).replace(/^"|"$/g, '') 1202 | .replace(/'/g, "\\'") 1203 | .replace(/\\"/g, '"') + '\''; 1204 | return ctx.stylize(simple, 'string'); 1205 | } 1206 | if (isNumber(value)) { 1207 | // Format -0 as '-0'. Strict equality won't distinguish 0 from -0, 1208 | // so instead we use the fact that 1 / -0 < 0 whereas 1 / 0 > 0 . 1209 | if (value === 0 && 1 / value < 0) 1210 | return ctx.stylize('-0', 'number'); 1211 | return ctx.stylize('' + value, 'number'); 1212 | } 1213 | if (isBoolean(value)) 1214 | return ctx.stylize('' + value, 'boolean'); 1215 | // For some reason typeof null is "object", so special case here. 1216 | if (isNull(value)) 1217 | return ctx.stylize('null', 'null'); 1218 | } 1219 | 1220 | 1221 | function formatPrimitiveNoColor(ctx, value) { 1222 | var stylize = ctx.stylize; 1223 | ctx.stylize = stylizeNoColor; 1224 | var str = formatPrimitive(ctx, value); 1225 | ctx.stylize = stylize; 1226 | return str; 1227 | } 1228 | 1229 | 1230 | function formatError(value) { 1231 | return '[' + Error.prototype.toString.call(value) + ']'; 1232 | } 1233 | 1234 | 1235 | function formatArray(ctx, value, recurseTimes, visibleKeys, keys) { 1236 | var output = []; 1237 | for (var i = 0, l = value.length; i < l; ++i) { 1238 | if (hasOwnProperty(value, String(i))) { 1239 | output.push(formatProperty(ctx, value, recurseTimes, visibleKeys, 1240 | String(i), true)); 1241 | } else { 1242 | output.push(''); 1243 | } 1244 | } 1245 | keys.forEach(function(key) { 1246 | if (!key.match(/^\d+$/)) { 1247 | output.push(formatProperty(ctx, value, recurseTimes, visibleKeys, 1248 | key, true)); 1249 | } 1250 | }); 1251 | return output; 1252 | } 1253 | 1254 | 1255 | function formatProperty(ctx, value, recurseTimes, visibleKeys, key, array) { 1256 | var name, str, desc; 1257 | desc = Object.getOwnPropertyDescriptor(value, key) || { value: value[key] }; 1258 | if (desc.get) { 1259 | if (desc.set) { 1260 | str = ctx.stylize('[Getter/Setter]', 'special'); 1261 | } else { 1262 | str = ctx.stylize('[Getter]', 'special'); 1263 | } 1264 | } else { 1265 | if (desc.set) { 1266 | str = ctx.stylize('[Setter]', 'special'); 1267 | } 1268 | } 1269 | if (!hasOwnProperty(visibleKeys, key)) { 1270 | name = '[' + key + ']'; 1271 | } 1272 | if (!str) { 1273 | if (ctx.seen.indexOf(desc.value) < 0) { 1274 | if (isNull(recurseTimes)) { 1275 | str = formatValue(ctx, desc.value, null); 1276 | } else { 1277 | str = formatValue(ctx, desc.value, recurseTimes - 1); 1278 | } 1279 | if (str.indexOf('\n') > -1) { 1280 | if (array) { 1281 | str = str.split('\n').map(function(line) { 1282 | return ' ' + line; 1283 | }).join('\n').substr(2); 1284 | } else { 1285 | str = '\n' + str.split('\n').map(function(line) { 1286 | return ' ' + line; 1287 | }).join('\n'); 1288 | } 1289 | } 1290 | } else { 1291 | str = ctx.stylize('[Circular]', 'special'); 1292 | } 1293 | } 1294 | if (isUndefined(name)) { 1295 | if (array && key.match(/^\d+$/)) { 1296 | return str; 1297 | } 1298 | name = JSON.stringify('' + key); 1299 | if (name.match(/^"([a-zA-Z_][a-zA-Z_0-9]*)"$/)) { 1300 | name = name.substr(1, name.length - 2); 1301 | name = ctx.stylize(name, 'name'); 1302 | } else { 1303 | name = name.replace(/'/g, "\\'") 1304 | .replace(/\\"/g, '"') 1305 | .replace(/(^"|"$)/g, "'") 1306 | .replace(/\\\\/g, '\\'); 1307 | name = ctx.stylize(name, 'string'); 1308 | } 1309 | } 1310 | 1311 | return name + ': ' + str; 1312 | } 1313 | 1314 | 1315 | function reduceToSingleString(output, base, braces) { 1316 | var length = output.reduce(function(prev, cur) { 1317 | return prev + cur.replace(/\u001b\[\d\d?m/g, '').length + 1; 1318 | }, 0); 1319 | 1320 | if (length > 60) { 1321 | return braces[0] + 1322 | (base === '' ? '' : base + '\n ') + 1323 | ' ' + 1324 | output.join(',\n ') + 1325 | ' ' + 1326 | braces[1]; 1327 | } 1328 | 1329 | return braces[0] + base + ' ' + output.join(', ') + ' ' + braces[1]; 1330 | } 1331 | 1332 | exports._extend = function _extend(origin, add) { 1333 | // Don't do anything if add isn't an object 1334 | if (!add || !isObject(add)) return origin; 1335 | 1336 | var keys = Object.keys(add); 1337 | var i = keys.length; 1338 | while (i--) { 1339 | origin[keys[i]] = add[keys[i]]; 1340 | } 1341 | return origin; 1342 | }; 1343 | 1344 | function hasOwnProperty(obj, prop) { 1345 | return Object.prototype.hasOwnProperty.call(obj, prop); 1346 | } 1347 | 1348 | },{"./util":15}],14:[function(_dereq_,module,exports){ 1349 | /*! 1350 | * Should 1351 | * Copyright(c) 2010-2014 TJ Holowaychuk 1352 | * MIT Licensed 1353 | */ 1354 | 1355 | 1356 | var util = _dereq_('./util'), 1357 | AssertionError = util.AssertionError, 1358 | inspect = util.inspect; 1359 | 1360 | /** 1361 | * Our function should 1362 | * @param obj 1363 | * @returns {Assertion} 1364 | */ 1365 | var should = function(obj) { 1366 | return new Assertion(util.isWrapperType(obj) ? obj.valueOf() : obj); 1367 | }; 1368 | 1369 | /** 1370 | * Initialize a new `Assertion` with the given _obj_. 1371 | * 1372 | * @param {*} obj 1373 | * @api private 1374 | */ 1375 | 1376 | var Assertion = should.Assertion = function Assertion(obj) { 1377 | this.obj = obj; 1378 | }; 1379 | 1380 | 1381 | /** 1382 | Way to extend Assertion function. It uses some logic 1383 | to define only positive assertions and itself rule with negative assertion. 1384 | 1385 | All actions happen in subcontext and this method take care about negation. 1386 | Potentially we can add some more modifiers that does not depends from state of assertion. 1387 | */ 1388 | Assertion.add = function(name, f, isGetter) { 1389 | var prop = { enumerable: true }; 1390 | prop[isGetter ? 'get' : 'value'] = function() { 1391 | var context = new Assertion(this.obj); 1392 | context.copy = context.copyIfMissing; 1393 | context.anyOne = this.anyOne; 1394 | 1395 | try { 1396 | f.apply(context, arguments); 1397 | } catch(e) { 1398 | //copy data from sub context to this 1399 | this.copy(context); 1400 | 1401 | //check for fail 1402 | if(e instanceof should.AssertionError) { 1403 | //negative fail 1404 | if(this.negate) { 1405 | this.obj = context.obj; 1406 | this.negate = false; 1407 | return this; 1408 | } 1409 | this.assert(false); 1410 | } 1411 | // throw if it is another exception 1412 | throw e; 1413 | } 1414 | //copy data from sub context to this 1415 | this.copy(context); 1416 | if(this.negate) { 1417 | this.assert(false); 1418 | } 1419 | 1420 | this.obj = context.obj; 1421 | this.negate = false; 1422 | return this; 1423 | }; 1424 | 1425 | Object.defineProperty(Assertion.prototype, name, prop); 1426 | }; 1427 | 1428 | Assertion.alias = function(from, to) { 1429 | var desc = Object.getOwnPropertyDescriptor(Assertion.prototype, from); 1430 | if(!desc) throw new Error('Alias ' + from + ' -> ' + to + ' could not be created as ' + from + ' not defined'); 1431 | Object.defineProperty(Assertion.prototype, to, desc); 1432 | }; 1433 | 1434 | should.AssertionError = AssertionError; 1435 | should.format = function (value) { 1436 | if(util.isDate(value) && typeof value.inspect !== 'function') return value.toISOString(); //show millis in dates 1437 | return inspect(value, { depth: null }); 1438 | }; 1439 | 1440 | should.use = function(f) { 1441 | f(this, Assertion); 1442 | return this; 1443 | }; 1444 | 1445 | 1446 | /** 1447 | * Expose should to external world. 1448 | */ 1449 | exports = module.exports = should; 1450 | 1451 | 1452 | /** 1453 | * Expose api via `Object#should`. 1454 | * 1455 | * @api public 1456 | */ 1457 | 1458 | Object.defineProperty(Object.prototype, 'should', { 1459 | set: function() { 1460 | }, 1461 | get: function() { 1462 | return should(this); 1463 | }, 1464 | configurable: true 1465 | }); 1466 | 1467 | 1468 | Assertion.prototype = { 1469 | constructor: Assertion, 1470 | 1471 | assert: function(expr) { 1472 | if(expr) return this; 1473 | 1474 | var params = this.params; 1475 | 1476 | var msg = params.message, generatedMessage = false; 1477 | if(!msg) { 1478 | msg = this.getMessage(); 1479 | generatedMessage = true; 1480 | } 1481 | 1482 | var err = new AssertionError({ 1483 | message: msg, actual: this.obj, expected: params.expected, stackStartFunction: this.assert 1484 | }); 1485 | 1486 | err.showDiff = params.showDiff; 1487 | err.operator = params.operator; 1488 | err.generatedMessage = generatedMessage; 1489 | 1490 | throw err; 1491 | }, 1492 | 1493 | getMessage: function() { 1494 | return 'expected ' + ('obj' in this.params ? this.params.obj: should.format(this.obj)) + (this.negate ? ' not ': ' ') + 1495 | this.params.operator + ('expected' in this.params ? ' ' + should.format(this.params.expected) : ''); 1496 | }, 1497 | 1498 | copy: function(other) { 1499 | this.params = other.params; 1500 | }, 1501 | 1502 | copyIfMissing: function(other) { 1503 | if(!this.params) this.params = other.params; 1504 | }, 1505 | 1506 | 1507 | /** 1508 | * Negation modifier. 1509 | * 1510 | * @api public 1511 | */ 1512 | 1513 | get not() { 1514 | this.negate = !this.negate; 1515 | return this; 1516 | }, 1517 | 1518 | /** 1519 | * Any modifier - it affect on execution of sequenced assertion to do not check all, but any of 1520 | * 1521 | * @api public 1522 | */ 1523 | get any() { 1524 | this.anyOne = true; 1525 | return this; 1526 | } 1527 | }; 1528 | 1529 | should 1530 | .use(_dereq_('./ext/assert')) 1531 | .use(_dereq_('./ext/chain')) 1532 | .use(_dereq_('./ext/bool')) 1533 | .use(_dereq_('./ext/number')) 1534 | .use(_dereq_('./ext/eql')) 1535 | .use(_dereq_('./ext/type')) 1536 | .use(_dereq_('./ext/string')) 1537 | .use(_dereq_('./ext/property')) 1538 | .use(_dereq_('./ext/error')) 1539 | .use(_dereq_('./ext/match')) 1540 | .use(_dereq_('./ext/contain')); 1541 | 1542 | },{"./ext/assert":2,"./ext/bool":3,"./ext/chain":4,"./ext/contain":5,"./ext/eql":6,"./ext/error":7,"./ext/match":8,"./ext/number":9,"./ext/property":10,"./ext/string":11,"./ext/type":12,"./util":15}],15:[function(_dereq_,module,exports){ 1543 | /*! 1544 | * Should 1545 | * Copyright(c) 2010-2014 TJ Holowaychuk 1546 | * MIT Licensed 1547 | */ 1548 | 1549 | /** 1550 | * Check if given obj just a primitive type wrapper 1551 | * @param {Object} obj 1552 | * @returns {boolean} 1553 | * @api private 1554 | */ 1555 | exports.isWrapperType = function(obj) { 1556 | return isNumber(obj) || isString(obj) || isBoolean(obj); 1557 | }; 1558 | 1559 | /** 1560 | * Merge object b with object a. 1561 | * 1562 | * var a = { foo: 'bar' } 1563 | * , b = { bar: 'baz' }; 1564 | * 1565 | * utils.merge(a, b); 1566 | * // => { foo: 'bar', bar: 'baz' } 1567 | * 1568 | * @param {Object} a 1569 | * @param {Object} b 1570 | * @return {Object} 1571 | * @api private 1572 | */ 1573 | 1574 | exports.merge = function(a, b){ 1575 | if (a && b) { 1576 | for (var key in b) { 1577 | a[key] = b[key]; 1578 | } 1579 | } 1580 | return a; 1581 | }; 1582 | 1583 | function isArray(arr) { 1584 | return isObject(arr) && (arr.__ArrayLike || Array.isArray(arr)); 1585 | } 1586 | 1587 | exports.isArray = isArray; 1588 | 1589 | function isNumber(arg) { 1590 | return typeof arg === 'number' || arg instanceof Number; 1591 | } 1592 | 1593 | exports.isNumber = isNumber; 1594 | 1595 | function isString(arg) { 1596 | return typeof arg === 'string' || arg instanceof String; 1597 | } 1598 | 1599 | function isBoolean(arg) { 1600 | return typeof arg === 'boolean' || arg instanceof Boolean; 1601 | } 1602 | exports.isBoolean = isBoolean; 1603 | 1604 | exports.isString = isString; 1605 | 1606 | function isBuffer(arg) { 1607 | return typeof Buffer !== 'undefined' && arg instanceof Buffer; 1608 | } 1609 | 1610 | exports.isBuffer = isBuffer; 1611 | 1612 | function isDate(d) { 1613 | return isObject(d) && objectToString(d) === '[object Date]'; 1614 | } 1615 | 1616 | exports.isDate = isDate; 1617 | 1618 | function objectToString(o) { 1619 | return Object.prototype.toString.call(o); 1620 | } 1621 | 1622 | function isObject(arg) { 1623 | return typeof arg === 'object' && arg !== null; 1624 | } 1625 | 1626 | exports.isObject = isObject; 1627 | 1628 | function isRegExp(re) { 1629 | return isObject(re) && objectToString(re) === '[object RegExp]'; 1630 | } 1631 | 1632 | exports.isRegExp = isRegExp; 1633 | 1634 | function isNullOrUndefined(arg) { 1635 | return arg == null; 1636 | } 1637 | 1638 | exports.isNullOrUndefined = isNullOrUndefined; 1639 | 1640 | function isNull(arg) { 1641 | return arg === null; 1642 | } 1643 | exports.isNull = isNull; 1644 | 1645 | function isArguments(object) { 1646 | return objectToString(object) === '[object Arguments]'; 1647 | } 1648 | 1649 | exports.isArguments = isArguments; 1650 | 1651 | exports.isFunction = function(arg) { 1652 | return typeof arg === 'function' || arg instanceof Function; 1653 | }; 1654 | 1655 | function isError(e) { 1656 | return (isObject(e) && objectToString(e) === '[object Error]') || (e instanceof Error); 1657 | } 1658 | exports.isError = isError; 1659 | 1660 | function isUndefined(arg) { 1661 | return arg === void 0; 1662 | } 1663 | 1664 | exports.isUndefined = isUndefined; 1665 | 1666 | exports.inspect = _dereq_('./inspect').inspect; 1667 | 1668 | exports.AssertionError = _dereq_('assert').AssertionError; 1669 | 1670 | var hasOwnProperty = Object.prototype.hasOwnProperty; 1671 | 1672 | exports.forOwn = function(obj, f, context) { 1673 | for(var prop in obj) { 1674 | if(hasOwnProperty.call(obj, prop)) { 1675 | f.call(context, obj[prop], prop); 1676 | } 1677 | } 1678 | }; 1679 | 1680 | var functionNameRE = /^\s*function\s*(\S*)\s*\(/; 1681 | 1682 | exports.functionName = function(f) { 1683 | if(f.name) { 1684 | return f.name; 1685 | } 1686 | var name = f.toString().match(functionNameRE)[1]; 1687 | return name; 1688 | }; 1689 | 1690 | exports.formatProp = function(name) { 1691 | name = JSON.stringify('' + name); 1692 | if (name.match(/^"([a-zA-Z_][a-zA-Z_0-9]*)"$/)) { 1693 | name = name.substr(1, name.length - 2); 1694 | } else { 1695 | name = name.replace(/'/g, "\\'") 1696 | .replace(/\\"/g, '"') 1697 | .replace(/(^"|"$)/g, "'") 1698 | .replace(/\\\\/g, '\\'); 1699 | } 1700 | return name; 1701 | } 1702 | },{"./inspect":13,"assert":16}],16:[function(_dereq_,module,exports){ 1703 | // http://wiki.commonjs.org/wiki/Unit_Testing/1.0 1704 | // 1705 | // THIS IS NOT TESTED NOR LIKELY TO WORK OUTSIDE V8! 1706 | // 1707 | // Originally from narwhal.js (http://narwhaljs.org) 1708 | // Copyright (c) 2009 Thomas Robinson <280north.com> 1709 | // 1710 | // Permission is hereby granted, free of charge, to any person obtaining a copy 1711 | // of this software and associated documentation files (the 'Software'), to 1712 | // deal in the Software without restriction, including without limitation the 1713 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 1714 | // sell copies of the Software, and to permit persons to whom the Software is 1715 | // furnished to do so, subject to the following conditions: 1716 | // 1717 | // The above copyright notice and this permission notice shall be included in 1718 | // all copies or substantial portions of the Software. 1719 | // 1720 | // THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 1721 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 1722 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 1723 | // AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 1724 | // ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 1725 | // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 1726 | 1727 | // when used in node, this will actually load the util module we depend on 1728 | // versus loading the builtin util module as happens otherwise 1729 | // this is a bug in node module loading as far as I am concerned 1730 | var util = _dereq_('util/'); 1731 | 1732 | var pSlice = Array.prototype.slice; 1733 | var hasOwn = Object.prototype.hasOwnProperty; 1734 | 1735 | // 1. The assert module provides functions that throw 1736 | // AssertionError's when particular conditions are not met. The 1737 | // assert module must conform to the following interface. 1738 | 1739 | var assert = module.exports = ok; 1740 | 1741 | // 2. The AssertionError is defined in assert. 1742 | // new assert.AssertionError({ message: message, 1743 | // actual: actual, 1744 | // expected: expected }) 1745 | 1746 | assert.AssertionError = function AssertionError(options) { 1747 | this.name = 'AssertionError'; 1748 | this.actual = options.actual; 1749 | this.expected = options.expected; 1750 | this.operator = options.operator; 1751 | if (options.message) { 1752 | this.message = options.message; 1753 | this.generatedMessage = false; 1754 | } else { 1755 | this.message = getMessage(this); 1756 | this.generatedMessage = true; 1757 | } 1758 | var stackStartFunction = options.stackStartFunction || fail; 1759 | 1760 | if (Error.captureStackTrace) { 1761 | Error.captureStackTrace(this, stackStartFunction); 1762 | } 1763 | else { 1764 | // non v8 browsers so we can have a stacktrace 1765 | var err = new Error(); 1766 | if (err.stack) { 1767 | var out = err.stack; 1768 | 1769 | // try to strip useless frames 1770 | var fn_name = stackStartFunction.name; 1771 | var idx = out.indexOf('\n' + fn_name); 1772 | if (idx >= 0) { 1773 | // once we have located the function frame 1774 | // we need to strip out everything before it (and its line) 1775 | var next_line = out.indexOf('\n', idx + 1); 1776 | out = out.substring(next_line + 1); 1777 | } 1778 | 1779 | this.stack = out; 1780 | } 1781 | } 1782 | }; 1783 | 1784 | // assert.AssertionError instanceof Error 1785 | util.inherits(assert.AssertionError, Error); 1786 | 1787 | function replacer(key, value) { 1788 | if (util.isUndefined(value)) { 1789 | return '' + value; 1790 | } 1791 | if (util.isNumber(value) && (isNaN(value) || !isFinite(value))) { 1792 | return value.toString(); 1793 | } 1794 | if (util.isFunction(value) || util.isRegExp(value)) { 1795 | return value.toString(); 1796 | } 1797 | return value; 1798 | } 1799 | 1800 | function truncate(s, n) { 1801 | if (util.isString(s)) { 1802 | return s.length < n ? s : s.slice(0, n); 1803 | } else { 1804 | return s; 1805 | } 1806 | } 1807 | 1808 | function getMessage(self) { 1809 | return truncate(JSON.stringify(self.actual, replacer), 128) + ' ' + 1810 | self.operator + ' ' + 1811 | truncate(JSON.stringify(self.expected, replacer), 128); 1812 | } 1813 | 1814 | // At present only the three keys mentioned above are used and 1815 | // understood by the spec. Implementations or sub modules can pass 1816 | // other keys to the AssertionError's constructor - they will be 1817 | // ignored. 1818 | 1819 | // 3. All of the following functions must throw an AssertionError 1820 | // when a corresponding condition is not met, with a message that 1821 | // may be undefined if not provided. All assertion methods provide 1822 | // both the actual and expected values to the assertion error for 1823 | // display purposes. 1824 | 1825 | function fail(actual, expected, message, operator, stackStartFunction) { 1826 | throw new assert.AssertionError({ 1827 | message: message, 1828 | actual: actual, 1829 | expected: expected, 1830 | operator: operator, 1831 | stackStartFunction: stackStartFunction 1832 | }); 1833 | } 1834 | 1835 | // EXTENSION! allows for well behaved errors defined elsewhere. 1836 | assert.fail = fail; 1837 | 1838 | // 4. Pure assertion tests whether a value is truthy, as determined 1839 | // by !!guard. 1840 | // assert.ok(guard, message_opt); 1841 | // This statement is equivalent to assert.equal(true, !!guard, 1842 | // message_opt);. To test strictly for the value true, use 1843 | // assert.strictEqual(true, guard, message_opt);. 1844 | 1845 | function ok(value, message) { 1846 | if (!value) fail(value, true, message, '==', assert.ok); 1847 | } 1848 | assert.ok = ok; 1849 | 1850 | // 5. The equality assertion tests shallow, coercive equality with 1851 | // ==. 1852 | // assert.equal(actual, expected, message_opt); 1853 | 1854 | assert.equal = function equal(actual, expected, message) { 1855 | if (actual != expected) fail(actual, expected, message, '==', assert.equal); 1856 | }; 1857 | 1858 | // 6. The non-equality assertion tests for whether two objects are not equal 1859 | // with != assert.notEqual(actual, expected, message_opt); 1860 | 1861 | assert.notEqual = function notEqual(actual, expected, message) { 1862 | if (actual == expected) { 1863 | fail(actual, expected, message, '!=', assert.notEqual); 1864 | } 1865 | }; 1866 | 1867 | // 7. The equivalence assertion tests a deep equality relation. 1868 | // assert.deepEqual(actual, expected, message_opt); 1869 | 1870 | assert.deepEqual = function deepEqual(actual, expected, message) { 1871 | if (!_deepEqual(actual, expected)) { 1872 | fail(actual, expected, message, 'deepEqual', assert.deepEqual); 1873 | } 1874 | }; 1875 | 1876 | function _deepEqual(actual, expected) { 1877 | // 7.1. All identical values are equivalent, as determined by ===. 1878 | if (actual === expected) { 1879 | return true; 1880 | 1881 | } else if (util.isBuffer(actual) && util.isBuffer(expected)) { 1882 | if (actual.length != expected.length) return false; 1883 | 1884 | for (var i = 0; i < actual.length; i++) { 1885 | if (actual[i] !== expected[i]) return false; 1886 | } 1887 | 1888 | return true; 1889 | 1890 | // 7.2. If the expected value is a Date object, the actual value is 1891 | // equivalent if it is also a Date object that refers to the same time. 1892 | } else if (util.isDate(actual) && util.isDate(expected)) { 1893 | return actual.getTime() === expected.getTime(); 1894 | 1895 | // 7.3 If the expected value is a RegExp object, the actual value is 1896 | // equivalent if it is also a RegExp object with the same source and 1897 | // properties (`global`, `multiline`, `lastIndex`, `ignoreCase`). 1898 | } else if (util.isRegExp(actual) && util.isRegExp(expected)) { 1899 | return actual.source === expected.source && 1900 | actual.global === expected.global && 1901 | actual.multiline === expected.multiline && 1902 | actual.lastIndex === expected.lastIndex && 1903 | actual.ignoreCase === expected.ignoreCase; 1904 | 1905 | // 7.4. Other pairs that do not both pass typeof value == 'object', 1906 | // equivalence is determined by ==. 1907 | } else if (!util.isObject(actual) && !util.isObject(expected)) { 1908 | return actual == expected; 1909 | 1910 | // 7.5 For all other Object pairs, including Array objects, equivalence is 1911 | // determined by having the same number of owned properties (as verified 1912 | // with Object.prototype.hasOwnProperty.call), the same set of keys 1913 | // (although not necessarily the same order), equivalent values for every 1914 | // corresponding key, and an identical 'prototype' property. Note: this 1915 | // accounts for both named and indexed properties on Arrays. 1916 | } else { 1917 | return objEquiv(actual, expected); 1918 | } 1919 | } 1920 | 1921 | function isArguments(object) { 1922 | return Object.prototype.toString.call(object) == '[object Arguments]'; 1923 | } 1924 | 1925 | function objEquiv(a, b) { 1926 | if (util.isNullOrUndefined(a) || util.isNullOrUndefined(b)) 1927 | return false; 1928 | // an identical 'prototype' property. 1929 | if (a.prototype !== b.prototype) return false; 1930 | //~~~I've managed to break Object.keys through screwy arguments passing. 1931 | // Converting to array solves the problem. 1932 | if (isArguments(a)) { 1933 | if (!isArguments(b)) { 1934 | return false; 1935 | } 1936 | a = pSlice.call(a); 1937 | b = pSlice.call(b); 1938 | return _deepEqual(a, b); 1939 | } 1940 | try { 1941 | var ka = objectKeys(a), 1942 | kb = objectKeys(b), 1943 | key, i; 1944 | } catch (e) {//happens when one is a string literal and the other isn't 1945 | return false; 1946 | } 1947 | // having the same number of owned properties (keys incorporates 1948 | // hasOwnProperty) 1949 | if (ka.length != kb.length) 1950 | return false; 1951 | //the same set of keys (although not necessarily the same order), 1952 | ka.sort(); 1953 | kb.sort(); 1954 | //~~~cheap key test 1955 | for (i = ka.length - 1; i >= 0; i--) { 1956 | if (ka[i] != kb[i]) 1957 | return false; 1958 | } 1959 | //equivalent values for every corresponding key, and 1960 | //~~~possibly expensive deep test 1961 | for (i = ka.length - 1; i >= 0; i--) { 1962 | key = ka[i]; 1963 | if (!_deepEqual(a[key], b[key])) return false; 1964 | } 1965 | return true; 1966 | } 1967 | 1968 | // 8. The non-equivalence assertion tests for any deep inequality. 1969 | // assert.notDeepEqual(actual, expected, message_opt); 1970 | 1971 | assert.notDeepEqual = function notDeepEqual(actual, expected, message) { 1972 | if (_deepEqual(actual, expected)) { 1973 | fail(actual, expected, message, 'notDeepEqual', assert.notDeepEqual); 1974 | } 1975 | }; 1976 | 1977 | // 9. The strict equality assertion tests strict equality, as determined by ===. 1978 | // assert.strictEqual(actual, expected, message_opt); 1979 | 1980 | assert.strictEqual = function strictEqual(actual, expected, message) { 1981 | if (actual !== expected) { 1982 | fail(actual, expected, message, '===', assert.strictEqual); 1983 | } 1984 | }; 1985 | 1986 | // 10. The strict non-equality assertion tests for strict inequality, as 1987 | // determined by !==. assert.notStrictEqual(actual, expected, message_opt); 1988 | 1989 | assert.notStrictEqual = function notStrictEqual(actual, expected, message) { 1990 | if (actual === expected) { 1991 | fail(actual, expected, message, '!==', assert.notStrictEqual); 1992 | } 1993 | }; 1994 | 1995 | function expectedException(actual, expected) { 1996 | if (!actual || !expected) { 1997 | return false; 1998 | } 1999 | 2000 | if (Object.prototype.toString.call(expected) == '[object RegExp]') { 2001 | return expected.test(actual); 2002 | } else if (actual instanceof expected) { 2003 | return true; 2004 | } else if (expected.call({}, actual) === true) { 2005 | return true; 2006 | } 2007 | 2008 | return false; 2009 | } 2010 | 2011 | function _throws(shouldThrow, block, expected, message) { 2012 | var actual; 2013 | 2014 | if (util.isString(expected)) { 2015 | message = expected; 2016 | expected = null; 2017 | } 2018 | 2019 | try { 2020 | block(); 2021 | } catch (e) { 2022 | actual = e; 2023 | } 2024 | 2025 | message = (expected && expected.name ? ' (' + expected.name + ').' : '.') + 2026 | (message ? ' ' + message : '.'); 2027 | 2028 | if (shouldThrow && !actual) { 2029 | fail(actual, expected, 'Missing expected exception' + message); 2030 | } 2031 | 2032 | if (!shouldThrow && expectedException(actual, expected)) { 2033 | fail(actual, expected, 'Got unwanted exception' + message); 2034 | } 2035 | 2036 | if ((shouldThrow && actual && expected && 2037 | !expectedException(actual, expected)) || (!shouldThrow && actual)) { 2038 | throw actual; 2039 | } 2040 | } 2041 | 2042 | // 11. Expected to throw an error: 2043 | // assert.throws(block, Error_opt, message_opt); 2044 | 2045 | assert.throws = function(block, /*optional*/error, /*optional*/message) { 2046 | _throws.apply(this, [true].concat(pSlice.call(arguments))); 2047 | }; 2048 | 2049 | // EXTENSION! This is annoying to write outside this module. 2050 | assert.doesNotThrow = function(block, /*optional*/message) { 2051 | _throws.apply(this, [false].concat(pSlice.call(arguments))); 2052 | }; 2053 | 2054 | assert.ifError = function(err) { if (err) {throw err;}}; 2055 | 2056 | var objectKeys = Object.keys || function (obj) { 2057 | var keys = []; 2058 | for (var key in obj) { 2059 | if (hasOwn.call(obj, key)) keys.push(key); 2060 | } 2061 | return keys; 2062 | }; 2063 | 2064 | },{"util/":18}],17:[function(_dereq_,module,exports){ 2065 | module.exports = function isBuffer(arg) { 2066 | return arg && typeof arg === 'object' 2067 | && typeof arg.copy === 'function' 2068 | && typeof arg.fill === 'function' 2069 | && typeof arg.readUInt8 === 'function'; 2070 | } 2071 | },{}],18:[function(_dereq_,module,exports){ 2072 | // Copyright Joyent, Inc. and other Node contributors. 2073 | // 2074 | // Permission is hereby granted, free of charge, to any person obtaining a 2075 | // copy of this software and associated documentation files (the 2076 | // "Software"), to deal in the Software without restriction, including 2077 | // without limitation the rights to use, copy, modify, merge, publish, 2078 | // distribute, sublicense, and/or sell copies of the Software, and to permit 2079 | // persons to whom the Software is furnished to do so, subject to the 2080 | // following conditions: 2081 | // 2082 | // The above copyright notice and this permission notice shall be included 2083 | // in all copies or substantial portions of the Software. 2084 | // 2085 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 2086 | // OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 2087 | // MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN 2088 | // NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 2089 | // DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 2090 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE 2091 | // USE OR OTHER DEALINGS IN THE SOFTWARE. 2092 | 2093 | var formatRegExp = /%[sdj%]/g; 2094 | exports.format = function(f) { 2095 | if (!isString(f)) { 2096 | var objects = []; 2097 | for (var i = 0; i < arguments.length; i++) { 2098 | objects.push(inspect(arguments[i])); 2099 | } 2100 | return objects.join(' '); 2101 | } 2102 | 2103 | var i = 1; 2104 | var args = arguments; 2105 | var len = args.length; 2106 | var str = String(f).replace(formatRegExp, function(x) { 2107 | if (x === '%%') return '%'; 2108 | if (i >= len) return x; 2109 | switch (x) { 2110 | case '%s': return String(args[i++]); 2111 | case '%d': return Number(args[i++]); 2112 | case '%j': 2113 | try { 2114 | return JSON.stringify(args[i++]); 2115 | } catch (_) { 2116 | return '[Circular]'; 2117 | } 2118 | default: 2119 | return x; 2120 | } 2121 | }); 2122 | for (var x = args[i]; i < len; x = args[++i]) { 2123 | if (isNull(x) || !isObject(x)) { 2124 | str += ' ' + x; 2125 | } else { 2126 | str += ' ' + inspect(x); 2127 | } 2128 | } 2129 | return str; 2130 | }; 2131 | 2132 | 2133 | // Mark that a method should not be used. 2134 | // Returns a modified function which warns once by default. 2135 | // If --no-deprecation is set, then it is a no-op. 2136 | exports.deprecate = function(fn, msg) { 2137 | // Allow for deprecating things in the process of starting up. 2138 | if (isUndefined(global.process)) { 2139 | return function() { 2140 | return exports.deprecate(fn, msg).apply(this, arguments); 2141 | }; 2142 | } 2143 | 2144 | if (process.noDeprecation === true) { 2145 | return fn; 2146 | } 2147 | 2148 | var warned = false; 2149 | function deprecated() { 2150 | if (!warned) { 2151 | if (process.throwDeprecation) { 2152 | throw new Error(msg); 2153 | } else if (process.traceDeprecation) { 2154 | console.trace(msg); 2155 | } else { 2156 | console.error(msg); 2157 | } 2158 | warned = true; 2159 | } 2160 | return fn.apply(this, arguments); 2161 | } 2162 | 2163 | return deprecated; 2164 | }; 2165 | 2166 | 2167 | var debugs = {}; 2168 | var debugEnviron; 2169 | exports.debuglog = function(set) { 2170 | if (isUndefined(debugEnviron)) 2171 | debugEnviron = process.env.NODE_DEBUG || ''; 2172 | set = set.toUpperCase(); 2173 | if (!debugs[set]) { 2174 | if (new RegExp('\\b' + set + '\\b', 'i').test(debugEnviron)) { 2175 | var pid = process.pid; 2176 | debugs[set] = function() { 2177 | var msg = exports.format.apply(exports, arguments); 2178 | console.error('%s %d: %s', set, pid, msg); 2179 | }; 2180 | } else { 2181 | debugs[set] = function() {}; 2182 | } 2183 | } 2184 | return debugs[set]; 2185 | }; 2186 | 2187 | 2188 | /** 2189 | * Echos the value of a value. Trys to print the value out 2190 | * in the best way possible given the different types. 2191 | * 2192 | * @param {Object} obj The object to print out. 2193 | * @param {Object} opts Optional options object that alters the output. 2194 | */ 2195 | /* legacy: obj, showHidden, depth, colors*/ 2196 | function inspect(obj, opts) { 2197 | // default options 2198 | var ctx = { 2199 | seen: [], 2200 | stylize: stylizeNoColor 2201 | }; 2202 | // legacy... 2203 | if (arguments.length >= 3) ctx.depth = arguments[2]; 2204 | if (arguments.length >= 4) ctx.colors = arguments[3]; 2205 | if (isBoolean(opts)) { 2206 | // legacy... 2207 | ctx.showHidden = opts; 2208 | } else if (opts) { 2209 | // got an "options" object 2210 | exports._extend(ctx, opts); 2211 | } 2212 | // set default options 2213 | if (isUndefined(ctx.showHidden)) ctx.showHidden = false; 2214 | if (isUndefined(ctx.depth)) ctx.depth = 2; 2215 | if (isUndefined(ctx.colors)) ctx.colors = false; 2216 | if (isUndefined(ctx.customInspect)) ctx.customInspect = true; 2217 | if (ctx.colors) ctx.stylize = stylizeWithColor; 2218 | return formatValue(ctx, obj, ctx.depth); 2219 | } 2220 | exports.inspect = inspect; 2221 | 2222 | 2223 | // http://en.wikipedia.org/wiki/ANSI_escape_code#graphics 2224 | inspect.colors = { 2225 | 'bold' : [1, 22], 2226 | 'italic' : [3, 23], 2227 | 'underline' : [4, 24], 2228 | 'inverse' : [7, 27], 2229 | 'white' : [37, 39], 2230 | 'grey' : [90, 39], 2231 | 'black' : [30, 39], 2232 | 'blue' : [34, 39], 2233 | 'cyan' : [36, 39], 2234 | 'green' : [32, 39], 2235 | 'magenta' : [35, 39], 2236 | 'red' : [31, 39], 2237 | 'yellow' : [33, 39] 2238 | }; 2239 | 2240 | // Don't use 'blue' not visible on cmd.exe 2241 | inspect.styles = { 2242 | 'special': 'cyan', 2243 | 'number': 'yellow', 2244 | 'boolean': 'yellow', 2245 | 'undefined': 'grey', 2246 | 'null': 'bold', 2247 | 'string': 'green', 2248 | 'date': 'magenta', 2249 | // "name": intentionally not styling 2250 | 'regexp': 'red' 2251 | }; 2252 | 2253 | 2254 | function stylizeWithColor(str, styleType) { 2255 | var style = inspect.styles[styleType]; 2256 | 2257 | if (style) { 2258 | return '\u001b[' + inspect.colors[style][0] + 'm' + str + 2259 | '\u001b[' + inspect.colors[style][1] + 'm'; 2260 | } else { 2261 | return str; 2262 | } 2263 | } 2264 | 2265 | 2266 | function stylizeNoColor(str, styleType) { 2267 | return str; 2268 | } 2269 | 2270 | 2271 | function arrayToHash(array) { 2272 | var hash = {}; 2273 | 2274 | array.forEach(function(val, idx) { 2275 | hash[val] = true; 2276 | }); 2277 | 2278 | return hash; 2279 | } 2280 | 2281 | 2282 | function formatValue(ctx, value, recurseTimes) { 2283 | // Provide a hook for user-specified inspect functions. 2284 | // Check that value is an object with an inspect function on it 2285 | if (ctx.customInspect && 2286 | value && 2287 | isFunction(value.inspect) && 2288 | // Filter out the util module, it's inspect function is special 2289 | value.inspect !== exports.inspect && 2290 | // Also filter out any prototype objects using the circular check. 2291 | !(value.constructor && value.constructor.prototype === value)) { 2292 | var ret = value.inspect(recurseTimes, ctx); 2293 | if (!isString(ret)) { 2294 | ret = formatValue(ctx, ret, recurseTimes); 2295 | } 2296 | return ret; 2297 | } 2298 | 2299 | // Primitive types cannot have properties 2300 | var primitive = formatPrimitive(ctx, value); 2301 | if (primitive) { 2302 | return primitive; 2303 | } 2304 | 2305 | // Look up the keys of the object. 2306 | var keys = Object.keys(value); 2307 | var visibleKeys = arrayToHash(keys); 2308 | 2309 | if (ctx.showHidden) { 2310 | keys = Object.getOwnPropertyNames(value); 2311 | } 2312 | 2313 | // IE doesn't make error fields non-enumerable 2314 | // http://msdn.microsoft.com/en-us/library/ie/dww52sbt(v=vs.94).aspx 2315 | if (isError(value) 2316 | && (keys.indexOf('message') >= 0 || keys.indexOf('description') >= 0)) { 2317 | return formatError(value); 2318 | } 2319 | 2320 | // Some type of object without properties can be shortcutted. 2321 | if (keys.length === 0) { 2322 | if (isFunction(value)) { 2323 | var name = value.name ? ': ' + value.name : ''; 2324 | return ctx.stylize('[Function' + name + ']', 'special'); 2325 | } 2326 | if (isRegExp(value)) { 2327 | return ctx.stylize(RegExp.prototype.toString.call(value), 'regexp'); 2328 | } 2329 | if (isDate(value)) { 2330 | return ctx.stylize(Date.prototype.toString.call(value), 'date'); 2331 | } 2332 | if (isError(value)) { 2333 | return formatError(value); 2334 | } 2335 | } 2336 | 2337 | var base = '', array = false, braces = ['{', '}']; 2338 | 2339 | // Make Array say that they are Array 2340 | if (isArray(value)) { 2341 | array = true; 2342 | braces = ['[', ']']; 2343 | } 2344 | 2345 | // Make functions say that they are functions 2346 | if (isFunction(value)) { 2347 | var n = value.name ? ': ' + value.name : ''; 2348 | base = ' [Function' + n + ']'; 2349 | } 2350 | 2351 | // Make RegExps say that they are RegExps 2352 | if (isRegExp(value)) { 2353 | base = ' ' + RegExp.prototype.toString.call(value); 2354 | } 2355 | 2356 | // Make dates with properties first say the date 2357 | if (isDate(value)) { 2358 | base = ' ' + Date.prototype.toUTCString.call(value); 2359 | } 2360 | 2361 | // Make error with message first say the error 2362 | if (isError(value)) { 2363 | base = ' ' + formatError(value); 2364 | } 2365 | 2366 | if (keys.length === 0 && (!array || value.length == 0)) { 2367 | return braces[0] + base + braces[1]; 2368 | } 2369 | 2370 | if (recurseTimes < 0) { 2371 | if (isRegExp(value)) { 2372 | return ctx.stylize(RegExp.prototype.toString.call(value), 'regexp'); 2373 | } else { 2374 | return ctx.stylize('[Object]', 'special'); 2375 | } 2376 | } 2377 | 2378 | ctx.seen.push(value); 2379 | 2380 | var output; 2381 | if (array) { 2382 | output = formatArray(ctx, value, recurseTimes, visibleKeys, keys); 2383 | } else { 2384 | output = keys.map(function(key) { 2385 | return formatProperty(ctx, value, recurseTimes, visibleKeys, key, array); 2386 | }); 2387 | } 2388 | 2389 | ctx.seen.pop(); 2390 | 2391 | return reduceToSingleString(output, base, braces); 2392 | } 2393 | 2394 | 2395 | function formatPrimitive(ctx, value) { 2396 | if (isUndefined(value)) 2397 | return ctx.stylize('undefined', 'undefined'); 2398 | if (isString(value)) { 2399 | var simple = '\'' + JSON.stringify(value).replace(/^"|"$/g, '') 2400 | .replace(/'/g, "\\'") 2401 | .replace(/\\"/g, '"') + '\''; 2402 | return ctx.stylize(simple, 'string'); 2403 | } 2404 | if (isNumber(value)) 2405 | return ctx.stylize('' + value, 'number'); 2406 | if (isBoolean(value)) 2407 | return ctx.stylize('' + value, 'boolean'); 2408 | // For some reason typeof null is "object", so special case here. 2409 | if (isNull(value)) 2410 | return ctx.stylize('null', 'null'); 2411 | } 2412 | 2413 | 2414 | function formatError(value) { 2415 | return '[' + Error.prototype.toString.call(value) + ']'; 2416 | } 2417 | 2418 | 2419 | function formatArray(ctx, value, recurseTimes, visibleKeys, keys) { 2420 | var output = []; 2421 | for (var i = 0, l = value.length; i < l; ++i) { 2422 | if (hasOwnProperty(value, String(i))) { 2423 | output.push(formatProperty(ctx, value, recurseTimes, visibleKeys, 2424 | String(i), true)); 2425 | } else { 2426 | output.push(''); 2427 | } 2428 | } 2429 | keys.forEach(function(key) { 2430 | if (!key.match(/^\d+$/)) { 2431 | output.push(formatProperty(ctx, value, recurseTimes, visibleKeys, 2432 | key, true)); 2433 | } 2434 | }); 2435 | return output; 2436 | } 2437 | 2438 | 2439 | function formatProperty(ctx, value, recurseTimes, visibleKeys, key, array) { 2440 | var name, str, desc; 2441 | desc = Object.getOwnPropertyDescriptor(value, key) || { value: value[key] }; 2442 | if (desc.get) { 2443 | if (desc.set) { 2444 | str = ctx.stylize('[Getter/Setter]', 'special'); 2445 | } else { 2446 | str = ctx.stylize('[Getter]', 'special'); 2447 | } 2448 | } else { 2449 | if (desc.set) { 2450 | str = ctx.stylize('[Setter]', 'special'); 2451 | } 2452 | } 2453 | if (!hasOwnProperty(visibleKeys, key)) { 2454 | name = '[' + key + ']'; 2455 | } 2456 | if (!str) { 2457 | if (ctx.seen.indexOf(desc.value) < 0) { 2458 | if (isNull(recurseTimes)) { 2459 | str = formatValue(ctx, desc.value, null); 2460 | } else { 2461 | str = formatValue(ctx, desc.value, recurseTimes - 1); 2462 | } 2463 | if (str.indexOf('\n') > -1) { 2464 | if (array) { 2465 | str = str.split('\n').map(function(line) { 2466 | return ' ' + line; 2467 | }).join('\n').substr(2); 2468 | } else { 2469 | str = '\n' + str.split('\n').map(function(line) { 2470 | return ' ' + line; 2471 | }).join('\n'); 2472 | } 2473 | } 2474 | } else { 2475 | str = ctx.stylize('[Circular]', 'special'); 2476 | } 2477 | } 2478 | if (isUndefined(name)) { 2479 | if (array && key.match(/^\d+$/)) { 2480 | return str; 2481 | } 2482 | name = JSON.stringify('' + key); 2483 | if (name.match(/^"([a-zA-Z_][a-zA-Z_0-9]*)"$/)) { 2484 | name = name.substr(1, name.length - 2); 2485 | name = ctx.stylize(name, 'name'); 2486 | } else { 2487 | name = name.replace(/'/g, "\\'") 2488 | .replace(/\\"/g, '"') 2489 | .replace(/(^"|"$)/g, "'"); 2490 | name = ctx.stylize(name, 'string'); 2491 | } 2492 | } 2493 | 2494 | return name + ': ' + str; 2495 | } 2496 | 2497 | 2498 | function reduceToSingleString(output, base, braces) { 2499 | var numLinesEst = 0; 2500 | var length = output.reduce(function(prev, cur) { 2501 | numLinesEst++; 2502 | if (cur.indexOf('\n') >= 0) numLinesEst++; 2503 | return prev + cur.replace(/\u001b\[\d\d?m/g, '').length + 1; 2504 | }, 0); 2505 | 2506 | if (length > 60) { 2507 | return braces[0] + 2508 | (base === '' ? '' : base + '\n ') + 2509 | ' ' + 2510 | output.join(',\n ') + 2511 | ' ' + 2512 | braces[1]; 2513 | } 2514 | 2515 | return braces[0] + base + ' ' + output.join(', ') + ' ' + braces[1]; 2516 | } 2517 | 2518 | 2519 | // NOTE: These type checking functions intentionally don't use `instanceof` 2520 | // because it is fragile and can be easily faked with `Object.create()`. 2521 | function isArray(ar) { 2522 | return Array.isArray(ar); 2523 | } 2524 | exports.isArray = isArray; 2525 | 2526 | function isBoolean(arg) { 2527 | return typeof arg === 'boolean'; 2528 | } 2529 | exports.isBoolean = isBoolean; 2530 | 2531 | function isNull(arg) { 2532 | return arg === null; 2533 | } 2534 | exports.isNull = isNull; 2535 | 2536 | function isNullOrUndefined(arg) { 2537 | return arg == null; 2538 | } 2539 | exports.isNullOrUndefined = isNullOrUndefined; 2540 | 2541 | function isNumber(arg) { 2542 | return typeof arg === 'number'; 2543 | } 2544 | exports.isNumber = isNumber; 2545 | 2546 | function isString(arg) { 2547 | return typeof arg === 'string'; 2548 | } 2549 | exports.isString = isString; 2550 | 2551 | function isSymbol(arg) { 2552 | return typeof arg === 'symbol'; 2553 | } 2554 | exports.isSymbol = isSymbol; 2555 | 2556 | function isUndefined(arg) { 2557 | return arg === void 0; 2558 | } 2559 | exports.isUndefined = isUndefined; 2560 | 2561 | function isRegExp(re) { 2562 | return isObject(re) && objectToString(re) === '[object RegExp]'; 2563 | } 2564 | exports.isRegExp = isRegExp; 2565 | 2566 | function isObject(arg) { 2567 | return typeof arg === 'object' && arg !== null; 2568 | } 2569 | exports.isObject = isObject; 2570 | 2571 | function isDate(d) { 2572 | return isObject(d) && objectToString(d) === '[object Date]'; 2573 | } 2574 | exports.isDate = isDate; 2575 | 2576 | function isError(e) { 2577 | return isObject(e) && 2578 | (objectToString(e) === '[object Error]' || e instanceof Error); 2579 | } 2580 | exports.isError = isError; 2581 | 2582 | function isFunction(arg) { 2583 | return typeof arg === 'function'; 2584 | } 2585 | exports.isFunction = isFunction; 2586 | 2587 | function isPrimitive(arg) { 2588 | return arg === null || 2589 | typeof arg === 'boolean' || 2590 | typeof arg === 'number' || 2591 | typeof arg === 'string' || 2592 | typeof arg === 'symbol' || // ES6 symbol 2593 | typeof arg === 'undefined'; 2594 | } 2595 | exports.isPrimitive = isPrimitive; 2596 | 2597 | exports.isBuffer = _dereq_('./support/isBuffer'); 2598 | 2599 | function objectToString(o) { 2600 | return Object.prototype.toString.call(o); 2601 | } 2602 | 2603 | 2604 | function pad(n) { 2605 | return n < 10 ? '0' + n.toString(10) : n.toString(10); 2606 | } 2607 | 2608 | 2609 | var months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 2610 | 'Oct', 'Nov', 'Dec']; 2611 | 2612 | // 26 Feb 16:19:34 2613 | function timestamp() { 2614 | var d = new Date(); 2615 | var time = [pad(d.getHours()), 2616 | pad(d.getMinutes()), 2617 | pad(d.getSeconds())].join(':'); 2618 | return [d.getDate(), months[d.getMonth()], time].join(' '); 2619 | } 2620 | 2621 | 2622 | // log is just a thin wrapper to console.log that prepends a timestamp 2623 | exports.log = function() { 2624 | console.log('%s - %s', timestamp(), exports.format.apply(exports, arguments)); 2625 | }; 2626 | 2627 | 2628 | /** 2629 | * Inherit the prototype methods from one constructor into another. 2630 | * 2631 | * The Function.prototype.inherits from lang.js rewritten as a standalone 2632 | * function (not on Function.prototype). NOTE: If this file is to be loaded 2633 | * during bootstrapping this function needs to be rewritten using some native 2634 | * functions as prototype setup using normal JavaScript does not work as 2635 | * expected during bootstrapping (see mirror.js in r114903). 2636 | * 2637 | * @param {function} ctor Constructor function which needs to inherit the 2638 | * prototype. 2639 | * @param {function} superCtor Constructor function to inherit prototype from. 2640 | */ 2641 | exports.inherits = _dereq_('inherits'); 2642 | 2643 | exports._extend = function(origin, add) { 2644 | // Don't do anything if add isn't an object 2645 | if (!add || !isObject(add)) return origin; 2646 | 2647 | var keys = Object.keys(add); 2648 | var i = keys.length; 2649 | while (i--) { 2650 | origin[keys[i]] = add[keys[i]]; 2651 | } 2652 | return origin; 2653 | }; 2654 | 2655 | function hasOwnProperty(obj, prop) { 2656 | return Object.prototype.hasOwnProperty.call(obj, prop); 2657 | } 2658 | 2659 | },{"./support/isBuffer":17,"inherits":19}],19:[function(_dereq_,module,exports){ 2660 | if (typeof Object.create === 'function') { 2661 | // implementation from standard node.js 'util' module 2662 | module.exports = function inherits(ctor, superCtor) { 2663 | ctor.super_ = superCtor 2664 | ctor.prototype = Object.create(superCtor.prototype, { 2665 | constructor: { 2666 | value: ctor, 2667 | enumerable: false, 2668 | writable: true, 2669 | configurable: true 2670 | } 2671 | }); 2672 | }; 2673 | } else { 2674 | // old school shim for old browsers 2675 | module.exports = function inherits(ctor, superCtor) { 2676 | ctor.super_ = superCtor 2677 | var TempCtor = function () {} 2678 | TempCtor.prototype = superCtor.prototype 2679 | ctor.prototype = new TempCtor() 2680 | ctor.prototype.constructor = ctor 2681 | } 2682 | } 2683 | 2684 | },{}]},{},[14]) 2685 | (14) 2686 | }); -------------------------------------------------------------------------------- /test/server/constructor.js: -------------------------------------------------------------------------------- 1 | var should = require('should'); 2 | 3 | var cerebellum = require('../../index'); 4 | var options; 5 | 6 | describe('Constructor', function() { 7 | 8 | beforeEach(function() { 9 | options = { 10 | storeId: "app", 11 | render: function() {}, 12 | routes: {}, 13 | stores: {}, 14 | staticFiles: __dirname+"/public" 15 | }; 16 | }); 17 | 18 | it('should initialize server with proper options', function() { 19 | (function() { cerebellum.server(options) }).should.not.throw(); 20 | }); 21 | 22 | it('should throw exception when missing storeId', function() { 23 | options.storeId = null; 24 | (function() { cerebellum.server(options) }).should.throw(); 25 | }); 26 | 27 | it('should throw exception when missing render', function() { 28 | options.render = null; 29 | (function() { cerebellum.server(options) }).should.throw(); 30 | }); 31 | 32 | it('should throw exception when missing routes', function() { 33 | options.routes = null; 34 | (function() { cerebellum.server(options) }).should.throw(); 35 | }); 36 | 37 | }); 38 | -------------------------------------------------------------------------------- /test/server/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Cerebellum tests 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | -------------------------------------------------------------------------------- /test/server/server.js: -------------------------------------------------------------------------------- 1 | var should = require('should'); 2 | var cheerio = require('cheerio'); 3 | require('native-promise-only'); 4 | 5 | var Server = require('../../server'); 6 | var Store = require('../../store'); 7 | var Collection = require('../..').Collection; 8 | var Model = require('../..').Model; 9 | 10 | var appId = "app"; 11 | var storeId = "store_state_from_server"; 12 | 13 | var options; 14 | 15 | describe('Server', function() { 16 | 17 | beforeEach(function() { 18 | options = { 19 | storeId: storeId, 20 | render: function render(document, options) { 21 | document("#"+appId).html( options.value ); 22 | return document.html(); 23 | }, 24 | routes: { 25 | "/": function() { 26 | return new Promise(function(resolve, reject) { 27 | resolve({value: "Index content"}); 28 | }); 29 | }, 30 | "/person": function() { 31 | return this.store.fetch("person").then(function(person) { 32 | return {value: person.get("value")}; 33 | }); 34 | } 35 | }, 36 | stores: { 37 | person: Model.extend({ 38 | cacheKey: function() { 39 | return "person"; 40 | }, 41 | fetch: function() { 42 | this.set("value", "Example person"); 43 | return new Promise(function(resolve, reject) { 44 | resolve(); 45 | }); 46 | } 47 | }) 48 | }, 49 | staticFiles: __dirname+"/public" 50 | }; 51 | }); 52 | 53 | it('should throw error if options.staticFiles not defined', function() { 54 | options.staticFiles = null; 55 | (function() { new Server(options) }).should.throw(); 56 | }); 57 | 58 | it('should render route handler response without store calls but the exported JSON should be empty', function() { 59 | var app = new Server(options); 60 | 61 | var res = { 62 | setHeader: function() {}, 63 | send: function(html) { 64 | cheerio("#"+appId, html).text().should.equal("Index content"); 65 | cheerio("#"+storeId, html).text().should.be.empty; 66 | done(); 67 | } 68 | }; 69 | 70 | app({ url: '/', method: 'GET', headers: {} }, res); 71 | }); 72 | 73 | it('should render store.fetch return value and exported JSON should not be empty', function(done) { 74 | var app = new Server(options); 75 | 76 | var res = { 77 | setHeader: function() {}, 78 | send: function(html) { 79 | cheerio("#"+appId, html).text().should.equal("Example person"); 80 | cheerio("#"+storeId, html).text().should.not.be.empty; 81 | done(); 82 | } 83 | }; 84 | 85 | app({ url: '/person', method: 'GET', headers: {} }, res); 86 | }); 87 | 88 | }); 89 | -------------------------------------------------------------------------------- /test/server/store.js: -------------------------------------------------------------------------------- 1 | var should = require('should'); 2 | var nock = require('nock'); 3 | var Immutable = require('immutable'); 4 | require('native-promise-only'); 5 | 6 | var Store = require('../../store'); 7 | var Collection = require('../..').Collection; 8 | var Model = require('../..').Model; 9 | 10 | // Mocks 11 | nock('http://cerebellum.local') 12 | .get('/collection/1') 13 | .reply(401); 14 | 15 | nock('http://cerebellum.local') 16 | .get('/collection/1') 17 | .reply(500, "Internal server error"); 18 | 19 | nock('http://cerebellum.local') 20 | .get('/collection/1') 21 | .reply(403); 22 | 23 | nock('http://cerebellum.local') 24 | .get('/collection/1') 25 | .reply(401); 26 | 27 | nock('http://cerebellum.local') 28 | .get('/cars/Ferrari') 29 | .times(12) 30 | .reply(200, { 31 | manufacturer: "Ferrari" 32 | }); 33 | 34 | nock('http://cerebellum.local') 35 | .get('/model') 36 | .times(2) 37 | .reply(200, {}); 38 | 39 | nock('http://cerebellum.local') 40 | .get('/nocachekeycollection') 41 | .reply(200, []); 42 | 43 | nock('http://cerebellum.local') 44 | .post('/cars/Ferrari') 45 | .times(3) 46 | .reply(200, { 47 | manufacturer: "Ferrari", 48 | model: "F40" 49 | }); 50 | 51 | nock('http://cerebellum.local') 52 | .post('/cars/Lotus') 53 | .reply(500, "Internal server error"); 54 | 55 | nock('http://cerebellum.local') 56 | .delete('/cars/Lotus') 57 | .reply(401, "Unauthorized"); 58 | 59 | nock('http://cerebellum.local') 60 | .delete('/cars/Maserati') 61 | .reply(200); 62 | 63 | nock('http://cerebellum.local') 64 | .post('/cars/Pagani') 65 | .reply(200, { 66 | manufacturer: "Pagani", 67 | model: "Zonda" 68 | }); 69 | 70 | nock('http://cerebellum.local') 71 | .get('/cars/Lotus') 72 | .reply(200, { 73 | manufacturer: "Lotus" 74 | }); 75 | 76 | nock('http://cerebellum.local') 77 | .post('/cars') 78 | .times(2) 79 | .reply(500, "Internal server error"); 80 | 81 | nock('http://cerebellum.local') 82 | .post('/cars') 83 | .reply(200, { 84 | manufacturer: "Bugatti" 85 | }); 86 | 87 | nock('http://cerebellum.local') 88 | .get('/cars') 89 | .times(3) 90 | .reply(200, [ 91 | {manufacturer: "Bugatti"}, 92 | {manufacturer: "Ferrari"}, 93 | {manufacturer: "Lotus"}, 94 | {manufacturer: "Maserati"}, 95 | {manufacturer: "Pagani"} 96 | ]); 97 | 98 | // Stores 99 | var stores = { 100 | model: Model.extend({ 101 | url: "http://cerebellum.local/model" 102 | }), 103 | collection: Collection.extend({ 104 | cacheKey: function() { 105 | return "collection/1"; 106 | }, 107 | url: "http://cerebellum.local/collection/1" 108 | }), 109 | car: Model.extend({ 110 | relatedCaches: function() { 111 | return {"cars": "/"}; 112 | }, 113 | cacheKey: function() { 114 | return this.storeOptions.id; 115 | }, 116 | url: function() { 117 | return "http://cerebellum.local/cars/" + this.storeOptions.id; 118 | } 119 | }), 120 | cars: Collection.extend({ 121 | url: function() { 122 | return "http://cerebellum.local/cars"; 123 | } 124 | }), 125 | noCacheKeyCollection: Collection.extend({ 126 | url: "http://cerebellum.local/nocachekeycollection" 127 | }) 128 | }; 129 | 130 | describe('Store', function() { 131 | 132 | describe('constructor', function() { 133 | 134 | it('initializes values', function() { 135 | var store = new Store(); 136 | store.should.have.properties('cached', 'stores'); 137 | }); 138 | 139 | it('stores and caches should be initialized', function() { 140 | var store = new Store(stores); 141 | should.exist(store.stores.model); 142 | should.exist(store.stores.collection); 143 | should.exist(store.cached.get("model")); 144 | should.exist(store.cached.get("collection")); 145 | }); 146 | 147 | it('options are handled', function() { 148 | var store = new Store([], {cookie: "test"}); 149 | store.should.have.property("cookie", "test"); 150 | }); 151 | 152 | }); 153 | 154 | describe('events', function() { 155 | it('should work as event bus', function() { 156 | var store = new Store(); 157 | store.should.have.properties('on', 'off', 'trigger', 'once', 'listenTo', 'stopListening', 'listenToOnce'); 158 | }); 159 | }); 160 | 161 | describe('fetch', function() { 162 | it('should fetch from server if cache not found', function() { 163 | var store = new Store(stores); 164 | return store.fetch("car", {id: "Ferrari"}).then(function(result) { 165 | result.get("manufacturer").should.be.equal("Ferrari"); 166 | }); 167 | }); 168 | 169 | it('should return cached value if found', function() { 170 | var store = new Store(stores); 171 | 172 | var lambo = store.get("car"); 173 | lambo = lambo.set("manufacturer", "Lamborghini (not set by fetch)"); 174 | lambo = lambo.set("model", "Aventador"); 175 | store.cached = store.cached.setIn(["car","Lamborghini"], Immutable.fromJS(lambo.toJSON())); 176 | 177 | return store.fetch("car", {id: "Lamborghini"}).then(function(result) { 178 | result.get("manufacturer").should.be.equal("Lamborghini (not set by fetch)"); 179 | result.get("model").should.be.equal("Aventador"); 180 | }); 181 | 182 | }); 183 | 184 | it('should return store object even if error occurs (statuses 401 & 403)', function() { 185 | var store = new Store(stores); 186 | var original = store.get("collection"); 187 | original.storeOptions = {}; 188 | var originalJSON = original.toJSON(); 189 | return store.fetch("collection").then(function(result) { 190 | result.toJSON().should.eql(originalJSON); 191 | }); 192 | }); 193 | 194 | it('should reject promise with error if error occurs (other statuses)', function() { 195 | var store = new Store(stores); 196 | var original = store.get("collection"); 197 | original.storeOptions = {}; 198 | return store.fetch("collection").catch(function(err) { 199 | err.status.should.equal(500); 200 | err.data.should.equal("Internal server error"); 201 | }); 202 | }); 203 | 204 | it('should reject 40x response statuses if explicitly passing empty array for allowedStatusCodes', function() { 205 | var store = new Store(stores, {allowedStatusCodes: []}); 206 | var original = store.get("collection"); 207 | original.storeOptions = {}; 208 | return store.fetch("collection").catch(function(err) { 209 | err.status.should.equal(403); 210 | }); 211 | }); 212 | 213 | it('should cache store value after server fetch', function() { 214 | var store = new Store(stores); 215 | return store.fetch("car", {id: "Ferrari"}).then(function(result) { 216 | Immutable.is(store.cached.getIn(["car","Ferrari"]), result).should.equal(true); 217 | }); 218 | }); 219 | 220 | it('should throw error if store is not registered', function() { 221 | var store = new Store(stores); 222 | return store.fetch("nonExistingId").catch(function(error) { 223 | error.message.should.equal("Store nonExistingId not registered."); 224 | }); 225 | }); 226 | 227 | it('should throw error if there\'s no cacheKey', function() { 228 | var store = new Store(stores); 229 | return store.fetch("model").catch(function(error) { 230 | error.message.should.equal("Store model has no cacheKey method."); 231 | }); 232 | }); 233 | 234 | it('should use model\'s storeOptions.id as fallback cacheKey', function() { 235 | var store = new Store(stores); 236 | return store.fetch("model", {id: "example"}).then(function() { 237 | should.exist(store.cached.getIn(["model","example"])); 238 | }); 239 | }); 240 | 241 | it('cacheKey should be optional for collections', function() { 242 | var store = new Store(stores); 243 | return store.fetch("noCacheKeyCollection").then(function() { 244 | should.exist(store.cached.getIn(["noCacheKeyCollection","/"])); 245 | }); 246 | }); 247 | 248 | it('should not modify original placeholder instance', function() { 249 | var store = new Store(stores); 250 | var original = store.get("car"); 251 | return store.fetch("car", {id: "Ferrari"}).then(function(result) { 252 | result.get("manufacturer").should.be.equal("Ferrari"); 253 | original.should.not.have.property("storeOptions"); 254 | (original.manufacturer === undefined).should.be.true; 255 | }); 256 | }); 257 | 258 | it('should reuse first fetch request if multiple concurrent fetches', function(done) { 259 | var store = new Store(stores); 260 | var firstFetch = store.fetch("car", {id: "Ferrari"}); 261 | var secondFetch = store.fetch("car", {id: "Ferrari"}); 262 | var thirdFetch = store.fetch("car", {id: "Ferrari"}); 263 | 264 | store.ongoingFetches.length.should.equal(1); 265 | store.ongoingFetches[0].id.should.equal("car"); 266 | store.ongoingFetches[0].key.should.equal("Ferrari"); 267 | 268 | Promise.all([firstFetch, secondFetch, thirdFetch]).then(function(results) { 269 | Immutable.is(results[0], results[1]).should.equal(true); 270 | Immutable.is(results[1], results[2]).should.equal(true); 271 | done(); 272 | }); 273 | }); 274 | 275 | }); 276 | 277 | describe('fetchAll', function() { 278 | it('should fetch all given stores with fetchAll', function() { 279 | var store = new Store(stores); 280 | return store.fetchAll({ 281 | "cars": {}, 282 | "car": {id: "Ferrari"} 283 | }).then(function(result) { 284 | var resultJSON = Object.keys(result).reduce(function(obj, key) { 285 | obj[key] = result[key].toJSON(); 286 | return obj; 287 | }, {}); 288 | resultJSON.should.eql({ 289 | cars: [ 290 | { manufacturer: 'Bugatti' }, 291 | { manufacturer: 'Ferrari' }, 292 | { manufacturer: 'Lotus' }, 293 | { manufacturer: 'Maserati' }, 294 | { manufacturer: 'Pagani' } 295 | ], 296 | car: { manufacturer: 'Ferrari' } 297 | }); 298 | }); 299 | }); 300 | it('should fail with invalid store id', function() { 301 | var store = new Store(stores); 302 | return store.fetchAll({ 303 | "motorcycles": {}, 304 | "car": {id: "Ferrari"} 305 | }).catch(function(err) { 306 | err.message.should.equal("Store motorcycles not registered."); 307 | }); 308 | }); 309 | }); 310 | 311 | describe('get', function() { 312 | it('should return empty placeholder instances', function() { 313 | var store = new Store(stores); 314 | store.get("model").should.be.instanceOf(Model); 315 | store.get("collection").should.be.instanceOf(Collection); 316 | store.get("model").toJSON().should.be.empty; 317 | store.get("collection").toJSON().should.be.empty; 318 | }); 319 | }); 320 | 321 | describe('bootstrap', function() { 322 | it('should set caches from initial JSON', function() { 323 | var store = new Store(stores); 324 | var json = JSON.stringify({ 325 | model: {}, 326 | collection: {}, 327 | car: { 328 | "Ferrari": {manufacturer: "Ferrari"}, 329 | "Lotus": {manufacturer: "Lotus"} 330 | } 331 | }); 332 | store.bootstrap(json); 333 | 334 | store.cached.get("car").size.should.be.equal(2); 335 | store.cached.get("model").size.should.be.equal(0); 336 | store.cached.get("collection").size.should.be.equal(0); 337 | store.cached.getIn(["car","Ferrari"]).get("manufacturer").should.be.equal("Ferrari"); 338 | store.cached.getIn(["car","Lotus"]).get("manufacturer").should.be.equal("Lotus"); 339 | }); 340 | }); 341 | 342 | describe('snapshot', function() { 343 | it('should export snapshot of currently cached stores to JSON', function() { 344 | var store = new Store(stores); 345 | var expectedJSON = JSON.stringify({ 346 | model: {}, 347 | collection: { 348 | "collection/1": [] 349 | }, 350 | car: { 351 | "Ferrari": {manufacturer: "Ferrari"}, 352 | "Lotus": {manufacturer: "Lotus"} 353 | }, 354 | cars: {}, 355 | noCacheKeyCollection: {} 356 | }); 357 | 358 | store.snapshot().should.be.eql(JSON.stringify({ 359 | model: {}, 360 | collection: {}, 361 | car: {}, 362 | cars: {}, 363 | noCacheKeyCollection: {} 364 | })); 365 | 366 | return Promise.all([ 367 | store.fetch("car", {id: "Ferrari"}), 368 | store.fetch("car", {id: "Lotus"}), 369 | store.fetch("collection") 370 | ]).then(function() { 371 | store.snapshot().should.be.eql(expectedJSON); 372 | }); 373 | }); 374 | }); 375 | 376 | describe('clearCache', function() { 377 | it('should clear cache automatically when passing autoClearCaches = true', function() { 378 | var store = new Store(stores, {autoClearCaches: true}); 379 | 380 | return store.fetch("car", {id: "Ferrari"}).then(function() { 381 | store.on("update:car", function(err, options) { 382 | should.not.exist(store.cached.getIn(["car","Ferrari"])); 383 | }); 384 | should.exist(store.cached.getIn(["car","Ferrari"])); 385 | store.dispatch("update", "car", {id: "Ferrari"}, { 386 | manufacturer: "Ferrari", 387 | model: "F40" 388 | }); 389 | }); 390 | }); 391 | 392 | it('should mark cache as stale when calling with storeId and cacheKey', function() { 393 | var store = new Store(stores); 394 | 395 | return store.fetch("car", {id: "Ferrari"}).then(function() { 396 | store.on("update:car", function(err, options) { 397 | should.exist(store.cached.getIn(["car","Ferrari"])); 398 | store.clearCache("car"); 399 | should.exist(store.staleCaches.car.Ferrari); 400 | }); 401 | should.exist(store.cached.getIn(["car","Ferrari"])); 402 | store.dispatch("update", "car", {id: "Ferrari"}, { 403 | manufacturer: "Ferrari", 404 | model: "F40" 405 | }); 406 | }); 407 | }); 408 | 409 | it('should clear related caches when using relatedCaches', function(done) { 410 | var store = new Store(stores, {autoClearCaches: true}); 411 | 412 | store.on("update:car", function(err, data) { 413 | if (err) { 414 | done(err); 415 | } 416 | should.exist(store.cached.getIn(["car","Ferrari"])); 417 | store.cached.get("cars").should.not.be.empty; 418 | var carIsStale = store.staleCaches.some(function(cache) { 419 | return cache.id === "car" && cache.key === "Ferrari"; 420 | }) 421 | carIsStale.should.equal(true); 422 | var carsIsStale = store.staleCaches.some(function(cache) { 423 | return cache.id === "cars" && cache.key === "/"; 424 | }) 425 | carsIsStale.should.equal(true); 426 | done(); 427 | }); 428 | 429 | return Promise.all([ 430 | store.fetch("car", {id: "Ferrari"}), 431 | store.fetch("cars") 432 | ]).then(function() { 433 | should.exist(store.cached.getIn(["car","Ferrari"])); 434 | store.cached.get("cars").should.not.be.empty; 435 | store.dispatch("update", "car", {id: "Ferrari"}, {model: "F40"}); 436 | }); 437 | }); 438 | }); 439 | 440 | describe('instantResolve', function() { 441 | it('should resolve fetch promise instantly when passing instantResolve = true', function(done) { 442 | var store = new Store(stores, {instantResolve: true}); 443 | store.on("fetch:cars", function(err, cars) { 444 | if (err) { 445 | done(err); 446 | } 447 | cars.toJSON().should.eql([ 448 | {manufacturer: "Bugatti"}, 449 | {manufacturer: "Ferrari"}, 450 | {manufacturer: "Lotus"}, 451 | {manufacturer: "Maserati"}, 452 | {manufacturer: "Pagani"} 453 | ]); 454 | done(); 455 | }); 456 | store.fetch("cars").then(function(cars) { 457 | cars.toJSON().should.eql([]); 458 | }); 459 | }); 460 | }); 461 | 462 | describe('create', function() { 463 | it('should trigger error when triggering create for model', function(done) { 464 | var store = new Store(stores); 465 | store.on("create:car", function(err, data) { 466 | err.message.should.equal("You can call create only for collections!"); 467 | done(); 468 | }); 469 | store.dispatch("create", "car", {manufacturer: "Mercedes-Benz"}); 470 | }); 471 | 472 | it('should trigger error when create fails', function(done) { 473 | var store = new Store(stores); 474 | store.on("create:cars", function(err, data) { 475 | err.message.should.equal("Creating new item to store 'cars' failed"); 476 | done(); 477 | }); 478 | store.dispatch("create", "cars", {manufacturer: "Mercedes-Benz"}); 479 | }); 480 | 481 | it('should reject dispatch return promise when create fails', function() { 482 | var store = new Store(stores); 483 | return store.dispatch("create", "cars", {manufacturer: "Mercedes-Benz"}).catch(function(err) { 484 | err.message.should.equal("Creating new item to store 'cars' failed"); 485 | }); 486 | }); 487 | 488 | it('should trigger success with proper object when create succeeds', function(done) { 489 | var store = new Store(stores); 490 | store.on("create:cars", function(err, data) { 491 | should.not.exist(err); 492 | data.cacheKey.should.equal("/"); 493 | data.store.should.equal("cars"); 494 | data.options.should.eql({}); 495 | data.result.get("manufacturer").should.equal("Bugatti"); 496 | done(); 497 | }); 498 | store.dispatch("create", "cars", {manufacturer: "Bugatti"}); 499 | }); 500 | }); 501 | 502 | describe('update', function() { 503 | it('should trigger error when triggering update for collection', function() { 504 | var store = new Store(stores); 505 | store.on("update:collection", function(err, data) { 506 | err.message.should.equal("You can call update only for models!"); 507 | }); 508 | store.dispatch("update", "collection", {title: "Updated collection"}); 509 | }); 510 | 511 | it('should trigger error when update fails', function(done) { 512 | var store = new Store(stores); 513 | store.on("update:car", function(err, data) { 514 | err.message.should.equal("Updating 'car' failed"); 515 | done(); 516 | }); 517 | store.dispatch("update", "car", {id: "Lotus"}, {manufacturer: "Lotus", model: "Exige"}); 518 | }); 519 | 520 | it('should reject dispatch return promise when update fails', function() { 521 | var store = new Store(stores); 522 | return store.dispatch("update", "car", {id: "Lotus"}, {manufacturer: "Lotus", model: "Exige"}).catch(function(err) { 523 | err.message.should.equal("Updating 'car' failed"); 524 | }); 525 | }); 526 | 527 | it('should trigger success with proper object when update succeeds', function() { 528 | var store = new Store(stores); 529 | store.on("update:car", function(err, data) { 530 | should.not.exist(err); 531 | data.cacheKey.should.equal("Pagani"); 532 | data.store.should.equal("car"); 533 | data.options.should.eql({id: "Pagani"}); 534 | data.result.get("manufacturer").should.equal("Pagani"); 535 | data.result.get("model").should.equal("Zonda"); 536 | done(); 537 | }); 538 | store.dispatch("update", "car", {id: "Pagani"}, {manufacturer: "Pagani", model: "Zonda"}); 539 | }); 540 | }); 541 | 542 | describe('delete', function() { 543 | it('should trigger error when triggering delete for collection', function() { 544 | var store = new Store(stores); 545 | store.on("delete:collection", function(err, data) { 546 | err.message.should.equal("You can call destroy only for models!"); 547 | }); 548 | store.dispatch("delete", "collection"); 549 | }); 550 | 551 | it('should trigger error when delete fails', function(done) { 552 | var store = new Store(stores); 553 | store.on("delete:car", function(err, data) { 554 | try { 555 | err.should.be.instanceOf(Error); 556 | err.message.should.equal("Deleting 'car' failed"); 557 | done(); 558 | } catch (error) { 559 | done(error); 560 | } 561 | }); 562 | store.dispatch("delete", "car", {id: "Lotus"}); 563 | }); 564 | 565 | it('should reject dispatch return promise when delete fails', function() { 566 | var store = new Store(stores); 567 | return store.dispatch("delete", "car", {id: "Lotus"}).catch(function(err) { 568 | err.message.should.equal("Deleting 'car' failed"); 569 | }); 570 | }); 571 | 572 | it('should trigger success with proper object when delete succeeds', function(done) { 573 | var store = new Store(stores); 574 | store.on("delete:car", function(err, data) { 575 | should.not.exist(err); 576 | data.cacheKey.should.equal("Maserati"); 577 | data.store.should.equal("car"); 578 | data.options.should.eql({id: "Maserati"}); 579 | done(); 580 | }); 581 | store.dispatch("delete", "car", {id: "Maserati"}); 582 | }); 583 | 584 | }); 585 | 586 | describe('expire', function() { 587 | it('should trigger success with proper object when expire succeeds', function(done) { 588 | var store = new Store(stores); 589 | store.on("expire:car", function(err, data) { 590 | should.not.exist(err); 591 | data.cacheKey.should.equal("Ferrari"); 592 | data.store.should.equal("car"); 593 | data.options.should.eql({id: "Ferrari"}); 594 | done(); 595 | }); 596 | return store.fetch("car", {id: "Ferrari"}).then(function(result) { 597 | store.cached.getIn(["car","Ferrari"]).get("manufacturer").should.be.equal("Ferrari"); 598 | store.dispatch("expire", "car", {id: "Ferrari"}); 599 | }); 600 | }); 601 | }); 602 | 603 | }); 604 | -------------------------------------------------------------------------------- /test/server/utils.js: -------------------------------------------------------------------------------- 1 | var should = require('should'); 2 | 3 | var Utils = require('../../lib/utils'); 4 | 5 | describe('Utils', function() { 6 | describe('extractParams', function() { 7 | 8 | it('should return empty array without any params', function() { 9 | Utils.extractParams("/", {}).should.eql([]); 10 | }); 11 | 12 | it('should return single item with single param', function() { 13 | Utils.extractParams("/:id", {id: "123"}).should.eql(["123"]); 14 | }); 15 | 16 | it('should support multiple params and return params in correct order', function() { 17 | Utils.extractParams("/:lib/:id", {id: "123", lib: "cerebellum"}).should.eql(["cerebellum", "123"]); 18 | }); 19 | 20 | }); 21 | }); 22 | --------------------------------------------------------------------------------