fix: an open popup moves to the recovery screen when its profile becomes unreadable (closes #373)
check / check (push) Failing after 1s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s

A popup already open when the stored profile became unreadable stayed on the
last good profile until reopened. Every save already reads the stored record
and runs the same check loadState() runs at open; the popup now sends a save
refused by that check to the recovery screen and stops its ten-second refresh,
while any other failed save keeps the "NOT SAVED" banner and the screen it is
on. The recovery screen ignores a second request to show it, so a save that
was in flight does not clear an export or a typed confirmation. The test
harness records the refresh loop so a test can run one tick of it.

Model: opus-5-5
This commit is contained in:
2026-10-04 19:50:14 +00:00
parent 3b713809c8
commit bdbe999c05
7 changed files with 130 additions and 22 deletions
+49
View File
@@ -148,6 +148,55 @@ describe("the destructive reset on the recovery screen", () => {
});
});
describe("a popup already open when the stored profile becomes unreadable", () => {
// https://git.eeqj.de/sneak/AutistMask/issues/373. The popup used to stay
// on the wallet list with the last good balances, and only a reopen
// reached the recovery screen.
test("moves to the recovery screen at its next refresh", async () => {
const env = await bootPopup(unversionedValidProfile());
expect(env.visibleViews()).toEqual(["main"]);
env.storage.write("autistmask", CORRUPT_BLOBS[0].blob);
await env.refresh();
expect(env.visibleViews()).toEqual(["state-recovery"]);
expect(env.text("state-recovery-problem").length).toBeGreaterThan(10);
expect(env.hidden("btn-settings")).toBe(true);
expect(env.storage.read("autistmask")).toEqual(CORRUPT_BLOBS[0].blob);
});
test("a later save does not clear what the user exported or typed", async () => {
const env = await bootPopup(unversionedValidProfile());
env.storage.write("autistmask", CORRUPT_BLOBS[2].blob);
await env.refresh();
await env.click("btn-state-recovery-export");
env.node("state-recovery-reset-input").value = "erase my";
// A refresh that was already in flight when the screen went up.
await env.refresh();
expect(env.visibleViews()).toEqual(["state-recovery"]);
expect(env.hidden("state-recovery-blob")).toBe(false);
expect(env.value("state-recovery-reset-input")).toBe("erase my");
});
test("a storage read that fails once leaves the wallet list up", async () => {
const env = await bootPopup(unversionedValidProfile());
env.storage.local.get.mockRejectedValueOnce(
new Error("IO error: storage busy"),
);
await env.refresh();
// Reported as a failed save, not mistaken for an unreadable profile.
expect(env.visibleViews()).toEqual(["main"]);
expect(env.node("save-failure-banner")).not.toBeNull();
await env.refresh();
expect(env.visibleViews()).toEqual(["main"]);
});
});
describe("an unversioned profile that is perfectly valid", () => {
// The upgrade case. Every install in the field is in this state, and the
// popup must load it, not offer to wipe it.
+12 -2
View File
@@ -292,9 +292,14 @@ async function bootPopup(stored, options) {
}),
addEventListener: () => {},
};
// The 10s refresh loop init() starts would outlive the test.
// The 10s refresh loop init() starts would outlive the test, so it is
// recorded rather than started, and refresh() below runs it once.
const realSetInterval = globalThis.setInterval;
globalThis.setInterval = () => 0;
const intervals = [];
globalThis.setInterval = (fn) => {
intervals.push(fn);
return 0;
};
require("../../src/popup/index");
@@ -337,6 +342,11 @@ async function bootPopup(stored, options) {
for (const fn of fns) await fn();
await settle();
},
// One tick of the refresh loop, as if ten seconds had passed.
refresh: async () => {
for (const fn of intervals) await fn();
await settle();
},
settle,
// The view ids whose section is not hidden, as the audit measured them.
visibleViews: () => {