Skip to content

Commit 834440f

Browse files
[WPT] Introduce RemoteContext.execute_script() and add basic BFCache tests + helpers
This PR adds `RemoteContext.execute_script()` and its documentation in `/common/dispatcher/`. This is based on with `execute_script()`-related parts of RFCs 88/89/91: - web-platform-tests/rfcs#88 - web-platform-tests/rfcs#89 - web-platform-tests/rfcs#91 and addresses comments: - web-platform-tests/rfcs#86 (comment) - #28950 (comment) plus additional clarifications around navigation, minus `testdriver` integration (so this PR is implemented using `send()`/`receive()` in `/common/dispatcher/`), minus web-platform-tests/rfcs#90 (so this PR leaves `send()`/`receive()` as-is). This PR also adds back-forward cache WPTs (basic event firing tests), as well as BFCache-specific helpers, based on `RemoteContext.execute_script()`. Design doc: https://docs.google.com/document/d/1p3G-qNYMTHf5LU9hykaXcYtJ0k3wYOwcdVKGeps6EkU/edit?usp=sharing Bug: 1107415 Change-Id: I034f9f5376dc3f9f32ca0b936dbd06e458c9160b
1 parent 7f2d2ca commit 834440f

File tree

7 files changed

+623
-12
lines changed

7 files changed

+623
-12
lines changed

common/dispatcher/README.md

