@company-semantics/contracts 35.1.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.
@@ -1,12 +1,21 @@
1
1
  /**
2
- * The Slack channel is a placeholder, so what is pinned here is the contract a
3
- * placeholder still owes — `supports` total and in agreement with `render`,
4
- * `render` pure and reading the brand and year from the context, every element
5
- * type rendering without throwing — and NOT the Block Kit wording, which is
6
- * invented and expected to change when a kind is actually posted to Slack.
2
+ * What this pins, now that the blocks are Slack's own vocabulary rather than a
3
+ * hand-written approximation (ADR-CONTRACTS-090):
7
4
  *
8
- * The mrkdwn escaping IS pinned. It is the one thing here that would be a real
9
- * defect rather than a fidelity gap.
5
+ * - the contract every renderer owes `supports` total and in agreement with
6
+ * `render`, `render` pure and reading the brand and year from the context,
7
+ * every element type rendering without throwing;
8
+ * - the ESCAPING RULE, both halves. mrkdwn escapes; `plain_text` and `rich_text`
9
+ * must not. This is the defect the new literal surfaces made possible, and the
10
+ * compiler cannot catch it — both surfaces are `string`.
11
+ * - the SHAPE — that each element reaches the Block Kit primitive it claims to.
12
+ * `@slack/types` proves a block is well-formed; only a test proves a `list`
13
+ * became a `rich_text_list` rather than a section full of bullet characters.
14
+ * - Slack's documented LIMITS, which are rejections at the API rather than
15
+ * fidelity gaps: header ≤150, section fields ≤10.
16
+ *
17
+ * Still NOT pinned: the wording. It is invented rather than relocated from a
18
+ * shipped Slack app, and it is expected to change when a kind is actually posted.
10
19
  */
11
20
 
12
21
  import { describe, expect, it } from "vitest";
@@ -67,6 +76,17 @@ const ALL_TYPES: NotificationElementType[] = [
67
76
  "heroImage",
68
77
  ];
69
78
 
