Group six more clusters out of web/src/lib

Takes the flat module count from 66 to 42, continuing what admin/ and
calendar/ started.

  lib/mailbox/  archiveDate, emptyFolder, folderMove, labelTree,
                mailboxName, mailboxRoute
  lib/sieve/    sieve, sieveApply, sieveFolders
  lib/input/    keyboard, swipe, touch, listSelection, dropUpload
  lib/notify/   notify, webpush, webpushEnable
  lib/sw/       swCache, swFacts, staleBuild
  lib/text/     html, markdown, text, emlName

FOUR THINGS THE FILENAMES GET WRONG, each checked by reading the file
rather than trusting what it is called:

  - appFolder is not a mailbox. It is the `ihasmail` folder in JMAP
    *Files*, where the client keeps signature images and synced settings.
    It stays flat.
  - format holds no formatting of text. It re-exports the date and clock
    formatters, so it belongs with dates/datetime, not with text/.
  - preview is the file viewer deciding what it can show without
    downloading, and source is where to point someone asking for this
    instance's AGPL source. Neither is about text.
  - notify is not Web Push. It is the tab title, the favicon badge and
    the new-mail sound -- in-app notification, which is why it sits with
    webpush rather than under sw/ with the service worker's own concerns.

threadScroll stays flat too: it decides where a conversation opens, which
is view state rather than a gesture, and input/ is honest only if
everything in it interprets something the reader did.

No behavior change. Almost every reference was on the @/ alias; eight
relative imports in files that did not move, or that moved away from a
sibling, needed rewriting by hand.
This commit is contained in:
jcoffey-dev committed 2026-09-15 23:17:50 -07:00
1 parent 5cc31037c1
commit bd6a605d61
95 files changed
+104 -104

No files matched your search

