@company-semantics/contracts 35.0.0 → 36.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -2
- package/src/notifications/README.md +21 -19
- package/src/notifications/__tests__/README.md +42 -47
- package/src/notifications/__tests__/__snapshots__/README.md +15 -8
- package/src/notifications/__tests__/__snapshots__/registry.test.ts.snap +31 -0
- package/src/notifications/__tests__/__snapshots__/render-snapshot.test.ts.snap +2580 -215
- package/src/notifications/__tests__/context.test.ts +5 -5
- package/src/notifications/__tests__/definition.test.ts +2 -2
- package/src/notifications/__tests__/fixtures.ts +226 -0
- package/src/notifications/__tests__/kinds.test.ts +2 -3
- package/src/notifications/__tests__/registry.test.ts +27 -27
- package/src/notifications/__tests__/render-snapshot.test.ts +10 -6
- package/src/notifications/content.ts +13 -1
- package/src/notifications/index.ts +15 -9
- package/src/notifications/kinds/README.md +2 -2
- package/src/notifications/render.ts +6 -6
- package/src/notifications/renderers/email/README.md +132 -17
- package/src/notifications/renderers/email/__tests__/README.md +19 -16
- package/src/notifications/renderers/email/__tests__/amp.test.ts +217 -0
- package/src/notifications/renderers/email/__tests__/colors.test.ts +227 -0
- package/src/notifications/renderers/email/__tests__/render.test.ts +130 -36
- package/src/notifications/renderers/email/__tests__/styles.test.ts +111 -0
- package/src/notifications/renderers/email/chat.ts +44 -32
- package/src/notifications/renderers/email/colors.ts +342 -0
- package/src/notifications/renderers/email/constants.ts +27 -3
- package/src/notifications/renderers/email/cta.ts +60 -10
- package/src/notifications/renderers/email/index.ts +23 -8
- package/src/notifications/renderers/email/render.ts +31 -15
- package/src/notifications/renderers/email/shells.ts +115 -11
- package/src/notifications/renderers/email/styles.ts +361 -0
- package/src/notifications/renderers/slack/README.md +66 -25
- package/src/notifications/renderers/slack/__tests__/index.test.ts +249 -41
- package/src/notifications/renderers/slack/index.ts +192 -107
- package/src/notifications/text.ts +2 -2
- package/src/notifications/__tests__/output-parity.golden.ts +0 -363
- package/src/notifications/__tests__/output-parity.test.ts +0 -122
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules that keep `../colors.ts` the only place colour lives
|
|
3
|
+
* (ADR-CONTRACTS-088).
|
|
4
|
+
*
|
|
5
|
+
* The markup is not asserted here — `../../../__tests__/render-snapshot.test.ts`
|
|
6
|
+
* locks that. These are the claims a snapshot cannot make, because a snapshot
|
|
7
|
+
* records what the output IS and every one of these is about what it must never
|
|
8
|
+
* become:
|
|
9
|
+
*
|
|
10
|
+
* - A dark stylesheet is invisible to the snapshot's reviewer in the sense that
|
|
11
|
+
* matters: you can read the `<style>` block in a diff and still not notice that
|
|
12
|
+
* a role is missing from it, because the light bytes look perfect either way.
|
|
13
|
+
* Dark mode fails silently. These tests are the alarm.
|
|
14
|
+
* - `DARK_STYLE` is a hand-written template, deliberately (it reads as the CSS it
|
|
15
|
+
* is). The cost of a template over generated output is that it can forget a
|
|
16
|
+
* role; this is where that cost is paid back.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
20
|
+
import { dirname, join } from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
|
|
23
|
+
import { describe, expect, it } from "vitest";
|
|
24
|
+
|
|
25
|
+
import type {
|
|
26
|
+
NotificationContent,
|
|
27
|
+
NotificationElement,
|
|
28
|
+
} from "../../../content";
|
|
29
|
+
import type { RenderContext } from "../../../context";
|
|
30
|
+
import {
|
|
31
|
+
BASE_STYLE,
|
|
32
|
+
CLASS_ROLES,
|
|
33
|
+
DARK_STYLE,
|
|
34
|
+
HOVER_ROLES,
|
|
35
|
+
palette,
|
|
36
|
+
ROLES,
|
|
37
|
+
roleClass,
|
|
38
|
+
} from "../colors";
|
|
39
|
+
import { emailRenderer } from "../index";
|
|
40
|
+
|
|
41
|
+
const CONTEXT: RenderContext = {
|
|
42
|
+
brand: { name: "Company Semantics", copyrightYear: 2026 },
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** The class name inside a role's attribute — `cs-meta` from `class="cs-meta"`. */
|
|
46
|
+
function classNameOf(role: (typeof ROLES)[number]): string {
|
|
47
|
+
const match = roleClass(role).match(/class="([^"]+)"/);
|
|
48
|
+
if (!match) throw new Error(`roleClass(${role}) is not a class attribute`);
|
|
49
|
+
return match[1];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* One notification exercising every colour-bearing element. The chat unit carries
|
|
54
|
+
* both continuation shapes on purpose: dots followed by a CTA fold into it, and
|
|
55
|
+
* dots followed by nothing render standalone — two different emitters, two
|
|
56
|
+
* chances to forget a class.
|
|
57
|
+
*/
|
|
58
|
+
const EVERY_COLOURED_ELEMENT: NotificationContent = {
|
|
59
|
+
metadata: { kind: "chat.shared", title: "Colour coverage" },
|
|
60
|
+
sections: [
|
|
61
|
+
{
|
|
62
|
+
elements: [
|
|
63
|
+
{ type: "warning" },
|
|
64
|
+
{ type: "divider" },
|
|
65
|
+
{ type: "callToAction", label: "Open", href: "https://example.com/a" },
|
|
66
|
+
{
|
|
67
|
+
type: "chatUnit",
|
|
68
|
+
items: [
|
|
69
|
+
{ type: "message", role: "user", text: "Hello", from: "Sam Chen" },
|
|
70
|
+
{ type: "continuation" },
|
|
71
|
+
{
|
|
72
|
+
type: "callToAction",
|
|
73
|
+
label: "Reply",
|
|
74
|
+
href: "https://example.com/b",
|
|
75
|
+
},
|
|
76
|
+
{ type: "message", role: "assistant", text: "Hi back" },
|
|
77
|
+
{ type: "continuation" },
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
{ type: "signature" },
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
describe("email colours", () => {
|
|
87
|
+
it("gives every role a dark answer that reaches the stylesheet", () => {
|
|
88
|
+
const dark = palette("dark");
|
|
89
|
+
for (const role of ROLES) {
|
|
90
|
+
expect(DARK_STYLE, `role "${role}" is absent from DARK_STYLE`).toContain(
|
|
91
|
+
dark[role],
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("writes a rule for every class-bearing role", () => {
|
|
97
|
+
for (const role of CLASS_ROLES) {
|
|
98
|
+
// `:hover` roles are selected as `.cs-x:hover {`, resting ones as `.cs-x {`.
|
|
99
|
+
const selector = new RegExp(`\\.${classNameOf(role)}(:[a-z-]+)? \\{`);
|
|
100
|
+
expect(DARK_STYLE, `role "${role}" has a class but no dark rule`).toMatch(
|
|
101
|
+
selector,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("states every hover role in BOTH schemes, since :hover cannot be inline", () => {
|
|
107
|
+
// Every other role is inline-light + dark override. A hover role has no inline
|
|
108
|
+
// form, so a light answer that never reaches BASE_STYLE is simply lost — and
|
|
109
|
+
// nothing else in this file would notice.
|
|
110
|
+
for (const role of HOVER_ROLES) {
|
|
111
|
+
const selector = new RegExp(`\\.${classNameOf(role)}:hover \\{`);
|
|
112
|
+
expect(BASE_STYLE, `hover role "${role}" has no light rule`).toMatch(
|
|
113
|
+
selector,
|
|
114
|
+
);
|
|
115
|
+
expect(BASE_STYLE, `hover role "${role}" light value`).toContain(
|
|
116
|
+
palette("light")[role],
|
|
117
|
+
);
|
|
118
|
+
expect(DARK_STYLE, `hover role "${role}" has no dark rule`).toMatch(
|
|
119
|
+
selector,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("marks every declaration !important, in both blocks", () => {
|
|
125
|
+
// Load-bearing, not defensive: the light colour is inline, inline beats a
|
|
126
|
+
// stylesheet on specificity, and a rule that loses that fight does nothing
|
|
127
|
+
// at all while looking entirely correct.
|
|
128
|
+
const rules = `${BASE_STYLE}\n${DARK_STYLE}`.split("\n").filter((line) => {
|
|
129
|
+
const start = line.trimStart();
|
|
130
|
+
return start.startsWith(".") || start.startsWith("body");
|
|
131
|
+
});
|
|
132
|
+
// Guards the filter above: a reformat that matches no lines would turn the
|
|
133
|
+
// loop below into a vacuous pass. `body` is the +1; BASE_STYLE contributes
|
|
134
|
+
// one line per hover role.
|
|
135
|
+
expect(rules).toHaveLength(CLASS_ROLES.length + 1 + HOVER_ROLES.length);
|
|
136
|
+
|
|
137
|
+
for (const rule of rules) {
|
|
138
|
+
const body = rule.slice(rule.indexOf("{") + 1, rule.lastIndexOf("}"));
|
|
139
|
+
const declarations = body
|
|
140
|
+
.split(";")
|
|
141
|
+
.map((d) => d.trim())
|
|
142
|
+
.filter(Boolean);
|
|
143
|
+
expect(declarations.length).toBeGreaterThan(0);
|
|
144
|
+
for (const declaration of declarations) {
|
|
145
|
+
expect(
|
|
146
|
+
declaration,
|
|
147
|
+
`"${declaration}" would lose to the inline style`,
|
|
148
|
+
).toContain("!important");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("styles every class the renderer actually emits", () => {
|
|
154
|
+
// The end-to-end direction: the tests above start from the palette, this one
|
|
155
|
+
// starts from the output, so a class hook added to a tag without a matching
|
|
156
|
+
// rule is caught from the side the palette cannot see.
|
|
157
|
+
const { html } = emailRenderer.render(EVERY_COLOURED_ELEMENT, CONTEXT);
|
|
158
|
+
// One attribute can carry several classes (`class="cs-cta cs-cta-hover"`).
|
|
159
|
+
const emitted = new Set(
|
|
160
|
+
[...html.matchAll(/class="([^"]+)"/g)].flatMap((match) =>
|
|
161
|
+
match[1].split(/\s+/),
|
|
162
|
+
),
|
|
163
|
+
);
|
|
164
|
+
expect(emitted.size).toBeGreaterThan(0);
|
|
165
|
+
const stylesheet = `${BASE_STYLE}\n${DARK_STYLE}`;
|
|
166
|
+
for (const className of emitted) {
|
|
167
|
+
expect(stylesheet, `${className} is emitted but never styled`).toMatch(
|
|
168
|
+
new RegExp(`\\.${className}(:[a-z-]+)? \\{`),
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("puts the hover hook on a CTA that links, and never on one that does not", () => {
|
|
174
|
+
// The invariant `./cta.ts` already carried — an unlinked label is a payload,
|
|
175
|
+
// not a button — extended to hover. Lighting up an OTP code under the pointer
|
|
176
|
+
// promises a click that does not exist.
|
|
177
|
+
const hoverClass = classNameOf("ctaHover");
|
|
178
|
+
// The classes the markup APPLIES, not every mention in the document — the
|
|
179
|
+
// stylesheet names `.cs-cta-hover` too, and matching that would pass whether
|
|
180
|
+
// or not any element wears it.
|
|
181
|
+
const applied = (cta: NotificationElement) => {
|
|
182
|
+
const { html } = emailRenderer.render(
|
|
183
|
+
{
|
|
184
|
+
metadata: { kind: "auth.otp", title: "t" },
|
|
185
|
+
sections: [{ elements: [cta] }],
|
|
186
|
+
},
|
|
187
|
+
CONTEXT,
|
|
188
|
+
);
|
|
189
|
+
return [...html.matchAll(/class="([^"]+)"/g)].flatMap((match) =>
|
|
190
|
+
match[1].split(/\s+/),
|
|
191
|
+
);
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const linked = applied({
|
|
195
|
+
type: "callToAction",
|
|
196
|
+
label: "Open",
|
|
197
|
+
href: "https://example.com/a",
|
|
198
|
+
});
|
|
199
|
+
const payload = applied({ type: "callToAction", label: "123456" });
|
|
200
|
+
|
|
201
|
+
expect(linked).toContain(hoverClass);
|
|
202
|
+
expect(payload).not.toContain(hoverClass);
|
|
203
|
+
// Both still wear the resting edge.
|
|
204
|
+
expect(payload).toContain(classNameOf("cta"));
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("leaves no raw colour anywhere else in the channel", () => {
|
|
208
|
+
// What stops `style="color:#888"` reappearing in chat.ts in six months. A
|
|
209
|
+
// test rather than a CI guard: guards live in company-semantics-ci and adding
|
|
210
|
+
// one is cross-repo plus its own ADR — revisit if this earns it.
|
|
211
|
+
const dir = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
212
|
+
const hex = /#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b/;
|
|
213
|
+
|
|
214
|
+
const sources = readdirSync(dir).filter(
|
|
215
|
+
(file) => file.endsWith(".ts") && file !== "colors.ts",
|
|
216
|
+
);
|
|
217
|
+
expect(sources.length).toBeGreaterThan(0);
|
|
218
|
+
|
|
219
|
+
for (const file of sources) {
|
|
220
|
+
const found = readFileSync(join(dir, file), "utf8").match(hex);
|
|
221
|
+
expect(
|
|
222
|
+
found?.[0],
|
|
223
|
+
`${file} states ${found?.[0]} directly — give it a role in colors.ts`,
|
|
224
|
+
).toBeUndefined();
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
});
|
|
@@ -1,48 +1,46 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The email renderer's
|
|
2
|
+
* The email renderer's behavioural rules (ADR-CONTRACTS-086) — the claims that
|
|
3
|
+
* hold no matter how the email is styled: it supports every element type, takes
|
|
4
|
+
* the subject from the content's title, takes the year and signer from context
|
|
5
|
+
* rather than a clock, restores the `:` the content model does not carry, and
|
|
6
|
+
* escapes user-controlled text.
|
|
3
7
|
*
|
|
4
|
-
* The
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* This does NOT replace `../../../__tests__/output-parity.test.ts`. That one
|
|
11
|
-
* pins the ENTRY POINT (`renderToChannel`, the whole pipeline) against the same
|
|
12
|
-
* golden; this pins the RENDERER alone, so a failure here says the markup moved
|
|
13
|
-
* rather than that the wiring did. Two levels, one golden: either drifting is a
|
|
14
|
-
* failure.
|
|
8
|
+
* The MARKUP is not asserted here. `../../../__tests__/render-snapshot.test.ts`
|
|
9
|
+
* locks that, as a reviewable regenerable diff. Until ADR-CONTRACTS-087 this
|
|
10
|
+
* file also asserted every fixture byte-for-byte against the 32.0.0 golden, to
|
|
11
|
+
* prove the multi-channel generalisation moved no bytes; that proof shipped in
|
|
12
|
+
* 35.0.0 and was retired with the golden, because email design is now allowed to
|
|
13
|
+
* change and byte-equality to a deleted layer cannot survive it being exercised.
|
|
15
14
|
*
|
|
16
15
|
* There is no fake timer here, and that is the improvement being locked in: the
|
|
17
|
-
* year comes from `RenderContext`, so the
|
|
16
|
+
* year comes from `RenderContext`, so the fixtures' clock is just a number.
|
|
18
17
|
*/
|
|
19
18
|
|
|
20
19
|
import { describe, expect, it } from "vitest";
|
|
21
20
|
|
|
22
21
|
import type {
|
|
23
22
|
NotificationContent,
|
|
23
|
+
NotificationElement,
|
|
24
24
|
NotificationElementType,
|
|
25
25
|
} from "../../../content";
|
|
26
26
|
import type { RenderContext } from "../../../context";
|
|
27
27
|
import { NOTIFICATION_DEFINITIONS } from "../../../registry";
|
|
28
28
|
import {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
OUTPUT_PARITY_GOLDEN,
|
|
29
|
+
FIXTURE_CLOCK,
|
|
30
|
+
NOTIFICATION_FIXTURES,
|
|
32
31
|
fixtureKey,
|
|
33
|
-
} from "../../../__tests__/
|
|
32
|
+
} from "../../../__tests__/fixtures";
|
|
34
33
|
import { emailRenderer } from "../index";
|
|
35
34
|
|
|
36
35
|
/**
|
|
37
|
-
* The
|
|
36
|
+
* The fixtures' context, stated rather than ticked. The old layer reached for
|
|
38
37
|
* `new Date().getFullYear()` mid-render, which is why every render test had to
|
|
39
|
-
* freeze the clock; here the year
|
|
40
|
-
* value.
|
|
38
|
+
* freeze the clock; here the year is simply a value.
|
|
41
39
|
*/
|
|
42
|
-
const
|
|
40
|
+
const FIXTURE_CONTEXT: RenderContext = {
|
|
43
41
|
brand: {
|
|
44
42
|
name: "Company Semantics",
|
|
45
|
-
copyrightYear: new Date(
|
|
43
|
+
copyrightYear: new Date(FIXTURE_CLOCK).getUTCFullYear(),
|
|
46
44
|
},
|
|
47
45
|
};
|
|
48
46
|
|
|
@@ -77,7 +75,7 @@ describe("emailRenderer", () => {
|
|
|
77
75
|
metadata: { kind: "org.invite", title: "Join Acme" },
|
|
78
76
|
sections: [{ elements: [{ type: "body", text: "Hello." }] }],
|
|
79
77
|
};
|
|
80
|
-
expect(emailRenderer.render(content,
|
|
78
|
+
expect(emailRenderer.render(content, FIXTURE_CONTEXT).subject).toBe(
|
|
81
79
|
"Join Acme",
|
|
82
80
|
);
|
|
83
81
|
});
|
|
@@ -100,7 +98,7 @@ describe("emailRenderer", () => {
|
|
|
100
98
|
metadata: { kind: "org.invite", title: "t" },
|
|
101
99
|
sections: [{ elements: [{ type: "signature", signer: "Grace" }] }],
|
|
102
100
|
};
|
|
103
|
-
expect(emailRenderer.render(content,
|
|
101
|
+
expect(emailRenderer.render(content, FIXTURE_CONTEXT).text).toContain(
|
|
104
102
|
"• Grace",
|
|
105
103
|
);
|
|
106
104
|
});
|
|
@@ -126,7 +124,7 @@ describe("emailRenderer", () => {
|
|
|
126
124
|
},
|
|
127
125
|
],
|
|
128
126
|
};
|
|
129
|
-
const { text } = emailRenderer.render(content,
|
|
127
|
+
const { text } = emailRenderer.render(content, FIXTURE_CONTEXT);
|
|
130
128
|
expect(text).toContain("Status: Valid");
|
|
131
129
|
expect(text).toContain("Request details:");
|
|
132
130
|
expect(text).toContain("IP address: 203.0.113.1");
|
|
@@ -141,11 +139,96 @@ describe("emailRenderer", () => {
|
|
|
141
139
|
},
|
|
142
140
|
],
|
|
143
141
|
};
|
|
144
|
-
const { html } = emailRenderer.render(content,
|
|
142
|
+
const { html } = emailRenderer.render(content, FIXTURE_CONTEXT);
|
|
145
143
|
expect(html).toContain("<script>");
|
|
146
144
|
expect(html).not.toContain("<script>");
|
|
147
145
|
});
|
|
148
146
|
|
|
147
|
+
it("escapes a URL, so it cannot close its attribute and forge another", () => {
|
|
148
|
+
// `href` and `src` are the two values that reach the markup as a URL rather
|
|
149
|
+
// than as text, and they used to be interpolated raw — an HTML injection on
|
|
150
|
+
// its own, and since ADR-CONTRACTS-089 a style injection too: `inlineStyles`
|
|
151
|
+
// rewrites class attributes, so a URL that can write `class="csr-body"` can
|
|
152
|
+
// write any declaration in the registry into someone else's element.
|
|
153
|
+
const html = (element: NotificationElement) =>
|
|
154
|
+
emailRenderer.render(
|
|
155
|
+
{
|
|
156
|
+
metadata: { kind: "auth.otp", title: "t" },
|
|
157
|
+
sections: [{ elements: [element] }],
|
|
158
|
+
},
|
|
159
|
+
FIXTURE_CONTEXT,
|
|
160
|
+
).html;
|
|
161
|
+
|
|
162
|
+
const cta = html({
|
|
163
|
+
type: "callToAction",
|
|
164
|
+
label: "Open",
|
|
165
|
+
href: 'https://x.test/" data-forged="1',
|
|
166
|
+
});
|
|
167
|
+
// The quote survives as text, so the forged attribute never becomes one.
|
|
168
|
+
// Asserted against `data-forged="` — the ATTRIBUTE form — because the escaped
|
|
169
|
+
// payload still contains the string `data-forged=`, and a looser match would
|
|
170
|
+
// pass whether or not the fix is present.
|
|
171
|
+
expect(cta).toContain(""");
|
|
172
|
+
expect(cta).not.toMatch(/data-forged="/);
|
|
173
|
+
|
|
174
|
+
const hero = html({
|
|
175
|
+
type: "heroImage",
|
|
176
|
+
src: '/x.png" onerror="alert(1)',
|
|
177
|
+
alt: "x",
|
|
178
|
+
});
|
|
179
|
+
expect(hero).not.toMatch(/onerror="/);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("makes a linked CTA's whole box the click target, an unlinked one not clickable", () => {
|
|
183
|
+
// The click target is the anchor, so it must fill the box — `display: block` +
|
|
184
|
+
// the padding on the `<a>` itself, not the cell. An OTP code has no href: it
|
|
185
|
+
// must not become an anchor at all, which is what keeps it uncopiable-as-a-link
|
|
186
|
+
// and unclickable.
|
|
187
|
+
const box = (cta: NotificationElement) =>
|
|
188
|
+
emailRenderer.render(
|
|
189
|
+
{
|
|
190
|
+
metadata: { kind: "auth.otp", title: "t" },
|
|
191
|
+
sections: [{ elements: [cta] }],
|
|
192
|
+
},
|
|
193
|
+
FIXTURE_CONTEXT,
|
|
194
|
+
).html;
|
|
195
|
+
|
|
196
|
+
const linked = box({
|
|
197
|
+
type: "callToAction",
|
|
198
|
+
label: "Open",
|
|
199
|
+
href: "https://example.com/x",
|
|
200
|
+
});
|
|
201
|
+
expect(linked).toMatch(
|
|
202
|
+
/<a [^>]*href="https:\/\/example\.com\/x"[^>]*style="display: block; padding:/,
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
const payload = box({ type: "callToAction", label: "123456" });
|
|
206
|
+
expect(payload).not.toContain("<a ");
|
|
207
|
+
// The cell carries the padding instead, so the code is still a padded box.
|
|
208
|
+
expect(payload).toMatch(/<td style="padding: /);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("restores the CTA's cell padding for Outlook, which cannot fill the anchor", () => {
|
|
212
|
+
// Outlook's Word engine ignores `display: block`, so the full-box target
|
|
213
|
+
// degrades to today's text click there. The MSO block must put the padding
|
|
214
|
+
// back on the cell, or Outlook renders the button cramped.
|
|
215
|
+
const { html } = emailRenderer.render(
|
|
216
|
+
{
|
|
217
|
+
metadata: { kind: "org.invite", title: "t" },
|
|
218
|
+
sections: [
|
|
219
|
+
{
|
|
220
|
+
elements: [
|
|
221
|
+
{ type: "callToAction", label: "Join", href: "https://x.test" },
|
|
222
|
+
],
|
|
223
|
+
},
|
|
224
|
+
],
|
|
225
|
+
},
|
|
226
|
+
FIXTURE_CONTEXT,
|
|
227
|
+
);
|
|
228
|
+
expect(html).toContain("<!--[if mso]>");
|
|
229
|
+
expect(html).toMatch(/\[if mso\]>.*\.cs-cta td \{ padding: .* \}/s);
|
|
230
|
+
});
|
|
231
|
+
|
|
149
232
|
it("is pure — same inputs, same bytes", () => {
|
|
150
233
|
const content = NOTIFICATION_DEFINITIONS["security.alert"].compose(
|
|
151
234
|
{
|
|
@@ -153,27 +236,38 @@ describe("emailRenderer", () => {
|
|
|
153
236
|
details: "5 codes in 2 minutes",
|
|
154
237
|
timestamp: "2026-07-15T00:00:00.000Z",
|
|
155
238
|
},
|
|
156
|
-
|
|
239
|
+
FIXTURE_CONTEXT,
|
|
157
240
|
);
|
|
158
|
-
expect(emailRenderer.render(content,
|
|
159
|
-
emailRenderer.render(content,
|
|
241
|
+
expect(emailRenderer.render(content, FIXTURE_CONTEXT)).toEqual(
|
|
242
|
+
emailRenderer.render(content, FIXTURE_CONTEXT),
|
|
160
243
|
);
|
|
161
244
|
});
|
|
162
245
|
|
|
163
|
-
describe("
|
|
164
|
-
for
|
|
246
|
+
describe("renders every fixture", () => {
|
|
247
|
+
// Was a byte-for-byte assertion against the 32.0.0 golden until
|
|
248
|
+
// ADR-CONTRACTS-087 retired it. The markup itself is locked by
|
|
249
|
+
// `../../../__tests__/render-snapshot.test.ts`; what is worth proving HERE
|
|
250
|
+
// is narrower and survives a redesign — that every kind in the registry
|
|
251
|
+
// composes and renders through this channel at all, on both surfaces. A kind
|
|
252
|
+
// whose `compose` reaches for a field its payload lacks fails here, at the
|
|
253
|
+
// renderer, rather than in whichever consumer sends it first.
|
|
254
|
+
for (const fixture of NOTIFICATION_FIXTURES) {
|
|
165
255
|
const key = fixtureKey(fixture.kind, fixture.name);
|
|
166
|
-
it(`${key}
|
|
256
|
+
it(`${key}`, () => {
|
|
167
257
|
const definition = NOTIFICATION_DEFINITIONS[fixture.kind];
|
|
168
258
|
// `payload as never`: the fixture call sites are type-checked in the
|
|
169
|
-
//
|
|
259
|
+
// fixtures module; this only bridges compose's per-kind payload.
|
|
170
260
|
const content = definition.compose(
|
|
171
261
|
fixture.payload as never,
|
|
172
|
-
|
|
262
|
+
FIXTURE_CONTEXT,
|
|
173
263
|
);
|
|
174
|
-
|
|
175
|
-
|
|
264
|
+
const { subject, text, html } = emailRenderer.render(
|
|
265
|
+
content,
|
|
266
|
+
FIXTURE_CONTEXT,
|
|
176
267
|
);
|
|
268
|
+
expect(subject).toBeTruthy();
|
|
269
|
+
expect(text).toBeTruthy();
|
|
270
|
+
expect(html).toContain("<!DOCTYPE html>");
|
|
177
271
|
});
|
|
178
272
|
}
|
|
179
273
|
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The style registry's contract (ADR-CONTRACTS-089).
|
|
3
|
+
*
|
|
4
|
+
* `../../__tests__/render-snapshot.test.ts` already proves the html surface's
|
|
5
|
+
* bytes, and it is the authority on what the email LOOKS like. What it cannot say
|
|
6
|
+
* is WHY those bytes are safe to derive, because a snapshot passes just as happily
|
|
7
|
+
* over markup that leaks a recipe class or drops a role hook. This file asserts the
|
|
8
|
+
* two properties the derivation rests on:
|
|
9
|
+
*
|
|
10
|
+
* - a `cs-` role hook SHIPS, because `darkStyle` needs it in the markup;
|
|
11
|
+
* - a `csr-` recipe class DOES NOT, because it is spent and dropped.
|
|
12
|
+
*
|
|
13
|
+
* Get either backwards and the snapshot still passes while dark mode silently
|
|
14
|
+
* stops working, or the shipped email carries dead classes nothing styles.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, expect, it } from "vitest";
|
|
18
|
+
|
|
19
|
+
import type { NotificationContent } from "../../../content";
|
|
20
|
+
import type { RenderContext } from "../../../context";
|
|
21
|
+
import { emailRenderer } from "../index";
|
|
22
|
+
import { inlineOf, inlineStyles, ruleOf, STYLE_NAMES } from "../styles";
|
|
23
|
+
|
|
24
|
+
const CONTEXT: RenderContext = {
|
|
25
|
+
brand: { name: "Company Semantics", copyrightYear: 2026 },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** One of everything that carries a class, so the scan below has something to see. */
|
|
29
|
+
const CONTENT: NotificationContent = {
|
|
30
|
+
metadata: { kind: "auth.otp", title: "t" },
|
|
31
|
+
sections: [
|
|
32
|
+
{
|
|
33
|
+
elements: [
|
|
34
|
+
{ type: "greeting", recipientName: "Sam" },
|
|
35
|
+
{ type: "warning" },
|
|
36
|
+
{ type: "divider" },
|
|
37
|
+
{ type: "callToAction", label: "Open", href: "https://x.test/a" },
|
|
38
|
+
{ type: "callToAction", label: "123456" },
|
|
39
|
+
{
|
|
40
|
+
type: "chatUnit",
|
|
41
|
+
items: [
|
|
42
|
+
{ type: "message", role: "user", text: "hi", from: "Sam" },
|
|
43
|
+
{ type: "message", role: "assistant", text: "hello" },
|
|
44
|
+
{ type: "continuation" },
|
|
45
|
+
{ type: "callToAction", label: "Read", href: "https://x.test/b" },
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
{ type: "signature" },
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Every class the shipped markup actually applies. */
|
|
55
|
+
function shippedClasses(html: string): string[] {
|
|
56
|
+
return [...html.matchAll(/class="([^"]+)"/g)].flatMap((m) =>
|
|
57
|
+
m[1].split(/\s+/),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe("the style registry", () => {
|
|
62
|
+
it("ships no recipe class — they are spent by the inliner, not delivered", () => {
|
|
63
|
+
const { html } = emailRenderer.render(CONTENT, CONTEXT);
|
|
64
|
+
// The whole document, not just the class attributes: a recipe class must not
|
|
65
|
+
// survive anywhere, including in a stylesheet the html surface never wants.
|
|
66
|
+
expect(html).not.toContain("csr-");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("ships every role hook, because the dark stylesheet needs it there", () => {
|
|
70
|
+
const { html } = emailRenderer.render(CONTENT, CONTEXT);
|
|
71
|
+
const shipped = shippedClasses(html);
|
|
72
|
+
expect(shipped.length).toBeGreaterThan(0);
|
|
73
|
+
// A sample across the roles this content exercises. `cs-cta-hover` is the
|
|
74
|
+
// interesting one: it is the only reason `.cs-cta` and the hover state are
|
|
75
|
+
// separate roles, and it rides on the linked CTA above.
|
|
76
|
+
for (const hook of ["cs-link", "cs-cta", "cs-cta-hover", "cs-bubble"]) {
|
|
77
|
+
expect(shipped, `${hook} never reached the markup`).toContain(hook);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("turns a recipe-only class attribute into a style, leaving no class behind", () => {
|
|
82
|
+
// The collapse that makes `<p class="csr-p-none">` render as the `<p style="…">`
|
|
83
|
+
// the channel shipped before this registry existed. A leftover `class=""` would
|
|
84
|
+
// be invisible in a client and a permanent diff in the snapshot.
|
|
85
|
+
expect(inlineStyles(`<p class="csr-p-none">x</p>`)).toBe(
|
|
86
|
+
`<p style="${inlineOf("p-none")}">x</p>`,
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("keeps role hooks and appends the style, in that order", () => {
|
|
91
|
+
// Order is not cosmetic: it is what makes the inliner reproduce the previous
|
|
92
|
+
// markup byte-for-byte rather than merely equivalently.
|
|
93
|
+
expect(inlineStyles(`<td class="cs-bubble csr-bubble-user">x</td>`)).toBe(
|
|
94
|
+
`<td class="cs-bubble" style="${inlineOf("bubble-user")}">x</td>`,
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("leaves markup with no classes untouched", () => {
|
|
99
|
+
const plain = `<tr><td></td></tr>`;
|
|
100
|
+
expect(inlineStyles(plain)).toBe(plain);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("states each recipe as a rule and as an inline style, from one source", () => {
|
|
104
|
+
// The two surfaces' only difference is the shape, never the content — which is
|
|
105
|
+
// the claim that lets `ampShell` and `htmlShell` share this registry.
|
|
106
|
+
for (const name of STYLE_NAMES) {
|
|
107
|
+
expect(ruleOf(name)).toBe(`.csr-${name} { ${inlineOf(name)} }`);
|
|
108
|
+
expect(inlineOf(name).endsWith(";")).toBe(true);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
});
|