The share address takes a plain form POST, which any website can make, and the app opened whatever arrived straight into a composer. It now shows what was shared -- the title, the start of the text and link, and the file names -- and opens a message only when the reader chooses to. Discarding drops it. Confirm dialogs now put a message that is not plain text in a div, since the summary has blocks of its own. Three new strings, translated in all nine catalogs.
131 lines
5.6 KiB
TypeScript
131 lines
5.6 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { shareSummary, collectShare, shareBody, SHARE_MAX_AGE_MS } from "@/lib/shareTarget";
|
|
import { SW_CACHE_NAME } from "@/lib/sw/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");
|
|
});
|
|
});
|
|
|
|
describe("shareSummary", () => {
|
|
const file = (name: string) => new File(["x"], name);
|
|
it("gives the title, the text and link together, and the file names", () => {
|
|
expect(shareSummary({ title: " Trip ", text: "See this", url: "https://example.com", files: [file("a.jpg")] })).toEqual({
|
|
title: "Trip",
|
|
preview: "See this https://example.com",
|
|
files: ["a.jpg"],
|
|
});
|
|
});
|
|
it("shortens a long text rather than showing all of it", () => {
|
|
const { preview } = shareSummary({ title: "", text: "word ".repeat(100), url: "", files: [] });
|
|
expect(preview.length).toBeLessThanOrEqual(160);
|
|
expect(preview.endsWith("…")).toBe(true);
|
|
});
|
|
});
|