/tests/test.html
in your favorite browser. (or serve if from a webserver for Firefox, which can't run indexedDB on local file.)
28 |
29 | # Node
30 |
31 | This is quite useless to most people, but there is also an npm module for this. It's useless because IndexedDB hasn't been (yet?) ported to node.js.
32 | It can be used in the context of [browserify](https://github.com/substack/node-browserify) though... and this is exactly why this npm version exists.
33 |
34 | # Implementation
35 |
36 | ## Database & Schema
37 |
38 | Both your Backbone model and collections need to point to a `database` and a `storeName` attributes that are used by the adapter.
39 |
40 | The `storeName` is the name of the store used for the objects of this Model or Collection. You _should_ use the same `storeName` for the model and collections of that same model.
41 |
42 | The `database` is an object literal that define the following :
43 |
44 | * `id` : and unique id for the database
45 | * `description` : a description of the database [OPTIONAL]
46 | * `migrations` : an array of migration to be applied to the database to get the schema that your app needs.
47 |
48 | The migrations are object literals with the following :
49 |
50 | * `version` : the version of the database once the migration is applied.
51 | * `migrate` : a Javascript function that will be called by the driver to perform the migration. It is called with a `IDBDatabase` object, a `IDBVersionChangeRequest` object and a function that needs to be called when the migration is performed, so that the next migration can be executed.
52 | * `before` *[optional]* : a Javascript function that will be called with the database, before the transaction is run. It's useful to update fields before updating the schema.
53 | * `after` *[optional]* : a Javascript function that will be called with the database, after the transaction has been run. It's useful to update fields after updating the schema.
54 |
55 | ### Example
56 |
57 | ```js
58 | var database = {
59 | id: "my-database",
60 | description: "The database for the Movies",
61 | migrations : [
62 | {
63 | version: "1.0",
64 | before: function(next) {
65 | // Do magic stuff before the migration. For example, before adding indices, the Chrome implementation requires to set define a value for each of the objects.
66 | next();
67 | }
68 | migrate: function(transaction, next) {
69 | var store = transaction.db.createObjectStore("movies"); // Adds a store, we will use "movies" as the storeName in our Movie model and Collections
70 | next();
71 | }
72 | }, {
73 | version: "1.1",
74 | migrate: function(transaction, next) {
75 | var store = transaction.db.objectStore("movies")
76 | store.createIndex("titleIndex", "title", { unique: true}); // Adds an index on the movies titles
77 | store.createIndex("formatIndex", "format", { unique: false}); // Adds an index on the movies formats
78 | store.createIndex("genreIndex", "genre", { unique: false}); // Adds an index on the movies genres
79 | next();
80 | }
81 | }
82 | ]
83 | }
84 | ```
85 |
86 | ## Models
87 |
88 | Not much change to your usual models. The only significant change is that you can now fetch a given model with its id, or with a value for one of its index.
89 |
90 | For example, in your traditional backbone apps, you would do something like :
91 |
92 | ```js
93 | var movie = new Movie({id: "123"})
94 | movie.fetch()
95 | ```
96 |
97 | to fetch from the remote server the Movie with the id `123`. This is convenient when you know the id. With this adapter, you can do something like
98 |
99 | ```js
100 | var movie = new Movie({title: "Avatar"})
101 | movie.fetch()
102 | ```
103 |
104 | Obviously, to perform this, you need to have and index on `title`, and a movie with "Avatar" as a title obviously. If the index is not unique, the database will only return the first one.
105 |
106 | ## Collections
107 |
108 | I added a lot of fun things to the collections, that make use of the `options` param used in Backbone to take advantage of IndexedDB's features, namely **indices, cursors and bounds**.
109 |
110 | First, you can `limit` and `offset` the number of items that are being fetched by a collection.
111 |
112 | ```js
113 | var theater = new Theater() // Theater is a collection of movies
114 | theater.fetch({
115 | offset: 1,
116 | limit: 3,
117 | success: function() {
118 | // The theater collection will be populated with at most 3 items, skipping the first one
119 | }
120 | });
121 | ```
122 |
123 | You can also *provide a range* applied to the id.
124 |
125 | ```js
126 | var theater = new Theater() // Theater is a collection of movies
127 | theater.fetch({
128 | range: ["a", "b"],
129 | success: function() {
130 | // The theater collection will be populated with all the items with an id comprised between "a" and "b" ("alphonse" is between "a" and "b")
131 | }
132 | });
133 | ```
134 |
135 | You can also get *all items with a given value for a specific value of an index*. We use the `conditions` keyword.
136 |
137 | ```js
138 | var theater = new Theater() // Theater is a collection of movies
139 | theater.fetch({
140 | conditions: {genre: "adventure"},
141 | success: function() {
142 | // The theater collection will be populated with all the movies whose genre is "adventure"
143 | }
144 | });
145 | ```
146 |
147 |
148 |
149 | You can also *get all items for which an indexed value is comprised between 2 values*. The collection will be sorted based on the order of these 2 keys.
150 |
151 | ```js
152 | var theater = new Theater() // Theater is a collection of movies
153 | theater.fetch({
154 | conditions: {genre: ["a", "e"]},
155 | success: function() {
156 | // The theater collection will be populated with all the movies whose genre is "adventure", "comic", "drama", but not "thriller".
157 | }
158 | });
159 | ```
160 |
161 | You can also selects indexed value with some "Comparison Query Operators" (like mongodb)
162 | The options are:
163 | * $gte = greater than or equal to (i.e. >=)
164 | * $gt = greater than (i.e. >)
165 | * $lte = less than or equal to (i.e. <=)
166 | * $lt = less than (i.e. <)
167 |
168 | See an example.
169 |
170 | ```js
171 | var theater = new Theater() // Theater is a collection of movies
172 | theater.fetch({
173 | conditions: {year: {$gte: 2013},
174 | success: function() {
175 | // The theater collection will be populated with all the movies with year >= 2013
176 | }
177 | });
178 | ```
179 |
180 | You can also *get all items after a certain object (excluding that object), or from a certain object (including) to a certain object (including)* (using their ids). This combined with the addIndividually option allows you to lazy load a full collection, by always loading the next element.
181 |
182 | ```js
183 | var theater = new Theater() // Theater is a collection of movies
184 | theater.fetch({
185 | from: new Movie({id: 12345, ...}),
186 | after: new Movie({id: 12345, ...}),
187 | to: new Movie({id: 12345, ...}),
188 | success: function() {
189 | // The theater collection will be populated with all the movies whose genre is "adventure", "comic", "drama", but not "thriller".
190 | }
191 | });
192 | ```
193 |
194 | You can also obviously combine all these.
195 |
196 | ## Optional Persistence
197 | If needing to persist via ajax as well as indexed-db, just override your model's sync to use ajax instead.
198 |
199 | ```coffeescript
200 | class MyMode extends Backbone.Model
201 |
202 | sync: Backbone.ajaxSync
203 | ```
204 |
205 | Any more complex dual persistence can be provided in method overrides, which could eventually drive out the design for a multi-layer persistence adapter.
206 |
--------------------------------------------------------------------------------
/backbone-indexeddb.js:
--------------------------------------------------------------------------------
1 | (function (root, factory) {
2 | if (typeof define === 'function' && define.amd) {
3 | // AMD. Register as an anonymous module.
4 | define(['backbone', 'underscore'], factory);
5 | } else if (typeof exports === 'object') {
6 | // Node. Does not work with strict CommonJS, but
7 | // only CommonJS-like environments that support module.exports,
8 | // like Node.
9 | module.exports = factory(require('backbone'), require('underscore'));
10 | } else {
11 | // Browser globals (root is window)
12 | root.returnExports = factory(root.Backbone, root._);
13 | }
14 | }(this, function (Backbone, _) {
15 |
16 | // Generate four random hex digits.
17 | function S4() {
18 | return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1);
19 | }
20 |
21 | // Generate a pseudo-GUID by concatenating random hexadecimal.
22 | function guid() {
23 | return (S4() + S4() + "-" + S4() + "-" + S4() + "-" + S4() + "-" + S4() + S4() + S4());
24 | }
25 |
26 | if (typeof indexedDB === "undefined") { return; }
27 |
28 | var Deferred = Backbone.$ && Backbone.$.Deferred;
29 |
30 | // Driver object
31 | // That's the interesting part.
32 | // There is a driver for each schema provided. The schema is a te combination of name (for the database), a version as well as migrations to reach that
33 | // version of the database.
34 | function Driver(schema, ready, nolog, onerror) {
35 | this.schema = schema;
36 | this.ready = ready;
37 | this.error = null;
38 | this.transactions = []; // Used to list all transactions and keep track of active ones.
39 | this.db = null;
40 | this.nolog = nolog;
41 | this.onerror = onerror;
42 | var lastMigrationPathVersion = _.last(this.schema.migrations).version;
43 | if (!this.nolog) debugLog("opening database " + this.schema.id + " in version #" + lastMigrationPathVersion);
44 | this.dbRequest = indexedDB.open(this.schema.id,lastMigrationPathVersion); //schema version need to be an unsigned long
45 |
46 | this.launchMigrationPath = function(dbVersion) {
47 | var transaction = this.dbRequest.transaction;
48 | var clonedMigrations = _.clone(schema.migrations);
49 | this.migrate(transaction, clonedMigrations, dbVersion, {
50 | error: _.bind(function(event) {
51 | this.error = "Database not up to date. " + dbVersion + " expected was " + lastMigrationPathVersion;
52 | }, this)
53 | });
54 | };
55 |
56 | this.dbRequest.onblocked = function(event){
57 | if (!this.nolog) debugLog("connection to database blocked");
58 | };
59 |
60 | this.dbRequest.onsuccess = _.bind(function (e) {
61 | this.db = e.target.result; // Attach the connection ot the queue.
62 | var currentIntDBVersion = (parseInt(this.db.version) || 0); // we need convert beacuse chrome store in integer and ie10 DP4+ in int;
63 | var lastMigrationInt = (parseInt(lastMigrationPathVersion) || 0); // And make sure we compare numbers with numbers.
64 |
65 | if (currentIntDBVersion === lastMigrationInt) { //if support new event onupgradeneeded will trigger the ready function
66 | // No migration to perform!
67 | this.ready();
68 | } else if (currentIntDBVersion < lastMigrationInt ) {
69 | // We need to migrate up to the current migration defined in the database
70 | this.launchMigrationPath(currentIntDBVersion);
71 | } else {
72 | // Looks like the IndexedDB is at a higher version than the current driver schema.
73 | this.error = "Database version is greater than current code " + currentIntDBVersion + " expected was " + lastMigrationInt;
74 | }
75 | }, this);
76 |
77 |
78 | this.dbRequest.onerror = _.bind(function (e) {
79 | // Failed to open the database
80 | this.error = "Couldn't not connect to the database"
81 | if (!this.nolog) debugLog("Couldn't not connect to the database");
82 | this.onerror();
83 | }, this);
84 |
85 | this.dbRequest.onabort = _.bind(function (e) {
86 | // Failed to open the database
87 | this.error = "Connection to the database aborted"
88 | if (!this.nolog) debugLog("Connection to the database aborted");
89 | this.onerror();
90 | }, this);
91 |
92 |
93 | this.dbRequest.onupgradeneeded = _.bind(function(iDBVersionChangeEvent){
94 | this.db =iDBVersionChangeEvent.target.result;
95 |
96 | var newVersion = iDBVersionChangeEvent.newVersion;
97 | var oldVersion = iDBVersionChangeEvent.oldVersion;
98 |
99 | // Fix Safari 8 and iOS 8 bug
100 | // at the first connection oldVersion is equal to 9223372036854776000
101 | // but the real value is 0
102 | if (oldVersion > 99999999999)
103 | oldVersion = 0;
104 |
105 | if (!this.nolog) debugLog("onupgradeneeded = " + oldVersion + " => " + newVersion);
106 | this.launchMigrationPath(oldVersion);
107 | }, this);
108 | }
109 |
110 | function debugLog(str) {
111 | if (typeof console !== "undefined" && typeof console.log === "function") {
112 | console.log(str);
113 | }
114 | }
115 |
116 | // Driver Prototype
117 | Driver.prototype = {
118 |
119 | // Tracks transactions. Mostly for debugging purposes. TO-IMPROVE
120 | _track_transaction: function(transaction) {
121 | this.transactions.push(transaction);
122 | var removeIt = _.bind(function() {
123 | var idx = this.transactions.indexOf(transaction);
124 | if (idx !== -1) {this.transactions.splice(idx); }
125 | }, this);
126 | transaction.oncomplete = removeIt;
127 | transaction.onabort = removeIt;
128 | transaction.onerror = removeIt;
129 | },
130 |
131 | // Performs all the migrations to reach the right version of the database.
132 | migrate: function (transaction, migrations, version, options) {
133 | transaction.onerror = options.error;
134 | transaction.onabort = options.error;
135 |
136 | if (!this.nolog) debugLog("migrate begin version from #" + version);
137 | var that = this;
138 | var migration = migrations.shift();
139 | if (migration) {
140 | if (!version || version < migration.version) {
141 | // We need to apply this migration-
142 | if (typeof migration.before == "undefined") {
143 | migration.before = function (next) {
144 | next();
145 | };
146 | }
147 | if (typeof migration.after == "undefined") {
148 | migration.after = function (next) {
149 | next();
150 | };
151 | }
152 | // First, let's run the before script
153 | if (!that.nolog) debugLog("migrate begin before version #" + migration.version);
154 | migration.before(function () {
155 | if (!that.nolog) debugLog("migrate done before version #" + migration.version);
156 |
157 | if (!that.nolog) debugLog("migrate begin migrate version #" + migration.version);
158 |
159 | migration.migrate(transaction, function () {
160 | if (!that.nolog) debugLog("migrate done migrate version #" + migration.version);
161 | // Migration successfully appliedn let's go to the next one!
162 | if (!that.nolog) debugLog("migrate begin after version #" + migration.version);
163 | migration.after(function () {
164 | if (!that.nolog) debugLog("migrate done after version #" + migration.version);
165 | if (!that.nolog) debugLog("Migrated to " + migration.version);
166 |
167 | //last modification occurred, need finish
168 | if(migrations.length ==0) {
169 | if (!that.nolog) {
170 | debugLog("migrate setting transaction.oncomplete to finish version #" + migration.version);
171 | transaction.oncomplete = function() {
172 | debugLog("migrate done transaction.oncomplete version #" + migration.version);
173 | debugLog("Done migrating");
174 | };
175 | }
176 | }
177 | else {
178 | if (!that.nolog) debugLog("migrate end from version #" + version + " to " + migration.version);
179 | that.migrate(transaction, migrations, version, options);
180 | }
181 |
182 | });
183 | });
184 | });
185 | } else {
186 | // No need to apply this migration
187 | if (!that.nolog) debugLog("Skipping migration " + migration.version);
188 | that.migrate(transaction, migrations, version, options);
189 | }
190 | }
191 | },
192 |
193 | // This is the main method, called by the ExecutionQueue when the driver is ready (database open and migration performed)
194 | execute: function (storeName, method, object, options) {
195 | if (!this.nolog) debugLog("execute : " + method + " on " + storeName + " for " + object.id);
196 | switch (method) {
197 | case "create":
198 | this.create(storeName, object, options);
199 | break;
200 | case "read":
201 | if (object.id || object.cid) {
202 | this.read(storeName, object, options); // It's a model
203 | } else {
204 | this.query(storeName, object, options); // It's a collection
205 | }
206 | break;
207 | case "update":
208 | this.update(storeName, object, options); // We may want to check that this is not a collection. TOFIX
209 | break;
210 | case "delete":
211 | if (object.id || object.cid) {
212 | this['delete'](storeName, object, options);
213 | } else {
214 | this.clear(storeName, object, options);
215 | }
216 | break;
217 | default:
218 | // Hum what?
219 | }
220 | },
221 |
222 | // Writes the json to the storeName in db. It is a create operations, which means it will fail if the key already exists
223 | // options are just success and error callbacks.
224 | create: function (storeName, object, options) {
225 | var writeTransaction = this.db.transaction([storeName], 'readwrite');
226 | //this._track_transaction(writeTransaction);
227 | var store = writeTransaction.objectStore(storeName);
228 | var json = object.toJSON();
229 | var idAttribute = _.result(object, 'idAttribute');
230 | var writeRequest;
231 |
232 | if (json[idAttribute] === undefined && !store.autoIncrement) json[idAttribute] = guid();
233 |
234 | writeTransaction.onerror = function (e) {
235 | options.error(e);
236 | };
237 | writeTransaction.oncomplete = function (e) {
238 | options.success(json);
239 | };
240 |
241 | if (!store.keyPath)
242 | writeRequest = store.add(json, json[idAttribute]);
243 | else
244 | writeRequest = store.add(json);
245 | },
246 |
247 | // Writes the json to the storeName in db. It is an update operation, which means it will overwrite the value if the key already exist
248 | // options are just success and error callbacks.
249 | update: function (storeName, object, options) {
250 | var writeTransaction = this.db.transaction([storeName], 'readwrite');
251 | //this._track_transaction(writeTransaction);
252 | var store = writeTransaction.objectStore(storeName);
253 | var json = object.toJSON();
254 | var idAttribute = _.result(object, 'idAttribute');
255 | var writeRequest;
256 |
257 | if (!json[idAttribute]) json[idAttribute] = guid();
258 |
259 | if (!store.keyPath)
260 | writeRequest = store.put(json, json[idAttribute]);
261 | else
262 | writeRequest = store.put(json);
263 |
264 | writeRequest.onerror = function (e) {
265 | options.error(e);
266 | };
267 | writeTransaction.oncomplete = function (e) {
268 | options.success(json);
269 | };
270 | },
271 |
272 | // Reads from storeName in db with json.id if it's there of with any json.xxxx as long as xxx is an index in storeName
273 | read: function (storeName, object, options) {
274 | var readTransaction = this.db.transaction([storeName], "readonly");
275 | this._track_transaction(readTransaction);
276 |
277 | var store = readTransaction.objectStore(storeName);
278 | var json = object.toJSON();
279 | var idAttribute = _.result(object, 'idAttribute');
280 |
281 | var getRequest = null;
282 | if (json[idAttribute]) {
283 | getRequest = store.get(json[idAttribute]);
284 | } else if(options.index) {
285 | var index = store.index(options.index.name);
286 | getRequest = index.get(options.index.value);
287 | } else {
288 | // We need to find which index we have
289 | var cardinality = 0; // try to fit the index with most matches
290 | _.each(store.indexNames, function (key) {
291 | var index = store.index(key);
292 | if(typeof index.keyPath === 'string' && 1 > cardinality) {
293 | // simple index
294 | if (json[index.keyPath] !== undefined) {
295 | getRequest = index.get(json[index.keyPath]);
296 | cardinality = 1;
297 | }
298 | } else if(typeof index.keyPath === 'object' && index.keyPath.length > cardinality) {
299 | // compound index
300 | var valid = true;
301 | var keyValue = _.map(index.keyPath, function(keyPart) {
302 | valid = valid && json[keyPart] !== undefined;
303 | return json[keyPart];
304 | });
305 | if(valid) {
306 | getRequest = index.get(keyValue);
307 | cardinality = index.keyPath.length;
308 | }
309 | }
310 | });
311 | }
312 | if (getRequest) {
313 | getRequest.onsuccess = function (event) {
314 | if (event.target.result) {
315 | options.success(event.target.result);
316 | } else {
317 | options.error("Not Found");
318 | }
319 | };
320 | getRequest.onerror = function () {
321 | options.error("Not Found"); // We couldn't find the record.
322 | }
323 | } else {
324 | options.error("Not Found"); // We couldn't even look for it, as we don't have enough data.
325 | }
326 | },
327 |
328 | // Deletes the json.id key and value in storeName from db.
329 | delete: function (storeName, object, options) {
330 | var deleteTransaction = this.db.transaction([storeName], 'readwrite');
331 | //this._track_transaction(deleteTransaction);
332 |
333 | var store = deleteTransaction.objectStore(storeName);
334 | var json = object.toJSON();
335 | var idAttribute = store.keyPath || _.result(object, 'idAttribute');
336 |
337 | var deleteRequest = store['delete'](json[idAttribute]);
338 |
339 | deleteTransaction.oncomplete = function (event) {
340 | options.success(null);
341 | };
342 | deleteRequest.onerror = function (event) {
343 | options.error("Not Deleted");
344 | };
345 | },
346 |
347 | // Clears all records for storeName from db.
348 | clear: function (storeName, object, options) {
349 | var deleteTransaction = this.db.transaction([storeName], "readwrite");
350 | //this._track_transaction(deleteTransaction);
351 |
352 | var store = deleteTransaction.objectStore(storeName);
353 |
354 | var deleteRequest = store.clear();
355 | deleteRequest.onsuccess = function (event) {
356 | options.success(null);
357 | };
358 | deleteRequest.onerror = function (event) {
359 | options.error("Not Cleared");
360 | };
361 | },
362 |
363 | // Performs a query on storeName in db.
364 | // options may include :
365 | // - conditions : value of an index, or range for an index
366 | // - range : range for the primary key
367 | // - limit : max number of elements to be yielded
368 | // - offset : skipped items.
369 | query: function (storeName, collection, options) {
370 | var elements = [];
371 | var skipped = 0, processed = 0;
372 | var queryTransaction = this.db.transaction([storeName], "readonly");
373 | //this._track_transaction(queryTransaction);
374 |
375 | var idAttribute = _.result(collection.model.prototype, 'idAttribute');
376 | var readCursor = null;
377 | var store = queryTransaction.objectStore(storeName);
378 | var index = null,
379 | lower = null,
380 | upper = null,
381 | bounds = null,
382 | key;
383 |
384 | if (options.conditions) {
385 | // We have a condition, we need to use it for the cursor
386 | _.each(store.indexNames, function (key) {
387 | if (!readCursor) {
388 | index = store.index(key);
389 | if (options.conditions[index.keyPath] instanceof Array) {
390 | lower = options.conditions[index.keyPath][0] > options.conditions[index.keyPath][1] ? options.conditions[index.keyPath][1] : options.conditions[index.keyPath][0];
391 | upper = options.conditions[index.keyPath][0] > options.conditions[index.keyPath][1] ? options.conditions[index.keyPath][0] : options.conditions[index.keyPath][1];
392 | bounds = IDBKeyRange.bound(lower, upper, true, true);
393 |
394 | if (options.conditions[index.keyPath][0] > options.conditions[index.keyPath][1]) {
395 | // Looks like we want the DESC order
396 | readCursor = index.openCursor(bounds, window.IDBCursor.PREV || "prev");
397 | } else {
398 | // We want ASC order
399 | readCursor = index.openCursor(bounds, window.IDBCursor.NEXT || "next");
400 | }
401 | } else if (typeof options.conditions[index.keyPath] === 'object' && ('$gt' in options.conditions[index.keyPath] || '$gte' in options.conditions[index.keyPath])) {
402 | if('$gt' in options.conditions[index.keyPath])
403 | bounds = IDBKeyRange.lowerBound(options.conditions[index.keyPath]['$gt'], true);
404 | else
405 | bounds = IDBKeyRange.lowerBound(options.conditions[index.keyPath]['$gte']);
406 | readCursor = index.openCursor(bounds, window.IDBCursor.NEXT || "next");
407 | } else if (typeof options.conditions[index.keyPath] === 'object' && ('$lt' in options.conditions[index.keyPath] || '$lte' in options.conditions[index.keyPath])) {
408 | if('$lt' in options.conditions[index.keyPath])
409 | bounds = IDBKeyRange.upperBound(options.conditions[index.keyPath]['$lt'], true);
410 | else
411 | bounds = IDBKeyRange.upperBound(options.conditions[index.keyPath]['$lte']);
412 | readCursor = index.openCursor(bounds, window.IDBCursor.NEXT || "next");
413 | } else if (options.conditions[index.keyPath] != undefined) {
414 | bounds = IDBKeyRange.only(options.conditions[index.keyPath]);
415 | readCursor = index.openCursor(bounds);
416 | }
417 | }
418 | });
419 | } else {
420 | // No conditions, use the index
421 | if (options.range) {
422 | lower = options.range[0] > options.range[1] ? options.range[1] : options.range[0];
423 | upper = options.range[0] > options.range[1] ? options.range[0] : options.range[1];
424 | bounds = IDBKeyRange.bound(lower, upper);
425 | if (options.range[0] > options.range[1]) {
426 | readCursor = store.openCursor(bounds, window.IDBCursor.PREV || "prev");
427 | } else {
428 | readCursor = store.openCursor(bounds, window.IDBCursor.NEXT || "next");
429 | }
430 | } else if (options.sort && options.sort.index) {
431 | if (options.sort.order === -1) {
432 | readCursor = store.index(options.sort.index).openCursor(null, window.IDBCursor.PREV || "prev");
433 | } else {
434 | readCursor = store.index(options.sort.index).openCursor(null, window.IDBCursor.NEXT || "next");
435 | }
436 | } else {
437 | readCursor = store.openCursor();
438 | }
439 | }
440 |
441 | if (typeof (readCursor) == "undefined" || !readCursor) {
442 | options.error("No Cursor");
443 | } else {
444 | readCursor.onerror = function(e){
445 | options.error("readCursor error", e);
446 | };
447 | // Setup a handler for the cursor’s `success` event:
448 | readCursor.onsuccess = function (e) {
449 | var cursor = e.target.result;
450 | if (!cursor) {
451 | if (options.addIndividually || options.clear) {
452 | options.success(elements, true);
453 | } else {
454 | options.success(elements); // We're done. No more elements.
455 | }
456 | }
457 | else {
458 | // Cursor is not over yet.
459 | if (options.abort || (options.limit && processed >= options.limit)) {
460 | // Yet, we have processed enough elements. So, let's just skip.
461 | if (bounds && options.conditions[index.keyPath]) {
462 | cursor["continue"](options.conditions[index.keyPath][1] + 1); /* We need to 'terminate' the cursor cleany, by moving to the end */
463 | } else {
464 | cursor["continue"](); /* We need to 'terminate' the cursor cleany, by moving to the end */
465 | }
466 | }
467 | else if (options.offset && options.offset > skipped) {
468 | skipped++;
469 | cursor["continue"](); /* We need to Moving the cursor forward */
470 | } else {
471 | // This time, it looks like it's good!
472 | if (!options.filter || typeof options.filter !== 'function' || options.filter(cursor.value)) {
473 | if (options.addIndividually) {
474 | collection.add(cursor.value);
475 | } else if (options.clear) {
476 | var deleteRequest = store['delete'](cursor.value[idAttribute]);
477 | deleteRequest.onsuccess = deleteRequest.onerror = function (event) {
478 | elements.push(cursor.value);
479 | };
480 |
481 | } else {
482 | elements.push(cursor.value);
483 | }
484 | }
485 | processed++;
486 | cursor["continue"]();
487 | }
488 | }
489 | };
490 | }
491 | },
492 | close :function(){
493 | if(this.db){
494 | this.db.close();
495 | }
496 | }
497 | };
498 |
499 | // ExecutionQueue object
500 | // The execution queue is an abstraction to buffer up requests to the database.
501 | // It holds a "driver". When the driver is ready, it just fires up the queue and executes in sync.
502 | function ExecutionQueue(schema,next,nolog) {
503 | this.driver = new Driver(schema, this.ready.bind(this), nolog, this.error.bind(this));
504 | this.started = false;
505 | this.failed = false;
506 | this.stack = [];
507 | this.version = _.last(schema.migrations).version;
508 | this.next = next;
509 | }
510 |
511 | // ExecutionQueue Prototype
512 | ExecutionQueue.prototype = {
513 | // Called when the driver is ready
514 | // It just loops over the elements in the queue and executes them.
515 | ready: function () {
516 | this.started = true;
517 | _.each(this.stack, this.execute, this);
518 | this.stack = []; // fix memory leak
519 | this.next();
520 | },
521 |
522 | error: function() {
523 | this.failed = true;
524 | _.each(this.stack, this.execute, this);
525 | this.stack = [];
526 | this.next();
527 | },
528 |
529 | // Executes a given command on the driver. If not started, just stacks up one more element.
530 | execute: function (message) {
531 | if (this.started) {
532 | this.driver.execute(message[2].storeName || message[1].storeName, message[0], message[1], message[2]); // Upon messages, we execute the query
533 | } else if (this.failed) {
534 | message[2].error();
535 | } else {
536 | this.stack.push(message);
537 | }
538 | },
539 |
540 | close : function(){
541 | this.driver.close();
542 | }
543 | };
544 |
545 | // Method used by Backbone for sync of data with data store. It was initially designed to work with "server side" APIs, This wrapper makes
546 | // it work with the local indexedDB stuff. It uses the schema attribute provided by the object.
547 | // The wrapper keeps an active Executuon Queue for each "schema", and executes querues agains it, based on the object type (collection or
548 | // single model), but also the method... etc.
549 | // Keeps track of the connections
550 | var Databases = {};
551 |
552 | function sync(method, object, options) {
553 |
554 | if(method == "closeall"){
555 | _.invoke(Databases, "close");
556 | // Clean up active databases object.
557 | Databases = {};
558 | return Deferred && Deferred().resolve();
559 | }
560 |
561 | // If a model or a collection does not define a database, fall back on ajaxSync
562 | if (!object || !_.isObject(object.database)) {
563 | return Backbone.ajaxSync(method, object, options);
564 | }
565 |
566 | var schema = object.database;
567 | if (Databases[schema.id]) {
568 | if(Databases[schema.id].version != _.last(schema.migrations).version){
569 | Databases[schema.id].close();
570 | delete Databases[schema.id];
571 | }
572 | }
573 |
574 | var dfd, promise;
575 | if (Deferred) {
576 | dfd = Deferred();
577 | promise = dfd.promise();
578 | promise.abort = function () {
579 | options.abort = true;
580 | };
581 | }
582 |
583 | var success = options.success;
584 | options.success = function(resp, silenced) {
585 | if (!silenced) {
586 | if (success) success(resp);
587 | object.trigger('sync', object, resp, options);
588 | }
589 | if (dfd) {
590 | if (!options.abort) {
591 | dfd.resolve(resp);
592 | } else {
593 | dfd.reject();
594 | }
595 | }
596 | };
597 |
598 | var error = options.error;
599 | options.error = function(resp) {
600 | if (error) error(resp);
601 | if (dfd) dfd.reject(resp);
602 | object.trigger('error', object, resp, options);
603 | };
604 |
605 | var next = function(){
606 | Databases[schema.id].execute([method, object, options]);
607 | };
608 |
609 | if (!Databases[schema.id]) {
610 | Databases[schema.id] = new ExecutionQueue(schema,next,schema.nolog);
611 | } else {
612 | next();
613 | }
614 |
615 | return promise;
616 | };
617 |
618 | Backbone.ajaxSync = Backbone.sync;
619 | Backbone.sync = sync;
620 |
621 | return { sync: sync, debugLog: debugLog};
622 | }));
623 |
--------------------------------------------------------------------------------
/lib/backbone-min.js:
--------------------------------------------------------------------------------
1 | (function(){var t=this;var e=t.Backbone;var i=[];var r=i.push;var s=i.slice;var n=i.splice;var a;if(typeof exports!=="undefined"){a=exports}else{a=t.Backbone={}}a.VERSION="1.0.0";var h=t._;if(!h&&typeof require!=="undefined")h=require("underscore");a.$=t.jQuery||t.Zepto||t.ender||t.$;a.noConflict=function(){t.Backbone=e;return this};a.emulateHTTP=false;a.emulateJSON=false;var o=a.Events={on:function(t,e,i){if(!l(this,"on",t,[e,i])||!e)return this;this._events||(this._events={});var r=this._events[t]||(this._events[t]=[]);r.push({callback:e,context:i,ctx:i||this});return this},once:function(t,e,i){if(!l(this,"once",t,[e,i])||!e)return this;var r=this;var s=h.once(function(){r.off(t,s);e.apply(this,arguments)});s._callback=e;return this.on(t,s,i)},off:function(t,e,i){var r,s,n,a,o,u,c,f;if(!this._events||!l(this,"off",t,[e,i]))return this;if(!t&&!e&&!i){this._events={};return this}a=t?[t]:h.keys(this._events);for(o=0,u=a.length;o").attr(t);this.setElement(e,false)}else{this.setElement(h.result(this,"el"),false)}}});a.sync=function(t,e,i){var r=k[t];h.defaults(i||(i={}),{emulateHTTP:a.emulateHTTP,emulateJSON:a.emulateJSON});var s={type:r,dataType:"json"};if(!i.url){s.url=h.result(e,"url")||U()}if(i.data==null&&e&&(t==="create"||t==="update"||t==="patch")){s.contentType="application/json";s.data=JSON.stringify(i.attrs||e.toJSON(i))}if(i.emulateJSON){s.contentType="application/x-www-form-urlencoded";s.data=s.data?{model:s.data}:{}}if(i.emulateHTTP&&(r==="PUT"||r==="DELETE"||r==="PATCH")){s.type="POST";if(i.emulateJSON)s.data._method=r;var n=i.beforeSend;i.beforeSend=function(t){t.setRequestHeader("X-HTTP-Method-Override",r);if(n)return n.apply(this,arguments)}}if(s.type!=="GET"&&!i.emulateJSON){s.processData=false}if(s.type==="PATCH"&&window.ActiveXObject&&!(window.external&&window.external.msActiveXFilteringEnabled)){s.xhr=function(){return new ActiveXObject("Microsoft.XMLHTTP")}}var o=i.xhr=a.ajax(h.extend(s,i));e.trigger("request",e,o,i);return o};var k={create:"POST",update:"PUT",patch:"PATCH","delete":"DELETE",read:"GET"};a.ajax=function(){return a.$.ajax.apply(a.$,arguments)};var S=a.Router=function(t){t||(t={});if(t.routes)this.routes=t.routes;this._bindRoutes();this.initialize.apply(this,arguments)};var $=/\((.*?)\)/g;var T=/(\(\?)?:\w+/g;var H=/\*\w+/g;var A=/[\-{}\[\]+?.,\\\^$|#\s]/g;h.extend(S.prototype,o,{initialize:function(){},route:function(t,e,i){if(!h.isRegExp(t))t=this._routeToRegExp(t);if(h.isFunction(e)){i=e;e=""}if(!i)i=this[e];var r=this;a.history.route(t,function(s){var n=r._extractParameters(t,s);i&&i.apply(r,n);r.trigger.apply(r,["route:"+e].concat(n));r.trigger("route",e,n);a.history.trigger("route",r,e,n)});return this},navigate:function(t,e){a.history.navigate(t,e);return this},_bindRoutes:function(){if(!this.routes)return;this.routes=h.result(this,"routes");var t,e=h.keys(this.routes);while((t=e.pop())!=null){this.route(t,this.routes[t])}},_routeToRegExp:function(t){t=t.replace(A,"\\$&").replace($,"(?:$1)?").replace(T,function(t,e){return e?t:"([^/]+)"}).replace(H,"(.*?)");return new RegExp("^"+t+"$")},_extractParameters:function(t,e){var i=t.exec(e).slice(1);return h.map(i,function(t){return t?decodeURIComponent(t):null})}});var I=a.History=function(){this.handlers=[];h.bindAll(this,"checkUrl");if(typeof window!=="undefined"){this.location=window.location;this.history=window.history}};var N=/^[#\/]|\s+$/g;var P=/^\/+|\/+$/g;var O=/msie [\w.]+/;var C=/\/$/;I.started=false;h.extend(I.prototype,o,{interval:50,getHash:function(t){var e=(t||this).location.href.match(/#(.*)$/);return e?e[1]:""},getFragment:function(t,e){if(t==null){if(this._hasPushState||!this._wantsHashChange||e){t=this.location.pathname;var i=this.root.replace(C,"");if(!t.indexOf(i))t=t.substr(i.length)}else{t=this.getHash()}}return t.replace(N,"")},start:function(t){if(I.started)throw new Error("Backbone.history has already been started");I.started=true;this.options=h.extend({},{root:"/"},this.options,t);this.root=this.options.root;this._wantsHashChange=this.options.hashChange!==false;this._wantsPushState=!!this.options.pushState;this._hasPushState=!!(this.options.pushState&&this.history&&this.history.pushState);var e=this.getFragment();var i=document.documentMode;var r=O.exec(navigator.userAgent.toLowerCase())&&(!i||i<=7);this.root=("/"+this.root+"/").replace(P,"/");if(r&&this._wantsHashChange){this.iframe=a.$('').hide().appendTo("body")[0].contentWindow;this.navigate(e)}if(this._hasPushState){a.$(window).on("popstate",this.checkUrl)}else if(this._wantsHashChange&&"onhashchange"in window&&!r){a.$(window).on("hashchange",this.checkUrl)}else if(this._wantsHashChange){this._checkUrlInterval=setInterval(this.checkUrl,this.interval)}this.fragment=e;var s=this.location;var n=s.pathname.replace(/[^\/]$/,"$&/")===this.root;if(this._wantsHashChange&&this._wantsPushState&&!this._hasPushState&&!n){this.fragment=this.getFragment(null,true);this.location.replace(this.root+this.location.search+"#"+this.fragment);return true}else if(this._wantsPushState&&this._hasPushState&&n&&s.hash){this.fragment=this.getHash().replace(N,"");this.history.replaceState({},document.title,this.root+this.fragment+s.search)}if(!this.options.silent)return this.loadUrl()},stop:function(){a.$(window).off("popstate",this.checkUrl).off("hashchange",this.checkUrl);clearInterval(this._checkUrlInterval);I.started=false},route:function(t,e){this.handlers.unshift({route:t,callback:e})},checkUrl:function(t){var e=this.getFragment();if(e===this.fragment&&this.iframe){e=this.getFragment(this.getHash(this.iframe))}if(e===this.fragment)return false;if(this.iframe)this.navigate(e);this.loadUrl()||this.loadUrl(this.getHash())},loadUrl:function(t){var e=this.fragment=this.getFragment(t);var i=h.any(this.handlers,function(t){if(t.route.test(e)){t.callback(e);return true}});return i},navigate:function(t,e){if(!I.started)return false;if(!e||e===true)e={trigger:e};t=this.getFragment(t||"");if(this.fragment===t)return;this.fragment=t;var i=this.root+t;if(this._hasPushState){this.history[e.replace?"replaceState":"pushState"]({},document.title,i)}else if(this._wantsHashChange){this._updateHash(this.location,t,e.replace);if(this.iframe&&t!==this.getFragment(this.getHash(this.iframe))){if(!e.replace)this.iframe.document.open().close();this._updateHash(this.iframe.location,t,e.replace)}}else{return this.location.assign(i)}if(e.trigger)this.loadUrl(t)},_updateHash:function(t,e,i){if(i){var r=t.href.replace(/(javascript:|#).*$/,"");t.replace(r+"#"+e)}else{t.hash="#"+e}}});a.history=new I;var j=function(t,e){var i=this;var r;if(t&&h.has(t,"constructor")){r=t.constructor}else{r=function(){return i.apply(this,arguments)}}h.extend(r,i,e);var s=function(){this.constructor=r};s.prototype=i.prototype;r.prototype=new s;if(t)h.extend(r.prototype,t);r.__super__=i.prototype;return r};d.extend=g.extend=S.extend=b.extend=I.extend=j;var U=function(){throw new Error('A "url" property or function must be specified')};var R=function(t,e){var i=e.error;e.error=function(r){if(i)i(t,r,e);t.trigger("error",t,r,e)}}}).call(this);
2 | /*
3 | //@ sourceMappingURL=backbone-min.map
4 | */
--------------------------------------------------------------------------------
/lib/qunit.css:
--------------------------------------------------------------------------------
1 | /*!
2 | * QUnit 1.14.0
3 | * http://qunitjs.com/
4 | *
5 | * Copyright 2013 jQuery Foundation and other contributors
6 | * Released under the MIT license
7 | * http://jquery.org/license
8 | *
9 | * Date: 2014-01-31T16:40Z
10 | */
11 |
12 | /** Font Family and Sizes */
13 |
14 | #qunit-tests, #qunit-header, #qunit-banner, #qunit-testrunner-toolbar, #qunit-userAgent, #qunit-testresult {
15 | font-family: "Helvetica Neue Light", "HelveticaNeue-Light", "Helvetica Neue", Calibri, Helvetica, Arial, sans-serif;
16 | }
17 |
18 | #qunit-testrunner-toolbar, #qunit-userAgent, #qunit-testresult, #qunit-tests li { font-size: small; }
19 | #qunit-tests { font-size: smaller; }
20 |
21 |
22 | /** Resets */
23 |
24 | #qunit-tests, #qunit-header, #qunit-banner, #qunit-userAgent, #qunit-testresult, #qunit-modulefilter {
25 | margin: 0;
26 | padding: 0;
27 | }
28 |
29 |
30 | /** Header */
31 |
32 | #qunit-header {
33 | padding: 0.5em 0 0.5em 1em;
34 |
35 | color: #8699A4;
36 | background-color: #0D3349;
37 |
38 | font-size: 1.5em;
39 | line-height: 1em;
40 | font-weight: 400;
41 |
42 | border-radius: 5px 5px 0 0;
43 | }
44 |
45 | #qunit-header a {
46 | text-decoration: none;
47 | color: #C2CCD1;
48 | }
49 |
50 | #qunit-header a:hover,
51 | #qunit-header a:focus {
52 | color: #FFF;
53 | }
54 |
55 | #qunit-testrunner-toolbar label {
56 | display: inline-block;
57 | padding: 0 0.5em 0 0.1em;
58 | }
59 |
60 | #qunit-banner {
61 | height: 5px;
62 | }
63 |
64 | #qunit-testrunner-toolbar {
65 | padding: 0.5em 0 0.5em 2em;
66 | color: #5E740B;
67 | background-color: #EEE;
68 | overflow: hidden;
69 | }
70 |
71 | #qunit-userAgent {
72 | padding: 0.5em 0 0.5em 2.5em;
73 | background-color: #2B81AF;
74 | color: #FFF;
75 | text-shadow: rgba(0, 0, 0, 0.5) 2px 2px 1px;
76 | }
77 |
78 | #qunit-modulefilter-container {
79 | float: right;
80 | }
81 |
82 | /** Tests: Pass/Fail */
83 |
84 | #qunit-tests {
85 | list-style-position: inside;
86 | }
87 |
88 | #qunit-tests li {
89 | padding: 0.4em 0.5em 0.4em 2.5em;
90 | border-bottom: 1px solid #FFF;
91 | list-style-position: inside;
92 | }
93 |
94 | #qunit-tests.hidepass li.pass, #qunit-tests.hidepass li.running {
95 | display: none;
96 | }
97 |
98 | #qunit-tests li strong {
99 | cursor: pointer;
100 | }
101 |
102 | #qunit-tests li a {
103 | padding: 0.5em;
104 | color: #C2CCD1;
105 | text-decoration: none;
106 | }
107 | #qunit-tests li a:hover,
108 | #qunit-tests li a:focus {
109 | color: #000;
110 | }
111 |
112 | #qunit-tests li .runtime {
113 | float: right;
114 | font-size: smaller;
115 | }
116 |
117 | .qunit-assert-list {
118 | margin-top: 0.5em;
119 | padding: 0.5em;
120 |
121 | background-color: #FFF;
122 |
123 | border-radius: 5px;
124 | }
125 |
126 | .qunit-collapsed {
127 | display: none;
128 | }
129 |
130 | #qunit-tests table {
131 | border-collapse: collapse;
132 | margin-top: 0.2em;
133 | }
134 |
135 | #qunit-tests th {
136 | text-align: right;
137 | vertical-align: top;
138 | padding: 0 0.5em 0 0;
139 | }
140 |
141 | #qunit-tests td {
142 | vertical-align: top;
143 | }
144 |
145 | #qunit-tests pre {
146 | margin: 0;
147 | white-space: pre-wrap;
148 | word-wrap: break-word;
149 | }
150 |
151 | #qunit-tests del {
152 | background-color: #E0F2BE;
153 | color: #374E0C;
154 | text-decoration: none;
155 | }
156 |
157 | #qunit-tests ins {
158 | background-color: #FFCACA;
159 | color: #500;
160 | text-decoration: none;
161 | }
162 |
163 | /*** Test Counts */
164 |
165 | #qunit-tests b.counts { color: #000; }
166 | #qunit-tests b.passed { color: #5E740B; }
167 | #qunit-tests b.failed { color: #710909; }
168 |
169 | #qunit-tests li li {
170 | padding: 5px;
171 | background-color: #FFF;
172 | border-bottom: none;
173 | list-style-position: inside;
174 | }
175 |
176 | /*** Passing Styles */
177 |
178 | #qunit-tests li li.pass {
179 | color: #3C510C;
180 | background-color: #FFF;
181 | border-left: 10px solid #C6E746;
182 | }
183 |
184 | #qunit-tests .pass { color: #528CE0; background-color: #D2E0E6; }
185 | #qunit-tests .pass .test-name { color: #366097; }
186 |
187 | #qunit-tests .pass .test-actual,
188 | #qunit-tests .pass .test-expected { color: #999; }
189 |
190 | #qunit-banner.qunit-pass { background-color: #C6E746; }
191 |
192 | /*** Failing Styles */
193 |
194 | #qunit-tests li li.fail {
195 | color: #710909;
196 | background-color: #FFF;
197 | border-left: 10px solid #EE5757;
198 | white-space: pre;
199 | }
200 |
201 | #qunit-tests > li:last-child {
202 | border-radius: 0 0 5px 5px;
203 | }
204 |
205 | #qunit-tests .fail { color: #000; background-color: #EE5757; }
206 | #qunit-tests .fail .test-name,
207 | #qunit-tests .fail .module-name { color: #000; }
208 |
209 | #qunit-tests .fail .test-actual { color: #EE5757; }
210 | #qunit-tests .fail .test-expected { color: #008000; }
211 |
212 | #qunit-banner.qunit-fail { background-color: #EE5757; }
213 |
214 |
215 | /** Result */
216 |
217 | #qunit-testresult {
218 | padding: 0.5em 0.5em 0.5em 2.5em;
219 |
220 | color: #2B81AF;
221 | background-color: #D2E0E6;
222 |
223 | border-bottom: 1px solid #FFF;
224 | }
225 | #qunit-testresult .module-name {
226 | font-weight: 700;
227 | }
228 |
229 | /** Fixture */
230 |
231 | #qunit-fixture {
232 | position: absolute;
233 | top: -10000px;
234 | left: -10000px;
235 | width: 1000px;
236 | height: 1000px;
237 | }
--------------------------------------------------------------------------------
/lib/qunit.js:
--------------------------------------------------------------------------------
1 | /*!
2 | * QUnit 1.14.0
3 | * http://qunitjs.com/
4 | *
5 | * Copyright 2013 jQuery Foundation and other contributors
6 | * Released under the MIT license
7 | * http://jquery.org/license
8 | *
9 | * Date: 2014-01-31T16:40Z
10 | */
11 |
12 | (function( window ) {
13 |
14 | var QUnit,
15 | assert,
16 | config,
17 | onErrorFnPrev,
18 | testId = 0,
19 | fileName = (sourceFromStacktrace( 0 ) || "" ).replace(/(:\d+)+\)?/, "").replace(/.+\//, ""),
20 | toString = Object.prototype.toString,
21 | hasOwn = Object.prototype.hasOwnProperty,
22 | // Keep a local reference to Date (GH-283)
23 | Date = window.Date,
24 | setTimeout = window.setTimeout,
25 | clearTimeout = window.clearTimeout,
26 | defined = {
27 | document: typeof window.document !== "undefined",
28 | setTimeout: typeof window.setTimeout !== "undefined",
29 | sessionStorage: (function() {
30 | var x = "qunit-test-string";
31 | try {
32 | sessionStorage.setItem( x, x );
33 | sessionStorage.removeItem( x );
34 | return true;
35 | } catch( e ) {
36 | return false;
37 | }
38 | }())
39 | },
40 | /**
41 | * Provides a normalized error string, correcting an issue
42 | * with IE 7 (and prior) where Error.prototype.toString is
43 | * not properly implemented
44 | *
45 | * Based on http://es5.github.com/#x15.11.4.4
46 | *
47 | * @param {String|Error} error
48 | * @return {String} error message
49 | */
50 | errorString = function( error ) {
51 | var name, message,
52 | errorString = error.toString();
53 | if ( errorString.substring( 0, 7 ) === "[object" ) {
54 | name = error.name ? error.name.toString() : "Error";
55 | message = error.message ? error.message.toString() : "";
56 | if ( name && message ) {
57 | return name + ": " + message;
58 | } else if ( name ) {
59 | return name;
60 | } else if ( message ) {
61 | return message;
62 | } else {
63 | return "Error";
64 | }
65 | } else {
66 | return errorString;
67 | }
68 | },
69 | /**
70 | * Makes a clone of an object using only Array or Object as base,
71 | * and copies over the own enumerable properties.
72 | *
73 | * @param {Object} obj
74 | * @return {Object} New object with only the own properties (recursively).
75 | */
76 | objectValues = function( obj ) {
77 | // Grunt 0.3.x uses an older version of jshint that still has jshint/jshint#392.
78 | /*jshint newcap: false */
79 | var key, val,
80 | vals = QUnit.is( "array", obj ) ? [] : {};
81 | for ( key in obj ) {
82 | if ( hasOwn.call( obj, key ) ) {
83 | val = obj[key];
84 | vals[key] = val === Object(val) ? objectValues(val) : val;
85 | }
86 | }
87 | return vals;
88 | };
89 |
90 |
91 | // Root QUnit object.
92 | // `QUnit` initialized at top of scope
93 | QUnit = {
94 |
95 | // call on start of module test to prepend name to all tests
96 | module: function( name, testEnvironment ) {
97 | config.currentModule = name;
98 | config.currentModuleTestEnvironment = testEnvironment;
99 | config.modules[name] = true;
100 | },
101 |
102 | asyncTest: function( testName, expected, callback ) {
103 | if ( arguments.length === 2 ) {
104 | callback = expected;
105 | expected = null;
106 | }
107 |
108 | QUnit.test( testName, expected, callback, true );
109 | },
110 |
111 | test: function( testName, expected, callback, async ) {
112 | var test,
113 | nameHtml = "" + escapeText( testName ) + "";
114 |
115 | if ( arguments.length === 2 ) {
116 | callback = expected;
117 | expected = null;
118 | }
119 |
120 | if ( config.currentModule ) {
121 | nameHtml = "" + escapeText( config.currentModule ) + ": " + nameHtml;
122 | }
123 |
124 | test = new Test({
125 | nameHtml: nameHtml,
126 | testName: testName,
127 | expected: expected,
128 | async: async,
129 | callback: callback,
130 | module: config.currentModule,
131 | moduleTestEnvironment: config.currentModuleTestEnvironment,
132 | stack: sourceFromStacktrace( 2 )
133 | });
134 |
135 | if ( !validTest( test ) ) {
136 | return;
137 | }
138 |
139 | test.queue();
140 | },
141 |
142 | // Specify the number of expected assertions to guarantee that failed test (no assertions are run at all) don't slip through.
143 | expect: function( asserts ) {
144 | if (arguments.length === 1) {
145 | config.current.expected = asserts;
146 | } else {
147 | return config.current.expected;
148 | }
149 | },
150 |
151 | start: function( count ) {
152 | // QUnit hasn't been initialized yet.
153 | // Note: RequireJS (et al) may delay onLoad
154 | if ( config.semaphore === undefined ) {
155 | QUnit.begin(function() {
156 | // This is triggered at the top of QUnit.load, push start() to the event loop, to allow QUnit.load to finish first
157 | setTimeout(function() {
158 | QUnit.start( count );
159 | });
160 | });
161 | return;
162 | }
163 |
164 | config.semaphore -= count || 1;
165 | // don't start until equal number of stop-calls
166 | if ( config.semaphore > 0 ) {
167 | return;
168 | }
169 | // ignore if start is called more often then stop
170 | if ( config.semaphore < 0 ) {
171 | config.semaphore = 0;
172 | QUnit.pushFailure( "Called start() while already started (QUnit.config.semaphore was 0 already)", null, sourceFromStacktrace(2) );
173 | return;
174 | }
175 | // A slight delay, to avoid any current callbacks
176 | if ( defined.setTimeout ) {
177 | setTimeout(function() {
178 | if ( config.semaphore > 0 ) {
179 | return;
180 | }
181 | if ( config.timeout ) {
182 | clearTimeout( config.timeout );
183 | }
184 |
185 | config.blocking = false;
186 | process( true );
187 | }, 13);
188 | } else {
189 | config.blocking = false;
190 | process( true );
191 | }
192 | },
193 |
194 | stop: function( count ) {
195 | config.semaphore += count || 1;
196 | config.blocking = true;
197 |
198 | if ( config.testTimeout && defined.setTimeout ) {
199 | clearTimeout( config.timeout );
200 | config.timeout = setTimeout(function() {
201 | QUnit.ok( false, "Test timed out" );
202 | config.semaphore = 1;
203 | QUnit.start();
204 | }, config.testTimeout );
205 | }
206 | }
207 | };
208 |
209 | // We use the prototype to distinguish between properties that should
210 | // be exposed as globals (and in exports) and those that shouldn't
211 | (function() {
212 | function F() {}
213 | F.prototype = QUnit;
214 | QUnit = new F();
215 | // Make F QUnit's constructor so that we can add to the prototype later
216 | QUnit.constructor = F;
217 | }());
218 |
219 | /**
220 | * Config object: Maintain internal state
221 | * Later exposed as QUnit.config
222 | * `config` initialized at top of scope
223 | */
224 | config = {
225 | // The queue of tests to run
226 | queue: [],
227 |
228 | // block until document ready
229 | blocking: true,
230 |
231 | // when enabled, show only failing tests
232 | // gets persisted through sessionStorage and can be changed in UI via checkbox
233 | hidepassed: false,
234 |
235 | // by default, run previously failed tests first
236 | // very useful in combination with "Hide passed tests" checked
237 | reorder: true,
238 |
239 | // by default, modify document.title when suite is done
240 | altertitle: true,
241 |
242 | // by default, scroll to top of the page when suite is done
243 | scrolltop: true,
244 |
245 | // when enabled, all tests must call expect()
246 | requireExpects: false,
247 |
248 | // add checkboxes that are persisted in the query-string
249 | // when enabled, the id is set to `true` as a `QUnit.config` property
250 | urlConfig: [
251 | {
252 | id: "noglobals",
253 | label: "Check for Globals",
254 | tooltip: "Enabling this will test if any test introduces new properties on the `window` object. Stored as query-strings."
255 | },
256 | {
257 | id: "notrycatch",
258 | label: "No try-catch",
259 | tooltip: "Enabling this will run tests outside of a try-catch block. Makes debugging exceptions in IE reasonable. Stored as query-strings."
260 | }
261 | ],
262 |
263 | // Set of all modules.
264 | modules: {},
265 |
266 | // logging callback queues
267 | begin: [],
268 | done: [],
269 | log: [],
270 | testStart: [],
271 | testDone: [],
272 | moduleStart: [],
273 | moduleDone: []
274 | };
275 |
276 | // Initialize more QUnit.config and QUnit.urlParams
277 | (function() {
278 | var i, current,
279 | location = window.location || { search: "", protocol: "file:" },
280 | params = location.search.slice( 1 ).split( "&" ),
281 | length = params.length,
282 | urlParams = {};
283 |
284 | if ( params[ 0 ] ) {
285 | for ( i = 0; i < length; i++ ) {
286 | current = params[ i ].split( "=" );
287 | current[ 0 ] = decodeURIComponent( current[ 0 ] );
288 |
289 | // allow just a key to turn on a flag, e.g., test.html?noglobals
290 | current[ 1 ] = current[ 1 ] ? decodeURIComponent( current[ 1 ] ) : true;
291 | if ( urlParams[ current[ 0 ] ] ) {
292 | urlParams[ current[ 0 ] ] = [].concat( urlParams[ current[ 0 ] ], current[ 1 ] );
293 | } else {
294 | urlParams[ current[ 0 ] ] = current[ 1 ];
295 | }
296 | }
297 | }
298 |
299 | QUnit.urlParams = urlParams;
300 |
301 | // String search anywhere in moduleName+testName
302 | config.filter = urlParams.filter;
303 |
304 | // Exact match of the module name
305 | config.module = urlParams.module;
306 |
307 | config.testNumber = [];
308 | if ( urlParams.testNumber ) {
309 |
310 | // Ensure that urlParams.testNumber is an array
311 | urlParams.testNumber = [].concat( urlParams.testNumber );
312 | for ( i = 0; i < urlParams.testNumber.length; i++ ) {
313 | current = urlParams.testNumber[ i ];
314 | config.testNumber.push( parseInt( current, 10 ) );
315 | }
316 | }
317 |
318 | // Figure out if we're running the tests from a server or not
319 | QUnit.isLocal = location.protocol === "file:";
320 | }());
321 |
322 | extend( QUnit, {
323 |
324 | config: config,
325 |
326 | // Initialize the configuration options
327 | init: function() {
328 | extend( config, {
329 | stats: { all: 0, bad: 0 },
330 | moduleStats: { all: 0, bad: 0 },
331 | started: +new Date(),
332 | updateRate: 1000,
333 | blocking: false,
334 | autostart: true,
335 | autorun: false,
336 | filter: "",
337 | queue: [],
338 | semaphore: 1
339 | });
340 |
341 | var tests, banner, result,
342 | qunit = id( "qunit" );
343 |
344 | if ( qunit ) {
345 | qunit.innerHTML =
346 | "Expected: | " + expected + " |
---|---|
Result: | " + actual + " |
Diff: | " + QUnit.diff( expected, actual ) + " |
Source: | " + escapeText( source ) + " |
Result: | " + escapeText( actual ) + " |
---|---|
Source: | " + escapeText( source ) + " |
Source: | " + 1520 | escapeText( source ) + 1521 | " |
---|