Part 3: Cancel pending write request when a new write request is made

The Async Clipboard API now allows using arbitrary promises for passing write data,
potentially enabling websites to delay writing data to an arbitrary future, which
may surprise the user. This patch introduces a solution: a new write request will
automatically cancel any previous pending request.

To implement that, this patch introduces a new method to nsIClipboard, new XPCOM
interfaces, and new IPC to efficiently track individual write requests. Additionally,
a new helper base class, ClipboardSetDataHelper, is introduced in widget to facilitate
platform code sharing.

Differential Revision: https://phabricator.services.mozilla.com/D174090

bugzilla-url: https://bugzilla.mozilla.org/show_bug.cgi?id=1712122
gecko-commit: 4d1cb082dad7b1988b716bf38943fd40e4ca8fdc
gecko-reviewers: nika, geckoview-reviewers, m_kato
diff --git a/clipboard-apis/async-navigator-clipboard-write-multiple.tentative.https.sub.html b/clipboard-apis/async-navigator-clipboard-write-multiple.tentative.https.sub.html
new file mode 100644
index 0000000..73cdd2f
--- /dev/null
+++ b/clipboard-apis/async-navigator-clipboard-write-multiple.tentative.https.sub.html
@@ -0,0 +1,106 @@
+<!doctype html>
+<meta charset="utf-8">
+<title>Async Clipboard write should cancel the prior pending request</title>
+<link rel="help" href="https://github.com/w3c/clipboard-apis/issues/161">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-actions.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<script src="resources/user-activation.js"></script>
+
+<iframe width="50" height="50" id="same" src="resources/page.html"></iframe><br>
+<iframe width="50" height="50" id="cross" src="https://{{hosts[alt][]}}:{{ports[https][1]}}/clipboard-apis/resources/page.html"></iframe><br>
+<input value="Test">
+<script>
+"use strict";
+
+// Permissions are required in order to invoke navigator.clipboard functions in
+// an automated test.
+async function getPermissions() {
+  await test_driver.set_permission({name: "clipboard-read"}, "granted");
+  await test_driver.set_permission({name: "clipboard-write"}, "granted");
+  await waitForUserActivation();
+}
+
+function waitForMessage(msg) {
+  return new Promise((resolve) => {
+    window.addEventListener("message", function handler(e) {
+      if (e.data == msg) {
+        window.removeEventListener("message", handler);
+        resolve();
+      }
+    });
+  });
+}
+
+function generateRandomString() {
+  return "random number: " + Math.random();
+}
+
+function testCancelPendingWrite(funcToMakeNewRequest, msg) {
+  promise_test(async t => {
+    await getPermissions();
+
+    // Create a pending write request which should be rejected after a new
+    // request is made.
+    let resolvePendingPromise;
+    let promise = navigator.clipboard.write([
+      new ClipboardItem({
+        "text/plain":  new Promise((resolve) => { resolvePendingPromise = resolve; }),
+      }),
+    ]).catch(e => { return e; });
+
+    // Make a new request that should cancel the prior pending request.
+    let str = generateRandomString();
+    await funcToMakeNewRequest(str);
+
+    // Pending request should be rejected with NotAllowedError.
+    let error = await promise;
+    assert_not_equals(error, undefined);
+    assert_equals(error.name, "NotAllowedError");
+
+    // Resolve pending promise.
+    resolvePendingPromise(generateRandomString());
+    // Check clipboard data.
+    assert_equals(await navigator.clipboard.readText(), str);
+  }, msg);
+}
+
+testCancelPendingWrite(async (str) => {
+  // A new write request should cancel the prior pending request.
+  await navigator.clipboard.write([
+    new ClipboardItem({
+      "text/plain":  str,
+    }),
+  ]).catch(() => {
+    assert_true(false, "should not fail");
+  });
+}, "clipboard.write() should cancel the prior pending one (same document)");
+
+testCancelPendingWrite(async (str) => {
+  // A new write should cancel the prior pending request.
+  const iframe = document.getElementById("same");
+  iframe.contentWindow.postMessage(["write", str], "*");
+  await waitForMessage("done");
+}, "clipboard.write() should cancel the prior pending one (same-origin iframe)");
+
+testCancelPendingWrite(async (str) => {
+  // A new write should cancel the prior pending request.
+  const iframe = document.getElementById("cross");
+  iframe.contentWindow.postMessage(["write", str], "*");
+  await waitForMessage("done");
+}, "clipboard.write() should cancel the prior pending one (cross-origin iframe)");
+
+testCancelPendingWrite(async (str) => {
+  const input = document.querySelector("input");
+  input.value = str;
+  input.focus();
+  input.select();
+
+  // A new copy action should cancel the prior pending request.
+  const modifier_key = navigator.platform.includes("Mac") ? "\uE03D" : "\uE009";
+  await new test_driver.Actions().keyDown(modifier_key).keyDown("c").send();
+}, "copy action should cancel the prior pending clipboard.write() request");
+
+</script>
diff --git a/clipboard-apis/resources/page.html b/clipboard-apis/resources/page.html
new file mode 100644
index 0000000..35bde8e
--- /dev/null
+++ b/clipboard-apis/resources/page.html
@@ -0,0 +1,26 @@
+<!DOCTYPE html>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-action.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<script src="user-activation.js"></script>
+
+<div style="width: 10px; height: 10px"></div>
+<script>
+window.addEventListener("message", async (e) => {
+  if (e.data && e.data[0] == "write") {
+    test_driver.set_test_context(window.parent);
+    await test_driver.set_permission({name: 'clipboard-read'}, 'granted');
+    await test_driver.set_permission({name: 'clipboard-write'}, 'granted');
+    await waitForUserActivation();
+    await navigator.clipboard.write([
+      new ClipboardItem({
+        "text/plain":  e.data[1],
+      }),
+    ]).catch(() => {
+      assert_true(false, `should not fail`);
+    });
+    window.parent.postMessage("done", "*");
+  }
+});
+</script>
+</html>