79
+ /** One content carrying one element — the shape assertions read better alone. */
80
+ function only(
81
+ element: NotificationContent["sections"][number]["elements"][number],
82
+ title = "Untitled",
83
+ ): NotificationContent {
84
+ return {
85
+ metadata: { kind: "org.invite", title },
86
+ sections: [{ elements: [element] }],
87
+ };
88
+ }
89
+
70
90
  describe("slackRenderer", () => {
71
91
  it("declares a stable channel id", () => {
72
92
  expect(slackRenderer.id).toBe("slack");
@@ -92,22 +112,22 @@ describe("slackRenderer", () => {
92
112
  const rendered = slackRenderer.render(CONTENT, CONTEXT);
93
113
 
94
114
  expect(JSON.stringify(rendered)).not.toContain("Hello?");
95
- // Eleven admitted elements, each yielding exactly one block here.
96
- expect(rendered.blocks).toHaveLength(11);
97
115
  });
98
116
 
99
117
  it("returns its natural type — a Block Kit record, not a string", () => {
100
118
  const rendered = slackRenderer.render(CONTENT, CONTEXT);
101
119
 
102
120
  // `text` is the fallback Slack shows where blocks cannot render. It is
103
- // metadata.title — the same field email spends as its subject.
121
+ // metadata.title — the same field email spends as its subject, and this
122
+ // channel ALSO leads with as a header.
104
123
  expect(rendered.text).toBe("Join Acme");
105
124
  expect(rendered.blocks.map((block) => block.type)).toEqual([
125
+ "header",
106
126
  "image",
107
127
  "section",
108
128
  "section",
109
129
  "section",
110
- "section",
130
+ "rich_text",
111
131
  "divider",
112
132
  "section",
113
133
  "context",
@@ -117,8 +137,127 @@ describe("slackRenderer", () => {
117
137
  ]);
118
138
  });
119
139
 
140
+ // ===========================================================================
141
+ // The header — metadata.title's third spend
142
+ // ===========================================================================
143
+
144
+ it("leads with a header block carrying the title", () => {
145
+ expect(slackRenderer.render(CONTENT, CONTEXT).blocks[0]).toEqual({
146
+ type: "header",
147
+ text: { type: "plain_text", text: "Join Acme" },
148
+ });
149
+ });
150
+
151
+ it("spends the title on BOTH the header and the fallback", () => {
152
+ // Neither use replaces the other: `text` is what a push notification and a
153
+ // screen reader get, the header is what the message looks like.
154
+ const rendered = slackRenderer.render(CONTENT, CONTEXT);
155
+
156
+ expect(rendered.text).toBe("Join Acme");
157
+ expect(JSON.stringify(rendered.blocks[0])).toContain("Join Acme");
158
+ });
159
+
160
+ it("truncates a header at Slack's documented 150 characters", () => {
161
+ const rendered = slackRenderer.render(
162
+ only({ type: "body", text: "x" }, "A".repeat(200)),
163
+ CONTEXT,
164
+ );
165
+ const header = rendered.blocks[0];
166
+
167
+ expect(header.type).toBe("header");
168
+ expect(header).toMatchObject({
169
+ text: { text: `${"A".repeat(149)}…` },
170
+ });
171
+ // The FALLBACK is not truncated — only the block has the limit.
172
+ expect(rendered.text).toHaveLength(200);
173
+ });
174
+
175
+ it("emits no header when the title is empty — not an empty block", () => {
176
+ const rendered = slackRenderer.render(
177
+ only({ type: "body", text: "x" }, " "),
178
+ CONTEXT,
179
+ );
180
+
181
+ expect(rendered.blocks.map((block) => block.type)).toEqual(["section"]);
182
+ });
183
+
184
+ // ===========================================================================
185
+ // Shape — each element reaches its real Block Kit primitive
186
+ // ===========================================================================
187
+
188
+ it("renders a keyValueTable as section fields, Slack's two-column device", () => {
189
+ const rendered = slackRenderer.render(
190
+ only({
191
+ type: "keyValueTable",
192
+ rows: [
193
+ { label: "Status", value: "Valid" },
194
+ { label: "Expires in", value: "10 minutes" },
195
+ ],
196
+ }),
197
+ CONTEXT,
198
+ );
199
+
200
+ expect(rendered.blocks[1]).toEqual({
201
+ type: "section",
202
+ fields: [
203
+ { type: "mrkdwn", text: "*Status*\nValid" },
204
+ { type: "mrkdwn", text: "*Expires in*\n10 minutes" },
205
+ ],
206
+ });
207
+ });
208
+
209
+ it("chunks a keyValueTable at ten fields — Slack rejects an eleventh", () => {
210
+ const rows = Array.from({ length: 23 }, (_, index) => ({
211
+ label: `L${index}`,
212
+ value: `V${index}`,
213
+ }));
214
+ const blocks = slackRenderer
215
+ .render(only({ type: "keyValueTable", rows }), CONTEXT)
216
+ .blocks.filter((block) => block.type === "section");
217
+
218
+ expect(blocks.map((block) => block.fields?.length)).toEqual([10, 10, 3]);
219
+ });
220
+
221
+ it("renders a list as a real rich_text_list, not bullet characters", () => {
222
+ const rendered = slackRenderer.render(
223
+ only({ type: "list", items: ["Read", "Write"] }),
224
+ CONTEXT,
225
+ );
226
+
227
+ expect(rendered.blocks[1]).toEqual({
228
+ type: "rich_text",
229
+ elements: [
230
+ {
231
+ type: "rich_text_list",
232
+ style: "bullet",
233
+ elements: [
234
+ {
235
+ type: "rich_text_section",
236
+ elements: [{ type: "text", text: "Read" }],
237
+ },
238
+ {
239
+ type: "rich_text_section",
240
+ elements: [{ type: "text", text: "Write" }],
241
+ },
242
+ ],
243
+ },
244
+ ],
245
+ });
246
+ });
247
+
248
+ it("maps an ordered list onto Slack's own ordered style", () => {
249
+ const rendered = slackRenderer.render(
250
+ only({ type: "list", items: ["First"], ordered: true }),
251
+ CONTEXT,
252
+ );
253
+
254
+ expect(rendered.blocks[1]).toMatchObject({
255
+ elements: [{ type: "rich_text_list", style: "ordered" }],
256
+ });
257
+ });
258
+
120
259
  it("takes the hero image the SMS channel has no surface for", () => {
121
- // The two stubs exist to differ: same content, no channel tags on it, and
260
+ // The channels exist to differ: same content, no channel tags on it, and
122
261
  // each channel keeps what it can depict.
123
262
  expect(slackRenderer.render(CONTENT, CONTEXT).blocks).toContainEqual({
124
263
  type: "image",
@@ -140,36 +279,106 @@ describe("slackRenderer", () => {
140
279
  });
141
280
  });
142
281
 
143
- it("renders a call to action without an href as text, not a button", () => {
282
+ it("renders a call to action without an href as a code block, not a button", () => {
144
283
  // `href` absent means the label IS the payload (an OTP code). A button with
145
- // no destination would be a lie about what the notification is asking for.
284
+ // no destination would be a lie about what the notification is asking for;
285
+ // `rich_text_preformatted` is Slack's own device for a selectable code.
146
286
  const otp: NotificationContent = {
147
287
  metadata: { kind: "auth.otp", title: "Your code" },
148
288
  sections: [{ elements: [{ type: "callToAction", label: "123456" }] }],
149
289
  };
150
- expect(slackRenderer.render(otp, CONTEXT).blocks).toEqual([
151
- { type: "section", text: { type: "mrkdwn", text: "*123456*" } },
152
- ]);
290
+
291
+ expect(slackRenderer.render(otp, CONTEXT).blocks[1]).toEqual({
292
+ type: "rich_text",
293
+ elements: [
294
+ {
295
+ type: "rich_text_preformatted",
296
+ elements: [{ type: "text", text: "123456" }],
297
+ },
298
+ ],
299
+ });
300
+ });
301
+
302
+ // ===========================================================================
303
+ // Escaping — the rule follows the SURFACE, not the string
304
+ // ===========================================================================
305
+
306
+ it("escapes Slack's reserved characters on an mrkdwn surface", () => {
307
+ const rendered = slackRenderer.render(
308
+ only({ type: "body", text: "a & b <c> <!channel>" }),
309
+ CONTEXT,
310
+ );
311
+
312
+ expect(rendered.blocks[1]).toEqual({
313
+ type: "section",
314
+ text: { type: "mrkdwn", text: "a &amp; b &lt;c&gt; &lt;!channel&gt;" },
315
+ });
316
+ });
317
+
318
+ it("does NOT escape a plain_text surface — a header is literal", () => {
319
+ // Escaping here is a real defect, not belt-and-braces: `plain_text` does not
320
+ // parse the entities, so the reader would see `Acme &amp; Co`.
321
+ const rendered = slackRenderer.render(
322
+ only({ type: "body", text: "x" }, "Acme & Co <the org>"),
323
+ CONTEXT,
324
+ );
325
+
326
+ expect(rendered.blocks[0]).toEqual({
327
+ type: "header",
328
+ text: { type: "plain_text", text: "Acme & Co <the org>" },
329
+ });
330
+ });
331
+
332
+ it("does NOT escape a plain_text surface — a button label is literal", () => {
333
+ const rendered = slackRenderer.render(
334
+ only({
335
+ type: "callToAction",
336
+ label: "Join Acme & Co",
337
+ href: "https://e.test",
338
+ }),
339
+ CONTEXT,
340
+ );
341
+
342
+ expect(rendered.blocks[1]).toMatchObject({
343
+ elements: [{ text: { type: "plain_text", text: "Join Acme & Co" } }],
344
+ });
153
345
  });
154
346
 
155
- it("escapes Slack's reserved characters in user-controlled fields", () => {
156
- const hostile: NotificationContent = {
157
- metadata: { kind: "org.invite", title: "Join Acme" },
158
- sections: [
159
- { elements: [{ type: "body", text: "a & b <c> <!channel>" }] },
347
+ it("does NOT escape a rich_text surface — the OTP is literal", () => {
348
+ const rendered = slackRenderer.render(
349
+ only({ type: "callToAction", label: "a&b<c>" }),
350
+ CONTEXT,
351
+ );
352
+
353
+ expect(rendered.blocks[1]).toMatchObject({
354
+ elements: [
355
+ {
356
+ type: "rich_text_preformatted",
357
+ elements: [{ type: "text", text: "a&b<c>" }],
358
+ },
160
359
  ],
161
- };
162
- expect(slackRenderer.render(hostile, CONTEXT).blocks).toEqual([
163
- {
164
- type: "section",
165
- text: {
166
- type: "mrkdwn",
167
- text: "a &amp; b &lt;c&gt; &lt;!channel&gt;",
360
+ });
361
+ });
362
+
363
+ it("does NOT escape a rich_text surface — list items are literal", () => {
364
+ const rendered = slackRenderer.render(
365
+ only({ type: "list", items: ["a & b"] }),
366
+ CONTEXT,
367
+ );
368
+
369
+ expect(rendered.blocks[1]).toMatchObject({
370
+ elements: [
371
+ {
372
+ elements: [{ elements: [{ type: "text", text: "a & b" }] }],
168
373
  },
169
- },
170
- ]);
374
+ ],
375
+ });
171
376
  });
172
377
 
378
+ // ===========================================================================
379
+ // The seam properties
380
+ // ===========================================================================
381
+
173
382
  it("reads the brand and year from context, never from a clock", () => {
174
383
  // No fake timer in this file: swapping the context moves the copyright line.
175
384
  const pinned: RenderContext = {
@@ -181,16 +390,15 @@ describe("slackRenderer", () => {
181
390
  });
182
391
 
183
392
  it("lets an element's signer override the context brand", () => {
184
- const onBehalf: NotificationContent = {
185
- metadata: { kind: "org.invite", title: "Join Acme" },
186
- sections: [{ elements: [{ type: "signature", signer: "Grace" }] }],
187
- };
188
- expect(slackRenderer.render(onBehalf, CONTEXT).blocks).toEqual([
189
- {
190
- type: "context",
191
- elements: [{ type: "mrkdwn", text: "© 2026 Grace" }],
192
- },
193
- ]);
393
+ const rendered = slackRenderer.render(
394
+ only({ type: "signature", signer: "Grace" }),
395
+ CONTEXT,
396
+ );
397
+
398
+ expect(rendered.blocks[1]).toEqual({
399
+ type: "context",
400
+ elements: [{ type: "mrkdwn", text: "© 2026 Grace" }],
401
+ });
194
402
  });
195
403
 
196
404
  it("is pure — same inputs, same output", () => {