Compare commits

..
1 Commits
Author SHA1 Message Date
sneak 30b59325fa test: the e2e suite waits for each save before it closes the popup (closes #446)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s
The Settings round trip switched the theme and the network and closed
the popup at once. A close before the change handler's save lands loses
the switch, and the suite then ran on Sepolia.

tests/e2e/run.js now has one helper that polls a field of the stored
record until it holds the expected value, in place of the wait that
only read viewStack. Each Settings switch and spam-filter toggle waits
for its save, the recovery-phrase reopen waits for its saved view, and
reopenPopup() waits until the view it expects to reopen on is the saved
one.

Model: opus-5-5
2026-10-05 01:47:00 +00:00
8 changed files with 24 additions and 160 deletions
+5 -4
View File
@@ -22,10 +22,11 @@ on: [push]
# These jobs REPORT, they do not gate. Whether a check blocks a merge is # These jobs REPORT, they do not gate. Whether a check blocks a merge is
# Gitea branch protection, which this repo does not configure, so a failure # Gitea branch protection, which this repo does not configure, so a failure
# here is a red mark a reviewer has to account for rather than a hard # here is a red mark a reviewer has to account for rather than a hard
# block. Making e2e-chrome a required check is blocked while reports of the # block. Making e2e-chrome a required check is blocked on the measured
# Chrome suite failing under load are still open; the "In CI" section of # flake in the dApp signing wait -- two of six runs of unmutated code on a
# README.md names them. A gate that fails at random teaches people to merge # loaded machine -- tracked as
# past red. # https://git.eeqj.de/sneak/AutistMask/issues/287. A gate that fails at
# random teaches people to merge past red.
# #
# Nothing here may pass vacuously. There is no continue-on-error and no # Nothing here may pass vacuously. There is no continue-on-error and no
# `|| true`. Both scripts exit non-zero when docker is missing, when the # `|| true`. Both scripts exit non-zero when docker is missing, when the
+8 -7
View File
@@ -619,13 +619,14 @@ The jobs **report, they do not gate.** A failure is a red mark against the
commit that a reviewer has to account for, not a hard block: whether a check commit that a reviewer has to account for, not a hard block: whether a check
blocks a merge is Gitea branch protection, which this repo does not configure. blocks a merge is Gitea branch protection, which this repo does not configure.
That is not only a statement about configuration. A report of the Chrome suite That is not only a statement about configuration. The Chrome suite is
**failing under load** is still open: **measurably flaky under load** — two of six runs of unmutated code on a busy
[#290](https://git.eeqj.de/sneak/AutistMask/issues/290), runs on a busy machine machine lost the approval popup out from under the dApp signing wait, always in
failing with `the extension opened no approval window within 30000ms`. So a red the `#183` section, tracked as
`e2e-chrome` has to be read before it is believed, and those failures are the [#287](https://git.eeqj.de/sneak/AutistMask/issues/287). So a red `e2e-chrome`
blocker to ever making this a required check. Do not answer them with a retry has to be read before it is believed, and that flake is the blocker to ever
wrapper: a suite that reruns until it is green stops being evidence. making this a required check. Do not answer it with a retry wrapper: a suite
that reruns until it is green stops being evidence.
Nothing in either job can pass vacuously. There is no `continue-on-error` and no Nothing in either job can pass vacuously. There is no `continue-on-error` and no
`|| true`; both scripts exit non-zero when docker is missing, when the image `|| true`; both scripts exit non-zero when docker is missing, when the image
+3 -30
View File
@@ -51,36 +51,9 @@ but the review is broader than any of them.
close before the save lands loses the switch; with the network left on close before the save lands loses the switch; with the network left on
Sepolia, a dozen later tests failed too. Each Settings switch and spam-filter Sepolia, a dozen later tests failed too. Each Settings switch and spam-filter
toggle is now waited for in storage before the close, and `reopenPopup()` toggle is now waited for in storage before the close, and `reopenPopup()`
waits until the view it expects to reopen on is the saved one. The restore waits until the view it expects to reopen on is the saved one. A change made
half of the round trip and the second filter toggle change a setting right while an earlier save from the same page is still running is lost even without
after a reopen, while the reopened popup's own saves may still be running; a close; that is [#448](https://git.eeqj.de/sneak/AutistMask/issues/448).
they rely on the fix for
[#448](https://git.eeqj.de/sneak/AutistMask/issues/448).
- 2026-10-05: A change made while an earlier save from the same page is still
running is stored ([#448](https://git.eeqj.de/sneak/AutistMask/issues/448)).
`saveStateOnce()` took its baseline from the page's state after the write, so
a change made while the save waited on storage counted as already stored and
the save queued after it wrote nothing. A setting changed during the read was
lost; so was a wallet added, a site revoked or an endpoint changed during the
write, and a wallet deleted then stayed in storage. The save now copies the
page's fields when it starts, writes from that copy, and keeps the copy as the
baseline.
- 2026-10-05: The extension no longer opens a window for a site-connection
prompt already answered
([#287](https://git.eeqj.de/sneak/AutistMask/issues/287)). When the prompt was
decided before the toolbar popup raised for it had loaded, that popup was torn
down, `chrome.action.openPopup()` rejected, and the background opened its
fallback window for the answered approval and then removed it. In the Chrome
end-to-end suite the next test could take that window for its own prompt and
lose it under its wait. `openApprovalWindow()` now opens nothing for an
approval that is no longer pending. The blocklist test's Reject, whose window
closes itself, is clicked as the other site Reject is, with the click
witnessed. Making `e2e-chrome` a required check is still blocked: other
reports of the Chrome suite failing under load are open, among them
[#290](https://git.eeqj.de/sneak/AutistMask/issues/290) and
[#446](https://git.eeqj.de/sneak/AutistMask/issues/446), as `README.md` says.
- 2026-10-05: The lost-password delete confirmation refuses an empty field and - 2026-10-05: The lost-password delete confirmation refuses an empty field and
ignores characters that paint nothing ignores characters that paint nothing
+1 -5
View File
@@ -413,7 +413,7 @@ function releaseApproval(approval) {
} }
} }
// Open approval in a separate popup window, unless it is no longer pending. // Open approval in a separate popup window.
// This is the primary mechanism for tx/sign approvals (triggered programmatically, // This is the primary mechanism for tx/sign approvals (triggered programmatically,
// not from a user gesture) and the fallback for site-connection approvals. // not from a user gesture) and the fallback for site-connection approvals.
// Never rejects. Its callers raise it from inside a Promise executor and drop // Never rejects. Its callers raise it from inside a Promise executor and drop
@@ -446,10 +446,6 @@ async function openApprovalWindow(id) {
); );
} }
// Already answered: a site-connection prompt decided before the toolbar
// popup raised for it had loaded, whose openPopup() rejects only now.
if (!pendingApprovals[id]) return;
let win = null; let win = null;
try { try {
win = await windowsCreate(opts); win = await windowsCreate(opts);
+5 -12
View File
@@ -122,9 +122,9 @@ function currentNetwork() {
return networkById(state.networkId); return networkById(state.networkId);
} }
// The persisted fields as this page held them when its last loadState() // The persisted fields as they stood at the end of this page's last
// finished, or when its last successful saveState() began. saveState() diffs // loadState() or saveState(). saveState() diffs the live state against this
// the live state against this to find only the fields THIS page changed since. // to find only the fields THIS page actually changed.
// //
// Deep-cloned, not a reference: callers mutate persisted objects and arrays // Deep-cloned, not a reference: callers mutate persisted objects and arrays
// in place (state.wallets.push(...)), and a reference baseline would mutate // in place (state.wallets.push(...)), and a reference baseline would mutate
@@ -464,10 +464,7 @@ function mergeNetworkEndpoints(base, ours, theirs) {
// does not own goes on being whatever its last loadState() saw, same as // does not own goes on being whatever its last loadState() saw, same as
// before this fix; only the persisted record is guaranteed current. // before this fix; only the persisted record is guaranteed current.
async function saveStateOnce() { async function saveStateOnce() {
// A copy, so what this save compares and writes is the page's state as it const current = snapshotPersisted();
// stood when the save began. A change made while it waits on storage is
// left for the next save, which compares against this copy.
const current = structuredClone(snapshotPersisted());
const result = await storageGet("autistmask"); const result = await storageGet("autistmask");
// The record in storage right now is about to be merged into and written // The record in storage right now is about to be merged into and written
// back, so it is validated exactly like a load validates it. Without this, // back, so it is validated exactly like a load validates it. Without this,
@@ -524,11 +521,7 @@ async function saveStateOnce() {
// exactly as it stood; see the note above. // exactly as it stood; see the note above.
rawState.hasWallet = rawState.wallets.length > 0; rawState.hasWallet = rawState.wallets.length > 0;
// What this save compared and wrote, not the page's state now: a change baseline = structuredClone(snapshotPersisted());
// made during the save must still differ from the baseline, or the save
// queued after it finds nothing to store
// (https://git.eeqj.de/sneak/AutistMask/issues/448).
baseline = current;
} }
// showView() calls saveState() on every navigation without awaiting it, so // showView() calls saveState() on every navigation without awaiting it, so
-21
View File
@@ -2235,27 +2235,6 @@ describe("a site connection decided as the popup closes", () => {
}); });
}); });
// The prompt is decided before the toolbar popup raised for it has
// loaded; that popup is torn down and openPopup() rejects only after.
test("a toolbar prompt already decided opens no window when openPopup() rejects", async () => {
const bg = loadBackground({ actionPopup: true });
const opening = deferred();
bg.openPopup.mockImplementation(() => opening.promise);
const pending = bg.requestSite();
await settle();
const port = bg.connectApproval(pending.id());
port.decide(true, false);
port.disconnect();
await settle();
expect(pending.result()).toEqual({ result: [signer.address] });
opening.reject(new Error("the toolbar popup closed before it loaded"));
await settle();
expect(bg.created).toHaveLength(0);
});
// The port carries a decision now, so it carries the sender check the // The port carries a decision now, so it carries the sender check the
// one-off message used to carry. A content script that guessed an // one-off message used to carry. A content script that guessed an
// approval id must not be able to connect the site it is running on. // approval id must not be able to connect the site it is running on.
+2 -4
View File
@@ -2812,7 +2812,7 @@ async function closeApprovalPages(ctx) {
// #btn-reject on the site prompt — NOT self-proving. A page that went away // #btn-reject on the site prompt — NOT self-proving. A page that went away
// without the click landing disconnects the approval port, the background // without the click landing disconnects the approval port, the background
// settles that as 4001, and 4001 is exactly what assertUserRejection // settles that as 4001, and 4001 is exactly what assertUserRejection
// accepts. Both call sites arm the click trace below and assert it. // accepts. That call site arms the click trace below and asserts it.
// //
// A button that is missing or unclickable raises a different error, which is // A button that is missing or unclickable raises a different error, which is
// rethrown. // rethrown.
@@ -3198,9 +3198,7 @@ test("a connect request from a blocklisted site is flagged (#219)", async (env)
// Not remembered: a remembered decision for this origin would // Not remembered: a remembered decision for this origin would
// outlive the test. // outlive the test.
await popup.uncheck("#approve-remember"); await popup.uncheck("#approve-remember");
await armClickTrace(env, popup, "#btn-reject"); await popup.click("#btn-reject");
await clickAndClose(popup, "#btn-reject");
await assertClickLanded(env, "#btn-reject");
await assertUserRejection( await assertUserRejection(
phishingDapp, phishingDapp,
-77
View File
@@ -423,80 +423,3 @@ describe("two wallets independently created with a colliding identity", () => {
expect(secrets).toContain("secret-b"); expect(secrets).toContain("secret-b");
}); });
}); });
// showView() saves on every navigation without waiting, so the user can change
// something while that save is still waiting on storage. The change is followed
// by its own saveState(), which runs after the first save; it must be stored
// (https://git.eeqj.de/sneak/AutistMask/issues/448).
describe("a change made while an earlier save from the same page is running", () => {
// Runs `change` inside the next call to `op` (the stub's get or set),
// before that call does its work.
function runInside(op, change) {
const real = op.getMockImplementation();
op.mockImplementationOnce(async (arg) => {
change();
return real(arg);
});
}
test("a network switched during the earlier save's read is stored", async () => {
const storage = makeStorageStub({
autistmask: { wallets: [W1], networkId: "sepolia" },
});
const { state, saveState, loadState } = loadPage(storage).state;
await loadState();
let queued;
runInside(storage.get, () => {
state.networkId = "mainnet";
queued = saveState();
});
state.theme = "dark";
await saveState();
await queued;
const stored = storage.read("autistmask");
expect(stored.theme).toBe("dark");
expect(stored.networkId).toBe("mainnet");
});
test("a wallet added during the earlier save's read is stored", async () => {
const storage = makeStorageStub({ autistmask: { wallets: [W1] } });
const { state, saveState, loadState } = loadPage(storage).state;
await loadState();
let queued;
runInside(storage.get, () => {
state.wallets.push(W2);
queued = saveState();
});
await saveState();
await queued;
const stored = storage.read("autistmask");
expect(stored.wallets.map((w) => w.encryptedSecret)).toEqual([
"secret-one",
"secret-two",
]);
});
test("a wallet added during the earlier save's write is stored", async () => {
const storage = makeStorageStub({ autistmask: { wallets: [W1] } });
const { state, saveState, loadState } = loadPage(storage).state;
await loadState();
let queued;
runInside(storage.set, () => {
state.wallets.push(W2);
queued = saveState();
});
await saveState();
await queued;
const stored = storage.read("autistmask");
expect(stored.wallets.map((w) => w.encryptedSecret)).toEqual([
"secret-one",
"secret-two",
]);
});
});