Skip to content

Commit f602d54

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 90c5aa6 commit f602d54

File tree

8 files changed

+626
-12
lines changed

8 files changed

+626
-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

+91
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,94 @@ 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 an remote executor with ID `this.uuid`.
93+
// For more detailed explanation see `README.md`.
94+
class RemoteContext {
95+
// Caller should create an executor with `this.uuid`.
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+
// `eval()` returns a Promise resolved with the value.
103+
// - If `expr` is evaluated to a non-Promise value:
104+
// `eval()` returns a Promise resolved with the value.
105+
// - If `expr` throws an error or is evaluated to a Promise that is rejected:
106+
// `eval()` returns a rejected Promise with the error's `message`.
107+
// Note that currently the type of error (e.g. DOMException) is not
108+
// preserved.
109+
// The values should be able to be serialized by JSON.stringify().
110+
async execute_script(fn, args) {
111+
const receiver = token();
112+
await this.send({receiver: receiver, fn: fn.toString(), args: args});
113+
const response = JSON.parse(await receive(receiver));
114+
if (response.status === 'success') {
115+
return response.value;
116+
}
117+
118+
// exception
119+
throw new Error(response.value);
120+
}
121+
122+
async send(msg) {
123+
return await send(this.context_id, JSON.stringify(msg));
124+
}
125+
};
126+
127+
class Executor {
128+
constructor(uuid) {
129+
this.uuid = uuid;
130+
131+
// If `suspend_callback` is not `null`, the executor should be suspended
132+
// when there are no ongoing tasks.
133+
this.suspend_callback = null;
134+
135+
this.execute();
136+
}
137+
138+
suspend(callback) {
139+
this.suspend_callback = callback;
140+
}
141+
142+
resume() {
143+
}
144+
145+
async execute() {
146+
while(true) {
147+
// At this point there are no ongoing tasks and thus it's safe to start
148+
// navigation.
149+
// Therefore we check whether the executor should be suspended.
150+
if (this.suspend_callback !== null) {
151+
this.suspend_callback();
152+
this.suspend_callback = null;
153+
// Wait for `resume()` to be called.
154+
await new Promise(resolve => this.resume = resolve);
155+
// Workaround for crbug.com/1244230.
156+
await new Promise(resolve => setTimeout(resolve, 0));
157+
continue;
158+
}
159+
160+
const task = JSON.parse(await receive(this.uuid));
161+
162+
let response;
163+
try {
164+
const value = await eval(task.fn).apply(null, task.args);
165+
response = JSON.stringify({
166+
status: 'success',
167+
value: value
168+
});
169+
} catch(e) {
170+
response = JSON.stringify({
171+
status: 'exception',
172+
value: e.message
173+
});
174+
}
175+
await send(task.receiver, response);
176+
}
177+
}
178+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
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 / receives values from the executor Windows 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.
40+
- Check other test expectations on the main test HTML,
41+
42+
as in [events.html](./events.html) and `runEventTest()` in
43+
[resources/helper.sub.js](resources/helper.sub.js).
44+
45+
# Asserting PRECONDITION_FAILED for unexpected BFCache eligibility
46+
47+
To distinguish failures due to unexpected BFCache eligibility (which might be
48+
acceptable due to different BFCache eligibility criteria across browsers),
49+
`assert_bfcached()` and `assert_not_bfcached()` asserts `PRECONDITION_FAILED`
50+
rather than ordinal failures.

0 commit comments

Comments
 (0)