fix: a slow answer to an approval still reaches the site (closes #540)
Chrome stopped the background worker about six minutes after a site's request even with the approval window open, and the approval and the site's waiting request went with it. Every approval is now added through addPendingApproval(), which starts a timer in the worker that calls runtime.getPlatformInfo() every 20 seconds while any approval is pending; Chrome counts each call as activity. tests/e2e/slowApproval.js, run by make test-e2e, leaves a transaction and a signature approval open for seven minutes in minimized windows and requires the site to receive both results. It drives Chromium over the DevTools pipe because Playwright keeps DevTools attached to the worker. Its loopback dApp server, shared with the Firefox suite, moves to tests/e2e/dapp.js. Model: opus-5-5
This commit is contained in:
@@ -35,10 +35,10 @@ on: [push]
|
|||||||
jobs:
|
jobs:
|
||||||
e2e-chrome:
|
e2e-chrome:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
# Bounds the image build, a cold cache included, and both Chrome
|
# Bounds the image build, a cold cache included, and the three Chrome
|
||||||
# programs, so a hung browser frees the shared runner. README.md
|
# programs, so a hung browser frees the shared runner. README.md
|
||||||
# "In CI" has the measured times.
|
# "In CI" has the measured times.
|
||||||
timeout-minutes: 20
|
timeout-minutes: 40
|
||||||
steps:
|
steps:
|
||||||
# actions/checkout v4.2.2, 2026-02-22
|
# actions/checkout v4.2.2, 2026-02-22
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||||
|
|||||||
@@ -542,6 +542,28 @@ one profile yields a single extension id, not two: Chrome does not load a second
|
|||||||
copy of an id it already has. The assertions are annotated as observations, so a
|
copy of an id it already has. The assertions are annotated as observations, so a
|
||||||
Chrome that ever changes this fails the run instead of passing it.
|
Chrome that ever changes this fails the run instead of passing it.
|
||||||
|
|
||||||
|
Last, `make test-e2e` runs `tests/e2e/slowApproval.js`, which leaves a
|
||||||
|
transaction approval and a signature approval open for seven minutes with both
|
||||||
|
windows minimized, then restores and approves both and requires the site to
|
||||||
|
receive the hash of the transaction the stub node received and a signature that
|
||||||
|
recovers to the address. Before the background worker kept itself running while
|
||||||
|
an approval is pending, Chrome stopped it about six minutes in and neither
|
||||||
|
request was ever answered
|
||||||
|
([#540](https://git.eeqj.de/sneak/AutistMask/issues/540)). The windows are
|
||||||
|
minimized through the DevTools protocol (`Browser.setWindowBounds`), and the run
|
||||||
|
fails unless both pages then report themselves hidden: Chrome slows a hidden
|
||||||
|
page's timers to once a minute, so anything the window did to keep the worker
|
||||||
|
running would fail here. It is its own program and does not use Playwright:
|
||||||
|
Playwright keeps DevTools attached to the background worker, and Chrome never
|
||||||
|
stops a worker DevTools is attached to, so no wait in the suite above can show
|
||||||
|
the worker stopping. It starts Chromium itself, speaks the DevTools protocol
|
||||||
|
over `--remote-debugging-pipe`, and attaches to pages only. Before raising the
|
||||||
|
approvals it requires the worker to stop on its own with nothing pending, so a
|
||||||
|
run in which something else keeps the worker running fails. The site is
|
||||||
|
connected and the RPC endpoint set through the extension's own screens, against
|
||||||
|
the loopback dApp page and stub node the Firefox suite also uses
|
||||||
|
(`tests/e2e/dapp.js`). Unlike the suite above, it does not fail on page errors.
|
||||||
|
|
||||||
### Firefox (`make test-e2e-firefox`)
|
### Firefox (`make test-e2e-firefox`)
|
||||||
|
|
||||||
`make test-e2e-firefox` builds `dist/firefox/` and drives the **real popup in a
|
`make test-e2e-firefox` builds `dist/firefox/` and drives the **real popup in a
|
||||||
@@ -555,9 +577,10 @@ background page and approval windows.
|
|||||||
|
|
||||||
The suite lives in `tests/e2e/firefox/`. Its WebDriver client (`driver.js`) has
|
The suite lives in `tests/e2e/firefox/`. Its WebDriver client (`driver.js`) has
|
||||||
**no npm dependencies at all**: it is built on global `fetch` and
|
**no npm dependencies at all**: it is built on global `fetch` and
|
||||||
`child_process` against geckodriver's HTTP API. The dApp fixture (`dapp.js`) and
|
`child_process` against geckodriver's HTTP API. The dApp fixture
|
||||||
the assertions do use `ethers`, and have to — a signature is recovered in the
|
(`tests/e2e/dapp.js`, shared with `tests/e2e/slowApproval.js`) and the
|
||||||
runner rather than believed from the extension, and the stub node has to answer
|
assertions do use `ethers`, and have to — a signature is recovered in the runner
|
||||||
|
rather than believed from the extension, and the stub node has to answer
|
||||||
`eth_sendRawTransaction` with the hash `ethers` computes for the artifact it
|
`eth_sendRawTransaction` with the hash `ethers` computes for the artifact it
|
||||||
sent, or `provider.broadcastTransaction()` refuses the answer.
|
sent, or `provider.broadcastTransaction()` refuses the answer.
|
||||||
|
|
||||||
@@ -663,16 +686,16 @@ Three limits are worth knowing, all real differences from the Chrome suite:
|
|||||||
- **Almost nothing is stubbed, which inverts the coverage of network-dependent
|
- **Almost nothing is stubbed, which inverts the coverage of network-dependent
|
||||||
code.** The container still runs with `--network none`, so the run is offline
|
code.** The container still runs with `--network none`, so the run is offline
|
||||||
and no request can escape. The one thing it can reach is the loopback fixture
|
and no request can escape. The one thing it can reach is the loopback fixture
|
||||||
in `tests/e2e/firefox/dapp.js`, which serves the dApp page and a JSON-RPC node
|
in `tests/e2e/dapp.js`, which serves the dApp page and a JSON-RPC node and
|
||||||
and which the extension's `rpcUrl` is pointed at for the dApp steps; a
|
which the extension's `rpcUrl` is pointed at for the dApp steps; a JSON-RPC
|
||||||
JSON-RPC method that fixture does not model fails the run rather than
|
method that fixture does not model fails the run rather than answering `null`.
|
||||||
answering `null`. Everything else, Blockscout and the price feed among it, has
|
Everything else, Blockscout and the price feed among it, has no fixture and
|
||||||
no fixture and simply fails, and the extension swallows its own fetch
|
simply fails, and the extension swallows its own fetch failures, so only the
|
||||||
failures, so only the _failure_ branches of that code are ever executed. A
|
_failure_ branches of that code are ever executed. A `ReferenceError` in the
|
||||||
`ReferenceError` in the success path of `renderTransactions`, or of price
|
success path of `renderTransactions`, or of price rendering, passes this suite
|
||||||
rendering, passes this suite green. The offline run is also weaker than the
|
green. The offline run is also weaker than the Chrome suite's interception for
|
||||||
Chrome suite's interception for those calls: it proves nothing got out, but it
|
those calls: it proves nothing got out, but it cannot report which requests
|
||||||
cannot report which requests were attempted.
|
were attempted.
|
||||||
- **The site-connection prompt always opens in a window of its own.** The
|
- **The site-connection prompt always opens in a window of its own.** The
|
||||||
profile turns off `extensions.openPopupWithoutUserGesture.enabled`, so
|
profile turns off `extensions.openPopupWithoutUserGesture.enabled`, so
|
||||||
`src/background/index.js` falls back from the toolbar popup, which WebDriver
|
`src/background/index.js` falls back from the toolbar popup, which WebDriver
|
||||||
@@ -722,11 +745,13 @@ installing Firefox, geckodriver and their system libraries. Both scripts now
|
|||||||
build their image with `--no-cache`, so every run pays the cold figure. Those
|
build their image with `--no-cache`, so every run pays the cold figure. Those
|
||||||
`e2e-chrome` runs predate the cases that wait in real time for a receipt to end
|
`e2e-chrome` runs predate the cases that wait in real time for a receipt to end
|
||||||
in error. `make test-e2e` took 3m51s locally with its image cached, so with the
|
in error. `make test-e2e` took 3m51s locally with its image cached, so with the
|
||||||
image rebuilt every run, an `e2e-chrome` run comes to about seven minutes.
|
image rebuilt every run, an `e2e-chrome` run comes to about seven minutes. The
|
||||||
|
approvals left open for seven minutes (`tests/e2e/slowApproval.js`) add about
|
||||||
|
eight more, so about fifteen.
|
||||||
|
|
||||||
Each e2e job has a `timeout-minutes` cap, so a hung build or browser ends the
|
Each e2e job has a `timeout-minutes` cap, so a hung build or browser ends the
|
||||||
job instead of holding the shared runner: `e2e-firefox` 15 minutes and
|
job instead of holding the shared runner: `e2e-firefox` 15 minutes and
|
||||||
`e2e-chrome` 20, each over two and a half times the job's slowest cold run. A
|
`e2e-chrome` 40, each over two and a half times the job's slowest cold run. A
|
||||||
job that reaches its cap has hung; read it as a hang, not as a slow run to
|
job that reaches its cap has hung; read it as a hang, not as a slow run to
|
||||||
retry. The `check` job has no cap: `.gitea/workflows/check.yml` is the canonical
|
retry. The `check` job has no cap: `.gitea/workflows/check.yml` is the canonical
|
||||||
copy from `sneak/prompts`, kept byte-identical.
|
copy from `sneak/prompts`, kept byte-identical.
|
||||||
@@ -821,8 +846,22 @@ on the next event. Two consequences shape every recurring job in the background:
|
|||||||
service worker at all — the one remaining user of it, `src/shared/ens.js`,
|
service worker at all — the one remaining user of it, `src/shared/ens.js`,
|
||||||
runs only in the popup and is marked as such.
|
runs only in the popup and is marked as such.
|
||||||
|
|
||||||
The job also carries a freshness guard, and a guard must never be timed to the
|
Pending approvals are the exception: an approval waiting for the user, and the
|
||||||
alarm period it gates. The guard is measured from the moment the last run
|
site's request waiting on it, live only in the worker's memory, and only the
|
||||||
|
worker that received the request can answer it. Left alone, Chrome stopped the
|
||||||
|
worker about six minutes after a site's request even with the approval window
|
||||||
|
still open, and the user's answer then reached nobody. So the worker keeps
|
||||||
|
itself running while any approval is pending: every approval is added through
|
||||||
|
`addPendingApproval()`, which starts a `setInterval` that calls
|
||||||
|
`runtime.getPlatformInfo()` every 20 seconds, and Chrome counts each extension
|
||||||
|
call as activity. The timer stops itself once nothing is pending, and the worker
|
||||||
|
then stops as it otherwise would. The timer is the worker's own, so it does not
|
||||||
|
depend on the approval window being visible; a timer in the window would,
|
||||||
|
because Chrome slows a hidden page's timers to once a minute. A worker that
|
||||||
|
stops for another reason, a crash say, still loses the approvals open in it.
|
||||||
|
|
||||||
|
The balance refresh carries a freshness guard, and a guard must never be timed
|
||||||
|
to the alarm period it gates. The guard is measured from the moment the last run
|
||||||
finished, which is one run-duration after the alarm that started it, so a guard
|
finished, which is one run-duration after the alarm that started it, so a guard
|
||||||
of exactly one period vetoes the very next tick and the real cadence becomes two
|
of exactly one period vetoes the very next tick and the real cadence becomes two
|
||||||
periods. The balance refresh guard exists to skip work an open popup has already
|
periods. The balance refresh guard exists to skip work an open popup has already
|
||||||
|
|||||||
@@ -97,6 +97,18 @@ main() {
|
|||||||
-e HOME=/tmp \
|
-e HOME=/tmp \
|
||||||
"$(cat "$IIDFILE")" \
|
"$(cat "$IIDFILE")" \
|
||||||
node tests/e2e/storagePartition.js
|
node tests/e2e/storagePartition.js
|
||||||
|
|
||||||
|
# Approvals left open for seven minutes in minimized windows, past the
|
||||||
|
# point where Chrome stopped the background worker when nothing kept it
|
||||||
|
# running. Its own program, without Playwright: Playwright keeps DevTools
|
||||||
|
# attached to the worker, and Chrome never stops a worker DevTools is
|
||||||
|
# attached to. It reaches nothing but its own loopback server.
|
||||||
|
echo "Running the approvals left open for seven minutes..."
|
||||||
|
docker run --rm \
|
||||||
|
--ipc=host \
|
||||||
|
-e HOME=/tmp \
|
||||||
|
"$(cat "$IIDFILE")" \
|
||||||
|
node tests/e2e/slowApproval.js
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
+35
-7
@@ -44,6 +44,7 @@ const {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
actionApi,
|
actionApi,
|
||||||
|
getPlatformInfo,
|
||||||
runtimeApi,
|
runtimeApi,
|
||||||
tabsQuery,
|
tabsQuery,
|
||||||
tabsSendMessage,
|
tabsSendMessage,
|
||||||
@@ -67,6 +68,33 @@ const connectedSites = {};
|
|||||||
// Pending approval requests: { id: { origin, resolve } }
|
// Pending approval requests: { id: { origin, resolve } }
|
||||||
const pendingApprovals = {};
|
const pendingApprovals = {};
|
||||||
|
|
||||||
|
// An approval, and the site's request waiting on it, exist only in this
|
||||||
|
// worker's memory. Left alone, Chrome stopped the worker about six minutes
|
||||||
|
// after the site's request while the user was still deciding, and the approval
|
||||||
|
// was lost with it. Chrome counts an extension call as activity, so while any
|
||||||
|
// approval is pending this worker makes one every 20 seconds, inside Chrome's
|
||||||
|
// 30-second idle limit. It is the worker's own timer, so it runs whether or not
|
||||||
|
// the approval window is visible, and it stops itself once nothing is pending.
|
||||||
|
let keepRunningTimer = null;
|
||||||
|
|
||||||
|
// Add an approval waiting for the user, and start the timer above if it is not
|
||||||
|
// running. This is the ONLY place an approval is added, as settleApproval() is
|
||||||
|
// the only place one is removed, so every kind of approval keeps the worker
|
||||||
|
// running while it waits.
|
||||||
|
function addPendingApproval(approval) {
|
||||||
|
pendingApprovals[approval.id] = approval;
|
||||||
|
if (keepRunningTimer) return;
|
||||||
|
keepRunningTimer = setInterval(() => {
|
||||||
|
if (Object.keys(pendingApprovals).length === 0) {
|
||||||
|
clearInterval(keepRunningTimer);
|
||||||
|
keepRunningTimer = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Only the call matters; its answer is not used.
|
||||||
|
getPlatformInfo().catch(() => {});
|
||||||
|
}, 20000);
|
||||||
|
}
|
||||||
|
|
||||||
// One transaction approval at a time, wallet-wide.
|
// One transaction approval at a time, wallet-wide.
|
||||||
//
|
//
|
||||||
// The transaction a site asks for is populated before its approval window
|
// The transaction a site asks for is populated before its approval window
|
||||||
@@ -514,7 +542,7 @@ async function openApprovalWindow(id) {
|
|||||||
function requestApproval(origin) {
|
function requestApproval(origin) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
pendingApprovals[id] = { id, origin, resolve, type: "site" };
|
addPendingApproval({ id, origin, resolve, type: "site" });
|
||||||
|
|
||||||
if (actionNs && typeof actionNs.openPopup === "function") {
|
if (actionNs && typeof actionNs.openPopup === "function") {
|
||||||
showInToolbarPopup(id);
|
showInToolbarPopup(id);
|
||||||
@@ -557,14 +585,14 @@ function showInToolbarPopup(id) {
|
|||||||
function requestTxApproval(origin, approvedTx, approvedFrom, slot) {
|
function requestTxApproval(origin, approvedTx, approvedFrom, slot) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
pendingApprovals[id] = {
|
addPendingApproval({
|
||||||
id,
|
id,
|
||||||
origin,
|
origin,
|
||||||
approvedTx,
|
approvedTx,
|
||||||
approvedFrom,
|
approvedFrom,
|
||||||
resolve,
|
resolve,
|
||||||
type: "tx",
|
type: "tx",
|
||||||
};
|
});
|
||||||
if (slot) slot.approvalId = id;
|
if (slot) slot.approvalId = id;
|
||||||
|
|
||||||
openApprovalWindow(id);
|
openApprovalWindow(id);
|
||||||
@@ -578,14 +606,14 @@ function requestTxApproval(origin, approvedTx, approvedFrom, slot) {
|
|||||||
function requestSignApproval(origin, signParams, approvedFrom) {
|
function requestSignApproval(origin, signParams, approvedFrom) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
pendingApprovals[id] = {
|
addPendingApproval({
|
||||||
id,
|
id,
|
||||||
origin,
|
origin,
|
||||||
signParams,
|
signParams,
|
||||||
approvedFrom,
|
approvedFrom,
|
||||||
resolve,
|
resolve,
|
||||||
type: "sign",
|
type: "sign",
|
||||||
};
|
});
|
||||||
|
|
||||||
openApprovalWindow(id);
|
openApprovalWindow(id);
|
||||||
});
|
});
|
||||||
@@ -598,14 +626,14 @@ function requestSignApproval(origin, signParams, approvedFrom) {
|
|||||||
function requestNetworkApproval(origin, currentNetworkId, requestedNetworkId) {
|
function requestNetworkApproval(origin, currentNetworkId, requestedNetworkId) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
pendingApprovals[id] = {
|
addPendingApproval({
|
||||||
id,
|
id,
|
||||||
origin,
|
origin,
|
||||||
currentNetworkId,
|
currentNetworkId,
|
||||||
requestedNetworkId,
|
requestedNetworkId,
|
||||||
resolve,
|
resolve,
|
||||||
type: "network",
|
type: "network",
|
||||||
};
|
});
|
||||||
|
|
||||||
openApprovalWindow(id);
|
openApprovalWindow(id);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -145,6 +145,14 @@ function sendMessage(message) {
|
|||||||
return invoke(runtimeApi(), "sendMessage", message);
|
return invoke(runtimeApi(), "sendMessage", message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {Promise<Object>} the operating system and processor the browser
|
||||||
|
* runs on.
|
||||||
|
*/
|
||||||
|
function getPlatformInfo() {
|
||||||
|
return invoke(runtimeApi(), "getPlatformInfo");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a message nobody is expected to answer, and swallow the fact that
|
* Send a message nobody is expected to answer, and swallow the fact that
|
||||||
* nobody did.
|
* nobody did.
|
||||||
@@ -262,6 +270,7 @@ module.exports = {
|
|||||||
actionApi,
|
actionApi,
|
||||||
alarmsApi,
|
alarmsApi,
|
||||||
extensionApi,
|
extensionApi,
|
||||||
|
getPlatformInfo,
|
||||||
hasBrowserNamespace,
|
hasBrowserNamespace,
|
||||||
notify,
|
notify,
|
||||||
runtimeApi,
|
runtimeApi,
|
||||||
|
|||||||
@@ -264,6 +264,7 @@ function loadBackground(options) {
|
|||||||
connectListener = fn;
|
connectListener = fn;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
getPlatformInfo: jest.fn((cb) => cb({ os: "linux" })),
|
||||||
lastError: null,
|
lastError: null,
|
||||||
},
|
},
|
||||||
windows: {
|
windows: {
|
||||||
@@ -878,6 +879,45 @@ describe("one transaction approval at a time", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Chrome stops an idle worker, and the approvals in it go with it
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/540). tests/e2e/slowApproval.js
|
||||||
|
// shows the worker kept running in Chrome; this shows the calls stop once the
|
||||||
|
// approval is answered, so the worker can stop again. Every kind of approval is
|
||||||
|
// added through addPendingApproval(), which starts the calls, so a transaction
|
||||||
|
// approval stands for all of them.
|
||||||
|
describe("the worker keeps running while an approval is pending", () => {
|
||||||
|
test("an extension call every 20 seconds until the approval is answered", async () => {
|
||||||
|
const startTimer = jest
|
||||||
|
.spyOn(global, "setInterval")
|
||||||
|
.mockImplementation(() => 1);
|
||||||
|
const stopTimer = jest
|
||||||
|
.spyOn(global, "clearInterval")
|
||||||
|
.mockImplementation(() => {});
|
||||||
|
try {
|
||||||
|
const bg = loadBackground();
|
||||||
|
const calls = global.chrome.runtime.getPlatformInfo;
|
||||||
|
bg.requestTx();
|
||||||
|
await settle();
|
||||||
|
expect(startTimer).toHaveBeenCalledTimes(1);
|
||||||
|
expect(startTimer.mock.calls[0][1]).toBe(20000);
|
||||||
|
const tick = startTimer.mock.calls[0][0];
|
||||||
|
|
||||||
|
tick();
|
||||||
|
expect(calls).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// The user closes the approval window, which rejects it.
|
||||||
|
bg.closeWindow(1);
|
||||||
|
await settle();
|
||||||
|
tick();
|
||||||
|
expect(calls).toHaveBeenCalledTimes(1);
|
||||||
|
expect(stopTimer).toHaveBeenCalledWith(1);
|
||||||
|
} finally {
|
||||||
|
startTimer.mockRestore();
|
||||||
|
stopTimer.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// A page that asks again before the user has answered its last connection or
|
// A page that asks again before the user has answered its last connection or
|
||||||
// signature request is refused, instead of opening one more window per call.
|
// signature request is refused, instead of opening one more window per call.
|
||||||
describe("one connection and one signature approval per site at a time", () => {
|
describe("one connection and one signature approval per site at a time", () => {
|
||||||
|
|||||||
@@ -1,25 +1,29 @@
|
|||||||
// A loopback dApp origin and stub Ethereum node for the Firefox suite.
|
// A loopback dApp origin and stub Ethereum node, used by the two browser
|
||||||
|
// programs that have no Playwright to answer the browser's requests for them:
|
||||||
|
// the Firefox suite (tests/e2e/firefox/run.js) and the Chrome program that
|
||||||
|
// leaves approvals open for seven minutes (tests/e2e/slowApproval.js). The
|
||||||
|
// Chrome suite in run.js answers its requests through network.js instead.
|
||||||
//
|
//
|
||||||
// The Firefox container runs with --network none, and the harness note in
|
// The Firefox container runs with --network none, and the harness note in
|
||||||
// driver.js records the consequence: with no http:// origin in reach, no
|
// firefox/driver.js records the consequence: with no http:// origin in reach,
|
||||||
// content script was ever injected, so content-script behaviour was
|
// no content script was ever injected, so content-script behaviour was
|
||||||
// UNVERIFIED and the dApp flows could not be driven at all.
|
// UNVERIFIED and the dApp flows could not be driven at all.
|
||||||
//
|
//
|
||||||
// --network none removes every interface except loopback, and loopback is
|
// --network none removes every interface except loopback, and loopback is
|
||||||
// enough. This serves the page and the JSON-RPC endpoint from 127.0.0.1
|
// enough. This serves the page and the JSON-RPC endpoint from 127.0.0.1
|
||||||
// inside the same container Firefox runs in, so the dApp round trips execute
|
// inside the same container the browser runs in, so the dApp round trips
|
||||||
// against a real http:// origin and the run stays as offline as it was: the
|
// execute against a real http:// origin and the run stays as offline as it
|
||||||
// only reachable peer is this process.
|
// was: the only reachable peer is this process.
|
||||||
//
|
//
|
||||||
// The page itself is not written twice. DAPP_HTML comes from the Chrome
|
// The page itself is not written twice. DAPP_HTML comes from the Chrome
|
||||||
// suite's fixture, so both harnesses drive the same __dapp API and the same
|
// suite's fixture, so every browser program drives the same __dapp API and
|
||||||
// message log.
|
// the same message log.
|
||||||
//
|
//
|
||||||
// Unlike driver.js this file does use ethers, and it has to: the node has to
|
// Unlike firefox/driver.js this file does use ethers, and it has to: the node
|
||||||
// answer eth_sendRawTransaction with the hash ethers computes for the
|
// has to answer eth_sendRawTransaction with the hash ethers computes for the
|
||||||
// artifact it was handed, or provider.broadcastTransaction() refuses the
|
// artifact it was handed, or provider.broadcastTransaction() refuses the
|
||||||
// answer, and the suite recovers signatures itself rather than believing the
|
// answer, and both programs recover signatures themselves rather than
|
||||||
// extension's own verdict.
|
// believing the extension's own verdict.
|
||||||
|
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
@@ -27,7 +31,7 @@ const http = require("http");
|
|||||||
|
|
||||||
const { Transaction } = require("ethers");
|
const { Transaction } = require("ethers");
|
||||||
|
|
||||||
const { DAPP_HTML } = require("../network");
|
const { DAPP_HTML } = require("./network");
|
||||||
|
|
||||||
// The same fee shape the Chrome suite uses, for the same reason: it has to
|
// The same fee shape the Chrome suite uses, for the same reason: it has to
|
||||||
// pass the ceilings in src/shared/approvalVerify.js and it has to leave the
|
// pass the ceilings in src/shared/approvalVerify.js and it has to leave the
|
||||||
@@ -170,11 +174,11 @@ async function startDappServer() {
|
|||||||
const payload = JSON.stringify(handleRpcBody(body, state));
|
const payload = JSON.stringify(handleRpcBody(body, state));
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
// The extension fetches this from its background
|
// In Firefox the extension fetches this from its
|
||||||
// page, whose origin is moz-extension://. Without CORS
|
// background page, whose origin is moz-extension://.
|
||||||
// the fetch fails and every transaction assertion
|
// Without CORS the fetch fails and every transaction
|
||||||
// fails for a reason that has nothing to do with the
|
// assertion fails for a reason that has nothing to do
|
||||||
// wallet.
|
// with the wallet.
|
||||||
"Access-Control-Allow-Origin": "*",
|
"Access-Control-Allow-Origin": "*",
|
||||||
});
|
});
|
||||||
res.end(payload);
|
res.end(payload);
|
||||||
@@ -193,8 +197,8 @@ async function startDappServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// An empty favicon rather than a 404: a 404 is a page error in
|
// An empty favicon rather than a 404: a 404 is a page error in
|
||||||
// Firefox's console under some settings, and the suite fails the run
|
// Firefox's console under some settings, and the Firefox suite fails
|
||||||
// on those.
|
// the run on those.
|
||||||
if (url.pathname === "/favicon.ico") {
|
if (url.pathname === "/favicon.ico") {
|
||||||
res.writeHead(200, { "Content-Type": "image/x-icon" });
|
res.writeHead(200, { "Content-Type": "image/x-icon" });
|
||||||
res.end("");
|
res.end("");
|
||||||
@@ -467,7 +467,7 @@ class Driver {
|
|||||||
// src/background/index.js, which kills the background page outright, fails
|
// src/background/index.js, which kills the background page outright, fails
|
||||||
// the run.
|
// the run.
|
||||||
//
|
//
|
||||||
// Content scripts ARE now exercised: tests/e2e/firefox/dapp.js serves a page
|
// Content scripts ARE now exercised: tests/e2e/dapp.js serves a page
|
||||||
// from loopback, which survives --network none, and the suite drives the
|
// from loopback, which survives --network none, and the suite drives the
|
||||||
// EIP-1193 round trips through the content script injected into it. What is
|
// EIP-1193 round trips through the content script injected into it. What is
|
||||||
// still unproven is the CAPTURE, not the execution — no probe has forced a
|
// still unproven is the CAPTURE, not the execution — no probe has forced a
|
||||||
|
|||||||
@@ -17,13 +17,13 @@
|
|||||||
// note in driver.js), so the two backends have no common substrate to
|
// note in driver.js), so the two backends have no common substrate to
|
||||||
// abstract over. Duplicated steps do not pay for a shim; revisit if this
|
// abstract over. Duplicated steps do not pay for a shim; revisit if this
|
||||||
// suite grows to where they do. What IS shared is the dApp page fixture
|
// suite grows to where they do. What IS shared is the dApp page fixture
|
||||||
// itself — DAPP_HTML, served here from loopback by dapp.js — so an assertion
|
// itself — DAPP_HTML, served here from loopback by tests/e2e/dapp.js — so an
|
||||||
// about the __dapp API means the same thing on both browsers.
|
// assertion about the __dapp API means the same thing on both browsers.
|
||||||
//
|
//
|
||||||
// The dApp steps need an http:// origin, which --network none was thought to
|
// The dApp steps need an http:// origin, which --network none was thought to
|
||||||
// rule out. It does not: loopback survives it, so the page and the stub node
|
// rule out. It does not: loopback survives it, so the page and the stub node
|
||||||
// are served from 127.0.0.1 inside the container and the run reaches nothing
|
// are served from 127.0.0.1 inside the container and the run reaches nothing
|
||||||
// but this process. See tests/e2e/firefox/dapp.js.
|
// but this process. See tests/e2e/dapp.js.
|
||||||
//
|
//
|
||||||
// LIMITATION, and the difference from the Chrome suite worth knowing: error
|
// LIMITATION, and the difference from the Chrome suite worth knowing: error
|
||||||
// capture here is POLL-BASED, not event-streamed. The console service is
|
// capture here is POLL-BASED, not event-streamed. The console service is
|
||||||
@@ -61,7 +61,7 @@ const {
|
|||||||
} = require("ethers");
|
} = require("ethers");
|
||||||
|
|
||||||
const { ConsoleErrors, EXTENSION_ORIGIN, start, sleep } = require("./driver");
|
const { ConsoleErrors, EXTENSION_ORIGIN, start, sleep } = require("./driver");
|
||||||
const { startDappServer } = require("./dapp");
|
const { startDappServer } = require("../dapp");
|
||||||
const { STUB_COUNTERPARTY } = require("../network");
|
const { STUB_COUNTERPARTY } = require("../network");
|
||||||
const { NETWORKS } = require("../../../src/shared/networks");
|
const { NETWORKS } = require("../../../src/shared/networks");
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ function word(value) {
|
|||||||
// is the real inpage provider and not a copy the harness wired up.
|
// is the real inpage provider and not a copy the harness wired up.
|
||||||
//
|
//
|
||||||
// DAPP_HTML below is exported and served verbatim by the Firefox suite too
|
// DAPP_HTML below is exported and served verbatim by the Firefox suite too
|
||||||
// (tests/e2e/firefox/dapp.js), from a loopback origin rather than through a
|
// (tests/e2e/dapp.js), from a loopback origin rather than through a
|
||||||
// route handler. The two suites drive different browsers over different
|
// route handler. The two suites drive different browsers over different
|
||||||
// protocols, but the page they drive — the __dapp API, the message log — is
|
// protocols, but the page they drive — the __dapp API, the message log — is
|
||||||
// one fixture, so an assertion written against it means the same thing on
|
// one fixture, so an assertion written against it means the same thing on
|
||||||
|
|||||||
@@ -0,0 +1,530 @@
|
|||||||
|
// A transaction approval and a signature approval the user leaves open for
|
||||||
|
// seven minutes, their windows minimized, are still answered, and the site
|
||||||
|
// receives both results (https://git.eeqj.de/sneak/AutistMask/issues/540).
|
||||||
|
//
|
||||||
|
// Both approvals, and the site's requests waiting on them, live only in the
|
||||||
|
// background worker's memory. With nothing else going on, Chrome stopped that
|
||||||
|
// worker about six minutes after the site's request, while the window was
|
||||||
|
// still open, and the user's answer then reached nobody. While an approval is
|
||||||
|
// pending, the worker now makes an extension call every 20 seconds, which
|
||||||
|
// keeps it running (addPendingApproval() in src/background/index.js). The
|
||||||
|
// windows are minimized for the wait because
|
||||||
|
// Chrome slows a hidden page's timers to once a minute after a few minutes,
|
||||||
|
// so anything the window itself did to keep the worker running would fail
|
||||||
|
// here.
|
||||||
|
//
|
||||||
|
// A separate program from run.js, and not driven by Playwright: Playwright
|
||||||
|
// keeps DevTools attached to the background worker, and Chrome never stops a
|
||||||
|
// worker DevTools is attached to, so in run.js the worker outlives any wait
|
||||||
|
// and a wait there proves nothing. This program starts Chromium itself and
|
||||||
|
// speaks the DevTools protocol over the pipe Chromium opens for it
|
||||||
|
// (--remote-debugging-pipe), attaching to pages only, never to the worker.
|
||||||
|
// Before the approvals are raised it watches the worker stop on its own, so a
|
||||||
|
// run in which something else keeps the worker running cannot pass.
|
||||||
|
//
|
||||||
|
// The dApp page and the stub node are the loopback ones the Firefox suite also
|
||||||
|
// uses (tests/e2e/dapp.js). The site is connected and the node chosen
|
||||||
|
// through the extension's own screens, so nothing is written to its storage
|
||||||
|
// from outside.
|
||||||
|
//
|
||||||
|
// Run through script/test-e2e, which builds the extension and the pinned
|
||||||
|
// container.
|
||||||
|
//
|
||||||
|
// node tests/e2e/slowApproval.js
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const os = require("os");
|
||||||
|
const path = require("path");
|
||||||
|
const { spawn } = require("child_process");
|
||||||
|
|
||||||
|
const { chromium } = require("playwright-core");
|
||||||
|
const {
|
||||||
|
Transaction,
|
||||||
|
getAddress,
|
||||||
|
getBytes,
|
||||||
|
hexlify,
|
||||||
|
parseEther,
|
||||||
|
toQuantity,
|
||||||
|
toUtf8Bytes,
|
||||||
|
verifyMessage,
|
||||||
|
} = require("ethers");
|
||||||
|
|
||||||
|
const { PASSWORD } = require("./harness");
|
||||||
|
const { STUB_COUNTERPARTY } = require("./network");
|
||||||
|
const { startDappServer } = require("./dapp");
|
||||||
|
|
||||||
|
const DIST_CHROME = path.resolve(__dirname, "..", "..", "dist", "chrome");
|
||||||
|
|
||||||
|
// Chrome stopped the worker six minutes after the site's request when nothing
|
||||||
|
// kept it running, so the approvals are left open for longer than that.
|
||||||
|
const APPROVAL_WAIT_MS = 7 * 60 * 1000;
|
||||||
|
|
||||||
|
// With nothing pending, Chrome stops the worker 30 seconds after its last
|
||||||
|
// event; this is how long the program waits to see that.
|
||||||
|
const IDLE_STOP_TIMEOUT_MS = 90000;
|
||||||
|
|
||||||
|
const SIGN_HEX = hexlify(toUtf8Bytes("AutistMask e2e: a slow signature"));
|
||||||
|
|
||||||
|
const checks = [];
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
function check(name, cond, detail) {
|
||||||
|
checks.push(name);
|
||||||
|
if (cond) {
|
||||||
|
console.log("ok " + checks.length + " - " + name);
|
||||||
|
} else {
|
||||||
|
failed += 1;
|
||||||
|
console.log("not ok " + checks.length + " - " + name);
|
||||||
|
if (detail) console.log(" " + detail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll `probe` until it returns something truthy, and return that.
|
||||||
|
async function until(what, probe, timeout = 30000) {
|
||||||
|
const deadline = Date.now() + timeout;
|
||||||
|
for (;;) {
|
||||||
|
const value = await probe().catch(() => null);
|
||||||
|
if (value) return value;
|
||||||
|
if (Date.now() > deadline) {
|
||||||
|
throw new Error(
|
||||||
|
"gave up after " + timeout + "ms waiting for " + what,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await sleep(250);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The DevTools protocol over Chromium's pipe: commands go to its fd 3, and
|
||||||
|
// replies and events come back on its fd 4, each one JSON message ended by a
|
||||||
|
// NUL byte. Events are not used and are dropped. Returns send(method, params,
|
||||||
|
// sessionId), which resolves with the command's result.
|
||||||
|
function devtools(browser) {
|
||||||
|
const waiting = new Map();
|
||||||
|
let nextId = 1;
|
||||||
|
let pending = "";
|
||||||
|
const replies = browser.stdio[4];
|
||||||
|
replies.setEncoding("utf8");
|
||||||
|
replies.on("data", (chunk) => {
|
||||||
|
pending += chunk;
|
||||||
|
let end;
|
||||||
|
while ((end = pending.indexOf("\0")) !== -1) {
|
||||||
|
const message = JSON.parse(pending.slice(0, end));
|
||||||
|
pending = pending.slice(end + 1);
|
||||||
|
const caller = waiting.get(message.id);
|
||||||
|
if (!caller) continue;
|
||||||
|
waiting.delete(message.id);
|
||||||
|
if (message.error) {
|
||||||
|
caller.reject(
|
||||||
|
new Error(caller.method + ": " + message.error.message),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
caller.resolve(message.result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
replies.on("close", () => {
|
||||||
|
for (const caller of waiting.values()) {
|
||||||
|
caller.reject(new Error("the browser exited"));
|
||||||
|
}
|
||||||
|
waiting.clear();
|
||||||
|
});
|
||||||
|
return (method, params = {}, sessionId) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const id = nextId++;
|
||||||
|
waiting.set(id, { method, resolve, reject });
|
||||||
|
browser.stdio[3].write(
|
||||||
|
JSON.stringify({ id, method, params, sessionId }) + "\0",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach to a page and return a function that evaluates an expression in it,
|
||||||
|
// awaiting a promise it returns, and resolves with the value.
|
||||||
|
async function attach(send, targetId) {
|
||||||
|
const { sessionId } = await send("Target.attachToTarget", {
|
||||||
|
targetId,
|
||||||
|
flatten: true,
|
||||||
|
});
|
||||||
|
return async (expression) => {
|
||||||
|
const r = await send(
|
||||||
|
"Runtime.evaluate",
|
||||||
|
{ expression, awaitPromise: true, returnByValue: true },
|
||||||
|
sessionId,
|
||||||
|
);
|
||||||
|
if (r.exceptionDetails) {
|
||||||
|
throw new Error(
|
||||||
|
"evaluating " +
|
||||||
|
expression.slice(0, 80) +
|
||||||
|
" threw: " +
|
||||||
|
JSON.stringify(r.exceptionDetails.exception || {}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return r.result.value;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether the page has loaded and shows the element with this id: the
|
||||||
|
// `hidden` class showView() keeps on every other view is on neither it nor
|
||||||
|
// anything around it.
|
||||||
|
function shows(id) {
|
||||||
|
return (
|
||||||
|
"document.readyState === 'complete' &&" +
|
||||||
|
" !!document.getElementById(" +
|
||||||
|
JSON.stringify(id) +
|
||||||
|
") && !document.getElementById(" +
|
||||||
|
JSON.stringify(id) +
|
||||||
|
").closest('.hidden')"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set an input's value and fire the input event typing would fire.
|
||||||
|
function typeInto(id, value) {
|
||||||
|
return (
|
||||||
|
"(() => { const el = document.getElementById(" +
|
||||||
|
JSON.stringify(id) +
|
||||||
|
"); el.value = " +
|
||||||
|
JSON.stringify(value) +
|
||||||
|
"; el.dispatchEvent(new Event('input', { bubbles: true })); })()"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function click(id) {
|
||||||
|
return "document.getElementById(" + JSON.stringify(id) + ").click()";
|
||||||
|
}
|
||||||
|
|
||||||
|
// The outcome of a request the dApp page started with __dapp.start(), or
|
||||||
|
// {settled: "pending"} if it has not settled within `timeout`.
|
||||||
|
function settled(key, timeout = 30000) {
|
||||||
|
return (
|
||||||
|
"Promise.race([window.__dapp.settle(" +
|
||||||
|
JSON.stringify(key) +
|
||||||
|
"), new Promise((r) => setTimeout(() => r({ settled: 'pending' }), " +
|
||||||
|
timeout +
|
||||||
|
"))])"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (!fs.existsSync(path.join(DIST_CHROME, "manifest.json"))) {
|
||||||
|
console.error(
|
||||||
|
"slowApproval: no unpacked build at " +
|
||||||
|
DIST_CHROME +
|
||||||
|
" — run make build first",
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = await startDappServer();
|
||||||
|
const profile = fs.mkdtempSync(
|
||||||
|
path.join(os.tmpdir(), "autistmask-slow-approval-"),
|
||||||
|
);
|
||||||
|
const browser = spawn(
|
||||||
|
chromium.executablePath(),
|
||||||
|
[
|
||||||
|
"--headless",
|
||||||
|
// The container runs unprivileged, as in harness.js.
|
||||||
|
"--no-sandbox",
|
||||||
|
"--remote-debugging-pipe",
|
||||||
|
"--user-data-dir=" + profile,
|
||||||
|
"--disable-extensions-except=" + DIST_CHROME,
|
||||||
|
"--load-extension=" + DIST_CHROME,
|
||||||
|
// Nothing but the loopback dApp page and stub node can be reached.
|
||||||
|
"--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE 127.0.0.1",
|
||||||
|
"about:blank",
|
||||||
|
],
|
||||||
|
{ stdio: ["ignore", "ignore", "ignore", "pipe", "pipe"] },
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const send = devtools(browser);
|
||||||
|
const targets = async () =>
|
||||||
|
(await send("Target.getTargets")).targetInfos;
|
||||||
|
|
||||||
|
const worker = await until("the background worker", async () =>
|
||||||
|
(await targets()).find(
|
||||||
|
(t) =>
|
||||||
|
t.type === "service_worker" &&
|
||||||
|
t.url.startsWith("chrome-extension://"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// The extension id is the host of the worker's URL, as in harness.js.
|
||||||
|
const popupUrl =
|
||||||
|
"chrome-extension://" +
|
||||||
|
new URL(worker.url).host +
|
||||||
|
"/src/popup/index.html";
|
||||||
|
const workerRunning = async () =>
|
||||||
|
(await targets()).some((t) => t.url === worker.url);
|
||||||
|
const openPage = async (url) => {
|
||||||
|
const { targetId } = await send("Target.createTarget", { url });
|
||||||
|
return { targetId, evaluate: await attach(send, targetId) };
|
||||||
|
};
|
||||||
|
const approvalPages = async () =>
|
||||||
|
(await targets()).filter(
|
||||||
|
(t) => t.type === "page" && t.url.includes("?approval="),
|
||||||
|
);
|
||||||
|
|
||||||
|
// A wallet, through the popup's own screens, and the stub node as its
|
||||||
|
// RPC endpoint, through Settings.
|
||||||
|
const popup = await openPage(popupUrl);
|
||||||
|
await until("the welcome screen", () =>
|
||||||
|
popup.evaluate(shows("view-welcome")),
|
||||||
|
);
|
||||||
|
await popup.evaluate(click("btn-welcome-add"));
|
||||||
|
await until("the add wallet screen", () =>
|
||||||
|
popup.evaluate(shows("view-add-wallet")),
|
||||||
|
);
|
||||||
|
await popup.evaluate(click("btn-generate-phrase"));
|
||||||
|
await until("a generated recovery phrase", () =>
|
||||||
|
popup.evaluate(
|
||||||
|
"document.getElementById('wallet-mnemonic').value.trim()" +
|
||||||
|
".split(/\\s+/).length >= 12",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await popup.evaluate(typeInto("add-wallet-password", PASSWORD));
|
||||||
|
await popup.evaluate(typeInto("add-wallet-password-confirm", PASSWORD));
|
||||||
|
await popup.evaluate(click("btn-add-wallet-confirm"));
|
||||||
|
await until(
|
||||||
|
"the main screen",
|
||||||
|
() => popup.evaluate(shows("view-main")),
|
||||||
|
60000,
|
||||||
|
);
|
||||||
|
await popup.evaluate(click("btn-settings"));
|
||||||
|
await until("the settings screen", () =>
|
||||||
|
popup.evaluate(shows("view-settings")),
|
||||||
|
);
|
||||||
|
await popup.evaluate(typeInto("settings-rpc", node.rpcUrl));
|
||||||
|
await popup.evaluate(click("btn-save-rpc"));
|
||||||
|
await until("the stub node saved as the RPC endpoint", () =>
|
||||||
|
popup.evaluate(
|
||||||
|
"new Promise((resolve) => chrome.storage.local.get(" +
|
||||||
|
"'autistmask', (r) => resolve(r.autistmask.rpcUrl === " +
|
||||||
|
JSON.stringify(node.rpcUrl) +
|
||||||
|
")))",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await send("Target.closeTarget", { targetId: popup.targetId });
|
||||||
|
|
||||||
|
// The site, connected through the real prompt with "Remember", so
|
||||||
|
// the connection is stored rather than held by the worker.
|
||||||
|
const dapp = await openPage(node.url);
|
||||||
|
await until("the injected provider", () =>
|
||||||
|
dapp.evaluate("!!window.ethereum && !!window.__dapp"),
|
||||||
|
);
|
||||||
|
await dapp.evaluate(
|
||||||
|
"window.__dapp.start('accounts', 'eth_requestAccounts', [])",
|
||||||
|
);
|
||||||
|
const prompt = await until(
|
||||||
|
"the connection prompt",
|
||||||
|
async () => (await approvalPages())[0],
|
||||||
|
);
|
||||||
|
const inPrompt = await attach(send, prompt.targetId);
|
||||||
|
await until("the connection prompt's screen", () =>
|
||||||
|
inPrompt(shows("view-approve-site")),
|
||||||
|
);
|
||||||
|
await inPrompt(
|
||||||
|
"document.getElementById('approve-remember').checked = true",
|
||||||
|
);
|
||||||
|
// The prompt closes itself on the click.
|
||||||
|
await inPrompt(click("btn-approve")).catch(() => {});
|
||||||
|
const accounts = await dapp.evaluate(settled("accounts"));
|
||||||
|
if (accounts.settled !== "resolved") {
|
||||||
|
throw new Error(
|
||||||
|
"the site could not be connected: " + JSON.stringify(accounts),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const address = getAddress(accounts.result[0]);
|
||||||
|
console.log("# address " + address + ", site " + node.origin);
|
||||||
|
|
||||||
|
// The control: with nothing pending, the worker stops on its own.
|
||||||
|
// Were something else keeping it running, the wait below would
|
||||||
|
// prove nothing.
|
||||||
|
const idleStart = Date.now();
|
||||||
|
const stoppedWhenIdle = await until(
|
||||||
|
"the idle worker to stop",
|
||||||
|
async () => !(await workerRunning()),
|
||||||
|
IDLE_STOP_TIMEOUT_MS,
|
||||||
|
).catch(() => false);
|
||||||
|
check(
|
||||||
|
"with nothing pending, Chrome stops the background worker on its own",
|
||||||
|
stoppedWhenIdle,
|
||||||
|
"the worker was still running " +
|
||||||
|
IDLE_STOP_TIMEOUT_MS / 1000 +
|
||||||
|
"s after its last event, so something other than the " +
|
||||||
|
"approval windows is keeping it running",
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
"# idle worker stopped after " +
|
||||||
|
Math.round((Date.now() - idleStart) / 1000) +
|
||||||
|
"s",
|
||||||
|
);
|
||||||
|
|
||||||
|
// A transaction approval and a signature approval, both open at once.
|
||||||
|
await dapp.evaluate(
|
||||||
|
"window.__dapp.start('tx', 'eth_sendTransaction', [" +
|
||||||
|
JSON.stringify({
|
||||||
|
from: address,
|
||||||
|
to: STUB_COUNTERPARTY,
|
||||||
|
value: toQuantity(parseEther("0.01")),
|
||||||
|
}) +
|
||||||
|
"])",
|
||||||
|
);
|
||||||
|
await until("the transaction approval window", async () =>
|
||||||
|
(await approvalPages()).length === 1 ? true : null,
|
||||||
|
);
|
||||||
|
await dapp.evaluate(
|
||||||
|
"window.__dapp.start('sign', 'personal_sign', [" +
|
||||||
|
JSON.stringify(SIGN_HEX) +
|
||||||
|
", " +
|
||||||
|
JSON.stringify(address) +
|
||||||
|
"])",
|
||||||
|
);
|
||||||
|
const windows = await until("both approval windows", async () => {
|
||||||
|
const pages = await approvalPages();
|
||||||
|
return pages.length === 2 ? pages : null;
|
||||||
|
});
|
||||||
|
let txWindow = null;
|
||||||
|
let signWindow = null;
|
||||||
|
const windowIds = [];
|
||||||
|
for (const page of windows) {
|
||||||
|
const evaluate = await attach(send, page.targetId);
|
||||||
|
const view = await until("an approval screen", () =>
|
||||||
|
evaluate(
|
||||||
|
"(" +
|
||||||
|
shows("view-approve-tx") +
|
||||||
|
") ? 'tx' : (" +
|
||||||
|
shows("view-approve-sign") +
|
||||||
|
") ? 'sign' : null",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (view === "tx") txWindow = evaluate;
|
||||||
|
if (view === "sign") signWindow = evaluate;
|
||||||
|
const { windowId } = await send("Browser.getWindowForTarget", {
|
||||||
|
targetId: page.targetId,
|
||||||
|
});
|
||||||
|
windowIds.push(windowId);
|
||||||
|
}
|
||||||
|
if (!txWindow || !signWindow) {
|
||||||
|
throw new Error("the two windows are not one of each kind");
|
||||||
|
}
|
||||||
|
const setWindowState = (windowState) =>
|
||||||
|
Promise.all(
|
||||||
|
windowIds.map((windowId) =>
|
||||||
|
send("Browser.setWindowBounds", {
|
||||||
|
windowId,
|
||||||
|
bounds: { windowState },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The user minimizes both windows while deciding.
|
||||||
|
await setWindowState("minimized");
|
||||||
|
const bothHidden = await until(
|
||||||
|
"both approval pages hidden",
|
||||||
|
async () =>
|
||||||
|
(await txWindow("document.visibilityState")) === "hidden" &&
|
||||||
|
(await signWindow("document.visibilityState")) === "hidden",
|
||||||
|
).catch(() => false);
|
||||||
|
check(
|
||||||
|
"both approval windows are minimized, and their pages are hidden",
|
||||||
|
bothHidden,
|
||||||
|
"a page still reports itself visible, so the wait below would " +
|
||||||
|
"not show what happens to a minimized approval",
|
||||||
|
);
|
||||||
|
|
||||||
|
// The user takes seven minutes. The worker is checked every two
|
||||||
|
// seconds, and the wait ends early if Chrome stops it.
|
||||||
|
const opened = Date.now();
|
||||||
|
let stoppedAfter = null;
|
||||||
|
while (Date.now() - opened < APPROVAL_WAIT_MS) {
|
||||||
|
if (!(await workerRunning())) {
|
||||||
|
stoppedAfter = Math.round((Date.now() - opened) / 1000);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await sleep(2000);
|
||||||
|
}
|
||||||
|
check(
|
||||||
|
"the background worker keeps running while both approvals wait in minimized windows",
|
||||||
|
stoppedAfter === null,
|
||||||
|
"Chrome stopped the worker " +
|
||||||
|
stoppedAfter +
|
||||||
|
"s after the approvals opened",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Back on screen to be answered.
|
||||||
|
await setWindowState("normal");
|
||||||
|
await txWindow(typeInto("approve-tx-password", PASSWORD));
|
||||||
|
await txWindow(click("btn-approve-tx"));
|
||||||
|
await signWindow(typeInto("approve-sign-password", PASSWORD));
|
||||||
|
// The window closes itself once the signature is handed over.
|
||||||
|
await signWindow(click("btn-approve-sign")).catch(() => {});
|
||||||
|
|
||||||
|
const tx = await dapp.evaluate(settled("tx"));
|
||||||
|
const broadcast = node.broadcast.map((raw) => Transaction.from(raw));
|
||||||
|
check(
|
||||||
|
"the transaction approved after seven minutes reaches the site",
|
||||||
|
tx.settled === "resolved" &&
|
||||||
|
broadcast.length === 1 &&
|
||||||
|
tx.result === broadcast[0].hash &&
|
||||||
|
getAddress(broadcast[0].from) === address,
|
||||||
|
"the page got " +
|
||||||
|
JSON.stringify(tx) +
|
||||||
|
" and the node received " +
|
||||||
|
broadcast.length +
|
||||||
|
" transaction(s)",
|
||||||
|
);
|
||||||
|
|
||||||
|
const sign = await dapp.evaluate(settled("sign"));
|
||||||
|
check(
|
||||||
|
"the signature approved after seven minutes reaches the site",
|
||||||
|
sign.settled === "resolved" &&
|
||||||
|
getAddress(verifyMessage(getBytes(SIGN_HEX), sign.result)) ===
|
||||||
|
address,
|
||||||
|
"the page got " + JSON.stringify(sign),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
failed += 1;
|
||||||
|
console.log("# ERROR: " + (e && e.stack ? e.stack : e));
|
||||||
|
} finally {
|
||||||
|
if (browser.exitCode === null && browser.signalCode === null) {
|
||||||
|
const exited = new Promise((resolve) =>
|
||||||
|
browser.once("exit", resolve),
|
||||||
|
);
|
||||||
|
browser.kill();
|
||||||
|
await exited;
|
||||||
|
}
|
||||||
|
await node.close();
|
||||||
|
// Chromium's helper processes can still be writing to the profile
|
||||||
|
// for a moment after the browser process itself has exited.
|
||||||
|
fs.rmSync(profile, { recursive: true, force: true, maxRetries: 5 });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("1.." + checks.length);
|
||||||
|
if (checks.length === 0) {
|
||||||
|
console.log("# FAILED: this program asserted nothing");
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
"# " +
|
||||||
|
(checks.length - failed) +
|
||||||
|
"/" +
|
||||||
|
checks.length +
|
||||||
|
" checks passed",
|
||||||
|
);
|
||||||
|
if (failed > 0) {
|
||||||
|
console.log("# FAILED");
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error("slowApproval: " + (e && e.stack ? e.stack : e));
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user