Skip to content

Commit 45d7eae

Browse files
committed
test(tools): pin the block catalog's rendered text to English
The showcase read as German to a German viewer. It is not: every string the catalog sends is English, and what the product owner saw is Slack rendering its own chrome in the reader's locale. "Morgen 7:00 Uhr" is the `<!date^…>` token we deliberately send so each reader gets the time in their own timezone, and "Button für <label>" wraps our English button labels in Slack's word for "Button". Hardcoding English over either would break every other locale, so neither is touched. What is now pinned is the half we control. The sweep walks all three states a reader can reach — page 1, page 2 before confirming, page 2 after confirming — collects every human-readable string in the payload `renderSlackMessage` hands to Slack, and rejects German diacritics and a bounded word deny-list. Keys Slack keys off rather than draws are skipped, so an `action_id` cannot mask a finding or invent one. Three things keep it honest. The harvest is floored per state, because a sweep over an empty harvest passes for free. Named strings from headings, container titles, table cells, chart labels and task titles must be in reach, so the walker cannot silently reach only the surface. And the detector is tested both ways: it fires on "Zeitzone", "Morgen 7:00 Uhr" and "Kanal auswählen", and stays quiet on "submit", "the tags we found" and "45 minutes" — the English that merely contains those letters. Injecting German into a container title, a page-2 label and a context note fails all three states with the path named. Dates and numbers are checked separately, because German conventions survive an all-English vocabulary: no `14.08.2026`, no decimal comma, the one hardcoded date ISO, and the date token's fallback ISO too. Live-verified against #bot-test: page 1 and page 2 with the confirmation both delivered whole, and the readback carries no German of ours.
1 parent 207583f commit 45d7eae

1 file changed

Lines changed: 169 additions & 0 deletions

File tree

app/tools/__tests__/block-catalog.test.tsx

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -597,6 +597,175 @@ describe("what a click carries back", () => {
597597
});
598598
});
599599

