Files
DumiFlex c7f4d91a10 fix(announce): never emit a half-formed role mention
Unset and empty were already safe. A MALFORMED id was not: any truthy
string went straight into `<@&…>`, so a stray space in the secret produced
a literal `<@&   >` and a pasted mention produced `<@&<@&123>>` — visible
junk at the top of the most public message the project sends.

`normalizeRoleId` is now the single decision point, applied inside
`buildPayload` rather than at the call site so every caller gets the same
guarantee. It trims, unwraps a pasted `<@&123>`, and requires a 17-20
digit snowflake. Anything else is rejected rather than guessed at: a ping
that silently does not happen is a far smaller problem than a broken one
that does.

`allowed_mentions.roles` is derived from the same normalised value, so
the two can never disagree — a content string naming a role the payload
does not permit would be a mention that renders but never notifies.

The CLI warns when a non-empty id was supplied and rejected. Dropping it
silently would leave someone wondering why the ping never fired.
2026-08-04 20:05:45 +03:00

349 lines
13 KiB
JavaScript

import { describe, it, expect } from "vitest";
import {
DISPATCH_URL,
normalizeRoleId,
splitBody,
toDiscordMarkdown,
buildDescription,
buildPayload,
buildRollupPayload,
clampToLimit,
EMBED_DESCRIPTION_LIMIT,
} from "./discord-notes.mjs";
/**
* The announcement is derived from notes that already exist, so the risk is not
* "does it write good copy" — it is "does it mangle copy that was already
* fine". Most of these pin a Discord-specific rendering gap.
*/
const REAL_SHAPE = `## 🎉 Wildcard Pipeline v2.11.0
📖 [Docs (wiki)](https://x/wiki) · 💬 [Discord](https://discord.gg/x) · 📦 [Install](https://x/q) · 🐛 [Issues](https://x/i)
### ✨ What's new
A correctness fix for held modules, plus tools for reconnecting workflow items.
- **Held modules stay held.** A module set to **Hold across run** could re-roll.
- **Search by id.** Pasting an 8-character id now finds the item.
<!-- /modal -->
### Also in this release
- Wire collapse survives a live renderer switch
- Modal keystrokes no longer escape
---
**Full changelog:** [v2.10.2…v2.11.0](https://x/compare)
`;
describe("splitBody", () => {
it("cuts at the modal marker and counts what is left behind", () => {
const { head, tailCount } = splitBody(REAL_SHAPE);
expect(head).toContain("Held modules stay held");
expect(head).not.toContain("Also in this release");
expect(tailCount).toBe(2);
});
it("treats a body with no marker as all highlights", () => {
const { head, tailCount } = splitBody("- one\n- two");
expect(head).toBe("- one\n- two");
expect(tailCount).toBe(0);
});
it("survives an empty or missing body", () => {
expect(splitBody("").head).toBe("");
expect(splitBody(undefined).head).toBe("");
});
});
describe("toDiscordMarkdown", () => {
it("drops the GitHub template header — the Discord link is noise on Discord", () => {
const out = toDiscordMarkdown(REAL_SHAPE);
expect(out).not.toContain("🎉 Wildcard Pipeline");
expect(out).not.toContain("discord.gg");
expect(out).not.toContain("Docs (wiki)");
});
it("keeps masked links, which DO render inside an embed", () => {
expect(toDiscordMarkdown("see [the docs](https://x/d)")).toContain("[the docs](https://x/d)");
});
it("turns headings into bold — an embed h2 outsizes the embed title", () => {
const out = toDiscordMarkdown("### What's new\nbody");
expect(out).toContain("**What's new**");
expect(out).not.toMatch(/^#/m);
});
it("converts tables to bullets, since Discord has none", () => {
const out = toDiscordMarkdown("| Item | Result |\n| --- | --- |\n| #29 | shipped |");
expect(out).toContain("- Item — Result");
expect(out).toContain("- #29 — shipped");
expect(out).not.toContain("|");
});
it("strips <details>, which renders as raw tags", () => {
const out = toDiscordMarkdown("keep\n<details><summary>x</summary>\n- hidden\n</details>");
expect(out).toContain("keep");
expect(out).not.toContain("hidden");
expect(out).not.toContain("<details>");
});
it("strips HTML comments and horizontal rules", () => {
const out = toDiscordMarkdown("a\n\n<!-- note -->\n\n---\n\nb");
expect(out).not.toContain("note");
expect(out).not.toMatch(/^-{3,}$/m);
expect(out).toContain("a");
expect(out).toContain("b");
});
it("leaves bold, italics and bullets alone", () => {
const out = toDiscordMarkdown("- **bold** and *italic* and `code`");
expect(out).toBe("- **bold** and *italic* and `code`");
});
});
describe("buildDescription", () => {
// A three-bullet highlights section over a forty-change release should not
// read as a quiet week.
it("says how much it is not showing", () => {
const out = buildDescription(REAL_SHAPE);
expect(out).toMatch(/plus 2 smaller changes/);
});
it("links the tail to the compare view when there is one", () => {
const out = buildDescription(REAL_SHAPE, { compareUrl: "https://x/compare" });
expect(out).toContain("](https://x/compare)");
});
// The "Also in this release" bullets live INSIDE a <details> block, and the
// file ends with an authoring comment that contains bullets of its own.
// Stripping details before counting reported a real 26-change release as 3.
it("counts the bullets inside <details>, and ignores those in comments", () => {
const body = [
"- highlight",
"<!-- /modal -->",
"<details><summary>Also in this release — 3 smaller changes</summary>",
"- one",
"- two",
"- three",
"</details>",
"<!--",
" - not a change, this is authoring guidance",
" - nor is this",
"-->",
].join("\n");
expect(splitBody(body).tailCount).toBe(3);
expect(buildDescription(body)).toMatch(/plus 3 smaller changes/);
});
it("says nothing about a tail that does not exist", () => {
expect(buildDescription("- only this")).not.toMatch(/smaller change/);
});
it("uses the singular for one", () => {
expect(buildDescription("- a\n<!-- /modal -->\n- b")).toMatch(/1 smaller change\b/);
});
});
describe("clampToLimit", () => {
it("leaves anything under the cap untouched", () => {
expect(clampToLimit("short")).toBe("short");
});
// A hard slice can land inside a link or a bold run, which Discord then
// renders as broken markup — worse than saying less.
it("cuts at a bullet boundary, not mid-markup", () => {
const body = Array.from({ length: 400 }, (_, i) => `- item ${i} with some text`).join("\n");
const out = clampToLimit(body);
expect(out.length).toBeLessThanOrEqual(EMBED_DESCRIPTION_LIMIT);
expect(out.endsWith("…")).toBe(true);
expect(out.split("\n").at(-3)).toMatch(/^- item \d+ with some text$/);
});
});
describe("buildPayload", () => {
it("puts the notes in an embed, because masked links need one", () => {
const p = buildPayload({ version: "v2.12.0", body: REAL_SHAPE, releaseUrl: "https://x/r" });
expect(p.embeds).toHaveLength(1);
expect(p.embeds[0].title).toBe("Wildcard Pipeline v2.12.0");
expect(p.embeds[0].url).toBe("https://x/r");
expect(p.embeds[0].footer.text).toMatch(/ComfyUI Manager/);
});
// Release notes are not written with @everyone in mind, and a webhook will
// happily resolve one that slipped in.
it("never lets the notes mention anyone by accident", () => {
const p = buildPayload({ version: "v1", body: "ping @everyone please" });
expect(p.allowed_mentions.parse).toEqual([]);
expect(p.allowed_mentions.roles).toEqual([]);
});
/**
* An unset secret was always safe. A MALFORMED one was not: any truthy string
* went straight into `<@&…>`, so a stray space produced a literal `<@& >`
* at the top of the most public message the project sends.
*/
describe("role ping", () => {
const content = (roleId) => buildPayload({ version: "v1", body: "x", roleId }).content;
it("stays silent for every flavour of absent", () => {
expect(content(undefined)).toBe("");
expect(content(null)).toBe("");
expect(content("")).toBe("");
expect(content(" ")).toBe("");
});
it("refuses anything that is not a snowflake, rather than guessing", () => {
expect(content("not-an-id")).toBe("");
expect(content("12345")).toBe(""); // too short
expect(content("1234567890123456789012")).toBe(""); // too long
expect(content("123abc456def78901")).toBe("");
});
it("never emits a half-formed mention", () => {
for (const bad of [" ", "not-an-id", "<@&>", "@everyone"]) {
expect(content(bad)).not.toContain("<@&");
}
});
// "Copy ID" and copying the mention text are equally easy mistakes.
it("accepts a pasted mention as well as a bare id", () => {
expect(content("123456789012345678")).toBe("<@&123456789012345678>");
expect(content("<@&123456789012345678>")).toBe("<@&123456789012345678>");
expect(content(" 123456789012345678 ")).toBe("<@&123456789012345678>");
});
it("keeps allowed_mentions in step with the content", () => {
const bad = buildPayload({ version: "v1", body: "x", roleId: "junk" });
expect(bad.allowed_mentions.roles).toEqual([]);
const good = buildPayload({ version: "v1", body: "x", roleId: "123456789012345678" });
expect(good.allowed_mentions.roles).toEqual(["123456789012345678"]);
expect(good.allowed_mentions.parse).toEqual([]);
});
it("normalizeRoleId is the single decision point", () => {
expect(normalizeRoleId("<@&123456789012345678>")).toBe("123456789012345678");
expect(normalizeRoleId("nope")).toBeUndefined();
expect(normalizeRoleId(undefined)).toBeUndefined();
});
});
it("pings only the configured role, and only when configured", () => {
expect(buildPayload({ version: "v1", body: "x" }).content).toBe("");
const p = buildPayload({ version: "v1", body: "x", roleId: "123456789012345678" });
expect(p.content).toBe("<@&123456789012345678>");
expect(p.allowed_mentions.roles).toEqual(["123456789012345678"]);
});
// A webhook posts under whatever name and avatar it was created with —
// Discord's default is a generic name ("Captain Hook") and a placeholder
// image, which is exactly what v2.12.0 went out as.
it("posts under the project's own name and avatar, not the webhook's", () => {
const p = buildPayload({ version: "v1", body: "x" });
expect(p.username).toBe("Wildcard Pipeline");
expect(p.avatar_url).toMatch(/^https:\/\/.+\.png$/);
});
it("uses a raster avatar — Discord will not render an SVG", () => {
expect(buildPayload({ version: "v1", body: "x" }).avatar_url).not.toMatch(/\.svg$/i);
});
it("lets a caller override the identity", () => {
const p = buildPayload({
version: "v1", body: "x", username: "WP Staff", avatarUrl: "https://x/a.png",
});
expect(p.username).toBe("WP Staff");
expect(p.avatar_url).toBe("https://x/a.png");
});
/**
* Two channels, deliberately asymmetric. The staff copy is the review step,
* so it carries the button that publishes it onward; the public copy is the
* announcement itself and must not tell readers how to re-send it.
*/
it("gives the staff copy a publish link, so review and button are one thing", () => {
const p = buildPayload({ version: "v1", body: "x", channel: "staff" });
const field = p.embeds[0].fields[0];
expect(field.value).toContain(DISPATCH_URL);
expect(field.value).toMatch(/channel.*public/i);
});
it("defaults to the staff copy — the safe one to send by accident", () => {
expect(buildPayload({ version: "v1", body: "x" }).embeds[0].fields).toBeTruthy();
});
it("leaves the public copy without the publish link", () => {
const p = buildPayload({ version: "v1", body: "x", channel: "public" });
// `undefined`, not `[]` — an empty field block renders as dead space.
expect(p.embeds[0].fields).toBeUndefined();
});
it("stays inside the embed description cap", () => {
const huge = Array.from({ length: 900 }, (_, i) => `- change ${i}`).join("\n");
const p = buildPayload({ version: "v1", body: huge });
expect(p.embeds[0].description.length).toBeLessThanOrEqual(EMBED_DESCRIPTION_LIMIT);
});
});
describe("buildRollupPayload", () => {
// The case that actually happens: several versions skipped, then one post.
it("labels each version so readers can tell what came from where", () => {
const p = buildRollupPayload({
releases: [
{ version: "v2.12.0", body: "- newest thing" },
{ version: "v2.11.0", body: "- older thing" },
],
sinceVersion: "v2.10.2",
releaseUrl: "https://x/r",
});
expect(p.embeds[0].title).toBe("Wildcard Pipeline v2.12.0 — everything since v2.10.2");
expect(p.embeds[0].description).toContain("**v2.12.0**");
expect(p.embeds[0].description).toContain("**v2.11.0**");
});
it("carries the channel through a roll-up", () => {
const staff = buildRollupPayload({
releases: [{ version: "v2.12.0", body: "- x" }], sinceVersion: "v2.11.0", channel: "staff",
});
const pub = buildRollupPayload({
releases: [{ version: "v2.12.0", body: "- x" }], sinceVersion: "v2.11.0", channel: "public",
});
expect(staff.embeds[0].fields).toBeTruthy();
expect(pub.embeds[0].fields).toBeUndefined();
});
it("keeps the identity on a roll-up too", () => {
const p = buildRollupPayload({
releases: [{ version: "v2.12.0", body: "- x" }],
sinceVersion: "v2.10.2",
});
expect(p.username).toBe("Wildcard Pipeline");
expect(p.avatar_url).toMatch(/\.png$/);
});
it("leads with the newest, which is what people decide to install", () => {
const p = buildRollupPayload({
releases: [
{ version: "v2.12.0", body: "- newest" },
{ version: "v2.11.0", body: "- older" },
],
sinceVersion: "v2.10.2",
});
const d = p.embeds[0].description;
expect(d.indexOf("**v2.12.0**")).toBeLessThan(d.indexOf("**v2.11.0**"));
});
it("drops each release's own tail so a roll-up does not become a changelog", () => {
const p = buildRollupPayload({
releases: [{ version: "v2.12.0", body: "- kept\n<!-- /modal -->\n- dropped" }],
sinceVersion: "v2.11.0",
});
expect(p.embeds[0].description).toContain("kept");
expect(p.embeds[0].description).not.toContain("dropped");
});
});