src/compose-send.test.tsx
import { test } from "node:test";
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { act, useState } from "react";
import { JSDOM } from "jsdom";
import { ComposePanel, sendCompose, type Compose } from "./compose/ComposePanel";
const initial: Compose = {
accountId: "synthetic",
to: ["recipient@example.com"],
subject: "Synthetic subject",
body: "Synthetic body",
};
function client(
send: Parameters<typeof sendCompose>[1]["send"],
reload: () => Promise<unknown> = async () => {},
initialDraft = initial,
lifecycle: Partial<Pick<Parameters<typeof sendCompose>[1], "prepare" | "complete">> = {},
) {
let draft: Compose | null = { ...initialDraft };
let busy = false;
let confirmed = false;
const notices: string[] = [];
const prompts: string[] = [];
const lock = { current: false };
const setDraft: Parameters<typeof sendCompose>[1]["setDraft"] = (value) => {
draft = typeof value === "function" ? value(draft) : value;
};
return {
get draft() { return draft; },
get busy() { return busy; },
notices, prompts, setDraft,
approve: () => { confirmed = true; },
submit: () => sendCompose(draft!, {
lock, send: async (draft) => ({ ok: true, ...await send(draft) }), reload, setDraft,
prepare: async () => {}, complete: async () => {}, ...lifecycle,
setBusy: (value) => { busy = value; },
setNotice: (value) => { notices.push(value); },
confirm: (message) => { prompts.push(message); return confirmed; },
}),
};
}
async function panel(draft: Compose, onSend: () => void, setDraft = (_draft: Compose) => {}, onClose = () => {}) {
const dom = new JSDOM("<!doctype html><div id='root'></div>", { url: "http://localhost/" });
const globals = { window: dom.window, document: dom.window.document, HTMLElement: dom.window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true };
const old = new Map(Object.keys(globals).map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
for (const [key, value] of Object.entries(globals)) Object.defineProperty(globalThis, key, { configurable: true, value });
const { createRoot } = await import("react-dom/client");
const container = dom.window.document.getElementById("root")!;
const root = createRoot(container);
function Editor() {
const [value, update] = useState(draft);
return <ComposePanel draft={value} onSend={onSend} setDraft={(next) => { update(next); setDraft(next); }} onClose={onClose} onDiscard={onClose} onRetry={() => {}} onSaveCopy={() => {}}
progress={{ phase: "saved", message: "Draft saved." }} busy={false} accounts={[{ id: "synthetic", email: "sender@example.com", provider: "imap" }, { id: "synthetic-other", email: "other@example.test", provider: "imap" }]} />;
}
await act(async () => root.render(<Editor />));
return {
container, form: container.querySelector("form")!,
async click(button: HTMLElement) { await act(async () => button.click()); },
async submit() { await act(async () => container.querySelector("form")!.dispatchEvent(new dom.window.Event("submit", { bubbles: true, cancelable: true }))); },
async edit(selector: string, value: string) {
const input = container.querySelector<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(selector)!;
const prototype = input.tagName === "TEXTAREA" ? dom.window.HTMLTextAreaElement.prototype : input.tagName === "SELECT" ? dom.window.HTMLSelectElement.prototype : dom.window.HTMLInputElement.prototype;
await act(async () => {
Object.getOwnPropertyDescriptor(prototype, "value")!.set!.call(input, value);
input.dispatchEvent(new dom.window.Event(input.tagName === "SELECT" ? "change" : "input", { bubbles: true }));
});
},
async close() {
await act(async () => root.unmount()); dom.window.close();
for (const [key, descriptor] of old) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
},
};
}
test("a lost response retains the draft and declined resend submits no second request", async (t) => {
const requests: unknown[] = [];
const server = createServer(async (request, response) => {
let body = "";
for await (const chunk of request) body += chunk;
requests.push(JSON.parse(body));
if (requests.length === 1) response.destroy();
else response.end(JSON.stringify({ ok: true }));
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
t.after(() => new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve())));
const address = server.address();
assert.ok(address && typeof address === "object");
const c = client(async (draft) => {
const response = await fetch(`http://127.0.0.1:${address.port}/send`, {
method: "POST", body: JSON.stringify(draft),
});
return response.json();
});
await c.submit();
assert.equal(requests.length, 1);
assert.deepEqual(c.draft, { ...initial, sendUnknown: true });
assert.equal(c.busy, false);
assert.match(c.notices.at(-1)!, /Send status unknown/);
await c.submit();
assert.equal(requests.length, 1);
assert.equal(c.prompts.length, 1);
assert.match(c.prompts[0], /already.*sent/);
assert.match(c.prompts[0], /duplicate/);
c.approve();
await c.submit();
assert.equal(requests.length, 2);
assert.equal(c.draft, null);
assert.equal(c.notices.at(-1), "Email sent.");
});
test("HTTP 400, parse failure and transport failure all retain an unknown draft", async (t) => {
for (const error of [new Error("HTTP 400"), new SyntaxError("Invalid JSON"), new TypeError("fetch failed")]) {
await t.test(error.message, async () => {
const c = client(async () => { throw error; });
await c.submit();
assert.equal(c.draft?.sendUnknown, true);
assert.equal(c.draft?.body, initial.body);
assert.equal(c.busy, false);
assert.match(c.notices.at(-1)!, /Send status unknown/);
assert.doesNotMatch(c.notices.at(-1)!, /Not sent|Try again/);
});
}
});
test("click and Enter form submissions share the unknown-send confirmation", async () => {
let requests = 0;
const c = client(async () => { requests++; return {}; }, undefined, { ...initial, sendUnknown: true });
let pending: Promise<void> | undefined;
const ui = await panel(c.draft!, () => { pending = c.submit(); });
try {
const button = ui.form.querySelector<HTMLButtonElement>('button[type="submit"]')!;
assert.equal(button.type, "submit");
for (const submit of [() => ui.click(button), () => ui.submit()]) {
await submit(); await pending;
assert.equal(requests, 0);
assert.equal(c.draft?.sendUnknown, true);
}
assert.equal(c.prompts.length, 2);
c.approve(); await ui.submit(); await pending;
assert.equal(requests, 1);
} finally { await ui.close(); }
});
test("editing an unknown draft preserves its warning and the next send guard", async () => {
let requests = 0;
const c = client(async () => { requests++; return {}; }, undefined, { ...initial, sendUnknown: true });
const ui = await panel(c.draft!, () => {}, c.setDraft);
try {
await ui.click(ui.container.querySelector<HTMLElement>('[role="combobox"][aria-label="From"]')!);
await ui.click(ui.container.querySelector<HTMLElement>('[role="option"]:last-child')!);
assert.equal(c.draft?.accountId, "synthetic-other");
assert.equal(c.draft?.sendUnknown, true);
for (const [selector, value] of [ ['input[type="email"]', "edited@example.com"], ['input[maxlength="998"]', "Edited subject"], ["textarea", "Edited body"]]) {
await ui.edit(selector, value);
assert.equal(c.draft?.sendUnknown, true);
}
assert.equal(c.draft?.body, "Edited body");
assert.match(ui.container.textContent!, /Send status unknown/);
assert.match(ui.container.textContent!, /may already have been sent/);
await c.submit(); assert.equal(requests, 0);
} finally { await ui.close(); }
});
test("pre-submission form validation never calls send", async () => {
let sends = 0;
const ui = await panel({ ...initial, to: ["invalid"], body: "" }, () => { sends++; });
try {
assert.equal(ui.form.querySelector<HTMLInputElement>('input[type="email"]')!.required, true);
assert.equal(ui.form.querySelector<HTMLTextAreaElement>("textarea")!.required, true);
assert.equal(ui.form.reportValidity(), false);
await ui.submit(); assert.equal(sends, 0);
} finally { await ui.close(); }
});
test("explicitly discarding an unknown draft warns about possible delivery without calling it unsent", async (t) => {
const previous = Object.getOwnPropertyDescriptor(globalThis, "confirm");
const prompts: string[] = [];
let approved = false;
Object.defineProperty(globalThis, "confirm", { configurable: true, value: (message: string) => {
prompts.push(message); return approved;
} });
t.after(() => {
if (previous) Object.defineProperty(globalThis, "confirm", previous);
else Reflect.deleteProperty(globalThis, "confirm");
});
let closes = 0;
const ui = await panel({ ...initial, to: [""], subject: "", body: "", sendUnknown: true }, () => {}, undefined, () => { closes++; });
try {
const discard = [...ui.container.querySelectorAll("button")].find((button) => button.getAttribute("aria-label") === "Delete draft")!;
await ui.click(discard); assert.equal(closes, 0);
assert.match(prompts[0], /already.*sent/); assert.doesNotMatch(prompts[0], /unsent/);
approved = true; await ui.click(discard); assert.equal(closes, 1);
} finally { await ui.close(); }
});
test("send success and provider warnings survive a separate inbox refresh failure", async () => {
for (const warning of [undefined, "Email was sent, but saving a Sent copy failed. Do not resend."]) {
const c = client(async () => ({ warning }), async () => { throw new Error("Synthetic refresh failure"); });
await c.submit();
assert.equal(c.draft, null);
assert.equal(c.busy, false);
assert.match(c.notices.at(-1)!, /Email (was )?sent/);
assert.match(c.notices.at(-1)!, /Inbox refresh failed/);
if (warning) assert.ok(c.notices.at(-1)!.includes(warning));
}
});
test("an in-flight send blocks another submission before React updates busy", async () => {
let resolve!: (value: {}) => void;
let sends = 0;
const c = client(() => { sends++; return new Promise((done) => { resolve = done; }); });
const pending = c.submit();
assert.equal(c.busy, true);
await c.submit();
assert.equal(sends, 1);
resolve({});
await pending;
assert.equal(c.busy, false);
});
test("a failure retains edits made while the send was pending and hides error details", async () => {
let reject!: (error: Error) => void;
const c = client(() => new Promise((_resolve, fail) => { reject = fail; }));
const pending = c.submit();
c.setDraft({ ...initial, body: "Edited while waiting" });
await Promise.resolve();
reject(new Error("Synthetic provider error for private@example.com"));
await pending;
assert.equal(c.draft?.body, "Edited while waiting");
assert.equal(c.draft?.sendUnknown, true);
assert.doesNotMatch(c.notices.at(-1)!, /private@example.com|Synthetic provider/);
});
test("an unacknowledged draft save blocks sending and cleanup", async () => {
let sends = 0, cleanups = 0;
const c = client(async () => { sends++; return {}; }, undefined, initial, {
prepare: async () => { throw new Error("Synthetic save failure"); },
complete: async () => { cleanups++; },
});
await c.submit();
assert.equal(sends, 0);
assert.equal(cleanups, 0);
assert.equal(c.draft?.body, initial.body);
assert.equal(c.busy, false);
assert.match(c.notices.at(-1)!, /No send was attempted/);
});
test("accepted send cleanup failure never becomes a send failure or retry", async () => {
const order: string[] = [];
const c = client(async () => { order.push("send"); return {}; }, undefined, initial, {
prepare: async () => { order.push("persist uncertainty"); },
complete: async () => { order.push("cleanup"); throw new Error("Synthetic cleanup failure"); },
});
await c.submit();
assert.deepEqual(order, ["persist uncertainty", "send", "cleanup"]);
assert.equal(c.draft, null);
assert.match(c.notices.at(-1)!, /Email sent/);
assert.match(c.notices.at(-1)!, /Do not resend/);
assert.doesNotMatch(c.notices.at(-1)!, /Send status unknown/);
});
test("a response without provider acceptance preserves uncertainty and skips cleanup", async () => {
let cleanups = 0;
const c = client(async () => ({ ok: false }), undefined, initial, {
complete: async () => { cleanups++; },
});
await c.submit();
assert.equal(cleanups, 0);
assert.equal(c.draft?.sendUnknown, true);
assert.match(c.notices.at(-1)!, /Send status unknown/);
});