Bootstrapping should (mostly) work with Browser-initiated navigation (e.g. clicking on links) #3857
Description
Preface
Right now, if anyone clicks a link (or in some other way navigates without using browser.get
), we have three major problems:
- None of our bootstrapping happens
- Users might not be able to update
browser.rootEl
,browser.ignoreSynchronization
, orbrowser.ng12Hybrid
at the correct time. - Selenium doesn't know there's a page load happening, and so sometimes lets commands run against a partially loaded page, or a page might unload while a selenium command is being executed (example).
This is an attempt to deal with problem (1).
Problem (2) is being mitigated by #3847, and possibly either #3858 or #3859.
(3) will be mitigated by #4053
Solution
A browser.get
call consists of three phases:
The idea for handling browser-initiated navigation is:
- The crucial part of setup can be done in a unload event listener.
- Bootstrapping can be be slightly delayed, as long as it happens before any actual commands are issued.
- Use redundancy to cover edge cases.
Setup from an unload listener
Setup currently does 3 things:
- Disables blocking proxy waiting so that we can interact with the browser more directly
- Navigate to a dummy URL so we can tell once navigation has finished
- Sets the
"NG_DEFER_BOOTSTRAP!"
label so that we can load mock modules
Of these steps, only the third is necessary for browser-initiated navigation. To do this, we just need to install an event listener for unload
that adds the defer label to window.name
:
window.addEventListener('unload', function() {
var deferLabel = "NG_DEFER_BOOTSTRAP!";
if (window.name.indexOf(deferLabel) == -1) {
window.name = deferLabel + window.name;
}
});
We always install this listener, even if synchronization is disabled. This is a change from the previous behavior, where bootstrap would only get deferred if synchronization was enabled.
It will still be necessary to set the NG_DEFER_BOOTSTRAP!"
label in browser.get
, since the first page load won’t be preceded by an unload. Additionally, since window.name
and unload events are both slightly unreliable, the redundancy is a good thing.
Bootstrapping
The bootstrapping process will be split out into a doBootstrap
helper function. It will also change in the following ways:
- It will manage a variable on the
window
object to ensure that the same page isn’t bootstrapped multiple times - It will add the unload listener described above
- It will be invoked in several places, including periodically every 100ms or so (details later on).
- It will be interruptible. See Refactor bootstrapping process and
browser.waitForAngular
to make them interruptible #4052 for details on what that means. When interrupted, the restart process will be retried from the beginning.- Interruptibility will be especially important so that the periodic calls don't mess with anything.
- It will always resume bootstrap on Angular pages
- Only AngularJS supports deferring bootstrap at the moment, so really it will only always resume bootstrap for AngularJS apps.
- Any extraneous
NG_DEFER_BOOTSTRAP!
labels will be removed.
Relative to the implementation in Protractor 5.1, the new bootstrapping process will be something like:
doBootstrap(timeout = this.getPageTimeout, fromGetFn = false): wdpromise.Promise<void> {
// If window.__PROTRACTOR_BOOTSTRAP_STATUS_ is undefined, we bootstrap in this call
// If window.__PROTRACTOR_BOOTSTRAP_STATUS_ is 'started', another call is bootstrapping
// already and we should wait for it to finish
// If window.__PROTRACTOR_BOOTSTRAP_STATUS_ is 'complete', bootstrapping has been
// completed by another call
return this.executeScriptWithDescription(function() {
if (window.__PROTRACTOR_BOOTSTRAP_STATUS_ === undefined) {
window.__PROTRACTOR_BOOTSTRAP_STATUS_ = 'started';
window.addEventListener('unload', function() {
if (window.name.indexOf("NG_DEFER_BOOTSTRAP!") == -1) {
window.name = "NG_DEFER_BOOTSTRAP!" + window.name;
}
});
return 'not started';
}
return window.__PROTRACTOR_BOOTSTRAP_STATUS_;
}, 'Protractor Bootstrap - get/set bootstrap status + add unload listener' )
.then((bootstrapStatus: string) => {
if (bootstrapStatus != 'not started') {
// Bootstrapping is either already finished on this page or is currently being
// managed by a previous call to doBootstrap. Either way, we should wait until
// we're sure bootstrap is complete
return this.driver.wait(() => {
return this.executeScriptWithDescription(function() {
return window.__PROTRACTOR_BOOTSTRAP_STATUS_;,
}, 'Protractor Bootstrap - get status'))
.then(
(status: any) => {
return status == 'complete';
},
(err: IError) => {
if (err.code == 13) {
// IE bug - ignore and try again
return false;
} else {
throw err;
}
});
}, timeout, 'Wait for bootstrap for ' + timeout + 'ms');
} else {
// It's our job to bootstrap
return this.waitForAngularEnabled().then((waitEnabled) => {
if (waitEnabled) {
... // Plugins.onPageLoad
... // testForAngular
.then((angularVersion: number) => {
// Load mock modules, but do not resume bootstrap yet.
// Return a promise for the module names.
});
}
})
.then((moduleNames?: string[]) => {
// Try to resume bootstrap
return this.executeScriptWithDescription(function(moduleNames) {
if (angular.resumeBootstrap) {
window.__TESTABILITY__NG1_APP_ROOT_INJECTOR__ =
angular.resumeBootstrap(arguments[0]);
} else {
window.name = window.name.replace(/^NG_DEFER_BOOTSTRAP!/, '');
}
}, 'Protractor Bootstrap - resume bootstrap', moduleNames || []));
})
.then(() => {
if (fromGetFn) {
... // Reset bpClient sync
}
})
.then(() => {
return this.waitForAngularEnabled().then((waitEnabled) => {
if (waitEnabled) {
... // Run plugins.onPageStable
}
});
})
.then(() => {
return this.executeScriptWithDescription(function() {
window.__PROTRACTOR_BOOTSTRAP_STATUS_ = 'complete';
}, 'Protractor Bootstrap - set status as "complete"')
});
}
});
We then invoke doBootstrap
in three places:
- At the start of
browser.waitForAngular
- This on its own ensured that the app is bootstrapped before any commands which require synchronization are run.
- Periodically, every 100ms or so.
- Because we are deferring bootstrap, we never want to get into a situation where the app spends a long time waiting for
angular.resumeBootstrap
, which could happen if a user is doing work which doesn't requirebrowser.waitForAngular
.
- Because we are deferring bootstrap, we never want to get into a situation where the app spends a long time waiting for
- At the end of
browser.get
, as we currently do.- This is no longer necessary, and indeed won't help at all for browser-initiated navigation, but it doesn't hurt anything either, and reduces reliance on the periodic check.
Edge Case: Interrupted by navigation
As per #4052, we need to refactor bootstrapping to make it more tolerant of interruption (i.e. navigation occurring part way through bootstrapping).
Initially, if we get interrupted, we'll simply give up on the remaining bootstrap work. This is to avoid accidentally running bootstrap code extraneously. As mentioned in #4052 (comment), when we make the changes described in this issue, we will want to change that behavior so that:
- If bootstrapping is interrupted, we will retrying bootstrapping from the beginning on the new page.
- If
waitForAngular
is interrupted, we will restart from the beginning ofwaitForAngular
, including retrying bootstrap.
Edge Case: Blocking Proxy
Pending the Blocking Proxy change described in #4052, this modified bootstrapping process should be compatible with Blocking Proxy.
However, it does go against the spirit of Blocking Proxy to add all this new work to waitForAngular
. The point of Blocking Proxy is that this kind of work should be done automatically for you. Pending #4064 and angular/blocking-proxy#16, we should be able to add a angular_bootstrap_barrier.ts
file to do bootstrapping before each command. We can execute this intentionally by doing a blank executeScript
at the end of browser.get
/periodically.