Skip to content

Commit 1a20d7c

Browse files
[BFCache] Add basic event tests + helpers for BFCache WPT
Design doc: https://docs.google.com/document/d/1p3G-qNYMTHf5LU9hykaXcYtJ0k3wYOwcdVKGeps6EkU/edit?usp=sharing Bug: 1107415 Change-Id: I034f9f5376dc3f9f32ca0b936dbd06e458c9160b
1 parent f6d8412 commit 1a20d7c

File tree

6 files changed

+287
-0
lines changed

6 files changed

+287
-0
lines changed
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Dispatcher/executor framework
2+
3+
In the BFCache tests, the main test HTML
4+
5+
1. Opens new executor Windows using `window.open()` + `noopener` option, and
6+
2. Injects scripts to / receives values from the executor Windows via send()/receive() methods provided by
7+
[the dispatcher/executor framework of COEP credentialless](../../../cross-origin-embedder-policy/credentialless/README.md)
8+
9+
because less isolated Windows (e.g. iframes and `window.open()` without `noopener` option) are often not eligible for BFCache (e.g. in Chromium).
10+
11+
# BFCache-specific helpers
12+
13+
- [resources/executor.html](resources/executor.html) is the BFCache-specific executor and contains helpers for executors.
14+
- [resources/helper.sub.js](resources/helper.sub.js) contains helpers for main test HTMLs.
15+
16+
In typical A-B-A scenarios (where we navigate from Page A to Page B and then navigate back to Page A, assuming Page A is (or isn't) in BFCache),
17+
18+
- Call `prepareNavigation()` on the executor, and then navigate to B, and then navigate back to Page A.
19+
- Call `assert_bfcached()` or `assert_not_bfcached()` on the main test HTML, to check the BFCache status.
20+
- Check other test expectations on the main test HTML.
21+
22+
Note that
23+
24+
- `await`ing `send()` calls (and other wrapper methods) is needed to serialize injected scripts.
25+
- `send()`/`receive()` uses Fetch API + server-side stash.
26+
`prepareNavigation()` suspends Fetch API calls until we navigate back to the page, to avoid conflicts with BFCache eligibility.
27+
28+
# Asserting PRECONDITION_FAILED for unexpected BFCache eligibility
29+
30+
To distinguish failures due to unexpected BFCache eligibility (which might be acceptable due to different BFCache eligibility criteria across browsers),
31+
`assert_bfcached()` and `assert_not_bfcached()` asserts `PRECONDITION_FAILED` rather than ordinal failures.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<!DOCTYPE HTML>
2+
<script src="/resources/testharness.js"></script>
3+
<script src="/resources/testharnessreport.js"></script>
4+
<script src="/common/utils.js"></script>
5+
<script src="/html/cross-origin-embedder-policy/credentialless/resources/dispatcher.js"></script>
6+
<script src="resources/helper.sub.js"></script>
7+
<script>
8+
for (const originType of ['SameOrigin', 'SameSite', 'CrossSite']) {
9+
promise_test(async t => {
10+
const idA = token();
11+
12+
window.open(
13+
executorPath + idA + '&events=pagehide,pageshow,load',
14+
'_blank', 'noopener');
15+
16+
// Navigate to a page that immediately triggers back navigation.
17+
const backUrl = eval(`origin${originType}`) + backPath;
18+
await send(idA, `
19+
prepareNavigation();
20+
location.href = '${backUrl}';
21+
`);
22+
23+
await assert_bfcached(idA);
24+
25+
assert_array_equals(
26+
await evalOn(idA, `getRecordedEvents()`),
27+
[
28+
'window.load',
29+
'window.pageshow',
30+
'window.pagehide.persisted',
31+
'window.pageshow.persisted'
32+
]);
33+
}, `Events fired (window.open + noopener, ${originType})`);
34+
}
35+
</script>
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<!DOCTYPE HTML>
2+
<script>
3+
window.onload = () => history.back();
4+
</script>
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
<!DOCTYPE HTML>
2+
<script src="/html/cross-origin-embedder-policy/credentialless/resources/dispatcher.js"></script>
3+
<script>
4+
const params = new URLSearchParams(window.location.search);
5+
const uuid = params.get('uuid');
6+
7+
// --------
8+
// Recording events
9+
10+
// The recorded events are stored in localStorage rather than global variables
11+
// to catch events fired just before navigating out.
12+
function getPushedItems(key) {
13+
return JSON.parse(localStorage.getItem(key) || '[]');
14+
}
15+
16+
function pushItem(key, value) {
17+
const array = getPushedItems(key);
18+
array.push(value);
19+
localStorage.setItem(key, JSON.stringify(array));
20+
}
21+
22+
function recordEvent(eventName) {
23+
pushItem(uuid + '.observedEvents', eventName);
24+
}
25+
26+
function getRecordedEvents() {
27+
return getPushedItems(uuid + '.observedEvents');
28+
}
29+
30+
// Records events fired on `window` and `document`, with names listed in
31+
// `eventNames`.
32+
function startRecordingEvents(eventNames) {
33+
for (const eventName of eventNames) {
34+
window.addEventListener(eventName, event => {
35+
let result = eventName;
36+
if (event.persisted) {
37+
result += '.persisted';
38+
}
39+
if (eventName === 'visibilitychange') {
40+
result += '.' + document.visibilityState;
41+
}
42+
recordEvent('window.' + result);
43+
});
44+
document.addEventListener(eventName, () => {
45+
let result = eventName;
46+
if (eventName === 'visibilitychange') {
47+
result += '.' + document.visibilityState;
48+
}
49+
recordEvent('document.' + result);
50+
});
51+
}
52+
}
53+
54+
// When a comma-separated list of event names are given as the `events`
55+
// parameter in the URL, start record the events of the given names.
56+
if (params.get('events')) {
57+
startRecordingEvents(params.get('events').split(','));
58+
}
59+
60+
// --------
61+
// Executor and BFCache detection
62+
63+
// When navigating out from this page and then back navigating,
64+
// call prepareNavigation() immediately before navigating out.
65+
//
66+
// In such scenarios, `assert_bfcached()` etc. in `helper.sub.js` can determine
67+
// whether the page is restored from BFCache or not, by observing
68+
// - isPageshowFired: whether the pageshow event listener added by the
69+
// prepareNavigation() before navigating out, and
70+
// - loadCount: whether this inline script is evaluated again
71+
72+
// prepareNavigation() also suspends task polling, to avoid in-flight fetch
73+
// requests during navigation that might evict the page from BFCache.
74+
// Task polling is resumed later
75+
// - (BFCache cases) when the pageshow event listener added by
76+
// prepareNavigation() is executed, or
77+
// - (Non-BFCache cases) when executeOrders() is called again during
78+
// non-BFCache page loading.
79+
80+
window.isPageshowFired = false;
81+
82+
window.shouldSuspendFetch = false;
83+
84+
window.loadCount = parseInt(localStorage.getItem(uuid + '.loadCount') || '0') + 1;
85+
localStorage.setItem(uuid + '.loadCount', loadCount);
86+
87+
function prepareNavigation() {
88+
window.shouldSuspendFetch = true;
89+
window.addEventListener(
90+
'pageshow',
91+
() => {
92+
window.isPageshowFired = true;
93+
window.shouldSuspendFetch = false;
94+
},
95+
{once: true});
96+
}
97+
98+
// Tasks are executed after a pageshow event is fired.
99+
window.addEventListener('pageshow', () => {
100+
const executeOrders = async function() {
101+
while (true) {
102+
if (!window.shouldSuspendFetch) {
103+
const task = await receive(uuid);
104+
await eval(`(async () => {${task}})()`);
105+
}
106+
await new Promise(resolve => setTimeout(resolve, 100));
107+
}
108+
};
109+
executeOrders();
110+
},
111+
{once: true});
112+
</script>
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// Helpers called on the main test HTMLs.
2+
// Scripts in `send()` arguments are evaluated on the executors
3+
// (`executor.html`), and helpers available on the executors are defined in
4+
// `executor.html`.
5+
6+
const idThis = token();
7+
8+
const originSameOrigin =
9+
location.protocol === 'http:' ?
10+
'http://{{host}}:{{ports[http][0]}}' :
11+
'https://{{host}}:{{ports[https][0]}}';
12+
const originSameSite =
13+
location.protocol === 'http:' ?
14+
'http://{{host}}:{{ports[http][1]}}' :
15+
'https://{{host}}:{{ports[https][1]}}';
16+
const originCrossSite =
17+
location.protocol === 'http:' ?
18+
'http://{{hosts[alt][www]}}:{{ports[http][0]}}' :
19+
'https://{{hosts[alt][www]}}:{{ports[https][0]}}';
20+
21+
const executorPath =
22+
'/html/browsers/browsing-the-web/back-forward-cache/resources/executor.html?uuid=';
23+
const backPath =
24+
'/html/browsers/browsing-the-web/back-forward-cache/resources/back.html';
25+
26+
// On the executor with uuid `idTarget`: Evaluates the script `expr`, and
27+
// On the caller: returns a Promise resolved with the result of `expr`.
28+
// This assumes the result can be serialized by JSON.stringify().
29+
async function evalOn(idTarget, expr) {
30+
await send(idTarget, `await send('${idThis}', JSON.stringify(${expr}));`);
31+
const result = await receive(idThis);
32+
return JSON.parse(result);
33+
}
34+
35+
// On the executor with uuid `idTarget`:
36+
// Evaluates `script` that returns a Promise resolved with `result`.
37+
// On the caller:
38+
// Returns a Promise resolved with `result`
39+
// (or 'Error' when the promise is rejected).
40+
// This assumes `result` can be serialized by JSON.stringify().
41+
async function asyncEvalOn(idTarget, script) {
42+
send(idTarget, `
43+
try {
44+
const result = await async function() { ${script} }();
45+
await send('${idThis}', JSON.stringify(result));
46+
}
47+
catch (error) {
48+
await send('${idThis}', '"Error"');
49+
}`);
50+
const result = await receive(idThis);
51+
return JSON.parse(result);
52+
}
53+
54+
async function runEligibilityCheck(script) {
55+
const idA = token();
56+
window.open(executorPath + idA, '_blank', 'noopener');
57+
await send(idA, script);
58+
await send(idA, `
59+
prepareNavigation();
60+
location.href = '${originCrossSite + backPath}';
61+
`);
62+
await assert_bfcached(idA);
63+
}
64+
65+
// Asserts that the executor with uuid `idTarget` is (or isn't, respectively)
66+
// restored from BFCache. These should be used in the following fashion:
67+
// 1. Call prepareNavigation() on the executor `idTarget` using send().
68+
// 2. Navigate the executor to another page.
69+
// 3. Navigate back to the executor `idTarget`.
70+
// 4. Call assert_bfcached() or assert_not_bfcached() on the main test HTML.
71+
//
72+
// These methods (and getBFCachedStatus()) should be called after the send()
73+
// Promise in Step 1 is resolved, but we don't need to wait for the completion
74+
// of the navigation and back navigation in Steps 2 and 3,
75+
// because the injected scripts to the executor are queued and aren't executed
76+
// between prepareNavigation() and the completion of the back navigation.
77+
async function assert_bfcached(idTarget) {
78+
const status = await getBFCachedStatus(idTarget);
79+
assert_implements_optional(status === 'BFCached', 'Should be BFCached');
80+
}
81+
82+
async function assert_not_bfcached(idTarget) {
83+
const status = await getBFCachedStatus(idTarget);
84+
assert_implements_optional(status !== 'BFCached', 'Should not be BFCached');
85+
}
86+
87+
async function getBFCachedStatus(idTarget) {
88+
const [loadCount, isPageshowFired] =
89+
await evalOn(idTarget, '[window.loadCount, window.isPageshowFired]');
90+
if (loadCount === 1 && isPageshowFired === true) {
91+
return 'BFCached';
92+
} else if (loadCount === 2 && isPageshowFired === false) {
93+
return 'Not BFCached';
94+
} else {
95+
// This can occur for example when this is called before first navigating
96+
// away (loadCount = 1, isPageshowFired = false), e.g. when
97+
// 1. sending a script for navigation and then
98+
// 2. calling getBFCachedStatus() without waiting for the completion of
99+
// the script on the `idTarget` page.
100+
assert_unreached(
101+
`Got unexpected BFCache status: loadCount = ${loadCount}, ` +
102+
`isPageshowFired = ${isPageshowFired}`);
103+
}
104+
}

lint.ignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,7 @@ SET TIMEOUT: resources/test/tests/unit/promise_setup.html
337337
SET TIMEOUT: resources/testharness.js
338338
SET TIMEOUT: scheduler/tentative/current-task-signal-async-abort.any.js
339339
SET TIMEOUT: scheduler/tentative/current-task-signal-async-priority.any.js
340+
SET TIMEOUT: html/browsers/browsing-the-web/back-forward-cache/resources/executor.html
340341

341342
# setTimeout use in reftests
342343
SET TIMEOUT: acid/acid3/test.html

0 commit comments

Comments
 (0)