+189-12
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,172 @@
1-
# Message passing API
1+
# `RemoteContext`: API for script execution in another context
2+
3+
`RemoteContext` in `/common/dispatcher/dispatcher.js` provides an interface to
4+
execute JavaScript in another global object (page or worker, the "executor"),
5+
based on:
6+
7+
- [WPT RFC 88: context IDs from uuid searchParams in URL](https://github.com/web-platform-tests/rfcs/pull/88),
8+
- [WPT RFC 89: execute_script](https://github.com/web-platform-tests/rfcs/pull/89) and
9+
- [WPT RFC 91: RemoteContext](https://github.com/web-platform-tests/rfcs/pull/91).
10+
11+
Tests can send arbitrary javascript to executors to evaluate in its global
12+
object, like:
13+
14+
```
15+
// injector.html
16+
const argOnLocalContext = ...;
17+
18+
async function execute() {
19+
window.open('executor.html?uuid=' + uuid);
20+
const ctx = new RemoteContext(uuid);
21+
await ctx.execute_script(
22+
(arg) => functionOnRemoteContext(arg),
23+
[argOnLocalContext]);
24+
};
25+
```
26+
27+
and on executor:
28+
29+
```
30+
// executor.html
31+
function functionOnRemoteContext(arg) { ... }
32+
33+
const uuid = new URLSearchParams(window.location.search).get('uuid');
34+
const executor = new Executor(uuid);
35+
```
36+
37+
For concrete examples, see
38+
[events.html](../../html/browsers/browsing-the-web/back-forward-cache/events.html)
39+
and
40+
[executor.html](../../html/browsers/browsing-the-web/back-forward-cache/resources/executor.html)
41+
in back-forward cache tests.
42+
Note that executor files under `/common/dispatcher/` are NOT for
43+
`RemoteContext.execute_script()`.
44+
45+
This is universal and avoids introducing many specific `XXX-helper.html`
46+
resources.
47+
Moreover, tests are easier to read, because the whole logic of the test can be
48+
defined in a single file.
49+
50+
## `new RemoteContext(uuid)`
51+
52+
- `uuid` is a UUID string that identifies the remote context and should match
53+
with the `uuid` parameter of the URL of the remote context.
54+
- Callers should create the remote context outside this constructor (e.g.
55+
`window.open('executor.html?uuid=' + uuid)`).
56+
57+
## `RemoteContext.execute_script(fn, args)`
58+
59+
- `fn` is a JavaScript function to execute on the remote context, which is
60+
converted to a string using `toString()` and sent to the remote context.
61+
- `args` is null or an array of arguments to pass to the function on the
62+
remote context. Arguments are passed as JSON.
63+
- If the return value of `fn` when executed in the remote context is a promise,
64+
the promise returned by `execute_script` resolves to the resolved value of
65+
that promise. Otherwise the `execute_script` promise resolves to the return
66+
value of `fn`.
67+
68+
Note that `fn` is evaluated on the remote context (`executor.html` in the
69+
example above), while `args` are evaluated on the caller context
70+
(`injector.html`) and then passed to the remote context.
71+
72+
## Return value of injected functions and `execute_script()`
73+
74+
If the return value of the injected function when executed in the remote
75+
context is a promise, the promise returned by `execute_script` resolves to the
76+
resolved value of that promise. Otherwise the `execute_script` promise resolves
77+
to the return value of the function.
78+
79+
When the return value of an injected script is a Promise, it should be resolved
80+
before any navigation starts on the remote context. For example, it shouldn't
81+
be resolved after navigating out and navigating back to the page again.
82+
It's fine to create a Promise to be resolved after navigations, if it's not the
83+
return value of the injected function.
84+
85+
## Calling timing of `execute_script()`
86+
87+
When `RemoteContext.execute_script()` is called when the remote context is not
88+
active (for example before it is created, before navigation to the page, or
89+
during the page is in back-forward cache), the injected script is evaluated
90+
after the remote context becomes active.
91+
92+
`RemoteContext.execute_script()` calls should be serialized by always waiting
93+
for the returned promise to be resolved.
94+
So it's a good practice to always write `await ctx.execute_script(...)`.
95+
96+
## Evaluation timing of injected functions
97+
98+
The script injected by `RemoteContext.execute_script()` can be evaluated any
99+
time during the remote context is active.
100+
For example, even before DOMContentLoaded events or even during navigation.
101+
It's the responsibility of test-specific code/helpers to ensure evaluation
102+
timing constraints (which can be also test-specific), if any needed.
103+
104+
### Ensuring evaluation timing around page load
105+
106+
For example, to ensure that injected functions (`mainFunction` below) are
107+
evaluated after the first `pageshow` event, we can use pure JavaScript code
108+
like below:
109+
110+
```
111+
// executor.html
112+
window.pageShowPromise = new Promise(resolve =>
113+
window.addEventListener('pageshow', resolve, {once: true}));
114+
115+
116+
// injector.html
117+
const waitForPageShow = async () => {
118+
while (!window.pageShowPromise) {
119+
await new Promise(resolve => setTimeout(resolve, 100));
120+
}
121+
await window.pageShowPromise;
122+
};
123+
124+
await ctx.execute(waitForPageShow);
125+
await ctx.execute(mainFunction);
126+
```
127+
128+
### Ensuring evaluation timing around navigation out/unloading
129+
130+
It can be important to ensure there are no injected functions nor code behind
131+
`RemoteContext` (such as Fetch APIs accessing server-side stash) running after
132+
navigation is initiated, for example in the case of back-forward cache testing.
133+
134+
To ensure this,
135+
136+
- Do not call the next `RemoteContext.execute()` for the remote context after
137+
triggering the navigation, until we are sure that the remote context is not
138+
active (e.g. after we confirm that the new page is loaded).
139+
- Call `Executor.suspend(callback)` synchronously within the injected script.
140+
This suspends executor-related code, and calls `callback` when it is ready
141+
to start navigation.
142+
143+
The code on the injector side would be like:
144+
145+
```
146+
// injector.html
147+
await ctx.execute_script(() => {
148+
executor.suspend(() => {
149+
location.href = 'new-url.html';
150+
});
151+
});
152+
```
153+
154+
## Future Work: Possible integration with `test_driver`
155+
156+
Currently `RemoteContext` is implemented by JavaScript and WPT-server-side
157+
stash, and not integrated with `test_driver` nor `testharness`.
158+
There is a proposal of `test_driver`-integrated version (see the RFCs listed
159+
above).
160+
161+
The API semantics and guidelines in this document are designed to be applicable
162+
to both the current stash-based `RemoteContext` and `test_driver`-based
163+
version, and thus the tests using `RemoteContext` will be migrated with minimum
164+
modifications (mostly in `/common/dispatcher/dispatcher.js` and executors), for
165+
example in a
166+
[draft CL](https://chromium-review.googlesource.com/c/chromium/src/+/3082215/).
167+
168+
169+
# `send()`/`receive()` Message passing APIs
2170

3171
`dispatcher.js` (and its server-side backend `dispatcher.py`) provides a
4172
universal queue-based message passing API.
@@ -17,17 +185,26 @@ listen, before sending the first message
17185
(but still need to wait for the resolution of the promise returned by `send()`
18186
to ensure the order between `send()`s).
19187

20-
# Executor framework
188+
## Executors
21189

22-
The message passing API can be used for sending arbitrary javascript to be
23-
evaluated in another page or worker (the "executor").
190+
Similar to `RemoteContext.execute_script()`, `send()`/`receive()` can be used
191+
for sending arbitrary javascript to be evaluated in another page or worker.
24192

25-
`executor.html` (as a Document), `executor-worker.js` (as a Web Worker), and
26-
`executor-service-worker.js` (as a Service Worker) are examples of executors.
27-
Tests can send arbitrary javascript to these executors to evaluate in its
28-
execution context.
193+
- `executor.html` (as a Document),
194+
- `executor-worker.js` (as a Web Worker), and
195+
- `executor-service-worker.js` (as a Service Worker)
29196

30-
This is universal and avoids introducing many specific `XXX-helper.html`
31-
resources.
32-
Moreover, tests are easier to read, because the whole logic of the test can be
33-
defined in a single file.
197+
are examples of executors.
198+
Note that these executors are NOT compatible with
199+
`RemoteContext.execute_script()`.
200+
201+
## Future Work
202+
203+
`send()`, `receive()` and the executors below are kept for COEP/COOP tests.
204+
205+
For remote script execution, new tests should use
206+
`RemoteContext.execute_script()` instead.
207+
208+
For message passing,
209+
[WPT RFC 90](https://github.com/web-platform-tests/rfcs/pull/90) is still under
210+
discussion.

common/dispatcher/dispatcher.js

+102
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,105 @@ const showRequestHeaders = function(origin, uuid) {
8585
const cacheableShowRequestHeaders = function(origin, uuid) {
8686
return origin + dispatcher_path + `?uuid=${uuid}&cacheable&show-headers`;
8787
}
88+
89+
// This script requires
90+
// - `/common/utils.js` for `token()`.
91+
92+
// Represents a remote executor. For more detailed explanation see `README.md`.
93+
class RemoteContext {
94+
// `uuid` is a UUID string that identifies the remote context and should
95+
// match with the `uuid` parameter of the URL of the remote context.
96+
constructor(uuid) {
97+
this.context_id = uuid;
98+
}
99+
100+
// Evaluates the script `expr` on the executor.
101+
// - If `expr` is evaluated to a Promise that is resolved with a value:
102+
// `execute_script()` returns a Promise resolved with the value.
103+
// - If `expr` is evaluated to a non-Promise value:
104+
// `execute_script()` returns a Promise resolved with the value.
105+
// - If `expr` throws an error or is evaluated to a Promise that is rejected:
106+
// `execute_script()` returns a rejected Promise with the error's
107+
// `message`.
108+
// Note that currently the type of error (e.g. DOMException) is not
109+
// preserved.
110+
// The values should be able to be serialized by JSON.stringify().
111+
async execute_script(fn, args) {
112+
const receiver = token();
113+
await this.send({receiver: receiver, fn: fn.toString(), args: args});
114+
const response = JSON.parse(await receive(receiver));
115+
if (response.status === 'success') {
116+
return response.value;
117+
}
118+
119+
// exception
120+
throw new Error(response.value);
121+
}
122+
123+
async send(msg) {
124+
return await send(this.context_id, JSON.stringify(msg));
125+
}
126+
};
127+
128+
class Executor {
129+
constructor(uuid) {
130+
this.uuid = uuid;
131+
132+
// If `suspend_callback` is not `null`, the executor should be suspended
133+
// when there are no ongoing tasks.
134+
this.suspend_callback = null;
135+
136+
this.execute();
137+
}
138+
139+
// Wait until there are no ongoing tasks nor fetch requests for polling
140+
// tasks, and then suspend the executor and call `callback()`.
141+
// Navigation from the executor page should be triggered inside `callback()`,
142+
// to avoid conflict with in-flight fetch requests.
143+
suspend(callback) {
144+
this.suspend_callback = callback;
145+
}
146+
147+
resume() {
148+
}
149+
150+
async execute() {
151+
while(true) {
152+
if (this.suspend_callback !== null) {
153+
this.suspend_callback();
154+
this.suspend_callback = null;
155+
// Wait for `resume()` to be called.
156+
await new Promise(resolve => this.resume = resolve);
157+
158+
// Workaround for https://crbug.com/1244230.
159+
// Without this workaround, the executor is resumed and the fetch
160+
// request to poll the next task is initiated synchronously from
161+
// pageshow event after the page restored from BFCache, and the fetch
162+
// request promise is never resolved (and thus the test results in
163+
// timeout) due to https://crbug.com/1244230. The root cause is not yet
164+
// known, but setTimeout() with 0ms causes the resume triggered on
165+
// another task and seems to resolve the issue.
166+
await new Promise(resolve => setTimeout(resolve, 0));
167+
168+
continue;
169+
}
170+
171+
const task = JSON.parse(await receive(this.uuid));
172+
173+
let response;
174+
try {
175+
const value = await eval(task.fn).apply(null, task.args);
176+
response = JSON.stringify({
177+
status: 'success',
178+
value: value
179+
});
180+
} catch(e) {
181+
response = JSON.stringify({
182+
status: 'exception',
183+
value: e.message
184+
});
185+
}
186+
await send(task.receiver, response);
187+
}
188+
}
189+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# How to write back-forward cache tests
2+
3+
In the back-forward cache tests, the main test HTML usually:
4+
5+
1. Opens new executor Windows using `window.open()` + `noopener` option,
6+
because less isolated Windows (e.g. iframes and `window.open()` without
7+
`noopener` option) are often not eligible for back-forward cache (e.g.
8+
in Chromium).
9+
2. Injects scripts to the executor Windows and receives the results via
10+
`RemoteContext.execute_script()` by
11+
[/common/dispatcher](../../../../common/dispatcher/README.md).
12+
Follow the semantics and guideline described there.
13+
14+
Back-forward cache specific helpers are in:
15+
16+
- [resources/executor.html](resources/executor.html):
17+
The BFCache-specific executor and contains helpers for executors.
18+
- [resources/helper.sub.js](resources/helper.sub.js):
19+
Helpers for main test HTMLs.
20+
21+
We must ensure that injected scripts are evaluated only after page load
22+
(more precisely, the first `pageshow` event) and not during navigation,
23+
to prevent unexpected interference between injected scripts, in-flight fetch
24+
requests behind `RemoteContext.execute_script()`, navigation and back-forward
25+
cache. To ensure this,
26+
27+
- Call `await remoteContext.execute_script(waitForPageShow)` before any
28+
other scripts are injected to the remote context, and
29+
- Call `prepareNavigation(callback)` synchronously from the script injected
30+
by `RemoteContext.execute_script()`, and trigger navigation on or after the
31+
callback is called.
32+
33+
In typical A-B-A scenarios (where we navigate from Page A to Page B and then
34+
navigate back to Page A, assuming Page A is (or isn't) in BFCache),
35+
36+
- Call `prepareNavigation()` on the executor, and then navigate to B, and then
37+
navigate back to Page A.
38+
- Call `assert_bfcached()` or `assert_not_bfcached()` on the main test HTML, to
39+
check the BFCache status. This is important to do to ensure the test would
40+
not fail normally and instead result in `PRECONDITION_FAILED` if the page is
41+
unexpectedly bfcached/not bfcached.
42+
- Check other test expectations on the main test HTML,
43+
44+
as in [events.html](./events.html) and `runEventTest()` in
45+
[resources/helper.sub.js](resources/helper.sub.js).
46+
47+
# Asserting PRECONDITION_FAILED for unexpected BFCache eligibility
48+
49+
To distinguish failures due to unexpected BFCache ineligibility (which might be
50+
acceptable due to different BFCache eligibility criteria across browsers),
51+
`assert_bfcached()` and `assert_not_bfcached()` results in
52+
`PRECONDITION_FAILED` rather than ordinal failures.

0 commit comments

Comments
 (0)