+126
View File
@@ -0,0 +1,126 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { reloadIfServerRebuilt, makeConnectionWatcher, startBuildWatch } from "@/lib/sw/staleBuild";
import { APP_VERSION } from "@/lib/version";
function healthReplies(body: unknown, ok = true) {
return vi.fn().mockResolvedValue({ ok, json: async () => body } as unknown as Response);
}
let reload: ReturnType<typeof vi.fn>;
beforeEach(() => {
sessionStorage.clear();
reload = vi.fn();
Object.defineProperty(window, "location", {
configurable: true,
value: { ...window.location, reload },
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("reloadIfServerRebuilt", () => {
it("reloads when the server reports a different build", async () => {
vi.stubGlobal("fetch", healthReplies({ ok: true, version: `${APP_VERSION}-newer` }));
expect(await reloadIfServerRebuilt()).toBe(true);
expect(reload).toHaveBeenCalledOnce();
});
it("leaves the page alone when the versions match", async () => {
vi.stubGlobal("fetch", healthReplies({ ok: true, version: APP_VERSION }));
expect(await reloadIfServerRebuilt()).toBe(false);
expect(reload).not.toHaveBeenCalled();
});
it("reloads once per version, not once per 401", async () => {
vi.stubGlobal("fetch", healthReplies({ ok: true, version: "9.9.9" }));
expect(await reloadIfServerRebuilt()).toBe(true);
expect(await reloadIfServerRebuilt()).toBe(false);
expect(reload).toHaveBeenCalledOnce();
});
it("clears the guard once the versions agree again", async () => {
vi.stubGlobal("fetch", healthReplies({ ok: true, version: "9.9.9" }));
await reloadIfServerRebuilt();
vi.stubGlobal("fetch", healthReplies({ ok: true, version: APP_VERSION }));
await reloadIfServerRebuilt();
vi.stubGlobal("fetch", healthReplies({ ok: true, version: "9.9.9" }));
expect(await reloadIfServerRebuilt()).toBe(true);
expect(reload).toHaveBeenCalledTimes(2);
});
it("does not reload when the server cannot be reached", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline")));
expect(await reloadIfServerRebuilt()).toBe(false);
expect(reload).not.toHaveBeenCalled();
});
it("does not reload on a bad response or a missing version", async () => {
vi.stubGlobal("fetch", healthReplies({ ok: true, version: "9.9.9" }, false));
expect(await reloadIfServerRebuilt()).toBe(false);
vi.stubGlobal("fetch", healthReplies({ ok: true }));
expect(await reloadIfServerRebuilt()).toBe(false);
expect(reload).not.toHaveBeenCalled();
});
});
describe("noticing without being asked", () => {
it("checks when the push stream drops, but not before it has connected", async () => {
const fetchMock = healthReplies({ ok: true, version: APP_VERSION });
vi.stubGlobal("fetch", fetchMock);
const onState = makeConnectionWatcher();
// never connected: a disconnect is not news
onState("connecting");
await new Promise((r) => setTimeout(r, 0));
expect(fetchMock).not.toHaveBeenCalled();
onState("connected");
onState("connecting");
await new Promise((r) => setTimeout(r, 0));
expect(fetchMock).toHaveBeenCalled();
});
it("asks the server once when several things notice at the same moment", async () => {
const fetchMock = healthReplies({ ok: true, version: APP_VERSION });
vi.stubGlobal("fetch", fetchMock);
await Promise.all([reloadIfServerRebuilt(), reloadIfServerRebuilt(), reloadIfServerRebuilt()]);
expect(fetchMock).toHaveBeenCalledOnce();
});
});
describe("the poll is what the guarantee rests on", () => {
it("checks on its own while the tab is visible, with nobody touching it", async () => {
vi.useFakeTimers();
const fetchMock = healthReplies({ ok: true, version: "9.9.9" });
vi.stubGlobal("fetch", fetchMock);
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => "visible" });
startBuildWatch();
expect(fetchMock).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(60_000);
expect(fetchMock).toHaveBeenCalled();
vi.useRealTimers();
});
it("leaves a hidden tab alone until it is looked at", async () => {
vi.useFakeTimers();
const fetchMock = healthReplies({ ok: true, version: APP_VERSION });
vi.stubGlobal("fetch", fetchMock);
let visibility = "hidden";
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => visibility });
startBuildWatch();
await vi.advanceTimersByTimeAsync(180_000);
expect(fetchMock).not.toHaveBeenCalled();
visibility = "visible";
document.dispatchEvent(new Event("visibilitychange"));
await vi.advanceTimersByTimeAsync(0);
expect(fetchMock).toHaveBeenCalled();
vi.useRealTimers();
});
});
+90
View File
@@ -0,0 +1,90 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { publishWorkerFacts, FACTS_KEY, type WorkerFacts } from "@/lib/sw/swFacts";
import { SW_CACHE_NAME } from "@/lib/sw/swCache";
import { setCatalog } from "@/lib/i18n";
import { catalog as de } from "@/locales/de";
/**
* The briefing is the only thing standing between a notification action and a
* button labeled in a language the reader does not use — the worker is plain
* JavaScript outside the bundle and cannot reach a catalog.
*
* It is also the only place the archive mailbox is named, and getting that
* wrong does not fail visibly: a message would be filed somewhere, just not
* where Archive means.
*/
function fakeCaches() {
const store = new Map<string, string>();
const cache = {
put: vi.fn(async (key: string, res: Response) => void store.set(key, await res.text())),
match: vi.fn(async (key: string) => (store.has(key) ? new Response(store.get(key)) : undefined)),
delete: vi.fn(async () => true),
};
// Only the worker's own cache: a briefing put anywhere else is one the
// worker will never read.
const other = { put: vi.fn(), match: vi.fn(), delete: vi.fn() };
vi.stubGlobal("caches", { open: vi.fn(async (name: string) => (name === SW_CACHE_NAME ? cache : other)) });
return { store, cache };
}
const written = (store: Map<string, string>) => JSON.parse(store.get(FACTS_KEY)!) as WorkerFacts;
afterEach(() => {
vi.unstubAllGlobals();
setCatalog("en", { strings: {}, plurals: {} });
});
describe("the worker's briefing", () => {
it("names the account and the archive mailbox", async () => {
const { store } = fakeCaches();
await publishWorkerFacts("a1", "mb-archive");
const facts = written(store);
expect(facts.accountId).toBe("a1");
expect(facts.archiveId).toBe("mb-archive");
});
it("carries the worker's text in the language the tab is in", async () => {
// The worker has no catalog. Everything it will say has to be said here
// first, or a German reader gets English buttons on their lock screen.
setCatalog("de", de);
const { store } = fakeCaches();
await publishWorkerFacts("a1", "mb-archive");
const facts = written(store);
expect(facts.strings.archive).toBe("Archivieren");
expect(facts.strings.markRead).toBe("Als gelesen markieren");
expect(facts.strings.newMail).toBe("Neue E-Mail");
expect(facts.strings.noSubject).toBe("(kein Betreff)");
expect(facts.strings.failed).not.toBe("");
});
it("says so when there is no archive folder, rather than inventing one", async () => {
// The worker draws no Archive button on a null. An account without an
// archive is not a reason to file mail somewhere else.
const { store } = fakeCaches();
await publishWorkerFacts("a1", null);
expect(written(store).archiveId).toBeNull();
});
it("writes nothing before there is an account", async () => {
const { cache } = fakeCaches();
await publishWorkerFacts(null, null);
expect(cache.put).not.toHaveBeenCalled();
});
it("does not throw where the browser has no cache storage", async () => {
vi.stubGlobal("caches", undefined);
await expect(publishWorkerFacts("a1", "mb-archive")).resolves.toBeUndefined();
});
it("carries every string the worker looks up", async () => {
// The worker reads these by name and shows `undefined` for a missing one,
// which is the kind of thing that only appears on somebody's lock screen.
const { store } = fakeCaches();
await publishWorkerFacts("a1", "mb-archive");
const facts = written(store);
for (const k of ["newMail", "newMessage", "noSubject", "archive", "markRead", "failed"] as const) {
expect(facts.strings[k], `missing ${k}`).toBeTruthy();
}
});
});