Skip to content

Commit 49e48ef

Browse files
author
Ben Kelly
authored
Verify service worker behavior with initial about:blank iframe documents. r=JakeA r=mattto (web-platform-tests#6304)
1 parent 87758be commit 49e48ef

6 files changed

+371
-0
lines changed
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
<!DOCTYPE html>
2+
<title>Service Worker: about:blank replacement handling</title>
3+
<meta name=timeout content=long>
4+
<script src="/resources/testharness.js"></script>
5+
<script src="/resources/testharnessreport.js"></script>
6+
<script src="/common/get-host-info.sub.js"></script>
7+
<script src="resources/test-helpers.sub.js"></script>
8+
<body>
9+
<script>
10+
// This test attempts to verify various initial about:blank document
11+
// creation is accurately reflected via the Clients API. The goal is
12+
// for Clients API to reflect what the browser actually does and not
13+
// to make special cases for the API.
14+
//
15+
// If your browser does not create an about:blank document in certain
16+
// cases then please just mark the test expected fail for now. The
17+
// reuse of globals from about:blank documents to the final load document
18+
// has particularly bad interop at the moment. Hopefully we can evolve
19+
// tests like this to eventually align browsers.
20+
21+
const worker = 'resources/about-blank-replacement-worker.js';
22+
23+
// Helper routine that creates an iframe that internally has some kind
24+
// of nested window. The nested window could be another iframe or
25+
// it could be a popup window.
26+
function createFrameWithNestedWindow(url) {
27+
return new Promise((resolve, reject) => {
28+
let frame = document.createElement('iframe');
29+
frame.src = url;
30+
document.body.appendChild(frame);
31+
32+
window.addEventListener('message', function onMsg(evt) {
33+
if (evt.data.type !== 'NESTED_LOADED') {
34+
return;
35+
}
36+
window.removeEventListener('message', onMsg);
37+
if (evt.data.result && evt.data.result.startsWith('failure:')) {
38+
reject(evt.data.result);
39+
return;
40+
}
41+
resolve(frame);
42+
});
43+
});
44+
}
45+
46+
// Helper routine to request the given worker find the client with
47+
// the specified URL using the clients.matchAll() API.
48+
function getClientIdByURL(worker, url) {
49+
return new Promise(resolve => {
50+
navigator.serviceWorker.addEventListener('message', function onMsg(evt) {
51+
if (evt.data.type !== 'GET_CLIENT_ID') {
52+
return;
53+
}
54+
navigator.serviceWorker.removeEventListener('message', onMsg);
55+
resolve(evt.data.result);
56+
});
57+
worker.postMessage({ type: 'GET_CLIENT_ID', url: url.toString() });
58+
});
59+
}
60+
61+
async function doAsyncTest(t, scope, extraSearchParams) {
62+
let reg = await service_worker_unregister_and_register(t, worker, scope);
63+
await wait_for_state(t, reg.installing, 'activated');
64+
65+
// Load the scope as a frame. We expect this in turn to have a nested
66+
// iframe. The service worker will intercept the load of the nested
67+
// iframe and populate its body with the client ID of the initial
68+
// about:blank document it sees via clients.matchAll().
69+
let frame = await createFrameWithNestedWindow(scope);
70+
let initialResult = frame.contentWindow.nested().document.body.textContent;
71+
assert_false(initialResult.startsWith('failure:'), `result: ${initialResult}`);
72+
73+
// Next, ask the service worker to find the final client ID for the fully
74+
// loaded nested frame.
75+
let nestedURL = new URL(scope, window.location);
76+
nestedURL.searchParams.set('nested', true);
77+
extraSearchParams = extraSearchParams || {};
78+
for (let p in extraSearchParams) {
79+
nestedURL.searchParams.set(p, extraSearchParams[p]);
80+
}
81+
let finalResult = await getClientIdByURL(reg.active, nestedURL);
82+
assert_false(finalResult.startsWith('failure:'), `result: ${finalResult}`);
83+
84+
// The initial about:blank client and the final loaded client should have
85+
// the same ID value.
86+
assert_equals(initialResult, finalResult, 'client ID values should match');
87+
88+
frame.remove();
89+
await service_worker_unregister_and_done(t, scope);
90+
}
91+
92+
promise_test(async function(t) {
93+
// Execute a test where the nested frame is simply loaded normally.
94+
await doAsyncTest(t, 'resources/about-blank-replacement-frame.py');
95+
}, 'Initial about:blank is controlled, exposed to clients.matchAll(), and ' +
96+
'matches final Client.');
97+
98+
promise_test(async function(t) {
99+
// Execute a test where the nested frame is modified immediately by
100+
// its parent. In this case we add a message listener so the service
101+
// worker can ping the client to verify its existence. This ping-pong
102+
// check is performed during the initial load and when verifying the
103+
// final loaded client.
104+
await doAsyncTest(t, 'resources/about-blank-replacement-ping-frame.py',
105+
{ 'ping': true });
106+
}, 'Initial about:blank modified by parent is controlled, exposed to ' +
107+
'clients.matchAll(), and matches final Client.');
108+
109+
promise_test(async function(t) {
110+
// Execute a test where the nested window is a popup window instead of
111+
// an iframe. This should behave the same as the simple iframe case.
112+
await doAsyncTest(t, 'resources/about-blank-replacement-popup-frame.py');
113+
}, 'Popup initial about:blank is controlled, exposed to clients.matchAll(), and ' +
114+
'matches final Client.');
115+
116+
promise_test(async function(t) {
117+
const scope = 'resources/about-blank-replacement-uncontrolled-nested-frame.html';
118+
119+
let reg = await service_worker_unregister_and_register(t, worker, scope);
120+
await wait_for_state(t, reg.installing, 'activated');
121+
122+
// Load the scope as a frame. We expect this in turn to have a nested
123+
// iframe. Unlike the other tests in this file the nested iframe URL
124+
// is not covered by a service worker scope. It should end up as
125+
// uncontrolled even though its initial about:blank is controlled.
126+
let frame = await createFrameWithNestedWindow(scope);
127+
let nested = frame.contentWindow.nested();
128+
let initialResult = nested.document.body.textContent;
129+
130+
// The nested iframe should not have been intercepted by the service
131+
// worker. The empty.html nested frame has "hello world" for its body.
132+
assert_equals(initialResult.trim(), 'hello world', `result: ${initialResult}`);
133+
134+
assert_not_equals(frame.contentWindow.navigator.serviceWorker.controller, null,
135+
'outer frame should be controlled');
136+
137+
assert_equals(nested.navigator.serviceWorker.controller, null,
138+
'nested frame should not be controlled');
139+
140+
frame.remove();
141+
await service_worker_unregister_and_done(t, scope);
142+
}, 'Initial about:blank is controlled, exposed to clients.matchAll(), and ' +
143+
'final Client is not controlled by a service worker.');
144+
145+
</script>
146+
</body>
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
def main(request, response):
2+
if 'nested' in request.GET:
3+
return (
4+
[('Content-Type', 'text/html')],
5+
'failed: nested frame was not intercepted by the service worker'
6+
)
7+
8+
return ([('Content-Type', 'text/html')], """
9+
<!doctype html>
10+
<html>
11+
<body>
12+
<script>
13+
function nestedLoaded() {
14+
parent.postMessage({ type: 'NESTED_LOADED' }, '*');
15+
}
16+
</script>
17+
<iframe src="?nested=true" id="nested" onload="nestedLoaded()"></iframe>
18+
<script>
19+
// Helper routine to make it slightly easier for our parent to find
20+
// the nested frame.
21+
function nested() {
22+
return document.getElementById('nested').contentWindow;
23+
}
24+
25+
// NOTE: Make sure not to touch the iframe directly here. We want to
26+
// test the case where the initial about:blank document is not
27+
// directly accessed before load.
28+
</script>
29+
</body>
30+
</html>
31+
""")
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
def main(request, response):
2+
if 'nested' in request.GET:
3+
return (
4+
[('Content-Type', 'text/html')],
5+
'failed: nested frame was not intercepted by the service worker'
6+
)
7+
8+
return ([('Content-Type', 'text/html')], """
9+
<!doctype html>
10+
<html>
11+
<body>
12+
<script>
13+
function nestedLoaded() {
14+
parent.postMessage({ type: 'NESTED_LOADED' }, '*');
15+
}
16+
</script>
17+
<iframe src="?nested=true&amp;ping=true" id="nested" onload="nestedLoaded()"></iframe>
18+
<script>
19+
// Helper routine to make it slightly easier for our parent to find
20+
// the nested frame.
21+
function nested() {
22+
return document.getElementById('nested').contentWindow;
23+
}
24+
25+
// This modifies the nested iframe immediately and does not wait for it to
26+
// load. This effectively modifies the global for the initial about:blank
27+
// document. Any modifications made here should be preserved after the
28+
// frame loads because the global should be re-used.
29+
let win = nested();
30+
if (win.location.href !== 'about:blank') {
31+
parent.postMessage({
32+
type: 'NESTED_LOADED',
33+
result: 'failed: nested iframe does not have an initial about:blank URL'
34+
}, '*');
35+
} else {
36+
win.navigator.serviceWorker.addEventListener('message', evt => {
37+
if (evt.data.type === 'PING') {
38+
evt.source.postMessage({
39+
type: 'PONG',
40+
location: win.location.toString()
41+
});
42+
}
43+
});
44+
}
45+
</script>
46+
</body>
47+
</html>
48+
""")
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
def main(request, response):
2+
if 'nested' in request.GET:
3+
return (
4+
[('Content-Type', 'text/html')],
5+
'failed: nested frame was not intercepted by the service worker'
6+
)
7+
8+
return ([('Content-Type', 'text/html')], """
9+
<!doctype html>
10+
<html>
11+
<body>
12+
<script>
13+
function nestedLoaded() {
14+
parent.postMessage({ type: 'NESTED_LOADED' }, '*');
15+
popup.close();
16+
}
17+
18+
let popup = window.open('?nested=true');
19+
popup.onload = nestedLoaded;
20+
21+
// Helper routine to make it slightly easier for our parent to find
22+
// the nested popup window.
23+
function nested() {
24+
return popup;
25+
}
26+
</script>
27+
</body>
28+
</html>
29+
""")
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<!doctype html>
2+
<html>
3+
<body>
4+
<script>
5+
function nestedLoaded() {
6+
parent.postMessage({ type: 'NESTED_LOADED' }, '*');
7+
}
8+
</script>
9+
<iframe src="empty.html?nested=true" id="nested" onload="nestedLoaded()"></iframe>
10+
<script>
11+
// Helper routine to make it slightly easier for our parent to find
12+
// the nested frame.
13+
function nested() {
14+
return document.getElementById('nested').contentWindow;
15+
}
16+
17+
// NOTE: Make sure not to touch the iframe directly here. We want to
18+
// test the case where the initial about:blank document is not
19+
// directly accessed before load.
20+
</script>
21+
</body>
22+
</html>
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Helper routine to find a client that matches a particular URL. Note, we
2+
// require that Client to be controlled to avoid false matches with other
3+
// about:blank windows the browser might have. The initial about:blank should
4+
// inherit the controller from its parent.
5+
async function getClientByURL(url) {
6+
let list = await clients.matchAll();
7+
return list.find(client => client.url === url);
8+
}
9+
10+
// Helper routine to perform a ping-pong with the given target client. We
11+
// expect the Client to respond with its location URL.
12+
async function pingPong(target) {
13+
function waitForPong() {
14+
return new Promise(resolve => {
15+
self.addEventListener('message', function onMessage(evt) {
16+
if (evt.data.type === 'PONG') {
17+
resolve(evt.data.location);
18+
}
19+
});
20+
});
21+
}
22+
23+
target.postMessage({ type: 'PING' })
24+
return await waitForPong(target);
25+
}
26+
27+
addEventListener('fetch', async evt => {
28+
let url = new URL(evt.request.url);
29+
if (!url.searchParams.get('nested')) {
30+
return;
31+
}
32+
33+
evt.respondWith(async function() {
34+
// Find the initial about:blank document.
35+
const client = await getClientByURL('about:blank');
36+
if (!client) {
37+
return new Response('failure: could not find about:blank client');
38+
}
39+
40+
// If the nested frame is configured to support a ping-pong, then
41+
// ping it now to verify its message listener exists. We also
42+
// verify the Client's idea of its own location URL while we are doing
43+
// this.
44+
if (url.searchParams.get('ping')) {
45+
const loc = await pingPong(client);
46+
if (loc !== 'about:blank') {
47+
return new Response(`failure: got location {$loc}, expected about:blank`);
48+
}
49+
}
50+
51+
// Finally, allow the nested frame to complete loading. We place the
52+
// Client ID we found for the initial about:blank in the body.
53+
return new Response(client.id);
54+
}());
55+
});
56+
57+
addEventListener('message', evt => {
58+
if (evt.data.type !== 'GET_CLIENT_ID') {
59+
return;
60+
}
61+
62+
evt.waitUntil(async function() {
63+
let url = new URL(evt.data.url);
64+
65+
// Find the given Client by its URL.
66+
let client = await getClientByURL(evt.data.url);
67+
if (!client) {
68+
evt.source.postMessage({
69+
type: 'GET_CLIENT_ID',
70+
result: `failure: could not find ${evt.data.url} client`
71+
});
72+
return;
73+
}
74+
75+
// If the Client supports a ping-pong, then do it now to verify
76+
// the message listener exists and its location matches the
77+
// Client object.
78+
if (url.searchParams.get('ping')) {
79+
let loc = await pingPong(client);
80+
if (loc !== evt.data.url) {
81+
evt.source.postMessage({
82+
type: 'GET_CLIENT_ID',
83+
result: `failure: got location ${loc}, expected ${evt.data.url}`
84+
});
85+
return;
86+
}
87+
}
88+
89+
// Finally, send the client ID back.
90+
evt.source.postMessage({
91+
type: 'GET_CLIENT_ID',
92+
result: client.id
93+
});
94+
}());
95+
});

0 commit comments

Comments
 (0)