ihasmail could hand a file to the share sheet as of #306, and was still not in it. Share a photo from the gallery, a link from the browser or a document from a file manager and ihasmail was not among the places it could go, which is the one piece of operating-system integration a mail app is expected to have. A share is a POST that navigates, and there is nothing on this side that can answer one: the app is a client-side router with no endpoint at that address, and the server behind it would need a route that understood the composer. So the service worker intercepts it, takes the form body, puts the files and text in its cache, and redirects to the app -- which finds them on start and opens a draft holding them. The subject is the shared title, the text and the link become the body, and files are attached and begin uploading. Nothing is addressed: a share says what to send, never who to. The body is pushed in above the signature rather than passed to open(), because open() only fits a signature when it is given no body at all -- the obvious version drops the signature from every message that started as a share, and nothing about the draft looks wrong afterwards. Collected on every start rather than when the launch URL says so. A share to a signed-out ihasmail lands on the sign-in page, and there is no account to attach to until it is done, so the payload has to outlive a redirect and a login -- which the query string does not. What that costs is a stash nobody came back for, so it carries a timestamp and expires after ten minutes. `accept` names wildcard families and explicit types and extensions both. A mail client attaches anything, but wildcards are not in the specification and operating systems differ over which form they match on, so the explicit list is what holds if the families are ignored. The cache name the worker and the app have to agree on now has one home on the app side. It was written out twice, and a drift would not fail -- a push verification would simply never complete and a share would arrive at an empty composer. One case is deliberately left to fail loudly: an app still installed whose worker has been cleared away POSTs to the server, which answers 405. A server route would trade a plain error for a silent nothing, and the payload is gone in both -- it only ever existed in that request body. Verified by test, not on a device: Android is the only place this exists at all, and the extension driving Chrome is not connected here. The handoff is pinned from the tab's side against a cache shaped exactly as the worker leaves it, since the two files never see each other.
115 lines
4.9 KiB
TypeScript
115 lines
4.9 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { collectShare, shareBody, SHARE_MAX_AGE_MS } from "@/lib/shareTarget";
|
|
import { SW_CACHE_NAME } from "@/lib/swCache";
|
|
|
|
/**
|
|
* The handoff, from the tab's side. The worker's half cannot be exercised here
|
|
* -- sw.js is copied to the build rather than imported, and there is no service
|
|
* worker under a test runner -- so what is stood up below is the cache it
|
|
* writes into, keyed and shaped exactly as `stashShare` leaves it.
|
|
*
|
|
* That shape is the contract between two files that never see each other, and
|
|
* it is the thing worth pinning: a drift on either side is silent. Nothing
|
|
* errors, a share simply arrives at an empty composer.
|
|
*/
|
|
|
|
interface Entry {
|
|
body: BodyInit;
|
|
type: string;
|
|
}
|
|
|
|
function fakeCaches(entries: Record<string, Entry>) {
|
|
const store = new Map(Object.entries(entries));
|
|
const cache = {
|
|
match: vi.fn(async (key: string) => {
|
|
const e = store.get(key);
|
|
return e ? new Response(e.body, { headers: { "content-type": e.type } }) : undefined;
|
|
}),
|
|
delete: vi.fn(async (key: string) => store.delete(key)),
|
|
put: vi.fn(async () => undefined),
|
|
};
|
|
vi.stubGlobal("caches", { open: vi.fn(async (name: string) => (name === SW_CACHE_NAME ? cache : { match: async () => undefined })) });
|
|
return { cache, store };
|
|
}
|
|
|
|
/** What the worker writes, at the keys it writes them under. */
|
|
function stash(meta: Record<string, unknown>, files: { name: string; type: string; body: string }[] = []) {
|
|
const entries: Record<string, Entry> = {};
|
|
const index = files.map((f, i) => ({ key: `/ihasmail-share/${i}`, name: f.name, type: f.type }));
|
|
entries["/ihasmail-share"] = { body: JSON.stringify({ at: Date.now(), files: index, ...meta }), type: "application/json" };
|
|
for (const [i, f] of files.entries()) entries[`/ihasmail-share/${i}`] = { body: f.body, type: f.type };
|
|
return entries;
|
|
}
|
|
|
|
beforeEach(() => vi.unstubAllGlobals());
|
|
afterEach(() => vi.unstubAllGlobals());
|
|
|
|
describe("collecting a share", () => {
|
|
it("finds nothing on an ordinary start, which is almost every start", async () => {
|
|
fakeCaches({});
|
|
await expect(collectShare()).resolves.toBeNull();
|
|
});
|
|
|
|
it("survives a browser with no cache storage at all", async () => {
|
|
vi.stubGlobal("caches", undefined);
|
|
await expect(collectShare()).resolves.toBeNull();
|
|
});
|
|
|
|
it("rebuilds the files, with their names and types intact", async () => {
|
|
fakeCaches(stash({ title: "Holiday", text: "", url: "" }, [
|
|
{ name: "beach.png", type: "image/png", body: "pixels" },
|
|
{ name: "notes.txt", type: "text/plain", body: "later" },
|
|
]));
|
|
const share = await collectShare();
|
|
expect(share?.title).toBe("Holiday");
|
|
expect(share?.files.map((f) => [f.name, f.type])).toEqual([["beach.png", "image/png"], ["notes.txt", "text/plain"]]);
|
|
// The bytes made the trip, not just the index entry describing them.
|
|
expect(share!.files[0]!.size).toBe("pixels".length);
|
|
});
|
|
|
|
it("leaves nothing behind, so it cannot be collected twice", async () => {
|
|
const { store } = fakeCaches(stash({ text: "hello" }, [{ name: "a.txt", type: "text/plain", body: "x" }]));
|
|
await collectShare();
|
|
expect(store.size).toBe(0);
|
|
});
|
|
|
|
it("ignores one nobody came back for, and still clears it", async () => {
|
|
// A share to a signed-out ihasmail waits through the sign-in page, so it
|
|
// cannot expire quickly -- but it must expire, or it opens a composer full
|
|
// of a forgotten photo on some unrelated morning.
|
|
const { store } = fakeCaches(stash({ at: Date.now() - SHARE_MAX_AGE_MS - 1000, text: "stale" }));
|
|
await expect(collectShare()).resolves.toBeNull();
|
|
expect(store.size).toBe(0);
|
|
});
|
|
|
|
it("treats an empty share as no share", async () => {
|
|
fakeCaches(stash({ title: "", text: "", url: "" }));
|
|
await expect(collectShare()).resolves.toBeNull();
|
|
});
|
|
|
|
it("does not throw on a stash it cannot read", async () => {
|
|
fakeCaches({ "/ihasmail-share": { body: "not json", type: "application/json" } });
|
|
await expect(collectShare()).resolves.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("the body a share turns into", () => {
|
|
it("keeps the link when the text does not already carry it", () => {
|
|
expect(shareBody({ text: "Look at this", url: "https://example.com/a" })).toBe("Look at this\n\nhttps://example.com/a");
|
|
});
|
|
|
|
it("does not repeat a link the sharing app already put in the text", () => {
|
|
// Which field a link arrives in is up to whatever shared it, and they do
|
|
// not agree. Appending unconditionally would double it more often than not.
|
|
expect(shareBody({ text: "https://example.com/a", url: "https://example.com/a" })).toBe("https://example.com/a");
|
|
});
|
|
|
|
it("is just the link when that is all there was", () => {
|
|
expect(shareBody({ text: "", url: "https://example.com/a" })).toBe("https://example.com/a");
|
|
});
|
|
|
|
it("is just the text when there was no link", () => {
|
|
expect(shareBody({ text: "a thought", url: "" })).toBe("a thought");
|
|
});
|
|
});
|