Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/improsocial/impro
An extensible Bluesky client for web impro.social
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830import { describe, it, mock, beforeEach, afterEach } from "node:test";import assert from "node:assert/strict";import { PushNotificationService } from "/js/push/pushNotificationService.js";import { ApiError } from "/js/api.js";import { Signal, effect } from "/js/signals.js";
const SUBSCRIPTION = { endpoint: "https://push.example/ep/abc", keys: { p256dh: "p", auth: "a" }, unsubscribe: async () => {},};
const FRESH_SUBSCRIPTION = { endpoint: "https://push.example/ep/fresh", keys: { p256dh: "p2", auth: "a2" }, unsubscribe: async () => {},};
// "BKxQ" is the base64url the service config carries; decoded it is the key a// subscription reports back through options.applicationServerKey.const VAPID_KEY = "BKxQ";const VAPID_KEY_BYTES = Uint8Array.from([4, 172, 80]);
function setupDom({ enabled = true, granted = true } = {}) { globalThis.localStorage = { _data: enabled ? { "push-notifications-enabled": "true" } : {}, getItem(k) { return this._data[k] ?? null; }, setItem(k, v) { this._data[k] = v; }, removeItem(k) { delete this._data[k]; }, }; globalThis.Notification = { permission: granted ? "granted" : "denied" }; // isSupported checks for these on window/navigator; jsdom has neither. globalThis.window.PushManager = function PushManager() {}; // Push needs an installed app on a touch-only device; the env's default // matchMedia stub reports every query as non-matching, which would read as // an uninstalled desktop browser. globalThis.window.matchMedia = (query) => ({ matches: query === "(hover: none) and (pointer: coarse)" || query === "(display-mode: standalone)", media: query, addEventListener: () => {}, removeEventListener: () => {}, }); globalThis.document = { _listeners: {}, addEventListener(type, fn) { this._listeners[type] = fn; }, removeEventListener(type) { delete this._listeners[type]; }, visibilityState: "visible", }; // Node 24 defines a read-only global `navigator`; replace it explicitly. const registration = { pushManager: { getSubscription: async () => SUBSCRIPTION, subscribe: async () => FRESH_SUBSCRIPTION, }, }; Object.defineProperty(globalThis, "navigator", { value: { serviceWorker: { register: async () => registration, ready: Promise.resolve(registration), getRegistration: async () => registration, }, }, configurable: true, writable: true, });}
// The chosen service is device state, so a service DID is seeded the same way// a previous launch would have left it: in localStorage.function createService(serviceDid = null) { if (serviceDid !== null) { globalThis.localStorage.setItem("push-notification-service", serviceDid); } const registerPush = mock.fn(async () => {}); const api = { registerPush, session: { did: "did:plc:current" } }; const auth = { getSession: async () => null, listAccounts: async () => [], }; const service = new PushNotificationService(api, auth); // Tests that care about the other-account fan-out override this. service._listAccountDids = async () => []; return { service, registerPush };}
describe("PushNotificationService registration", () => { let originals;
beforeEach(() => { // The parallel runner reuses processes across test files, so every global // this suite touches must be restored exactly — a leftover fake document // breaks whatever runs next in the same worker. originals = { localStorage: globalThis.localStorage, notification: globalThis.Notification, document: globalThis.document, navigator: Object.getOwnPropertyDescriptor(globalThis, "navigator"), pushManager: globalThis.window.PushManager, matchMedia: globalThis.window.matchMedia, }; });
afterEach(() => { globalThis.localStorage = originals.localStorage; globalThis.Notification = originals.notification; globalThis.document = originals.document; globalThis.window.matchMedia = originals.matchMedia; Object.defineProperty(globalThis, "navigator", originals.navigator); if (originals.pushManager === undefined) { delete globalThis.window.PushManager; } else { globalThis.window.PushManager = originals.pushManager; } });
it("reuses the existing subscription rather than prompting again", async () => { setupDom(); const { service, registerPush } = createService(); await service._subscribeAndRegister({ vapidPublicKey: "k" }); assert.equal(registerPush.mock.calls.length, 1); const args = registerPush.mock.calls[0].arguments[0]; assert.equal(args.appId, "social.impro"); assert.equal(args.platform, "web"); // The token is the serialized PushSubscription (functions don't survive). assert.deepEqual(JSON.parse(args.token), { endpoint: SUBSCRIPTION.endpoint, keys: SUBSCRIPTION.keys, }); });
// A PushSubscription is bound to the VAPID key it was created with, so one // left behind by a different service can't receive this service's pushes — // the gateway rejects them, silently, forever. it("replaces a subscription bound to another service's key", async () => { setupDom(); const { service, registerPush } = createService("did:web:notifs.example"); const unsubscribe = mock.fn(async () => {}); SUBSCRIPTION.options = { applicationServerKey: Uint8Array.from([9, 9, 9]).buffer, }; SUBSCRIPTION.unsubscribe = unsubscribe;
try { await service._subscribeAndRegister({ vapidPublicKey: VAPID_KEY }); } finally { delete SUBSCRIPTION.options; SUBSCRIPTION.unsubscribe = async () => {}; }
assert.equal(unsubscribe.mock.calls.length, 1); assert.equal( JSON.parse(registerPush.mock.calls[0].arguments[0].token).endpoint, FRESH_SUBSCRIPTION.endpoint, ); });
it("keeps a subscription bound to this service's key", async () => { setupDom(); const { service, registerPush } = createService("did:web:notifs.example"); const unsubscribe = mock.fn(async () => {}); SUBSCRIPTION.options = { applicationServerKey: VAPID_KEY_BYTES.buffer }; SUBSCRIPTION.unsubscribe = unsubscribe;
try { await service._subscribeAndRegister({ vapidPublicKey: VAPID_KEY }); } finally { delete SUBSCRIPTION.options; SUBSCRIPTION.unsubscribe = async () => {}; }
assert.equal(unsubscribe.mock.calls.length, 0); assert.equal( JSON.parse(registerPush.mock.calls[0].arguments[0].token).endpoint, SUBSCRIPTION.endpoint, ); });
it("skips the launch re-assert when no service is selected", async () => { setupDom(); const { service, registerPush } = createService(); await service.reassertIfEnabled(); assert.equal(registerPush.mock.calls.length, 0); });
it("skips the launch re-assert when push is disabled", async () => { setupDom({ enabled: false }); const { service, registerPush } = createService("did:web:notifs.example"); await service.reassertIfEnabled(); assert.equal(registerPush.mock.calls.length, 0); });
// Revoking notifications in browser site settings tells us nothing, so the // stored flag is only reconciled the next time we go looking. it("clears the stored flag when permission was revoked out-of-band", async () => { setupDom({ granted: false }); const { service, registerPush } = createService("did:web:notifs.example"); assert.deepEqual(await service.reassertIfEnabled(), { enabled: false }); assert.equal(registerPush.mock.calls.length, 0); assert.equal(service.$enabled.get(), false); });
it("disable() unregisters this device and clears the flag", async () => { setupDom(); const { service } = createService("did:web:notifs.example"); const unregisterPush = mock.fn(async () => {}); service.api.unregisterPush = unregisterPush; await service.disable(); assert.equal(unregisterPush.mock.calls.length, 1); const args = unregisterPush.mock.calls[0].arguments[0]; assert.equal(args.appId, "social.impro"); assert.equal(args.platform, "web"); assert.deepEqual(JSON.parse(args.token), { endpoint: SUBSCRIPTION.endpoint, keys: SUBSCRIPTION.keys, }); assert.equal(service.$enabled.get(), false); });
// Only iOS withholds push from a browser tab, so installation is required // there and nowhere else. function simulateUninstalled({ ios }) { globalThis.window.matchMedia = (query) => ({ matches: query === "(hover: none) and (pointer: coarse)", media: query, addEventListener: () => {}, removeEventListener: () => {}, }); navigator.userAgent = ios ? "iPhone" : "Android"; }
it("is unsupported in an uninstalled iOS browser", () => { setupDom(); simulateUninstalled({ ios: true }); const { service } = createService(); assert.equal(service.isSupported, false); assert.equal(service.isEnabled, false); // The one refusal the user can act on, so the settings copy can say how. assert.equal(service.requiresInstall, true); });
it("is supported in an uninstalled non-iOS mobile browser", () => { setupDom(); simulateUninstalled({ ios: false }); const { service } = createService("did:web:notifs.example"); assert.equal(service.isSupported, true); assert.equal(service.requiresInstall, false); });
it("is unsupported on a device that isn't touch-only", () => { setupDom(); // Desktop gets DesktopNotificationService's in-tab notifications instead, // which is gated on the same check the other way round. globalThis.window.matchMedia = (query) => ({ matches: false, media: query, addEventListener: () => {}, removeEventListener: () => {}, }); const { service } = createService(); assert.equal(service.isSupported, false); assert.equal(service.isEnabled, false); });
it("registers against the selected service, not the default", async () => { setupDom(); const { service, registerPush } = createService( "did:web:elsewhere.example", ); await service._subscribeAndRegister({ vapidPublicKey: "k" }); assert.equal( registerPush.mock.calls[0].arguments[0].serviceDid, "did:web:elsewhere.example", ); });
// Removing an account happens from a different account's session, so the // teardown borrows the removed account's own session. function stubRemovedAccount(service) { const unregisterPush = mock.fn(async () => {}); service._apiForAccount = mock.fn(async () => ({ unregisterPush })); return { unregisterPush }; }
it("unregisters a removed account at this device's service", async () => { setupDom(); const { service } = createService("did:web:notifs.example"); const { unregisterPush } = stubRemovedAccount(service);
await service.unregisterAccount("did:plc:removed");
assert.equal( service._apiForAccount.mock.calls[0].arguments[0], "did:plc:removed", ); assert.equal(unregisterPush.mock.calls.length, 1); const args = unregisterPush.mock.calls[0].arguments[0]; assert.equal(args.serviceDid, "did:web:notifs.example"); assert.equal(args.appId, "social.impro"); assert.equal(args.platform, "web"); assert.deepEqual(JSON.parse(args.token), { endpoint: SUBSCRIPTION.endpoint, keys: SUBSCRIPTION.keys, }); });
it("skips the teardown on a device with no service", async () => { setupDom(); const { service } = createService(); const { unregisterPush } = stubRemovedAccount(service);
await service.unregisterAccount("did:plc:removed");
assert.equal(unregisterPush.mock.calls.length, 0); });
// One browser subscription serves every signed-in account, so dropping it // would silently kill push for the accounts that remain. it("leaves the shared browser subscription in place", async () => { setupDom(); const { service } = createService("did:web:notifs.example"); const unsubscribe = mock.fn(async () => {}); const originalUnsubscribe = SUBSCRIPTION.unsubscribe; SUBSCRIPTION.unsubscribe = unsubscribe; stubRemovedAccount(service);
try { await service.unregisterAccount("did:plc:removed"); } finally { SUBSCRIPTION.unsubscribe = originalUnsubscribe; }
assert.equal(unsubscribe.mock.calls.length, 0); assert.equal(service.$enabled.get(), true); });
// The caller decides what a failed teardown means; here it stays // best-effort, so the error has to surface rather than be swallowed. it("surfaces a failed teardown to the caller", async () => { setupDom(); const { service } = createService("did:web:notifs.example"); service._apiForAccount = async () => { throw new Error("session already gone"); }; await assert.rejects( () => service.unregisterAccount("did:plc:removed"), /session already gone/, ); });
// Tearing down the subscription strands every account still registered // against it, so disable() has to cover all of them. it("unregisters every signed-in account before unsubscribing", async () => { setupDom(); const { service } = createService("did:web:notifs.example"); const order = []; const unregisterPush = mock.fn(async () => {}); service.api.unregisterPush = mock.fn(async () => order.push("current")); service._listAccountDids = async () => [ "did:plc:current", "did:plc:other", "did:plc:third", ]; service._apiForAccount = mock.fn(async (did) => ({ unregisterPush: async (payload) => { order.push(did); return unregisterPush(payload); }, })); const originalUnsubscribe = SUBSCRIPTION.unsubscribe; SUBSCRIPTION.unsubscribe = mock.fn(async () => order.push("unsubscribe"));
try { await service.disable(); } finally { SUBSCRIPTION.unsubscribe = originalUnsubscribe; }
// The current account goes through the app's own api, and is not // unregistered twice. assert.deepEqual(order, [ "current", "did:plc:other", "did:plc:third", "unsubscribe", ]); assert.equal( unregisterPush.mock.calls[0].arguments[0].serviceDid, "did:web:notifs.example", ); });
it("still unsubscribes when another account's teardown fails", async () => { setupDom(); const { service } = createService("did:web:notifs.example"); service.api.unregisterPush = mock.fn(async () => {}); service._listAccountDids = async () => ["did:plc:other"]; service._apiForAccount = async () => { throw new Error("session already gone"); }; const unsubscribe = mock.fn(async () => {}); const originalUnsubscribe = SUBSCRIPTION.unsubscribe; SUBSCRIPTION.unsubscribe = unsubscribe; const originalError = console.error; console.error = () => {};
try { await service.disable(); } finally { SUBSCRIPTION.unsubscribe = originalUnsubscribe; console.error = originalError; }
assert.equal(unsubscribe.mock.calls.length, 1); assert.equal(service.$enabled.get(), false); });});
describe("PushNotificationService service selection", () => { let originals; let fetchCalls;
const CONFIG = { name: "Example Notifs", vapidPublicKey: "k", authUrl: "u" };
beforeEach(() => { originals = { localStorage: globalThis.localStorage, notification: globalThis.Notification, document: globalThis.document, navigator: Object.getOwnPropertyDescriptor(globalThis, "navigator"), pushManager: globalThis.window.PushManager, matchMedia: globalThis.window.matchMedia, fetch: globalThis.fetch, }; setupDom({ enabled: false }); fetchCalls = []; globalThis.fetch = async (url) => { fetchCalls.push(String(url)); const body = String(url).includes("notif-service.json") ? CONFIG : { service: [ { id: "#bsky_notif", type: "BskyNotificationService", serviceEndpoint: "https://notifs.example", }, ], }; return { ok: true, status: 200, json: async () => body }; }; });
afterEach(() => { globalThis.localStorage = originals.localStorage; globalThis.Notification = originals.notification; globalThis.document = originals.document; globalThis.window.matchMedia = originals.matchMedia; Object.defineProperty(globalThis, "navigator", originals.navigator); globalThis.fetch = originals.fetch; if (originals.pushManager === undefined) { delete globalThis.window.PushManager; } else { globalThis.window.PushManager = originals.pushManager; } });
// Impro suggests no service of its own, so nothing can happen until the // user names one. it("has no service until the user names one", () => { const { service } = createService(); assert.equal(service.serviceDid, null); assert.equal(service.hasService, false); });
it("cannot be enabled while no service is named", () => { const { service } = createService(); globalThis.localStorage.setItem("push-notifications-enabled", "true"); assert.equal(service.isEnabled, false); });
it("refuses to resolve a config while no service is named", async () => { const { service } = createService(); await assert.rejects(() => service.fetchServiceConfig(), { message: "No notification service selected", }); assert.equal(fetchCalls.length, 0, "it must not hit the network"); });
// The choice is device state: it belongs to the browser, not the account, // because one subscription serves every account signed in here. it("naming a service persists it on the device across launches", async () => { const { service } = createService(); await service.selectService("did:web:notifs.example"); assert.equal(service.serviceDid, "did:web:notifs.example"); assert.equal(service.hasService, true); assert.equal( globalThis.localStorage.getItem("push-notification-service"), "did:web:notifs.example", );
// Same device, later launch. const { service: relaunched } = createService(); assert.equal(relaunched.serviceDid, "did:web:notifs.example"); });
it("clearing the service clears the device's choice", async () => { const { service } = createService("did:web:notifs.example");
await service.clearService();
assert.equal(service.serviceDid, null); assert.equal(service.hasService, false); assert.equal( globalThis.localStorage.getItem("push-notification-service"), null, ); });
it("resolves a service through its DID document", async () => { const { service } = createService(); const preview = await service.previewService("did:web:notifs.example"); assert.equal(preview.name, "Example Notifs"); assert.equal(preview.authUrl, "u"); });
it("resolves a service once per session", async () => { const { service } = createService(); await service.selectService("did:web:notifs.example"); await service.fetchServiceConfig(); const afterFirst = fetchCalls.length; await service.fetchServiceConfig(); assert.equal( fetchCalls.length, afterFirst, "a second lookup should not hit the network", );
// A fresh instance is the app-launch case. It re-resolves rather than // reading a persisted copy: how long the config stays good is the // service's call, made in its own cache headers. const { service: relaunched } = createService(); await relaunched.fetchServiceConfig(); assert.ok(fetchCalls.length > afterFirst); });
it("does not cache a failed lookup as the answer", async () => { const { service } = createService(); await service.selectService("did:web:notifs.example"); globalThis.fetch = async () => { throw new Error("offline"); }; await assert.rejects(() => service.fetchServiceConfig());
globalThis.fetch = async (url) => ({ ok: true, status: 200, json: async () => String(url).includes("notif-service.json") ? CONFIG : { service: [ { id: "#bsky_notif", serviceEndpoint: "https://notifs.example", }, ], }, }); const config = await service.fetchServiceConfig(); assert.equal(config.name, "Example Notifs"); });
it("switching services unregisters the old one first", async () => { globalThis.localStorage.setItem("push-notifications-enabled", "true"); const { service } = createService(); await service.selectService("did:web:notifs.example"); const unregisterPush = mock.fn(async () => {}); service.api.unregisterPush = unregisterPush;
await service.selectService("did:web:elsewhere.example");
// The old service polls server-side; leaving it registered would keep it // delivering after the user believes they have moved away from it. assert.equal(unregisterPush.mock.calls.length, 1); assert.equal(service.serviceDid, "did:web:elsewhere.example"); assert.equal(service.isEnabled, false); });
it("clearing the service unregisters it first", async () => { globalThis.localStorage.setItem("push-notifications-enabled", "true"); const { service } = createService(); await service.selectService("did:web:notifs.example"); const unregisterPush = mock.fn(async () => {}); service.api.unregisterPush = unregisterPush;
await service.clearService();
// Same reason as switching: the old service polls server-side, so it // must be unregistered before nothing points at it any more. assert.equal(unregisterPush.mock.calls.length, 1); assert.equal(service.serviceDid, null); assert.equal(service.hasService, false); assert.equal(service.isEnabled, false); });
// Views read the service directly rather than re-reading storage when they // are navigated back to, so its state has to notify on change. it("notifies reactive readers when the service changes", async () => { const { service } = createService(); const seen = []; // Effects flush on an animation frame, so each change needs one to land. const flush = () => new Promise((resolve) => requestAnimationFrame(() => resolve())); const dispose = effect(() => { seen.push(service.serviceDid); });
await service.selectService("did:web:notifs.example"); await flush(); await service.clearService(); await flush();
assert.deepEqual(seen, [null, "did:web:notifs.example", null]); dispose(); });
it("switching services replaces the stored choice", async () => { const { service } = createService(); await service.selectService("did:web:notifs.example"); await service.selectService("did:web:elsewhere.example"); assert.equal(service.serviceDid, "did:web:elsewhere.example"); });
it("switching services drops the previous service's cached config", async () => { const { service } = createService(); await service.selectService("did:web:notifs.example"); await service.fetchServiceConfig(); await service.selectService("did:web:elsewhere.example"); const before = fetchCalls.length; await service.fetchServiceConfig(); assert.ok( fetchCalls.length > before, "the new service must be resolved, not served from the old cache", ); });});
describe("PushNotificationService re-authorization", () => { let originals;
const CONFIG = { name: "Example Notifs", vapidPublicKey: "k", authUrl: "u" };
function authError(status) { return new ApiError({ status, statusText: "Unauthorized", data: null, headers: null, url: "https://pds.example/xrpc/app.bsky.notification.registerPush", }); }
beforeEach(() => { originals = { localStorage: globalThis.localStorage, notification: globalThis.Notification, document: globalThis.document, navigator: Object.getOwnPropertyDescriptor(globalThis, "navigator"), pushManager: globalThis.window.PushManager, matchMedia: globalThis.window.matchMedia, fetch: globalThis.fetch, consoleError: console.error, }; setupDom(); globalThis.fetch = async (url) => ({ ok: true, status: 200, json: async () => String(url).includes("notif-service.json") ? CONFIG : { service: [ { id: "#bsky_notif", type: "BskyNotificationService", serviceEndpoint: "https://notifs.example", }, ], }, }); });
afterEach(() => { globalThis.localStorage = originals.localStorage; globalThis.Notification = originals.notification; globalThis.document = originals.document; globalThis.window.matchMedia = originals.matchMedia; Object.defineProperty(globalThis, "navigator", originals.navigator); globalThis.fetch = originals.fetch; console.error = originals.consoleError; if (originals.pushManager === undefined) { delete globalThis.window.PushManager; } else { globalThis.window.PushManager = originals.pushManager; } });
it("flags a 401 registration rejection and reports the first detection", async () => { const { service, registerPush } = createService("did:web:notifs.example"); registerPush.mock.mockImplementation(async () => { throw authError(401); });
const result = await service.reassertIfEnabled();
assert.deepEqual(result, { enabled: true, newlyRevoked: true }); assert.equal(service.needsReauth, true); assert.equal( globalThis.localStorage.getItem("push-notifications-needs-reauth"), "true", ); // The user keeps push notifications on; only the authorization is stale. assert.equal(service.isEnabled, true); });
it("reports an already-known rejection as not new", async () => { const { service, registerPush } = createService("did:web:notifs.example"); registerPush.mock.mockImplementation(async () => { throw authError(403); });
assert.deepEqual(await service.reassertIfEnabled(), { enabled: true, newlyRevoked: true, }); assert.deepEqual(await service.reassertIfEnabled(), { enabled: true, newlyRevoked: false, }); assert.equal(service.needsReauth, true); });
it("does not flag transient registration failures", async () => { const { service, registerPush } = createService("did:web:notifs.example"); console.error = () => {}; for (const failure of [authError(500), new TypeError("Failed to fetch")]) { registerPush.mock.mockImplementation(async () => { throw failure; }); assert.deepEqual(await service.reassertIfEnabled(), { enabled: true }); assert.equal(service.needsReauth, false); } assert.equal( globalThis.localStorage.getItem("push-notifications-needs-reauth"), null, ); });
it("clears the flag once a registration succeeds again", async () => { globalThis.localStorage.setItem("push-notifications-needs-reauth", "true"); const { service } = createService("did:web:notifs.example"); assert.equal(service.needsReauth, true);
assert.deepEqual(await service.reassertIfEnabled(), { enabled: true });
assert.equal(service.needsReauth, false); assert.equal( globalThis.localStorage.getItem("push-notifications-needs-reauth"), null, ); });
it("disable() clears the flag", async () => { globalThis.localStorage.setItem("push-notifications-needs-reauth", "true"); const { service } = createService("did:web:notifs.example"); service.api.unregisterPush = mock.fn(async () => {});
await service.disable();
assert.equal(service.needsReauth, false); assert.equal( globalThis.localStorage.getItem("push-notifications-needs-reauth"), null, ); });
it("switching services clears the flag", async () => { globalThis.localStorage.setItem("push-notifications-needs-reauth", "true"); globalThis.localStorage.removeItem("push-notifications-enabled"); const { service } = createService("did:web:notifs.example");
await service.selectService("did:web:elsewhere.example");
assert.equal(service.needsReauth, false); });
it("notifies reactive readers when the flag changes", async () => { const { service, registerPush } = createService("did:web:notifs.example"); registerPush.mock.mockImplementation(async () => { throw authError(401); }); const seen = []; // Effects flush on an animation frame, so each change needs one to land. const flush = () => new Promise((resolve) => requestAnimationFrame(() => resolve())); const dispose = effect(() => { seen.push(service.needsReauth); });
await service.reassertIfEnabled(); await flush();
assert.deepEqual(seen, [false, true]); dispose(); });});