Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28f8a43f2f | ||
|
|
6a86b726d2 | ||
|
|
18bdafd130 |
@@ -22,11 +22,10 @@ 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 on the measured
|
# block. Making e2e-chrome a required check is blocked while reports of the
|
||||||
# flake in the dApp signing wait -- two of six runs of unmutated code on a
|
# Chrome suite failing under load are still open; the "In CI" section of
|
||||||
# loaded machine -- tracked as
|
# README.md names them. A gate that fails at random teaches people to merge
|
||||||
# https://git.eeqj.de/sneak/AutistMask/issues/287. A gate that fails at
|
# past red.
|
||||||
# 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
|
||||||
|
|||||||
@@ -619,14 +619,13 @@ 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. The Chrome suite is
|
That is not only a statement about configuration. A report of the Chrome suite
|
||||||
**measurably flaky under load** — two of six runs of unmutated code on a busy
|
**failing under load** is still open:
|
||||||
machine lost the approval popup out from under the dApp signing wait, always in
|
[#290](https://git.eeqj.de/sneak/AutistMask/issues/290), runs on a busy machine
|
||||||
the `#183` section, tracked as
|
failing with `the extension opened no approval window within 30000ms`. So a red
|
||||||
[#287](https://git.eeqj.de/sneak/AutistMask/issues/287). So a red `e2e-chrome`
|
`e2e-chrome` has to be read before it is believed, and those failures are the
|
||||||
has to be read before it is believed, and that flake is the blocker to ever
|
blocker to ever making this a required check. Do not answer them with a retry
|
||||||
making this a required check. Do not answer it with a retry wrapper: a suite
|
wrapper: a suite that reruns until it is green stops being evidence.
|
||||||
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
|
||||||
|
|||||||
@@ -51,9 +51,36 @@ 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. A change made
|
waits until the view it expects to reopen on is the saved one. The restore
|
||||||
while an earlier save from the same page is still running is lost even without
|
half of the round trip and the second filter toggle change a setting right
|
||||||
a close; that is [#448](https://git.eeqj.de/sneak/AutistMask/issues/448).
|
after a reopen, while the reopened popup's own saves may still be running;
|
||||||
|
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
|
||||||
|
|||||||
@@ -413,7 +413,7 @@ function releaseApproval(approval) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open approval in a separate popup window.
|
// Open approval in a separate popup window, unless it is no longer pending.
|
||||||
// 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,6 +446,10 @@ 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);
|
||||||
|
|||||||
+12
-5
@@ -122,9 +122,9 @@ function currentNetwork() {
|
|||||||
return networkById(state.networkId);
|
return networkById(state.networkId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The persisted fields as they stood at the end of this page's last
|
// The persisted fields as this page held them when its last loadState()
|
||||||
// loadState() or saveState(). saveState() diffs the live state against this
|
// finished, or when its last successful saveState() began. saveState() diffs
|
||||||
// to find only the fields THIS page actually changed.
|
// the live state against this to find only the fields THIS page changed since.
|
||||||
//
|
//
|
||||||
// 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,7 +464,10 @@ 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() {
|
||||||
const current = snapshotPersisted();
|
// A copy, so what this save compares and writes is the page's state as it
|
||||||
|
// 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,
|
||||||
@@ -521,7 +524,11 @@ 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;
|
||||||
|
|
||||||
baseline = structuredClone(snapshotPersisted());
|
// What this save compared and wrote, not the page's state now: a change
|
||||||
|
// 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
|
||||||
|
|||||||
@@ -2235,6 +2235,27 @@ 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.
|
||||||
|
|||||||
+4
-2
@@ -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. That call site arms the click trace below and asserts it.
|
// accepts. Both call sites arm the click trace below and assert 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,7 +3198,9 @@ 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 popup.click("#btn-reject");
|
await armClickTrace(env, popup, "#btn-reject");
|
||||||
|
await clickAndClose(popup, "#btn-reject");
|
||||||
|
await assertClickLanded(env, "#btn-reject");
|
||||||
|
|
||||||
await assertUserRejection(
|
await assertUserRejection(
|
||||||
phishingDapp,
|
phishingDapp,
|
||||||
|
|||||||
@@ -423,3 +423,80 @@ 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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user