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.
174 lines
7.9 KiB
TypeScript
174 lines
7.9 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { useSieve } from "@/store/sieve";
|
|
import { newRule, rulesToSieve } from "@/lib/sieve/sieve";
|
|
import type { SieveScript } from "@/jmap/types";
|
|
|
|
/**
|
|
* Issue #76: adding a filter from a message reported success, and the script
|
|
* on the server never held more than two rules.
|
|
*
|
|
* The chain was three links long, and each looked reasonable alone:
|
|
*
|
|
* 1. `load()` recorded a *failed* blob fetch as `contents[id] = ""`.
|
|
* 2. `sieveToRules("")` returns `[]` — "this script has no rules", which is
|
|
* indistinguishable from "we could not read this script".
|
|
* 3. Saving writes the whole script from that baseline, so every existing
|
|
* rule was deleted, and the UI reported success because the write worked.
|
|
*
|
|
* The fix is to keep "unknown" and "empty" apart at every step. These pin that:
|
|
* an unreadable script must never present as an empty one.
|
|
*/
|
|
|
|
const SCRIPT: SieveScript = { id: "s1", name: "ihasmail", isActive: true, blobId: "b1" } as SieveScript;
|
|
const threeRules = [newRule({ name: "One" }), newRule({ name: "Two" }), newRule({ name: "Three" })];
|
|
|
|
beforeEach(() => {
|
|
useSieve.setState({ accountId: "a1", scripts: [SCRIPT], contents: {}, loading: false, error: null });
|
|
});
|
|
|
|
describe("a script whose content could not be read", () => {
|
|
it("reports its rules as unknown, not as none", () => {
|
|
// contents is empty: the fetch failed, or has not happened yet.
|
|
const { rules, loaded } = useSieve.getState().rules();
|
|
expect(rules).toBeNull();
|
|
expect(loaded).toBe(false);
|
|
});
|
|
|
|
it("refuses to save rather than overwriting what it cannot see", async () => {
|
|
await expect(useSieve.getState().saveRules([newRule({ name: "New" })])).rejects.toThrow(/could not be read/i);
|
|
});
|
|
|
|
it("says so in terms that point at the fix", async () => {
|
|
// "Reload and try again" is recoverable advice; a generic failure is not.
|
|
await expect(useSieve.getState().saveRules([newRule({ name: "New" })])).rejects.toThrow(/reload/i);
|
|
});
|
|
});
|
|
|
|
describe("a script that is genuinely empty", () => {
|
|
it("is distinguishable from one that could not be read", () => {
|
|
useSieve.setState({ contents: { s1: "" } });
|
|
const { rules, loaded } = useSieve.getState().rules();
|
|
expect(loaded).toBe(true);
|
|
expect(rules).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("a script that was read", () => {
|
|
it("hands back every rule in it", () => {
|
|
useSieve.setState({ contents: { s1: rulesToSieve(threeRules) } });
|
|
const { rules, loaded } = useSieve.getState().rules();
|
|
expect(loaded).toBe(true);
|
|
expect(rules).toHaveLength(3);
|
|
expect(rules?.map((r) => r.name)).toEqual(["One", "Two", "Three"]);
|
|
});
|
|
|
|
it("does not lose rules across a save-shaped round trip", () => {
|
|
// The regression in one line: N rules in, N + 1 out after adding one.
|
|
useSieve.setState({ contents: { s1: rulesToSieve(threeRules) } });
|
|
const before = useSieve.getState().rules().rules!;
|
|
const after = [...before, newRule({ name: "Four" })];
|
|
useSieve.setState({ contents: { s1: rulesToSieve(after) } });
|
|
expect(useSieve.getState().rules().rules).toHaveLength(4);
|
|
});
|
|
});
|
|
|
|
describe("reloading", () => {
|
|
it("does not discard content it already holds when a refetch yields nothing", () => {
|
|
// saveScript caches what it just wrote, then reloads. A reload whose fetch
|
|
// failed used to replace the whole map and wipe that.
|
|
useSieve.setState({ contents: { s1: rulesToSieve(threeRules) } });
|
|
const kept = useSieve.getState().contents.s1;
|
|
useSieve.setState((st) => ({ contents: { ...st.contents } })); // merge, not replace
|
|
expect(useSieve.getState().contents.s1).toBe(kept);
|
|
expect(useSieve.getState().rules().rules).toHaveLength(3);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Issue #76, second round. The transport fault is fixed in the blob proxy, but
|
|
* the save path had no answer for a script that arrives *partly* read: it is
|
|
* neither unknown nor empty, so the guards above all pass it through. It parses
|
|
* into a shorter rule list that looks exactly like a script with fewer rules,
|
|
* and saving writes that shorter version back over the real one.
|
|
*
|
|
* These pin the third state: read, but not all of it.
|
|
*/
|
|
describe("a script that was only partly read", () => {
|
|
/** Cut at 384 bytes, the way a compressing hop cut the reporter's script. */
|
|
const truncate = (content: string, at: number) => content.slice(0, at);
|
|
const full = rulesToSieve(threeRules);
|
|
|
|
it("reports its rules as unknown rather than handing back the ones that parsed", () => {
|
|
useSieve.setState({ contents: { s1: truncate(full, 384) } });
|
|
const { rules, loaded, damage } = useSieve.getState().rules();
|
|
expect(rules).toBeNull();
|
|
expect(loaded).toBe(true);
|
|
expect(damage).toBeTruthy();
|
|
});
|
|
|
|
it("refuses to save over the part it never saw", async () => {
|
|
useSieve.setState({ contents: { s1: truncate(full, 384) } });
|
|
await expect(useSieve.getState().saveRules([newRule({ name: "New" })])).rejects.toThrow(/overwrite the rest of it/i);
|
|
});
|
|
|
|
it("catches a cut at every offset through the script, not just a lucky one", () => {
|
|
// The offsets that cannot be caught are the ends of complete rule blocks:
|
|
// each is a valid shorter script and nothing in the bytes says otherwise.
|
|
// That is the residual the proxy fix covers and this check cannot.
|
|
const safe = new Set<number>();
|
|
for (let n = 0; n <= threeRules.length; n++) safe.add(rulesToSieve(threeRules.slice(0, n)).length);
|
|
let missed = 0;
|
|
for (let at = 1; at < full.length; at++) {
|
|
useSieve.setState({ contents: { s1: truncate(full, at) } });
|
|
const { damage } = useSieve.getState().rules();
|
|
if (!damage && !safe.has(at)) missed++;
|
|
}
|
|
expect(missed).toBe(0);
|
|
});
|
|
|
|
it("leaves an intact script alone at every length it can legitimately have", () => {
|
|
for (let n = 0; n <= threeRules.length; n++) {
|
|
useSieve.setState({ contents: { s1: rulesToSieve(threeRules.slice(0, n)) } });
|
|
const { rules, damage } = useSieve.getState().rules();
|
|
expect(damage).toBeNull();
|
|
expect(rules).toHaveLength(n);
|
|
}
|
|
});
|
|
|
|
it("leaves the shapes a rule can take alone — disabled, many actions, extensions", () => {
|
|
// A false positive here costs someone the use of the rules editor, so the
|
|
// walk has to pass everything rulesToSieve can legitimately produce.
|
|
const varied = [
|
|
newRule({ name: "Disabled", enabled: false }),
|
|
newRule({ name: "Many actions", actions: [{ type: "fileinto", mailbox: "A" }, { type: "markread" }, { type: "flag" }, { type: "stop" }] }),
|
|
newRule({ name: "Two tests", join: "anyof", tests: [{ type: "body", op: "contains", value: "x" }, { type: "size", op: "over", value: 1024 }] }),
|
|
newRule({ name: "No actions at all", actions: [] }),
|
|
newRule({ name: "Quotes \" and \\ backslash" }),
|
|
];
|
|
useSieve.setState({ contents: { s1: rulesToSieve(varied) } });
|
|
const { rules, damage } = useSieve.getState().rules();
|
|
expect(damage).toBeNull();
|
|
expect(rules).toHaveLength(varied.length);
|
|
});
|
|
|
|
it("catches a cut at every offset through that script too", () => {
|
|
const varied = [newRule({ name: "Disabled", enabled: false }), newRule({ name: "Live" }), newRule({ name: "Also off", enabled: false })];
|
|
const full = rulesToSieve(varied);
|
|
const safe = new Set<number>();
|
|
for (let n = 0; n <= varied.length; n++) safe.add(rulesToSieve(varied.slice(0, n)).length);
|
|
let missed = 0;
|
|
for (let at = 1; at < full.length; at++) {
|
|
useSieve.setState({ contents: { s1: full.slice(0, at) } });
|
|
if (!useSieve.getState().rules().damage && !safe.has(at)) missed++;
|
|
}
|
|
expect(missed).toBe(0);
|
|
});
|
|
|
|
it("does not call a hand-written script damaged", () => {
|
|
useSieve.setState({ contents: { s1: 'require ["fileinto"];\nif header :contains "from" "x" { fileinto "X"; }' } });
|
|
const { rules, damage } = useSieve.getState().rules();
|
|
expect(damage).toBeNull();
|
|
expect(rules).toBeNull(); // hand-written, which is a different refusal
|
|
});
|
|
});
|