Better isolation of services, to avoid hitting network during test runs

1. All services are disabled in testing now to prevent network accesses
2. Only services are enabled when needed to test something
3. Many changes throughout code to allow iD to run with services disabled
   (e.g. check for osm service instead of assuming context.connection() will work)
4. Actually export the services so we can disable and enable them
This commit is contained in:
Bryan Housel
2017-08-09 22:04:09 -04:00
parent b7958415b3
commit 99a3741b0c
22 changed files with 219 additions and 117 deletions
+12 -4
View File
@@ -88,7 +88,9 @@ export function coreContext() {
}
context.preauth = function(options) {
connection.switch(options);
if (connection) {
connection.switch(options);
}
return context;
};
@@ -97,7 +99,9 @@ export function coreContext() {
entitiesLoaded(err, result);
if (callback) callback(err, result);
}
connection.loadTiles(projection, dimensions, done);
if (connection) {
connection.loadTiles(projection, dimensions, done);
}
};
context.loadEntity = function(id, callback) {
@@ -105,7 +109,9 @@ export function coreContext() {
entitiesLoaded(err, result);
if (callback) callback(err, result);
}
connection.loadEntity(id, done);
if (connection) {
connection.loadEntity(id, done);
}
};
context.zoomToEntity = function(id, zoomTo) {
@@ -136,7 +142,9 @@ export function coreContext() {
context.minEditableZoom = function(_) {
if (!arguments.length) return minEditableZoom;
minEditableZoom = _;
connection.tileZoom(_);
if (connection) {
connection.tileZoom(_);
}
return context;
};
+4 -4
View File
@@ -454,6 +454,7 @@ export function coreHistory(context) {
// When we restore a modified way, we also need to fetch any missing
// childnodes that would normally have been downloaded with it.. #2142
if (loadChildNodes) {
var osm = context.connection();
var missing = _(baseEntities)
.filter({ type: 'way' })
.map('nodes')
@@ -462,7 +463,7 @@ export function coreHistory(context) {
.reject(function(n) { return stack[0].graph.hasEntity(n); })
.value();
if (!_.isEmpty(missing)) {
if (!_.isEmpty(missing) && osm) {
loadComplete = false;
context.redrawEnable(false);
@@ -480,8 +481,7 @@ export function coreHistory(context) {
// fetch older versions of nodes that were deleted..
_.each(visible.false, function(entity) {
context.connection()
.loadEntityVersion(entity.id, +entity.version - 1, childNodesLoaded);
osm.loadEntityVersion(entity.id, +entity.version - 1, childNodesLoaded);
});
}
@@ -492,7 +492,7 @@ export function coreHistory(context) {
}
};
context.connection().loadMultiple(missing, childNodesLoaded);
osm.loadMultiple(missing, childNodesLoaded);
}
}
}
+15 -9
View File
@@ -35,8 +35,8 @@ export function modeSave(context) {
};
var commit = uiCommit(context)
.on('cancel', cancel)
.on('save', save);
.on('cancel', cancel)
.on('save', save);
function cancel() {
@@ -46,7 +46,8 @@ export function modeSave(context) {
function save(changeset, tryAgain) {
var loading = uiLoading(context).message(t('save.uploading')).blocking(true),
var osm = context.connection(),
loading = uiLoading(context).message(t('save.uploading')).blocking(true),
history = context.history(),
origChanges = history.changes(actionDiscardTags(history.difference())),
localGraph = context.graph(),
@@ -57,6 +58,8 @@ export function modeSave(context) {
conflicts = [],
errors = [];
if (!osm) return;
if (!tryAgain) {
history.perform(actionNoop()); // checkpoint
}
@@ -64,7 +67,7 @@ export function modeSave(context) {
context.container().call(loading);
if (toCheck.length) {
context.connection().loadMultiple(toLoad, loaded);
osm.loadMultiple(toLoad, loaded);
} else {
upload();
}
@@ -119,7 +122,7 @@ export function modeSave(context) {
if (loadMore.length) {
toLoad.push.apply(toLoad, loadMore);
context.connection().loadMultiple(loadMore, loaded);
osm.loadMultiple(loadMore, loaded);
}
if (!toLoad.length) {
@@ -134,7 +137,7 @@ export function modeSave(context) {
return { id: id, text: text, action: function() { history.replace(action); } };
}
function formatUser(d) {
return '<a href="' + context.connection().userURL(d) + '" target="_blank">' + d + '</a>';
return '<a href="' + osm.userURL(d) + '" target="_blank">' + d + '</a>';
}
function entityName(entity) {
return utilDisplayName(entity) || (utilDisplayType(entity.id) + ' ' + entity.id);
@@ -201,7 +204,7 @@ export function modeSave(context) {
} else {
var changes = history.changes(actionDiscardTags(history.difference()));
if (changes.modified.length || changes.created.length || changes.deleted.length) {
context.connection().putChangeset(changeset, changes, uploadCallback);
osm.putChangeset(changeset, changes, uploadCallback);
} else { // changes were insignificant or reverted by user
d3.select('.inspector-wrap *').remove();
loading.close();
@@ -360,10 +363,13 @@ export function modeSave(context) {
context.container().selectAll('#content')
.attr('class', 'inactive');
if (context.connection().authenticated()) {
var osm = context.connection();
if (!osm) return;
if (osm.authenticated()) {
done();
} else {
context.connection().authenticate(function(err) {
osm.authenticate(function(err) {
if (err) {
cancel();
} else {
+6 -1
View File
@@ -76,7 +76,9 @@ export function rendererBackground(context) {
delete q.offset;
}
window.location.replace('#' + utilQsString(q, true));
if (!window.mocha) {
window.location.replace('#' + utilQsString(q, true));
}
var imageryUsed = [b.imageryUsed()];
@@ -124,6 +126,9 @@ export function rendererBackground(context) {
if (!arguments.length) return baseLayer.source();
// test source against OSM imagery blacklists..
var osm = context.connection();
if (!osm) return background;
var blacklists = context.connection().imageryBlacklists();
var template = d.template(),
+4 -2
View File
@@ -73,8 +73,10 @@ export function rendererMap(context) {
context
.on('change.map', immediateRedraw);
context.connection()
.on('change.map', immediateRedraw);
var osm = context.connection();
if (osm) {
osm.on('change.map', immediateRedraw);
}
context.history()
.on('change.map', immediateRedraw)
+9
View File
@@ -13,3 +13,12 @@ export var services = {
wikidata: serviceWikidata,
wikipedia: serviceWikipedia
};
export {
serviceMapillary,
serviceNominatim,
serviceOsm,
serviceTaginfo,
serviceWikidata,
serviceWikipedia
};
+11 -9
View File
@@ -4,17 +4,19 @@ import { svgIcon } from '../svg/index';
export function uiAccount(context) {
var connection = context.connection();
var osm = context.connection();
function update(selection) {
if (!connection.authenticated()) {
if (!osm) return;
if (!osm.authenticated()) {
selection.selectAll('#userLink, #logoutLink')
.classed('hide', true);
return;
}
connection.userDetails(function(err, details) {
osm.userDetails(function(err, details) {
var userLink = selection.select('#userLink'),
logoutLink = selection.select('#logoutLink');
@@ -28,7 +30,7 @@ export function uiAccount(context) {
// Link
userLink.append('a')
.attr('href', connection.userURL(details.display_name))
.attr('href', osm.userURL(details.display_name))
.attr('target', '_blank');
// Add thumbnail or dont
@@ -52,7 +54,7 @@ export function uiAccount(context) {
.text(t('logout'))
.on('click.logout', function() {
d3.event.preventDefault();
connection.logout();
osm.logout();
});
});
}
@@ -67,9 +69,9 @@ export function uiAccount(context) {
.attr('id', 'userLink')
.classed('hide', true);
connection
.on('change.account', function() { update(selection); });
update(selection);
if (osm) {
osm.on('change.account', function() { update(selection); });
update(selection);
}
};
}
+6 -3
View File
@@ -26,6 +26,9 @@ export function uiCommit(context) {
function commit(selection) {
var osm = context.connection();
if (!osm) return;
if (!changeset) {
var detected = utilDetect(),
tags = {
@@ -87,7 +90,7 @@ export function uiCommit(context) {
commentField.node().select();
context.connection().userChangesets(function (err, changesets) {
osm.userChangesets(function (err, changesets) {
if (err) return;
var comments = changesets.map(function(changeset) {
@@ -174,7 +177,7 @@ export function uiCommit(context) {
.html(t('commit.upload_explanation'));
context.connection().userDetails(function(err, user) {
osm.userDetails(function(err, user) {
if (err) return;
var userLink = d3.select(document.createElement('div'));
@@ -190,7 +193,7 @@ export function uiCommit(context) {
.append('a')
.attr('class','user-info')
.text(user.display_name)
.attr('href', context.connection().userURL(user.display_name))
.attr('href', osm.userURL(user.display_name))
.attr('tabindex', -1)
.attr('target', '_blank');
+8 -4
View File
@@ -5,13 +5,16 @@ import { svgIcon } from '../svg/index';
export function uiContributors(context) {
var debouncedUpdate = _.debounce(function() { update(); }, 1000),
var osm = context.connection(),
debouncedUpdate = _.debounce(function() { update(); }, 1000),
limit = 4,
hidden = false,
wrap = d3.select(null);
function update() {
if (!osm) return;
var users = {},
entities = context.intersects(context.map().extent());
@@ -32,7 +35,7 @@ export function uiContributors(context) {
.enter()
.append('a')
.attr('class', 'user-link')
.attr('href', function(d) { return context.connection().userURL(d); })
.attr('href', function(d) { return osm.userURL(d); })
.attr('target', '_blank')
.attr('tabindex', -1)
.text(String);
@@ -44,7 +47,7 @@ export function uiContributors(context) {
.attr('target', '_blank')
.attr('tabindex', -1)
.attr('href', function() {
return context.connection().changesetsURL(context.map().center(), context.map().zoom());
return osm.changesetsURL(context.map().center(), context.map().zoom());
})
.text(u.length - limit + 1);
@@ -71,10 +74,11 @@ export function uiContributors(context) {
return function(selection) {
if (!osm) return;
wrap = selection;
update();
context.connection().on('loaded.contributors', debouncedUpdate);
osm.on('loaded.contributors', debouncedUpdate);
context.map().on('move.contributors', debouncedUpdate);
};
}
+12 -11
View File
@@ -290,18 +290,19 @@ export function uiInit(context) {
.call(uiShortcuts(context));
}
var authenticating = uiLoading(context)
.message(t('loading_auth'))
.blocking(true);
var osm = context.connection(),
auth = uiLoading(context).message(t('loading_auth')).blocking(true);
context.connection()
.on('authLoading.ui', function() {
context.container()
.call(authenticating);
})
.on('authDone.ui', function() {
authenticating.close();
});
if (osm && auth) {
osm
.on('authLoading.ui', function() {
context.container()
.call(auth);
})
.on('authDone.ui', function() {
auth.close();
});
}
uiInitCounter++;
+6 -5
View File
@@ -55,14 +55,15 @@ export function uiIntro(context) {
context.enter(modeBrowse(context));
// Save current map state
var history = context.history().toJSON(),
var osm = context.connection(),
history = context.history().toJSON(),
hash = window.location.hash,
center = context.map().center(),
zoom = context.map().zoom(),
background = context.background().baseLayerSource(),
overlays = context.background().overlayLayerSources(),
opacity = d3.selectAll('#map .layer-background').style('opacity'),
loadedTiles = context.connection().loadedTiles(),
loadedTiles = osm && osm.loadedTiles(),
baseEntities = context.history().graph().base().entities,
countryCode = services.geocoder.countryCode;
@@ -70,7 +71,7 @@ export function uiIntro(context) {
context.inIntro(true);
// Load semi-real data used in intro
context.connection().toggle(false).reset();
if (osm) { osm.toggle(false).reset(); }
context.history().reset();
context.history().merge(d3.values(coreGraph().load(introGraph).entities));
context.history().checkpoint('initial');
@@ -109,11 +110,11 @@ export function uiIntro(context) {
curtain.remove();
navwrap.remove();
d3.selectAll('#map .layer-background').style('opacity', opacity);
context.connection().toggle(true).reset().loadedTiles(loadedTiles);
if (osm) { osm.toggle(true).reset().loadedTiles(loadedTiles); }
context.history().reset().merge(d3.values(baseEntities));
context.background().baseLayerSource(background);
overlays.forEach(function (d) { context.background().toggleOverlayLayer(d); });
if (history) context.history().fromJSON(history, false);
if (history) { context.history().fromJSON(history, false); }
context.map().centerZoom(center, zoom);
window.location.replace(hash);
services.geocoder.countryCode = countryCode;
+32 -24
View File
@@ -4,7 +4,7 @@ import { svgIcon } from '../../svg';
export function uiPanelHistory(context) {
var osm;
function displayTimestamp(entity) {
if (!entity.timestamp) return t('info_panels.history.unknown');
@@ -33,13 +33,15 @@ export function uiPanelHistory(context) {
.append('div')
.attr('class', 'links');
links
.append('a')
.attr('class', 'user-osm-link')
.attr('href', context.connection().userURL(entity.user))
.attr('target', '_blank')
.attr('tabindex', -1)
.text('OSM');
if (osm) {
links
.append('a')
.attr('class', 'user-osm-link')
.attr('href', osm.userURL(entity.user))
.attr('target', '_blank')
.attr('tabindex', -1)
.text('OSM');
}
links
.append('a')
@@ -68,13 +70,15 @@ export function uiPanelHistory(context) {
.append('div')
.attr('class', 'links');
links
.append('a')
.attr('class', 'changeset-osm-link')
.attr('href', context.connection().changesetURL(entity.changeset))
.attr('target', '_blank')
.attr('tabindex', -1)
.text('OSM');
if (osm) {
links
.append('a')
.attr('class', 'changeset-osm-link')
.attr('href', osm.changesetURL(entity.changeset))
.attr('target', '_blank')
.attr('tabindex', -1)
.text('OSM');
}
links
.append('a')
@@ -90,6 +94,8 @@ export function uiPanelHistory(context) {
var selected = _.filter(context.selectedIDs(), function(e) { return context.hasEntity(e); }),
singular = selected.length === 1 ? selected[0] : null;
osm = context.connection();
selection.html('');
selection
@@ -122,15 +128,17 @@ export function uiPanelHistory(context) {
.text(t('info_panels.history.changeset') + ': ')
.call(displayChangeset, entity);
selection
.append('a')
.attr('class', 'view-history-on-osm')
.attr('target', '_blank')
.attr('tabindex', -1)
.attr('href', context.connection().historyURL(entity))
.call(svgIcon('#icon-out-link', 'inline'))
.append('span')
.text(t('info_panels.history.link_text'));
if (osm) {
selection
.append('a')
.attr('class', 'view-history-on-osm')
.attr('target', '_blank')
.attr('tabindex', -1)
.attr('href', osm.historyURL(entity))
.call(svgIcon('#icon-out-link', 'inline'))
.append('span')
.text(t('info_panels.history.link_text'));
}
}
+12 -12
View File
@@ -1,5 +1,5 @@
export function uiSpinner(context) {
var connection = context.connection();
var osm = context.connection();
return function(selection) {
@@ -8,16 +8,16 @@ export function uiSpinner(context) {
.attr('src', context.imagePath('loader-black.gif'))
.style('opacity', 0);
connection
.on('loading.spinner', function() {
img.transition()
.style('opacity', 1);
});
connection
.on('loaded.spinner', function() {
img.transition()
.style('opacity', 0);
});
if (osm) {
osm
.on('loading.spinner', function() {
img.transition()
.style('opacity', 1);
})
.on('loaded.spinner', function() {
img.transition()
.style('opacity', 0);
});
}
};
}
+6 -5
View File
@@ -4,12 +4,14 @@ import { svgIcon } from '../svg/index';
export function uiStatus(context) {
var connection = context.connection();
var osm = context.connection();
return function(selection) {
if (!osm) return;
function update() {
connection.status(function(err, apiStatus) {
osm.status(function(err, apiStatus) {
selection.html('');
if (err) {
@@ -24,7 +26,7 @@ export function uiStatus(context) {
.text(t('login'))
.on('click.login', function() {
d3.event.preventDefault();
connection.authenticate();
osm.authenticate();
});
} else {
// TODO: nice messages for different error types
@@ -41,8 +43,7 @@ export function uiStatus(context) {
});
}
connection
.on('change', function() { update(selection); });
osm.on('change', function() { update(selection); });
window.setInterval(update, 90000);
update(selection);
+4 -3
View File
@@ -43,8 +43,10 @@ export function uiSuccess(context) {
.append('span')
.text(t('success.help_link_text'));
var changesetURL = context.connection().changesetURL(changeset.id);
var osm = context.connection();
if (!osm) return;
var changesetURL = osm.changesetURL(changeset.id);
var viewOnOsm = body
.append('a')
@@ -62,9 +64,8 @@ export function uiSuccess(context) {
.append('div')
.text(t('success.view_on_osm'));
var message = (changeset.tags.comment || t('success.edited_osm')).substring(0, 130) +
' ' + context.connection().changesetURL(changeset.id);
' ' + changesetURL;
var sharing = {
facebook: 'https://facebook.com/sharer/sharer.php?u=' + encodeURIComponent(changesetURL),
+1 -2
View File
@@ -5,8 +5,6 @@ describe('iD.behaviorHash', function () {
beforeEach(function () {
context = iD.Context();
context.connection().loadTiles = function () {}; // Neuter connection
var container = d3.select(document.createElement('div'));
context.container(container);
container.call(context.map());
@@ -15,6 +13,7 @@ describe('iD.behaviorHash', function () {
afterEach(function () {
hash.off();
location.hash = '';
});
it('sets hadHash if location.hash is present', function () {
+8
View File
@@ -6,6 +6,14 @@ describe('iD.serviceMapillary', function() {
context, server, mapillary, orig;
before(function() {
iD.services.mapillary = iD.serviceMapillary;
});
after(function() {
delete iD.services.mapillary;
});
beforeEach(function() {
context = iD.Context().assetPath('../dist/');
context.projection
+9
View File
@@ -1,6 +1,15 @@
describe('iD.serviceNominatim', function() {
var server, nominatim;
before(function() {
iD.services.geocoder = iD.serviceNominatim;
});
after(function() {
delete iD.services.geocoder;
});
beforeEach(function() {
server = sinon.fakeServer.create();
nominatim = iD.services.geocoder;
+19 -11
View File
@@ -1,5 +1,5 @@
describe('iD.serviceOsm', function () {
var context, connection, spy;
var context, connection, server, spy;
function login() {
if (!connection) return;
@@ -17,7 +17,16 @@ describe('iD.serviceOsm', function () {
connection.logout();
}
before(function() {
iD.services.osm = iD.serviceOsm;
});
after(function() {
delete iD.services.osm;
});
beforeEach(function () {
server = sinon.fakeServer.create();
context = iD.Context();
connection = context.connection();
connection.switch({ urlroot: 'http://www.openstreetmap.org' });
@@ -25,6 +34,10 @@ describe('iD.serviceOsm', function () {
spy = sinon.spy();
});
afterEach(function() {
server.restore();
});
it('is instantiated', function () {
expect(connection).to.be.ok;
@@ -109,8 +122,7 @@ describe('iD.serviceOsm', function () {
});
describe('#loadFromAPI', function () {
var server,
path = '/api/0.6/map?bbox=-74.542,40.655,-74.541,40.656',
var path = '/api/0.6/map?bbox=-74.542,40.655,-74.541,40.656',
response = '<?xml version="1.0" encoding="UTF-8"?>' +
'<osm version="0.6">' +
' <bounds minlat="40.655" minlon="-74.542" maxlat="40.656" maxlon="-74.541' +
@@ -267,8 +279,7 @@ describe('iD.serviceOsm', function () {
});
describe('#loadEntity', function () {
var server,
nodeXML = '<?xml version="1.0" encoding="UTF-8"?><osm>' +
var nodeXML = '<?xml version="1.0" encoding="UTF-8"?><osm>' +
'<node id="1" version="1" changeset="1" lat="0" lon="0" visible="true" timestamp="2009-03-07T03:26:33Z"></node>' +
'</osm>',
wayXML = '<?xml version="1.0" encoding="UTF-8"?><osm>' +
@@ -312,8 +323,7 @@ describe('iD.serviceOsm', function () {
});
describe('#loadEntityVersion', function () {
var server,
nodeXML = '<?xml version="1.0" encoding="UTF-8"?><osm>' +
var nodeXML = '<?xml version="1.0" encoding="UTF-8"?><osm>' +
'<node id="1" version="1" changeset="1" lat="0" lon="0" visible="true" timestamp="2009-03-07T03:26:33Z"></node>' +
'</osm>',
wayXML = '<?xml version="1.0" encoding="UTF-8"?><osm>' +
@@ -356,7 +366,6 @@ describe('iD.serviceOsm', function () {
});
describe('#loadMultiple', function () {
var server;
beforeEach(function() {
server = sinon.fakeServer.create();
});
@@ -372,7 +381,7 @@ describe('iD.serviceOsm', function () {
describe('#userChangesets', function() {
var server, userDetailsFn;
var userDetailsFn;
beforeEach(function() {
server = sinon.fakeServer.create();
@@ -477,8 +486,7 @@ describe('iD.serviceOsm', function () {
describe('API capabilities', function() {
var server,
capabilitiesXML = '<?xml version="1.0" encoding="UTF-8"?><osm>' +
var capabilitiesXML = '<?xml version="1.0" encoding="UTF-8"?><osm>' +
'<api>' +
'<version minimum="0.6" maximum="0.6"/>' +
'<area maximum="0.25"/>' +
+9
View File
@@ -1,6 +1,15 @@
describe('iD.serviceTaginfo', function() {
var server, taginfo;
before(function() {
iD.services.taginfo = iD.serviceTaginfo;
});
after(function() {
delete iD.services.taginfo;
});
beforeEach(function() {
server = sinon.fakeServer.create();
taginfo = iD.services.taginfo;
+3
View File
@@ -1,7 +1,10 @@
/* globals chai:false */
iD.debug = true;
// disable things that use the network
iD.data.imagery = [];
_.forEach(iD.services, function(v,k) { delete iD.services[k]; });
mocha.setup({
ui: 'bdd',
+23 -8
View File
@@ -1,7 +1,6 @@
describe('iD.uiFieldWikipedia', function() {
var entity, context, selection, field;
function changeTags(changed) {
var e = context.entity(entity.id),
annotation = 'Changed tags.',
@@ -18,6 +17,16 @@ describe('iD.uiFieldWikipedia', function() {
}
}
before(function() {
iD.services.wikipedia = iD.serviceWikipedia;
iD.services.wikidata = iD.serviceWikidata;
});
after(function() {
delete iD.services.wikipedia;
delete iD.services.wikidata;
});
beforeEach(function() {
entity = iD.Node({id: 'n12345'});
context = iD.Context();
@@ -95,7 +104,7 @@ describe('iD.uiFieldWikipedia', function() {
var wikipedia = iD.uiFieldWikipedia(field, context).entity(entity);
wikipedia.on('change', changeTags);
selection.call(wikipedia);
window.JSONP_DELAY = 20;
window.JSONP_DELAY = 60;
var spy = sinon.spy();
wikipedia.on('change.spy', spy);
@@ -105,21 +114,27 @@ describe('iD.uiFieldWikipedia', function() {
iD.utilGetSetValue(selection.selectAll('.wiki-title'), 'Skip');
happen.once(selection.selectAll('.wiki-title').node(), { type: 'change' });
happen.once(selection.selectAll('.wiki-title').node(), { type: 'blur' });
// t0
expect(context.entity(entity.id).tags.wikidata).to.be.undefined;
// Set title to "Title" after 10ms
// t30: graph change - Set title to "Title"
window.setTimeout(function() {
iD.utilGetSetValue(selection.selectAll('.wiki-title'), 'Title');
happen.once(selection.selectAll('.wiki-title').node(), { type: 'change' });
happen.once(selection.selectAll('.wiki-title').node(), { type: 'blur' });
}, 10);
}, 30);
// wikidata not set (t0 + 20ms) after wikipedia="skip" because graph changed
// t60: at t0 + 60ms (JSONP_DELAY), wikidata SHOULD NOT be set because graph has changed.
// t70: check that wikidata unchanged
window.setTimeout(function() {
expect(context.entity(entity.id).tags.wikidata).to.be.undefined;
}, 25);
}, 70);
// wikidata set (t10 + 20ms) after wikipedia="title" because graph unchanged
// t90: at t30 + 60ms (JSONP_DELAY), wikidata SHOULD be set because graph is unchanged.
// t100: check that wikidata has changed
window.setTimeout(function() {
expect(context.entity(entity.id).tags.wikidata).to.equal('Q216353');
@@ -129,7 +144,7 @@ describe('iD.uiFieldWikipedia', function() {
expect(spy.getCall(2)).to.have.been.calledWith({ wikipedia: 'de:Title' }); // 'Title' on change +10ms
expect(spy.getCall(3)).to.have.been.calledWith({ wikipedia: 'de:Title' }); // 'Title' on blur +10ms
done();
}, 35);
}, 100);
});
});