mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 10:04:18 +00:00
Scheduled send did nothing when a time was picked by hand, and it did it
silently. ScheduledSendResolver refuses anything closer than MIN_SECONDS (60s);
the picker only checked whether a time had PASSED, so the entire band between
now and a minute from now sailed through the browser. The server then reported
the refusal with $form->addError() — a ROOT error — and _window.html.twig
rendered only form.toAddresses's errors, so the message went nowhere at all.
Turbo replaced the compose frame with a re-render identical to what was already
on screen: menu closed, composer unchanged, no toast, one request in the
profiler, console clean.
Which is exactly the click a person makes to test the feature — they type the
next whole minute. Every green test used a PRESET; the custom picker appeared
in one test that drives it to a refusal, so "pick a time and have it actually
schedule" was asserted nowhere.
* the picker mirrors MIN_SECONDS and refuses below it, locally and in words
that are true of the time chosen ("past" and "too soon" are now two
different things to be told, in three catalogues)
* root-level form errors have somewhere to render, so no refusal from
schedule() can be swallowed again
* the spec drives the custom picker to a real schedule, all the way to a
persisted badge in Drafts — and pins the floor case as a local refusal
with no round trip at all
And the rest of the pass:
* the signature no longer eats the first paragraph. Clicking the empty space
BELOW it put the caret at the end of the sign-off, so typing welded onto
it; those clicks now land in the writing space above. The emptiness check
counted the signature as not-the-user's-writing unconditionally, so text
that had landed inside it was subtracted before the count and Send claimed
a visibly full message had none — the block is skipped only while it is
untouched.
* the subject/body confirmations are the in-app panel the plain-text switch
already uses, not window.confirm(), which froze a test session three times.
Both reasons are one question asked once, and the retry goes back through
the same submitter so a scheduled send stays scheduled.
* the To field: the refusal clears when a recipient is added, names the
invalid address rather than blaming the count, and keeps what was typed
(Tom Select wiped the box on blur before submit ever saw it). The
suggestion panel no longer reopens over the Subject row on every focus.
* aria-invalid and aria-describedby tie the refusal to the input; the From
select and the Tom Select original are named; the picker's refusal is a
role="alert".
* a subject typed on its own is worth autosaving — the threshold counted the
body alone, so "TEST-RELOAD halbfertig" was lost on reload with no warning
and nothing in Drafts. Below the threshold, beforeunload interrupts.
* the emoji panel is capped against the compose WINDOW rather than the
viewport (its top edge sat 33px outside it), the grid's scrollbar is thin
rather than a 15px native bar with arrows, and the skin-tone button is off
the panel outline.
* vergrößern, Schriftgröße, groß — the compose labels were the only corner
of the UI spelling it the Swiss way, and a spec was asserting it.
phpunit OK (3427 tests, 8849 assertions); composer stan [OK] No errors;
120 compose e2e passed, plus 48 adjacent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
472 lines
18 KiB
TypeScript
472 lines
18 KiB
TypeScript
import { test, expect, type Page } from "./support/test";
|
||
import { seed } from "./support/config";
|
||
|
||
/**
|
||
* The compose window's accidental-send guards, and the window's own manners.
|
||
*
|
||
* All of this comes from one external bug report against a live install, and
|
||
* the first item is the reason the rest were found: typing a string with no
|
||
* `@` into To and pressing Enter *sent the message* — no subject, no body, a
|
||
* garbage recipient — leaving only the 10s undo window to take it back, and
|
||
* that window did not work either.
|
||
*
|
||
* The mechanism was implicit form submission. Tom Select calls preventDefault
|
||
* on Enter only when it has something to do with the keystroke: an active
|
||
* suggestion to commit, or a `createItem()` that succeeds. A string matching no
|
||
* contact AND failing `createFilter` (no `@`) satisfies neither, so the keydown
|
||
* reached the <form> — whose action is the send URL.
|
||
*
|
||
* That is why compose-recipients.spec.ts never caught it: every address it
|
||
* types is valid, so `createItem()` always succeeds and always swallows the
|
||
* key. The invalid address is the whole test.
|
||
*/
|
||
|
||
const DOCK = "#compose_dock";
|
||
const GARBAGE = "keine-gueltige-adresse";
|
||
const VALID = "safety-valid@example.test";
|
||
|
||
test.beforeAll(() => {
|
||
seed("seed-mail", "clear-drafts");
|
||
});
|
||
|
||
async function openCompose(page: Page): Promise<void> {
|
||
await page.goto("/mail/inbox");
|
||
await page.getByRole("link", { name: "Compose" }).first().click();
|
||
await expect(page.locator(`${DOCK} .ts-control`).first()).toBeVisible();
|
||
}
|
||
|
||
function toInput(page: Page) {
|
||
return page.locator(`${DOCK} .ts-control input`).first();
|
||
}
|
||
|
||
function chips(page: Page) {
|
||
return page.locator(`${DOCK} .ts-control`).first().locator(".item");
|
||
}
|
||
|
||
/**
|
||
* Records every send POST the page makes. Asserting "nothing was sent" needs a
|
||
* witness that survives the assertion — waitForResponse would only report a
|
||
* send that happened inside its own timeout.
|
||
*/
|
||
function watchSends(page: Page): string[] {
|
||
const sends: string[] = [];
|
||
|
||
page.on("request", (request) => {
|
||
if (request.method() === "POST" && /\/compose\/send/.test(request.url())) {
|
||
sends.push(request.url());
|
||
}
|
||
});
|
||
|
||
return sends;
|
||
}
|
||
|
||
async function fillMessage(page: Page, address: string): Promise<void> {
|
||
await toInput(page).fill(address);
|
||
await toInput(page).press("Enter");
|
||
await page.locator(`${DOCK} [data-compose--compose-target="subject"]`).fill("Safety subject");
|
||
await page
|
||
.locator(`${DOCK} [data-compose--compose-toolbar-target="editor"]`)
|
||
.fill("Safety body text, long enough to save.");
|
||
}
|
||
|
||
test.describe("compose safety", () => {
|
||
// ── K-01 ──────────────────────────────────────────────────────────
|
||
|
||
test("Enter on an invalid address does not send the message", async ({ page }) => {
|
||
const sends = watchSends(page);
|
||
|
||
await openCompose(page);
|
||
|
||
await toInput(page).fill(GARBAGE);
|
||
await toInput(page).press("Enter");
|
||
|
||
// Give a send that was going to happen the time to happen.
|
||
await page.waitForTimeout(1_000);
|
||
|
||
expect(sends).toHaveLength(0);
|
||
|
||
await expect(page.locator(`${DOCK} .ts-control`).first()).toBeVisible();
|
||
await expect(
|
||
page.locator(`${DOCK} [data-compose--compose-toolbar-target="editor"]`),
|
||
).toBeVisible();
|
||
});
|
||
|
||
test("Enter on a valid address commits a chip and does not send", async ({ page }) => {
|
||
const sends = watchSends(page);
|
||
|
||
await openCompose(page);
|
||
|
||
await toInput(page).fill(VALID);
|
||
await toInput(page).press("Enter");
|
||
|
||
await expect(chips(page)).toHaveCount(1);
|
||
await expect(chips(page).first()).toContainText(VALID);
|
||
|
||
await page.waitForTimeout(500);
|
||
expect(sends).toHaveLength(0);
|
||
});
|
||
|
||
/** Ctrl+Enter stays a send — the one deliberate keyboard shortcut. */
|
||
test("Ctrl+Enter sends", async ({ page }) => {
|
||
const sends = watchSends(page);
|
||
|
||
await openCompose(page);
|
||
await fillMessage(page, VALID);
|
||
|
||
await page.locator(`${DOCK} [data-compose--compose-target="subject"]`).press("Control+Enter");
|
||
|
||
await expect.poll(() => sends.length, { timeout: 10_000 }).toBe(1);
|
||
});
|
||
|
||
// ── K-02 ──────────────────────────────────────────────────────────
|
||
|
||
test("an invalid address is refused rather than chipped, and says so", async ({ page }) => {
|
||
await openCompose(page);
|
||
|
||
await toInput(page).fill(GARBAGE);
|
||
await toInput(page).press("Enter");
|
||
|
||
await expect(chips(page)).toHaveCount(0);
|
||
await expect(page.locator(`${DOCK} [data-compose--compose-target="errors"]`)).toContainText(
|
||
GARBAGE,
|
||
);
|
||
});
|
||
|
||
test("sending with no recipient is refused", async ({ page }) => {
|
||
const sends = watchSends(page);
|
||
|
||
await openCompose(page);
|
||
await page.locator(`${DOCK} [data-compose--compose-target="subject"]`).fill("No recipient");
|
||
await page
|
||
.locator(`${DOCK} [data-compose--compose-toolbar-target="editor"]`)
|
||
.fill("Body with no addressee at all.");
|
||
|
||
await page.locator(DOCK).getByRole("button", { name: "Send", exact: true }).click();
|
||
|
||
await page.waitForTimeout(1_000);
|
||
|
||
expect(sends).toHaveLength(0);
|
||
await expect(page.locator(`${DOCK} [data-compose--compose-target="errors"]`)).toBeVisible();
|
||
});
|
||
|
||
/**
|
||
* The server's own refusal, reached past the window entirely.
|
||
*
|
||
* "The API must refuse, not just the UI" is the point: the guards above all
|
||
* live in a controller the user can simply not run.
|
||
*/
|
||
test("the server refuses a send with no valid recipient", async ({ page }) => {
|
||
await openCompose(page);
|
||
|
||
const status = await page.evaluate(async (garbage) => {
|
||
const form = document.querySelector("#compose_dock form") as HTMLFormElement;
|
||
const body = new FormData(form);
|
||
|
||
body.set("compose[toAddresses][]", garbage);
|
||
|
||
const response = await fetch("/compose/send", {
|
||
method: "POST",
|
||
body,
|
||
headers: { "X-Requested-With": "XMLHttpRequest" },
|
||
});
|
||
|
||
return { ok: response.ok, text: (await response.text()).slice(0, 4000) };
|
||
}, GARBAGE);
|
||
|
||
// Not a send toast — the window comes back carrying the refusal.
|
||
expect(status.text).not.toContain("toast-region");
|
||
expect(status.text).toMatch(/recipient|Empfänger/i);
|
||
});
|
||
|
||
/**
|
||
* The question is asked IN THE APP, not by the browser.
|
||
*
|
||
* It was `window.confirm()`, which froze a tester's session three times: a
|
||
* native dialog blocks the whole tab, takes no styling, and sat a few
|
||
* centimetres from the in-app panel this window already uses to ask whether
|
||
* to remove formatting. Two kinds of confirmation for two versions of one
|
||
* question.
|
||
*
|
||
* The old version of this test dismissed a `dialog` event, which is why it
|
||
* would keep passing whether the dialog appeared or not — nothing was sent
|
||
* either way. It now names the panel, and fails if a native dialog is what
|
||
* comes back.
|
||
*/
|
||
test("an empty subject asks before sending, in the app rather than in a browser dialog", async ({
|
||
page,
|
||
}) => {
|
||
const sends = watchSends(page);
|
||
const native: string[] = [];
|
||
|
||
page.on("dialog", (dialog) => {
|
||
native.push(dialog.message());
|
||
void dialog.dismiss();
|
||
});
|
||
|
||
await openCompose(page);
|
||
|
||
await toInput(page).fill(VALID);
|
||
await toInput(page).press("Enter");
|
||
await page
|
||
.locator(`${DOCK} [data-compose--compose-toolbar-target="editor"]`)
|
||
.fill("Body but no subject line.");
|
||
|
||
await page.locator(DOCK).getByRole("button", { name: "Send", exact: true }).click();
|
||
|
||
const panel = page.locator(`${DOCK} [data-compose--compose-target="sendWarning"]`);
|
||
|
||
await expect(panel).toBeVisible();
|
||
await expect(panel).toHaveAttribute("role", "alertdialog");
|
||
await expect(panel).toContainText(/no subject/i);
|
||
expect(native, "the browser must not be the one asking").toHaveLength(0);
|
||
|
||
// Declining sends nothing and keeps the window as it was.
|
||
await panel.getByRole("button", { name: "Keep editing" }).click();
|
||
|
||
await expect(panel).toBeHidden();
|
||
await page.waitForTimeout(1_000);
|
||
expect(sends).toHaveLength(0);
|
||
await expect(page.locator(`${DOCK} .compose-window`)).toBeVisible();
|
||
});
|
||
|
||
/** And accepting it actually sends — the panel is a gate, not a wall. */
|
||
test("the send-anyway panel lets the message through when it is accepted", async ({ page }) => {
|
||
const sends = watchSends(page);
|
||
|
||
await openCompose(page);
|
||
|
||
await toInput(page).fill(VALID);
|
||
await toInput(page).press("Enter");
|
||
await page
|
||
.locator(`${DOCK} [data-compose--compose-toolbar-target="editor"]`)
|
||
.fill("Body but no subject line.");
|
||
|
||
await page.locator(DOCK).getByRole("button", { name: "Send", exact: true }).click();
|
||
|
||
const panel = page.locator(`${DOCK} [data-compose--compose-target="sendWarning"]`);
|
||
await expect(panel).toBeVisible();
|
||
|
||
await panel.getByRole("button", { name: "Send anyway" }).click();
|
||
|
||
await expect(page.locator(`${DOCK} .compose-window`)).toHaveCount(0, { timeout: 10_000 });
|
||
expect(sends.length, "the accepted send goes out").toBeGreaterThan(0);
|
||
});
|
||
|
||
/**
|
||
* Both reasons at once are one question, asked once.
|
||
*
|
||
* Sequential `window.confirm()`s meant a message with neither subject nor
|
||
* body asked twice, and the second dialog arrived after the first had been
|
||
* answered with no way back to it.
|
||
*/
|
||
test("a message missing both subject and text asks once, naming both", async ({ page }) => {
|
||
await openCompose(page);
|
||
|
||
await toInput(page).fill(VALID);
|
||
await toInput(page).press("Enter");
|
||
|
||
await page.locator(DOCK).getByRole("button", { name: "Send", exact: true }).click();
|
||
|
||
const panel = page.locator(`${DOCK} [data-compose--compose-target="sendWarning"]`);
|
||
|
||
await expect(panel).toBeVisible();
|
||
await expect(panel).toContainText(/no subject/i);
|
||
await expect(panel).toContainText(/no text/i);
|
||
});
|
||
|
||
/**
|
||
* Enter on a toolbar control is neither a send nor a failed address.
|
||
*
|
||
* The typeface and size pickers are Tom Selects too, so "is this a Tom
|
||
* Select?" is not the same question as "is this an address field?" — the
|
||
* Enter handler keys off the address rows, not off `.ts-wrapper`.
|
||
*
|
||
* (These two pickers hold three and four options, below ui--select's
|
||
* search-box threshold, so they render no text input and the handler
|
||
* declines them a step earlier still. The assertion is on the behaviour
|
||
* either way: Enter here does nothing at all.)
|
||
*/
|
||
test("Enter on a formatting control neither sends nor reports an address", async ({ page }) => {
|
||
const sends = watchSends(page);
|
||
|
||
await openCompose(page);
|
||
|
||
const typeface = page.locator(`${DOCK} [data-compose--compose-toolbar-target="fontFamily"]`);
|
||
await expect(typeface).toBeAttached();
|
||
|
||
// The widget Tom Select built in its place, which is what has focus.
|
||
await page
|
||
.locator(`${DOCK} [data-compose--compose-target="formatBar"] .ts-control`)
|
||
.first()
|
||
.press("Enter");
|
||
|
||
await expect(page.locator(`${DOCK} [data-compose--compose-target="errors"]`)).toBeHidden();
|
||
|
||
await page.waitForTimeout(500);
|
||
expect(sends).toHaveLength(0);
|
||
});
|
||
|
||
// ── K-03 / K-04 ───────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Undo has to put the window back, with everything that was typed in it.
|
||
*
|
||
* It used to 500: the undo response rendered the compose window without
|
||
* `pickerIntegrations`, and the cancel flag was already flushed by then —
|
||
* so the send was called off, the toast faded, and the draft was filed with
|
||
* no way back to it.
|
||
*/
|
||
test("undo reopens the window with the recipient intact", async ({ page }) => {
|
||
await openCompose(page);
|
||
await fillMessage(page, VALID);
|
||
|
||
await page.locator(DOCK).getByRole("button", { name: "Send", exact: true }).click();
|
||
|
||
const undo = page.locator("[data-controller='compose--undo-send']");
|
||
await expect(undo).toBeVisible({ timeout: 10_000 });
|
||
await undo.click();
|
||
|
||
// The window is back…
|
||
await expect(page.locator(`${DOCK} .compose-window`)).toBeVisible({ timeout: 10_000 });
|
||
|
||
// …and so is everything that was in it.
|
||
await expect(chips(page)).toHaveCount(1);
|
||
await expect(chips(page).first()).toContainText(VALID);
|
||
await expect(page.locator(`${DOCK} [data-compose--compose-target="subject"]`)).toHaveValue(
|
||
"Safety subject",
|
||
);
|
||
await expect(
|
||
page.locator(`${DOCK} [data-compose--compose-toolbar-target="editor"]`),
|
||
).toContainText("Safety body text");
|
||
});
|
||
|
||
// ── A-03 / A-05 ───────────────────────────────────────────────────
|
||
|
||
test("every compose control has an accessible name", async ({ page }) => {
|
||
await openCompose(page);
|
||
|
||
const dock = page.locator(DOCK);
|
||
|
||
for (const name of [
|
||
"To",
|
||
"Send",
|
||
"Minimize",
|
||
"Expand",
|
||
"Attach files",
|
||
"Insert emoji",
|
||
"Insert image",
|
||
"Encrypt message",
|
||
"Insert signature",
|
||
"More options",
|
||
"Text colour",
|
||
"Typeface",
|
||
"Text size",
|
||
]) {
|
||
await expect(
|
||
dock.getByRole("button", { name, exact: true })
|
||
.or(dock.getByRole("combobox", { name, exact: true }))
|
||
.or(dock.getByRole("textbox", { name, exact: true }))
|
||
.first(),
|
||
`"${name}" should be reachable by its accessible name`,
|
||
).toBeAttached();
|
||
}
|
||
|
||
// A-05: the text-colour control was not in the accessibility tree at
|
||
// all — a <label> wrapping a 0×0, pointer-events:none, tabindex="-1"
|
||
// input. It has to be focusable.
|
||
const colour = dock.getByRole("textbox", { name: "Text colour" });
|
||
await colour.focus();
|
||
await expect(colour).toBeFocused();
|
||
|
||
// No control may be left nameless.
|
||
const unnamed = await dock.evaluate((root) =>
|
||
Array.from(root.querySelectorAll("button"))
|
||
.filter((b) => {
|
||
const el = b as HTMLElement;
|
||
if (el.offsetParent === null) return false;
|
||
const name =
|
||
el.getAttribute("aria-label") ??
|
||
el.getAttribute("title") ??
|
||
el.textContent?.trim() ??
|
||
"";
|
||
return name === "";
|
||
})
|
||
.map((b) => b.outerHTML.slice(0, 120)),
|
||
);
|
||
|
||
expect(unnamed).toEqual([]);
|
||
});
|
||
});
|
||
|
||
// ── I-01 / I-03: the window speaks the UI's language ──────────────────
|
||
|
||
/**
|
||
* Switch the signed-in user's language through the real settings form.
|
||
*
|
||
* The active language's button is `disabled`, so asking for the one already in
|
||
* force is a no-op rather than a failed click — which matters in the cleanup
|
||
* hook, where the test may have failed before it ever changed anything.
|
||
*/
|
||
async function useLocale(page: Page, locale: string): Promise<void> {
|
||
await page.goto("/settings?section=general");
|
||
|
||
const button = page
|
||
.locator(`form[action*='locale']:has(input[value='${locale}']) button`)
|
||
.first();
|
||
|
||
if (await button.isDisabled()) {
|
||
return;
|
||
}
|
||
|
||
await button.click();
|
||
await expect(page).toHaveURL(/section=general/);
|
||
}
|
||
|
||
test.describe("compose localization", () => {
|
||
// Restore English for whatever runs next on this worker's user.
|
||
test.afterEach(async ({ page }) => {
|
||
await useLocale(page, "en");
|
||
});
|
||
|
||
test("the compose window is German in a German UI", async ({ page }) => {
|
||
await useLocale(page, "de");
|
||
|
||
await page.goto("/mail/inbox");
|
||
await page.getByRole("link", { name: /Schreiben|Compose/ }).first().click();
|
||
|
||
const dock = page.locator(DOCK);
|
||
await expect(dock.locator(".ts-control").first()).toBeVisible();
|
||
|
||
const text = await dock.innerText();
|
||
|
||
// The words the report named, in German.
|
||
for (const german of ["Neue Nachricht", "Von", "An", "Betr", "Senden"]) {
|
||
expect(text).toContain(german);
|
||
}
|
||
|
||
// And none of the English it used to show beside "Serifenlos".
|
||
for (const english of ["New Message", "Subj", "Write your message"]) {
|
||
expect(text).not.toContain(english);
|
||
}
|
||
|
||
// The dock chrome and the formatting toolbar too (I-03).
|
||
for (const name of [
|
||
"Minimieren",
|
||
// ß, not Swiss ss. The compose labels were the only corner of the
|
||
// UI spelling it "vergrössern" — and this list is why it survived
|
||
// an audit: the spec asserted the misspelling, so the catalogue and
|
||
// the test agreed with each other and with nothing else.
|
||
"Fenster vergrößern",
|
||
"Linksbündig ausrichten",
|
||
"Nummerierte Liste",
|
||
"Rückgängig",
|
||
"Einzug vergrößern",
|
||
]) {
|
||
await expect(
|
||
dock.getByRole("button", { name, exact: true }),
|
||
`the toolbar should offer "${name}"`,
|
||
).toBeAttached();
|
||
}
|
||
});
|
||
});
|