Compare commits

..

3 Commits

Author SHA1 Message Date
dbba3b0b97 build: add ESLint to script/lint and containerize linting (closes #152)
All checks were successful
check / check (push) Successful in 54s
script/lint ran `prettier --check .`, byte for byte what script/fmt-check
runs, so make check checked formatting twice and did no static analysis on
a cryptocurrency wallet. Two used-but-not-imported crashes shipped past it.

ESLint is pinned in package.json with @eslint/js recommended as the base and
a flat config in eslint.config.js. no-undef and no-unused-vars are restated
error-level so a future recommended-set change cannot downgrade them.
Globals are declared per tree rather than globally, because a too-wide set
hides the next unimported identifier: browser for the popup and content
scripts, service worker for src/background/ and src/shared/, browser for the
one documented POPUP ONLY module in src/shared/, jest for tests/, node for
build.js, and both for the e2e harnesses, which carry the callbacks they
ship into the page inline.

Two rules new to the recommended set are narrowed, and both would have cost
something to satisfy. no-useless-assignment is off for approval.js and
confirmTx.js only: it flags the `password = null` and `decryptedSecret =
null` wipes at 9 sites there, which are dead by construction — that is what
a best-effort wipe of decrypted key material is — and the rule's fix is to
delete the wipe. It stays on for the rest of the tree, so an ordinary dead
store elsewhere is still an error. preserve-caught-error is off tree-wide:
it would change what the wallet's error paths throw at 3 sites
(src/shared/balances.js 207 and 215, tests/e2e/firefox/run.js 131), and
adopting `{ cause }` is a decision of its own rather than a side effect of
turning a linter on, so new code is not held to it either pending that
decision.

Every remaining violation is fixed: 41 unused bindings and 53 undefined
identifiers. Unused catch bindings became `catch {`, which the repo already
used; the shared init(ctx) view signature keeps its parameter as _ctx in the
three views that do not read it. src/shared/uniswap.js keeps its unused
V2_SWAP_EXACT_OUT decoder behind a scoped disable, because deleting it would
widen the gap it represents rather than close it (#283). driver.js's waitFor
had a plain dead store in its `last` initializer, which the newly scoped
no-useless-assignment catches; the initializer is dropped.

Linting is containerized. script/lint builds the Dockerfile's new lint stage
so the ESLint deciding whether this repo is green is the pinned one and not
whatever the host has; AUTISTMASK_LINT_NATIVE, set only in that image, is
what makes make check inside the CI build lint in place instead of recursing
into docker, and a value set to anything else is now an error rather than a
silent fall-through to the docker path. The check stage takes a COPY --from=
lint dependency so a lint failure fails the whole build early rather than
racing it.

The lint stage roughly doubles the image build, which exposed script/test's
30s cap as marginal rather than a bound: on the first CI run to rebuild the
base stage cold it killed a healthy suite at 30.6s with nothing asserting
false. The cap is a guard against a hung suite, not a wall-clock budget, and
one a healthy suite can trip teaches "just run it again". It stays at 30s on
a host, where the suite runs in about 8s and REPO_POLICIES' figure holds,
and the Dockerfile raises it to 180s through AUTISTMASK_TEST_TIMEOUT for the
in-image run, which also pays a cold jest cache and shares the runner with
the rest of the build. script/test now names a timeout kill as one instead
of reporting it as a test failure, and skips the verbose rerun in that case,
which would only spend the same wall clock to be killed again.

No --fix anywhere in the lint path: make check remains non-mutating.

The README claim that a used-but-not-imported identifier is invisible to
make check, and the same claim in script/test-e2e, are no longer true and
are corrected.
2026-08-17 06:13:50 +00:00
d9d50f05d2 test: assert the #150 and #151 items the harness did not cover (closes #188)
All checks were successful
check / check (push) Successful in 27s
Covers the four DoD items #188 scoped: the AddToken quick-pick populating the address field, Back out of AddToken unwinding the persisted navigation stack exactly once, a native ETH transfer rendering in TransactionDetail with no token contract row, and tap-to-copy reading the real clipboard back.

The native path needed a fixture: the Blockscout normal-transactions endpoint answered [] unconditionally, so there was no non-ERC-20 row to open.

Each assertion demonstrated to discriminate by mutation, one break at a time with the pre-existing tests staying green: a double viewStack push, a no-op quick-pick handler, an un-hidden token contract row, the ERC-20 branch forced onto a native transaction, and a dropped clipboard write each turn exactly the corresponding test red and no other.

Four further DoD items from #150 and #151 remain outside this scope and are tracked in #295.
2026-08-17 08:10:37 +02:00
51e84aefa6 docs: drop the README limit that #274 fixed (closes #285)
All checks were successful
check / check (push) Successful in 26s
The EIP-1193 error code now survives the last hop into the page, so the third standing limit in the End-to-End Tests section no longer describes the code. Verified against src/content/inpage.js (ProviderRpcError carries code and data) and tests/e2e/run.js (the page-side error is asserted, not only the wire).
2026-08-17 08:03:02 +02:00
9 changed files with 429 additions and 40 deletions

View File

@@ -8,6 +8,14 @@ WORKDIR /app
# image sets it.
ENV AUTISTMASK_LINT_NATIVE=1
# script/test's default 30s bound is the host figure, against a suite that
# runs in about 8s there. In here the same suite starts on a cold jest cache
# and shares the runner with the rest of the build, so 30s is marginal rather
# than a bound — it killed a healthy suite at 30.6s on a cold CI cache. 180s
# still catches a hang in three minutes and cannot be tripped by a suite that
# is merely running on contended hardware.
ENV AUTISTMASK_TEST_TIMEOUT=180
# script/bootstrap installs all prerequisites (make via apt here; node
# is already in the base image, yarn comes via corepack) and runs
# yarn install --frozen-lockfile. Dependency manifests are copied first

View File

@@ -191,17 +191,14 @@ to the background — with the message that would carry it required to be presen
so that check cannot pass by observing nothing. That last one is the standing
floor under [#157](https://git.eeqj.de/sneak/AutistMask/issues/157).
Three limits of that coverage, none of them papered over. The RPC is stubbed
Two limits of that coverage, neither of them papered over. The RPC is stubbed
throughout, so this is **not** a real dApp against a real network with real
funds; that remains a human pass before 1.0.0. The site-connection prompt is
raised through `chrome.action.openPopup()`, and headless Chromium's
browser-action popup is not a page Playwright can see or click, so that one
prompt is driven at the URL the extension itself puts on the action — the same
page and the same approval id, but whether a real toolbar click shows it is not
observable here. And the EIP-1193 error code does not survive the last hop: the
rejection that crosses the boundary carries code 4001 and is asserted to, but
`src/content/inpage.js` rebuilds it as `new Error(message)`, so the calling page
catches an error with no `code` property.
observable here.
Any test that drives a failure path on purpose declares the `console.error` it
is about to provoke, via `errors.expect()`. That is not a mute: the declaration

31
TODO.md
View File

@@ -43,6 +43,31 @@ but the review is broader than any of them.
# Completed Steps
- 2026-08-17: `README.md` no longer advertises a defect the wallet does not
have. The End-to-End Tests section listed the EIP-1193 code being dropped in
the last hop into the page as a standing limit of the dApp coverage; that
stopped being true when
[#274](https://git.eeqj.de/sneak/AutistMask/issues/274) landed and did not
touch the README. The paragraph is deleted and the two remaining limits — the
stubbed RPC and the unobservable toolbar popup — were checked against the
current `src/content/inpage.js` and `tests/e2e/` and left as they are
([#285](https://git.eeqj.de/sneak/AutistMask/issues/285)).
- 2026-08-14: The parts of the
[#150](https://git.eeqj.de/sneak/AutistMask/issues/150) and
[#151](https://git.eeqj.de/sneak/AutistMask/issues/151) definition of done the
e2e suite did not cover are asserted. It had only shown that the two screens
open without throwing. Now: the Add Token round trip leaves the navigation
stack exactly as it found it, read out of extension storage rather than
inferred from which screen is up, so an orphaned entry — the second-order
damage of #150 — is caught where it happens rather than one Back press later;
a common-token quick-pick puts its contract address in the field; the native
ETH detail path renders with its own type, value and raw quantity and with the
token contract row still hidden, against a new `seedNativeTransfer` fixture,
since the normal-transactions endpoint answered `[]` unconditionally and there
was no non-ERC-20 row to open; and tapping the token contract address puts it
on the real clipboard, read back after a sentinel write. Each of the four was
demonstrated failing against a deliberately broken build
([#188](https://git.eeqj.de/sneak/AutistMask/issues/188)).
- 2026-08-14: `make check` does static analysis. `script/lint` ran
`prettier --check .`, byte-identical to `script/fmt-check`, so a wallet with
two shipped used-but-not-imported crashes behind it was green. ESLint is now
@@ -54,7 +79,11 @@ but the review is broader than any of them.
fixed, and dropping a call to an unimported `foo()` into any `src/` file fails
`make lint`. Linting is also containerized now: `script/lint` builds the
Dockerfile's new `lint` stage, so the ESLint that decides whether this repo is
green is the pinned one and not the host's
green is the pinned one and not the host's. The lint stage roughly doubles the
image build, so `script/test`'s hard timeout is now a bound on a hung suite
rather than a wall-clock budget: 30s on the host, where the suite runs in
about 8s, and `AUTISTMASK_TEST_TIMEOUT` raises it inside the image, where a
cold build pays install and contention costs the policy budget never described
([#152](https://git.eeqj.de/sneak/AutistMask/issues/152)).
- 2026-08-12: EIP-1193 error codes now reach the page. `src/content/inpage.js`
rebuilt every failure as `new Error(error.message)`, so the code the

View File

@@ -23,12 +23,7 @@ const commonjs = {
module.exports = [
{
ignores: [
"dist/",
"node_modules/",
// Emitted by build.js, not authored here.
"src/popup/styles/",
],
ignores: ["dist/", "node_modules/"],
},
js.configs.recommended,
@@ -46,20 +41,33 @@ module.exports = [
// already does, so caught errors stay checked.
"no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
// Off: it flags `password = null` and `decryptedSecret = null` in
// approval.js and confirmTx.js, which are the best-effort wipes of
// decrypted key material after use. The assignments are dead by
// construction — that is the point of them — and satisfying the
// rule would mean deleting the wipes.
"no-useless-assignment": "off",
// Off: it requires every rethrow to carry `{ cause }`. That is a
// change to what the wallet's error paths actually throw, which is
// not this config's business; adopting it is its own decision.
// Off tree-wide: it requires every rethrow to carry `{ cause }`,
// at 3 sites today (src/shared/balances.js 207 and 215,
// tests/e2e/firefox/run.js 131). That is a change to what the
// wallet's error paths actually throw, and it is a decision of its
// own rather than a side effect of turning a linter on — so it is
// off everywhere, including for new code, until that decision is
// made. Unlike no-useless-assignment below, this is not an
// accommodation of particular sites and must not be scoped to
// them.
"preserve-caught-error": "off",
},
},
// no-useless-assignment stays on everywhere except the two files that
// wipe decrypted key material: the `password = null` and
// `decryptedSecret = null` assignments after use are dead by construction
// — that is what a best-effort wipe is — and the rule's fix is to delete
// the wipe. 9 sites: approval.js 582, 593, 618, 648, 692, 703, 728, 764
// and confirmTx.js 459. Everything else in the tree is still checked, so
// an ordinary dead store elsewhere is still an error.
{
files: ["src/popup/views/approval.js", "src/popup/views/confirmTx.js"],
rules: {
"no-useless-assignment": "off",
},
},
// Popup and content scripts: page/window context.
{
files: ["src/popup/**/*.js", "src/content/**/*.js"],

View File

@@ -16,11 +16,21 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
if [ "${AUTISTMASK_LINT_NATIVE:-}" = "1" ]; then
case "${AUTISTMASK_LINT_NATIVE:-}" in
1)
echo "Linting..."
yarn run lint 2>&1
return 0
fi
;;
"") ;;
*)
# Set but not recognized: say so rather than silently taking the
# docker path, which would look like the variable had no effect.
echo "lint: AUTISTMASK_LINT_NATIVE is set to" \
"'${AUTISTMASK_LINT_NATIVE}'; the only recognized value is 1" >&2
exit 1
;;
esac
if ! command -v docker >/dev/null 2>&1; then
echo "lint: docker is required; linting does not run on the host" >&2

View File

@@ -1,19 +1,40 @@
#!/bin/sh
# script/test: run the test suite.
#
# The timeout bounds a hung suite; it is not a performance budget. On a
# developer host the suite finishes in about 8s and REPO_POLICIES' 30s cap is
# the bound. Inside the image the same suite also pays a cold jest cache and
# shares the runner with the rest of the build, which is not what that budget
# describes, so the Dockerfile raises the bound through
# AUTISTMASK_TEST_TIMEOUT. A cap a healthy suite can trip on a cold cache
# produces a red that means nothing, and teaches "just run it again".
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
TIMEOUT="${AUTISTMASK_TEST_TIMEOUT:-30}"
main() {
cd "$ROOT"
echo "Running tests..."
timeout 30 yarn run test 2>&1 || {
echo "Running tests (timeout ${TIMEOUT}s)..."
status=0
timeout "$TIMEOUT" yarn run test 2>&1 || status=$?
[ "$status" -eq 0 ] && return 0
# 124 is timeout(1) killing the suite. Say so: a kill is not a failed
# assertion, and the verbose rerun would only spend the same wall clock
# to be killed again.
if [ "$status" -eq 124 ]; then
echo "tests: TIMED OUT after ${TIMEOUT}s (no assertion failed)" >&2
echo "tests: raise AUTISTMASK_TEST_TIMEOUT if the suite is healthy" >&2
exit 1
fi
echo "--- Rerunning with --verbose for details ---"
timeout 30 yarn run test:verbose 2>&1 || true
timeout "$TIMEOUT" yarn run test:verbose 2>&1 || true
# Always fail: the first run already proved the tests are broken, so a
# flaky pass on the rerun must not turn the build green.
exit 1
}
}
main "$@"

View File

@@ -199,7 +199,9 @@ class Driver {
// condition it was waiting on rather than "timed out".
async waitFor(what, script, args = [], timeout = DEFAULT_WAIT_MS) {
const deadline = Date.now() + timeout;
let last = null;
// Assigned on every path through the loop body before it is read, so
// there is no initializer to give it.
let last;
for (;;) {
try {
const v = await this.execute(script, args);

View File

@@ -46,9 +46,24 @@ const STUB_TX_HASH =
const STUB_BLOCK_NUMBER = 21000000;
// The native ETH transfer, seeded by opts.seedNativeTransfer. Its own hash
// and an older block, so it is a second row rather than a leg of the token
// transfer: mergeTransactions() consolidates a native entry and a token
// transfer that share a hash into one row, which would leave nothing native
// to open. 0.25 ETH clears the 100000 gwei dust threshold the default
// filters apply, so the row is not silently dropped.
const STUB_NATIVE_TX_HASH =
"0xe7e0000000000000000000000000000000000000000000000000000000000e7e";
const STUB_NATIVE_BLOCK_NUMBER = STUB_BLOCK_NUMBER - 1;
const STUB_NATIVE_VALUE_WEI = "250000000000000000";
// Fixed instant so timeAgo() output is stable across runs.
const STUB_TX_TIMESTAMP = "2026-01-02T03:04:05.000000Z";
const STUB_NATIVE_TX_TIMESTAMP = "2026-01-02T02:03:04.000000Z";
// A 32-byte zero word. Returned for every eth_call, which is what makes
// ethers' ENS reverse lookup resolve to "no resolver set" and return null
// instead of throwing. A throw would be logged by src/shared/ens.js via
@@ -258,6 +273,25 @@ function tokenTransferItems(address) {
];
}
// One received native ETH transfer, in the shape src/shared/transactions.js
// parses. to.is_contract is false and there is no method, so parseTx() keeps
// it a plain transfer rather than a contract call — which is what makes the
// detail screen classify it "Native ETH Transfer" and leave the token
// contract row hidden.
function nativeTransactionItems(address) {
return [
{
hash: STUB_NATIVE_TX_HASH,
block_number: STUB_NATIVE_BLOCK_NUMBER,
timestamp: STUB_NATIVE_TX_TIMESTAMP,
from: { hash: STUB_COUNTERPARTY },
to: { hash: address, is_contract: false },
value: STUB_NATIVE_VALUE_WEI,
status: "ok",
},
];
}
// A holding of 1.5 E2E, in the shape src/shared/balances.js parses. Serving
// this is what puts an ERC-20 in the send screen's token dropdown, which is
// the only way the confirmation screen's ERC-20 path can be reached.
@@ -270,12 +304,17 @@ function tokenBalanceItems() {
];
}
// Full details for STUB_TX_HASH. raw_input is "0x" so the calldata
// decoder short-circuits; the on-chain detail fields still populate.
function transactionDetails() {
// Full details for either seeded transaction — the detail screen fetches
// them for whichever row was opened, and an unstubbed hash would be
// reported as escaping traffic. raw_input is "0x" so the calldata decoder
// short-circuits; the on-chain detail fields still populate.
function transactionDetails(hash) {
return {
hash: STUB_TX_HASH,
block_number: STUB_BLOCK_NUMBER,
hash: hash,
block_number:
hash === STUB_NATIVE_TX_HASH
? STUB_NATIVE_BLOCK_NUMBER
: STUB_BLOCK_NUMBER,
nonce: 7,
gas_used: "51000",
gas_price: "1000000000",
@@ -479,6 +518,10 @@ function traceEnabled(raw) {
* @param {boolean} [opts.seedTokenTransfer] serve the stubbed ERC-20
* transfer. Read at request time, so a test can flip it on the same
* options object without re-registering the route.
* @param {boolean} [opts.seedNativeTransfer] serve the stubbed native ETH
* transfer, read at request time like seedTokenTransfer. Without it the
* normal-transactions endpoint answers with an empty list, so there is no
* non-ERC-20 row to open.
* @param {boolean} [opts.seedTokenBalance] serve the stubbed ERC-20
* holding, which is what makes the token reachable from the send screen.
* @param {string} [opts.ethBalanceWei] hex wei answered to eth_getBalance;
@@ -550,7 +593,13 @@ async function installNetworkStubs(ctx, opts) {
// Blockscout v2
if (p.includes("/api/v2/")) {
if (/\/addresses\/0x[0-9a-fA-F]{40}\/transactions$/.test(p)) {
return jsonResponse(route, { items: [] });
const addr = blockscoutAddress(p);
return jsonResponse(route, {
items:
opts.seedNativeTransfer && addr
? nativeTransactionItems(addr)
: [],
});
}
if (/\/addresses\/0x[0-9a-fA-F]{40}\/token-transfers$/.test(p)) {
const addr = blockscoutAddress(p);
@@ -567,8 +616,10 @@ async function installNetworkStubs(ctx, opts) {
opts.seedTokenBalance ? tokenBalanceItems() : [],
);
}
if (p.endsWith("/transactions/" + STUB_TX_HASH)) {
return jsonResponse(route, transactionDetails());
for (const hash of [STUB_TX_HASH, STUB_NATIVE_TX_HASH]) {
if (p.endsWith("/transactions/" + hash)) {
return jsonResponse(route, transactionDetails(hash));
}
}
}
@@ -640,6 +691,8 @@ module.exports = {
FEE_ESTIMATE_WEI,
FEE_RESERVE_WEI,
STUB_COUNTERPARTY,
STUB_NATIVE_TX_HASH,
STUB_NATIVE_VALUE_WEI,
STUB_TOKEN,
STUB_TX_HASH,
};

View File

@@ -36,6 +36,8 @@ const {
FEE_ESTIMATE_WEI,
FEE_RESERVE_WEI,
STUB_COUNTERPARTY,
STUB_NATIVE_TX_HASH,
STUB_NATIVE_VALUE_WEI,
STUB_TOKEN,
STUB_TX_HASH,
} = require("./network");
@@ -173,6 +175,264 @@ test("transaction detail renders an ERC-20 transfer (#151)", async (env) => {
assert(dots > 0, "token contract row rendered without its colour dot");
});
// --------------------- the rest of the #150 and #151 definition of done
//
// The two tests above assert that the screens #150 and #151 broke now open
// without throwing, which is narrower than what those issues asked for.
// The four items below are the remainder (#188): the navigation stack out
// of Add Token, the quick-pick actually populating the field, the native
// ETH detail path the ERC-20 fix could have regressed, and tap-to-copy.
// Leave the transaction detail screen for the address screen it was opened
// from. The two tests above finish on it, and so does the last test here.
async function leaveTransactionDetail(page) {
if (await page.isVisible("#view-transaction")) {
await page.click("#btn-tx-back");
}
await openAddressDetail(page);
}
// Back out to Home from wherever the previous test finished.
async function goHome(page) {
await leaveTransactionDetail(page);
await page.click("#btn-address-back");
await visible(page, "#view-main");
}
// The navigation stack as it was actually persisted, read out of extension
// storage rather than inferred from which screen is showing. A stale entry
// left behind by a forward navigation that threw is invisible on screen
// until the user presses Back one time too many — which is exactly the
// second-order damage #150 did — so the stack itself is what gets asserted.
function persistedViewStack(page) {
return page.evaluate(
() =>
new Promise((resolve) => {
chrome.storage.local.get("autistmask", (r) => {
resolve((r.autistmask && r.autistmask.viewStack) || []);
});
}),
);
}
// saveState() is fired from showView() without being awaited, so the write
// lands shortly after the screen does. Polling for the expected stack keeps
// that race out of the assertion; a stack that never becomes the expected
// one fails with what it actually was.
const VIEW_STACK_SETTLE_MS = 5000;
async function waitForViewStack(page, expected, where) {
const want = JSON.stringify(expected);
const deadline = Date.now() + VIEW_STACK_SETTLE_MS;
let seen;
for (;;) {
seen = await persistedViewStack(page);
if (JSON.stringify(seen) === want) return;
if (Date.now() >= deadline) break;
await sleep(50);
}
throw new Error(
"navigation stack " +
where +
" is " +
JSON.stringify(seen) +
", expected " +
want,
);
}
// The invariant is stated as a delta against whatever the earlier tests
// left on the stack, not as an absolute: a round trip into Add Token and
// back out must leave the stack exactly as it found it. That is what "no
// duplicated or orphaned stack entry" means, and it holds whatever the
// starting depth is.
test("Back from Add Token unwinds the stack exactly once (#150)", async (env) => {
await goHome(env.page);
const base = await persistedViewStack(env.page);
await env.page.locator("#wallet-list .btn-addr-info").first().click();
await visible(env.page, "#view-address");
await waitForViewStack(env.page, base.concat("main"), "on address detail");
await env.page.click("#btn-add-token");
await visible(env.page, "#view-add-token");
await waitForViewStack(
env.page,
base.concat("main", "address"),
"on the add token screen",
);
await env.page.click("#btn-add-token-back");
await visible(env.page, "#view-address");
assert(
!(await env.page.isVisible("#view-add-token")),
"the add token screen is still showing after Back",
);
await waitForViewStack(
env.page,
base.concat("main"),
"after Back from add token",
);
await env.page.click("#btn-address-back");
await visible(env.page, "#view-main");
await waitForViewStack(env.page, base, "after a second Back");
});
test("a common-token quick-pick fills in the contract address (#150)", async (env) => {
await openAddressDetail(env.page);
await env.page.click("#btn-add-token");
await visible(env.page, "#view-add-token");
const before = await env.page.inputValue("#add-token-address");
assert(
before === "",
"the add token screen opened with the address field already filled: " +
JSON.stringify(before),
);
const pick = env.page.locator("#common-token-list .common-token").first();
const wanted = await pick.getAttribute("data-address");
assert(
/^0x[0-9a-fA-F]{40}$/.test(wanted || ""),
"the first quick-pick button carries no contract address: " +
JSON.stringify(wanted),
);
await pick.click();
const after = await env.page.inputValue("#add-token-address");
assert(
after === wanted,
"clicking the " +
(await pick.innerText()).trim() +
" quick-pick left the address field as " +
JSON.stringify(after) +
", expected " +
JSON.stringify(wanted),
);
await env.page.click("#btn-add-token-back");
await visible(env.page, "#view-address");
});
// The native amount as the transaction list writes it (four decimals) and
// as the detail screen writes it (full precision). Both are rendered here
// from the fixture rather than read off the screen, so the assertions
// compare against the wei the stub served.
const NATIVE_ROW_TEXT =
parseFloat(formatEther(STUB_NATIVE_VALUE_WEI)).toFixed(4) + " ETH";
const NATIVE_DETAIL_TEXT = formatEther(STUB_NATIVE_VALUE_WEI) + " ETH";
test("the native ETH transaction detail still renders (#151)", async (env) => {
// The ERC-20 fix could only have regressed this path by making the
// token-contract branch run for a transfer that has no contract, so
// the assertions below are as much about that row staying hidden as
// about the screen coming up.
env.routeOpts.seedNativeTransfer = true;
await env.page.reload();
await openAddressDetail(env.page);
const row = env.page
.locator("#tx-list .tx-row")
.filter({ hasText: NATIVE_ROW_TEXT });
await row.waitFor({ state: "visible", timeout: 30000 });
await row.click();
await visible(env.page, "#view-transaction");
const hash = await env.page.locator("#tx-detail-hash").innerText();
assert(
hash.includes(STUB_NATIVE_TX_HASH),
"the native transaction detail shows the wrong hash: " + hash,
);
const type = (await env.page.locator("#tx-detail-type").innerText()).trim();
assert(
type === "Native ETH Transfer",
"the native transaction was classified " + JSON.stringify(type),
);
const value = await env.page.locator("#tx-detail-value").innerText();
assert(
value.includes(NATIVE_DETAIL_TEXT),
"the native transaction detail shows " +
JSON.stringify(value) +
", expected it to contain " +
NATIVE_DETAIL_TEXT,
);
const native = await env.page.locator("#tx-detail-native").innerText();
assert(
native.includes(STUB_NATIVE_VALUE_WEI + " wei"),
"the raw quantity row shows " +
JSON.stringify(native) +
", expected the value in wei",
);
assert(
!(await env.page.isVisible("#tx-detail-token-contract-section")),
"the token contract row is showing on a transfer that has no token " +
"contract",
);
// Back to one seeded transaction for everything after this: the tests
// below were written against a list holding the token transfer alone.
env.routeOpts.seedNativeTransfer = false;
});
test("tap-to-copy on the transaction detail screen copies the address (#151)", async (env) => {
// Read the clipboard back rather than watching the handler run: what
// #151 asks for is the address reaching the clipboard, and a spy on
// navigator.clipboard would assert the call and not the effect.
//
// Granted context-wide rather than for the popup's origin: an
// origin-scoped grant is refused for chrome-extension: URLs, which
// both Playwright and Chrome treat as opaque here.
await env.ctx.grantPermissions(["clipboard-read", "clipboard-write"]);
await leaveTransactionDetail(env.page);
const row = env.page
.locator("#tx-list .tx-row")
.filter({ hasText: STUB_TOKEN.symbol });
await row.waitFor({ state: "visible", timeout: 30000 });
await row.click();
await visible(env.page, "#view-transaction");
await visible(env.page, "#tx-detail-token-contract-section");
// Seed a sentinel first, so a clipboard that nothing writes to cannot
// pass on whatever was left in it.
const SENTINEL = "e2e-clipboard-untouched";
await env.page.evaluate((s) => navigator.clipboard.writeText(s), SENTINEL);
const seeded = await env.page.evaluate(() =>
navigator.clipboard.readText(),
);
assert(
seeded === SENTINEL,
"the harness could not seed the clipboard, so the assertion below " +
"would prove nothing; it read back " +
JSON.stringify(seeded),
);
await env.page.locator("#tx-detail-token-contract [data-copy]").click();
const copied = await env.page.evaluate(() =>
navigator.clipboard.readText(),
);
assert(
copied.toLowerCase() === STUB_TOKEN.address,
"tapping the token contract address put " +
JSON.stringify(copied) +
" on the clipboard, expected " +
STUB_TOKEN.address,
);
const flash = await env.page.locator("#flash-msg").innerText();
assert(
flash.trim() === "Copied!",
"the copy gave no confirmation, flash line reads " +
JSON.stringify(flash),
);
});
// -------------------------------------------- recovery phrase (#161)
// The gear toggles, so pressing it while Settings is already up leaves it.
@@ -2375,6 +2635,7 @@ async function main() {
// starting state of a run is readable without hunting through tests.
const routeOpts = {
seedTokenTransfer: false,
seedNativeTransfer: false,
seedTokenBalance: false,
ethBalanceWei: null,
failGasEstimate: false,