test: assert the #150 and #151 items the harness did not cover (closes #188)
All checks were successful
check / check (push) Successful in 33s
All checks were successful
check / check (push) Successful in 33s
The suite asserted that the two screens those issues broke now open without throwing, which is narrower than their definition of done. The four remaining items are asserted here, additively; nothing existing was restructured. Back navigation out of Add Token is checked against the persisted navigation stack, read from extension storage, as a delta: the round trip Home -> AddressDetail -> AddToken -> Back -> Back must leave the stack exactly as it found it. A stale entry is invisible on screen until the user presses Back one time too many, which is precisely the second-order damage of #150, so the stack rather than the visible view is what gets asserted. Stating it as a delta keeps it independent of whatever depth earlier tests leave behind. The quick-pick test clicks a button and requires the address field to hold that button's contract address; the old assertion only counted the buttons rendered. The native ETH detail path needed a fixture: the normal-transactions endpoint answered with an empty list unconditionally, so there was no non-ERC-20 row to open at all. seedNativeTransfer serves one, and the detail screen must show the native type, the value, the raw wei quantity and no token contract row - the row whose branch is where a regression of the non-ERC-20 case would land. Tap-to-copy reads the real clipboard back rather than watching the handler run, after seeding a sentinel so an untouched clipboard cannot pass. Clipboard permissions are granted context-wide because an origin-scoped grant is refused for chrome-extension: URLs. Each of the four was demonstrated failing against a deliberately broken build; the captured output is in the pull request.
This commit is contained in:
16
TODO.md
16
TODO.md
@@ -45,6 +45,22 @@ undefined identifiers, which is how
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 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-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
|
||||
background produced and the content script relayed intact was dropped in the
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
261
tests/e2e/run.js
261
tests/e2e/run.js
@@ -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");
|
||||
@@ -169,6 +171,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.
|
||||
@@ -2371,6 +2631,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,
|
||||
|
||||
Reference in New Issue
Block a user