fix(Page): support anchor navigation (#2338)

This patch fixes puppeteer navigation primitives to work with
same-document navigation.

Same-document navigation happens when document's URL is changed,
but document instance is not re-created. Some common scenarios
for same-document navigation are:
- History API
- anchor navigation

With this patch:
- pptr starts dispatching `framenavigated` event when frame's URL gets
changed due to same-document navigation
- `page.waitForNavigation` now works with same-document navigation
- `page.goBack()` and `page.goForward()` are handled correctly.

Fixes #257.
This commit is contained in:
Andrey Lushnikov 2018-04-09 23:38:20 -07:00 committed by GitHub
parent 2b95774af9
commit fafd156d7b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 123 additions and 5 deletions

View File

@ -39,6 +39,7 @@ class FrameManager extends EventEmitter {
this._client.on('Page.frameAttached', event => this._onFrameAttached(event.frameId, event.parentFrameId));
this._client.on('Page.frameNavigated', event => this._onFrameNavigated(event.frame));
this._client.on('Page.navigatedWithinDocument', event => this._onFrameNavigatedWithinDocument(event.frameId, event.url));
this._client.on('Page.frameDetached', event => this._onFrameDetached(event.frameId));
this._client.on('Runtime.executionContextCreated', event => this._onExecutionContextCreated(event.context));
this._client.on('Runtime.executionContextDestroyed', event => this._onExecutionContextDestroyed(event.executionContextId));
@ -144,6 +145,19 @@ class FrameManager extends EventEmitter {
this.emit(FrameManager.Events.FrameNavigated, frame);
}
/**
* @param {string} frameId
* @param {string} url
*/
_onFrameNavigatedWithinDocument(frameId, url) {
const frame = this._frames.get(frameId);
if (!frame)
return;
frame._navigatedWithinDocument(url);
this.emit(FrameManager.Events.FrameNavigatedWithinDocument, frame);
this.emit(FrameManager.Events.FrameNavigated, frame);
}
/**
* @param {string} frameId
*/
@ -217,7 +231,8 @@ FrameManager.Events = {
FrameAttached: 'frameattached',
FrameNavigated: 'framenavigated',
FrameDetached: 'framedetached',
LifecycleEvent: 'lifecycleevent'
LifecycleEvent: 'lifecycleevent',
FrameNavigatedWithinDocument: 'framenavigatedwithindocument'
};
/**
@ -750,6 +765,13 @@ class Frame {
this._url = framePayload.url;
}
/**
* @param {string} url
*/
_navigatedWithinDocument(url) {
this._url = url;
}
/**
* @param {string} loaderId
* @param {string} name

View File

@ -43,8 +43,10 @@ class NavigatorWatcher {
this._frame = frame;
this._initialLoaderId = frame._loaderId;
this._timeout = timeout;
this._hasSameDocumentNavigation = false;
this._eventListeners = [
helper.addEventListener(this._frameManager, FrameManager.Events.LifecycleEvent, this._checkLifecycleComplete.bind(this)),
helper.addEventListener(this._frameManager, FrameManager.Events.FrameNavigatedWithinDocument, this._navigatedWithinDocument.bind(this)),
helper.addEventListener(this._frameManager, FrameManager.Events.FrameDetached, this._checkLifecycleComplete.bind(this))
];
@ -78,9 +80,19 @@ class NavigatorWatcher {
return this._navigationPromise;
}
/**
* @param {!Puppeteer.Frame} frame
*/
_navigatedWithinDocument(frame) {
if (frame !== this._frame)
return;
this._hasSameDocumentNavigation = true;
this._checkLifecycleComplete();
}
_checkLifecycleComplete() {
// We expect navigation to commit.
if (this._frame._loaderId === this._initialLoaderId)
if (this._frame._loaderId === this._initialLoaderId && !this._hasSameDocumentNavigation)
return;
if (!checkLifecycle(this._frame, this._expectedLifecycle))
return;

View File

@ -422,6 +422,14 @@ module.exports.addTests = function({testRunner, expect}) {
expect(detachedFrames.length).toBe(1);
expect(detachedFrames[0].isDetached()).toBe(true);
});
it('should send "framenavigated" when navigating on anchor URLs', async({page, server}) => {
await page.goto(server.EMPTY_PAGE);
await Promise.all([
page.goto(server.EMPTY_PAGE + '#foo'),
utils.waitEvent(page, 'framenavigated')
]);
expect(page.url()).toBe(server.EMPTY_PAGE + '#foo');
});
it('should persist mainFrame on cross-process navigation', async({page, server}) => {
await page.goto(server.EMPTY_PAGE);
const mainFrame = page.mainFrame();

View File

@ -613,6 +613,67 @@ module.exports.addTests = function({testRunner, expect, puppeteer, DeviceDescrip
await bothFiredPromise;
await navigationPromise;
});
it('should work with clicking on anchor links', async({page, server}) => {
await page.goto(server.EMPTY_PAGE);
await page.setContent(`<a href='#foobar'>foobar</a>`);
await Promise.all([
page.click('a'),
page.waitForNavigation()
]);
expect(page.url()).toBe(server.EMPTY_PAGE + '#foobar');
});
it('should work with history.pushState()', async({page, server}) => {
await page.goto(server.EMPTY_PAGE);
await page.setContent(`
<a onclick='javascript:pushState()'>SPA</a>
<script>
function pushState() { history.pushState({}, '', 'wow.html') }
</script>
`);
await Promise.all([
page.click('a'),
page.waitForNavigation()
]);
expect(page.url()).toBe(server.PREFIX + '/wow.html');
});
it('should work with history.replaceState()', async({page, server}) => {
await page.goto(server.EMPTY_PAGE);
await page.setContent(`
<a onclick='javascript:replaceState()'>SPA</a>
<script>
function replaceState() { history.replaceState({}, '', '/replaced.html') }
</script>
`);
await Promise.all([
page.click('a'),
page.waitForNavigation()
]);
expect(page.url()).toBe(server.PREFIX + '/replaced.html');
});
it('should work with DOM history.back()/history.forward()', async({page, server}) => {
await page.goto(server.EMPTY_PAGE);
await page.setContent(`
<a id=back onclick='javascript:goBack()'>back</a>
<a id=forward onclick='javascript:goForward()'>forward</a>
<script>
function goBack() { history.back(); }
function goForward() { history.forward(); }
history.pushState({}, '', '/first.html');
history.pushState({}, '', '/second.html');
</script>
`);
expect(page.url()).toBe(server.PREFIX + '/second.html');
await Promise.all([
page.click('a#back'),
page.waitForNavigation()
]);
expect(page.url()).toBe(server.PREFIX + '/first.html');
await Promise.all([
page.click('a#forward'),
page.waitForNavigation()
]);
expect(page.url()).toBe(server.PREFIX + '/second.html');
});
});
describe('Page.goBack', function() {
@ -631,6 +692,21 @@ module.exports.addTests = function({testRunner, expect, puppeteer, DeviceDescrip
response = await page.goForward();
expect(response).toBe(null);
});
it('should work with HistoryAPI', async({page, server}) => {
await page.goto(server.EMPTY_PAGE);
await page.evaluate(() => {
history.pushState({}, '', '/first.html');
history.pushState({}, '', '/second.html');
});
expect(page.url()).toBe(server.PREFIX + '/second.html');
await page.goBack();
expect(page.url()).toBe(server.PREFIX + '/first.html');
await page.goBack();
expect(page.url()).toBe(server.EMPTY_PAGE);
await page.goForward();
expect(page.url()).toBe(server.PREFIX + '/first.html');
});
});
describe('Page.exposeFunction', function() {

View File

@ -99,9 +99,6 @@ beforeEach(async({server, httpsServer}) => {
httpsServer.reset();
});
// Top-level tests that launch Browser themselves.
require('./puppeteer.spec.js').addTests({testRunner, expect, PROJECT_ROOT, defaultBrowserOptions});
describe('Page', function() {
beforeAll(async state => {
state.browser = await puppeteer.launch(defaultBrowserOptions);
@ -136,6 +133,9 @@ describe('Page', function() {
require('./tracing.spec.js').addTests({testRunner, expect});
});
// Top-level tests that launch Browser themselves.
require('./puppeteer.spec.js').addTests({testRunner, expect, PROJECT_ROOT, defaultBrowserOptions});
if (process.env.COVERAGE) {
describe('COVERAGE', function(){
const coverage = helper.publicAPICoverage();