600+
/**
601+
* Keys Slack keys off rather than draws: ids, block and element types, urls,
602+
* enum values. Skipping them keeps the sweep below on the text a reader
603+
* actually reads, so neither a `value` of `"30m"` nor an `action_id` can mask a
604+
* real finding or invent a false one.
605+
*/
606+
const STRUCTURAL_KEYS = new Set([
607+
"type",
608+
"block_id",
609+
"action_id",
610+
"task_id",
611+
"value",
612+
"url",
613+
"image_url",
614+
"video_url",
615+
"title_url",
616+
"thumbnail_url",
617+
"style",
618+
"icon",
619+
"status",
620+
"initial_date",
621+
"trigger_actions_on",
622+
]);
623+
624+
/** Every string a reader can see in a rendered payload, each with its path. */
625+
function humanStrings(node: unknown, path = "blocks"): [string, string][] {
626+
if (typeof node === "string") return [[path, node]];
627+
if (Array.isArray(node)) {
628+
return node.flatMap((value, i) => humanStrings(value, `${path}[${i}]`));
629+
}
630+
if (node && typeof node === "object") {
631+
return Object.entries(node).flatMap(([key, value]) =>
632+
STRUCTURAL_KEYS.has(key) ? [] : humanStrings(value, `${path}.${key}`),
633+
);
634+
}
635+
return [];
636+
}
637+
638+
/** The cheapest tell, and one with no English hits at all. */
639+
const GERMAN_DIACRITIC = /[äöüßÄÖÜ]/;
640+
641+
/**
642+
* German that survives without a diacritic, each word bounded by a letter
643+
* lookaround so an English word cannot carry it in — `submit` must not read
644+
* as `mit`, nor `found` as `und`. Words that are German *and* English —
645+
* `Text`, `Video`, `Team`, `Tag`, `Minute`, `Links`, `Region` — are
646+
* deliberately absent: they would fail on the English copy this message ships.
647+
*/
648+
const GERMAN_WORD =
649+
/(?<!\p{L})(und|oder|nicht|für|fuer|mit|Uhr|Uhrzeit|Zeit|Zeitzone|Datum|Kanal|Kanäle|Auswahl|auswählen|wählen|Woche|Wochen|Monat|Monate|Stunde|Stunden|Minuten|Sekunden|bestätigen|bestätigt|Bestätigung|Einladung|Besprechung|Termin|senden|gesendet|zeigen|Beispiel|Beispiele|Überschrift|Trennlinie|Knopf|Feld|Felder|Nachricht|Seite|Seiten|zurück|weiter|Absenden|Abbrechen|Dauer|Länge|Empfänger|Adresse|Betreff|morgen|heute|gestern|jetzt|Kalender|Buchung|buchen|Antwort|Frage|Fragen|Benutzer|Übersicht|Zusammenfassung|Währung|Rechnung|Zahlung|Vertrag|Prüfung|prüfen|erstellen|löschen|ändern|speichern|öffnen|schließen)(?!\p{L})/iu;
650+
651+
const isGerman = (text: string) =>
652+
GERMAN_DIACRITIC.test(text) || GERMAN_WORD.test(text);
653+
654+
/**
655+
* The product owner read "Zeitzone" and "Morgen 7:00 Uhr" off the rendered
656+
* confirmation. Both are Slack's own localisation — a `<!date^…>` token and
657+
* the date picker's chrome are drawn in the *reader's* locale from a payload
658+
* that carries no German at all — so the fix is not to hardcode English over
659+
* them but to guard the half we control: every human-readable string we send.
660+
*/
661+
describe("nothing we send is German", () => {
662+
/**
663+
* Every state a reader can reach. Page 2 appears twice because the
664+
* confirmation exists only after Confirm, and it is the one place a value is
665+
* formatted into prose rather than passed straight through.
666+
*/
667+
const STATES: [string, Partial<CatalogState>][] = [
668+
["page 1", { page: 0 }],
669+
["page 2 before confirming", { page: 1 }],
670+
[
671+
"page 2 after confirming",
672+
{ page: 1, booking: BOOKING, confirmed: BOOKING },
673+
],
674+
];
675+
676+
it.each(STATES)("carries no German on %s", (_label, state) => {
677+
const strings = humanStrings(page(state));
678+
679+
// A sweep over an empty harvest passes for free. Page 2 before confirming
680+
// is the thinnest state and still renders fourteen reader-facing strings.
681+
expect(strings.length).toBeGreaterThanOrEqual(14);
682+
for (const [where, text] of strings) {
683+
expect(isGerman(text) ? `${where}${text}` : null).toBeNull();
684+
}
685+
});
686+
687+
it("harvests the strings a reader really reads, not a thin slice of them", () => {
688+
const texts = humanStrings(page({ page: 0 })).map(([, text]) => text);
689+
690+
// Headings, container titles, table cells, chart labels, button labels and
691+
// task titles all have to be in reach, or the sweep proves nothing.
692+
expect(texts.length).toBeGreaterThan(120);
693+
expect(texts).toContain("Where completion stands");
694+
expect(texts).toContain("Renewal rate by month");
695+
expect(texts).toContain("Ember Support Desk");
696+
expect(texts).toContain("Confirm the security review dates");
697+
expect(texts).toContain("Next · book a meeting →");
698+
});
699+
700+
it("catches German when there is German to catch", () => {
701+
// What a German reader saw, plus the wording this message would most
702+
// plausibly regress into.
703+
for (const sample of [
704+
"Zeitzone",
705+
"Morgen 7:00 Uhr",
706+
"Bestätigung senden",
707+
"Länge der Besprechung",
708+
"Kanal auswählen",
709+
"Seite 1 von 2",
710+
]) {
711+
expect(isGerman(sample)).toBe(true);
712+
}
713+
714+
// And does not fire on English that merely contains those letters.
715+
for (const sample of [
716+
"submit the form",
717+
"the tags we found",
718+
"A video that plays in place",
719+
"45 minutes",
720+
"Page 1 of 2",
721+
"Region",
722+
"Not useful",
723+
]) {
724+
expect(isGerman(sample)).toBe(false);
725+
}
726+
});
727+
728+
it("leaves Slack's own localisation to Slack", () => {
729+
const when = humanStrings(
730+
page({ page: 1, booking: BOOKING, confirmed: BOOKING }),
731+
)
732+
.map(([, text]) => text)
733+
.find((text) => text.includes("<!date^"));
734+
735+
// A `<!date^…>` token and a `<#C…>` mention are what a German client
736+
// turns into "Morgen 7:00 Uhr" and a channel name. Sending the token is
737+
// the point: hardcoding an English date would break every other locale.
738+
expect(when).toContain("<!date^1786451118^{date_long_pretty} at {time}|");
739+
expect(when).toContain("<#C0BM9JJK6E8>");
740+
expect(isGerman(when!)).toBe(false);
741+
742+
// The date-and-time picker carries no text of ours beyond its label, so the
743+
// timezone hint under it is Slack's chrome and not ours to restate.
744+
const picker = page({ page: 1 }).find(
745+
(block) =>
746+
(block as { element?: { type?: string } }).element?.type ===
747+
"datetimepicker",
748+
) as { element: Record<string, unknown> };
749+
750+
expect(Object.keys(picker.element)).toEqual(["type"]);
751+
});
752+
753+
it("formats the dates and numbers we choose ourselves language-neutrally", () => {
754+
const raw = STATES.map(([, state]) => JSON.stringify(page(state))).join(
755+
"\n",
756+
);
757+
758+
// `14.08.2026` and a decimal comma are the German conventions that survive
759+
// an entirely English vocabulary.
760+
expect(raw).not.toMatch(/\d{1,2}\.\d{1,2}\.\d{4}/);
761+
expect(raw).not.toMatch(/\d+,\d+/);
762+
// The one date we hardcode is ISO, and the one time is a Slack token whose
763+
// fallback is ISO too.
764+
expect(raw).toContain('"initial_date":"2026-09-12"');
765+
expect(raw).toContain("|2026-08-11 12:25 UTC>");
766+
});
767+
});
768+
600769
describe("OPENTAG_SHOW_BLOCK_CATALOG", () => {
601770
const names = (env: NodeJS.ProcessEnv) =>
602771
createAppTools("OpenTag", env).map(({ name }) => name);

0 commit comments

Comments
 (0)