| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { assert, it } from "@effect/vitest"; |
| 3 | import * as Cause from "effect/Cause"; |
| 4 | import * as ConfigProvider from "effect/ConfigProvider"; |
| 5 | import * as Deferred from "effect/Deferred"; |
| 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as Fiber from "effect/Fiber"; |
| 8 | import * as FileSystem from "effect/FileSystem"; |
| 9 | import * as Layer from "effect/Layer"; |
| 10 | import * as Logger from "effect/Logger"; |
| 11 | import * as Path from "effect/Path"; |
| 12 | import * as Schema from "effect/Schema"; |
| 13 | import * as TestClock from "effect/testing/TestClock"; |
| 14 | import { Command } from "effect/cli"; |
| 15 | import { HttpClient, HttpClientError, HttpClientResponse, UrlParams } from "effect/http"; |
| 16 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; |
| 17 | |
| 18 | import { |
| 19 | buildDiscordReleaseAnnouncement, |
| 20 | notifyDiscordReleaseCommand, |
| 21 | postDiscordWebhook, |
| 22 | } from "./notify-discord-release.ts"; |
| 23 | |
| 24 | const latestAnnouncement = { |
| 25 | target: "latest", |
| 26 | roleId: "222222222222222222", |
| 27 | releaseName: "T3 Code v1.2.3", |
| 28 | version: "1.2.3", |
| 29 | tag: "v1.2.3", |
| 30 | releaseUrl: new URL("https://github.com/pingdotgg/t3code/releases/tag/v1.2.3"), |
| 31 | timestamp: "2026-05-01T01:41:00.000Z", |
| 32 | } as const; |
| 33 | const nightlyAnnouncement = { ...latestAnnouncement, target: "prerelease" } as const; |
| 34 | // Deliberately fake. All HTTP requests below use an injected client. |
| 35 | const webhookUrl = new URL("https://discord.com/api/webhooks/123456/test-secret-token"); |
| 36 | const intro = "A new T3 Code prerelease is available for nightly testers."; |
| 37 | const notes = `## What's Changed\n* Fix remote reconnection by @contributor in https://github.com/pingdotgg/t3code/pull/10\n* Improve thread search in https://github.com/pingdotgg/t3code/pull/11\n\n**Full Changelog**: https://github.com/pingdotgg/t3code/compare/v1.2.2...v1.2.3`; |
| 38 | const formattedNotes = `## What's Changed\n* [Fix remote reconnection](https://github.com/pingdotgg/t3code/pull/10) by [@contributor](https://github.com/contributor)\n* [Improve thread search](https://github.com/pingdotgg/t3code/pull/11)\n\n[Full Changelog](https://github.com/pingdotgg/t3code/compare/v1.2.2...v1.2.3)`; |
| 39 | const runCli = Command.runWith(notifyDiscordReleaseCommand, { version: "0.0.0" }); |
| 40 | const cliArgs = (target: "latest" | "prerelease") => [ |
| 41 | target, |
| 42 | "--role-id", |
| 43 | latestAnnouncement.roleId, |
| 44 | "--release-name", |
| 45 | latestAnnouncement.releaseName, |
| 46 | "--release-version", |
| 47 | latestAnnouncement.version, |
| 48 | "--tag", |
| 49 | latestAnnouncement.tag, |
| 50 | "--release-url", |
| 51 | latestAnnouncement.releaseUrl.href, |
| 52 | ]; |
| 53 | const configLayer = ConfigProvider.layer( |
| 54 | ConfigProvider.fromEnv({ env: { DISCORD_WEBHOOK_URL: webhookUrl.href } }), |
| 55 | ); |
| 56 | const PayloadSchema = Schema.fromJsonString( |
| 57 | Schema.Struct({ |
| 58 | content: Schema.String, |
| 59 | allowed_mentions: Schema.Struct({ |
| 60 | parse: Schema.Array(Schema.String), |
| 61 | roles: Schema.Array(Schema.String), |
| 62 | }), |
| 63 | embeds: Schema.Array( |
| 64 | Schema.Struct({ title: Schema.String, description: Schema.String, url: Schema.String }), |
| 65 | ), |
| 66 | }), |
| 67 | ); |
| 68 | const decodePayload = Schema.decodeUnknownSync(PayloadSchema); |
| 69 | const payloadDescription = (payloads: ReturnType<typeof buildDiscordReleaseAnnouncement>) => |
| 70 | payloads.flatMap((payload) => payload.embeds.map((embed) => embed.description)).join(""); |
| 71 | const captureClient = (requests: string[], status = 204) => |
| 72 | Layer.succeed( |
| 73 | HttpClient.HttpClient, |
| 74 | HttpClient.make((request) => { |
| 75 | assert.deepStrictEqual(UrlParams.getAll(request.urlParams, "wait"), ["true"]); |
| 76 | assert.equal(request.body._tag, "Uint8Array"); |
| 77 | if (request.body._tag !== "Uint8Array") return Effect.die("Expected JSON request body"); |
| 78 | requests.push(new TextDecoder().decode(request.body.body)); |
| 79 | return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status }))); |
| 80 | }), |
| 81 | ); |
| 82 | |
| 83 | function assertDiscordLimits(payloads: ReturnType<typeof buildDiscordReleaseAnnouncement>) { |
| 84 | for (const payload of payloads) { |
| 85 | assert.ok(payload.content.length <= 2000); |
| 86 | assert.equal(payload.embeds.length, 1); |
| 87 | let total = 0; |
| 88 | for (const embed of payload.embeds) { |
| 89 | assert.ok(embed.title.length <= 256); |
| 90 | assert.ok(embed.description.length <= 4096); |
| 91 | assert.ok(embed.footer.text.length <= 2048); |
| 92 | total += embed.title.length + embed.description.length + embed.footer.text.length; |
| 93 | } |
| 94 | assert.ok(total <= 6000); |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | it("includes nightly changes in order with their links and existing metadata", () => { |
| 99 | const payloads = buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, releaseNotes: notes }); |
| 100 | assert.deepStrictEqual(payloads, [ |
| 101 | { |
| 102 | content: "-# <@&222222222222222222>", |
| 103 | allowed_mentions: { parse: [], roles: [latestAnnouncement.roleId] }, |
| 104 | embeds: [ |
| 105 | { |
| 106 | title: latestAnnouncement.releaseName, |
| 107 | url: latestAnnouncement.releaseUrl.href, |
| 108 | description: `${intro}\n\n${formattedNotes}`, |
| 109 | color: 0x5865f2, |
| 110 | footer: { text: "v1.2.3" }, |
| 111 | timestamp: latestAnnouncement.timestamp, |
| 112 | }, |
| 113 | ], |
| 114 | }, |
| 115 | ]); |
| 116 | assertDiscordLimits(payloads); |
| 117 | }); |
| 118 | |
| 119 | it("keeps stable announcements short even when a long changelog is provided", () => { |
| 120 | assert.deepStrictEqual( |
| 121 | buildDiscordReleaseAnnouncement({ ...latestAnnouncement, releaseNotes: notes.repeat(200) }), |
| 122 | [ |
| 123 | { |
| 124 | content: "-# <@&222222222222222222>", |
| 125 | allowed_mentions: { parse: [], roles: [latestAnnouncement.roleId] }, |
| 126 | embeds: [ |
| 127 | { |
| 128 | title: latestAnnouncement.releaseName, |
| 129 | url: latestAnnouncement.releaseUrl.href, |
| 130 | description: `[View full release notes on GitHub](${latestAnnouncement.releaseUrl.href})`, |
| 131 | color: 0x2ecc71, |
| 132 | footer: { text: "v1.2.3" }, |
| 133 | timestamp: latestAnnouncement.timestamp, |
| 134 | }, |
| 135 | ], |
| 136 | }, |
| 137 | ], |
| 138 | ); |
| 139 | }); |
| 140 | |
| 141 | it("links contributor profiles and escapes brackets in change titles and bot handles", () => { |
| 142 | const releaseNotes = |
| 143 | "* Fix [web] links by @octo-user in https://github.com/pingdotgg/t3code/pull/12\n* Bump dependencies by @dependabot[bot] in https://github.com/pingdotgg/t3code/pull/13"; |
| 144 | const payloads = buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, releaseNotes }); |
| 145 | assert.equal( |
| 146 | payloadDescription(payloads), |
| 147 | `${intro}\n\n* [Fix \\[web\\] links](https://github.com/pingdotgg/t3code/pull/12) by [@octo-user](https://github.com/octo-user)\n* [Bump dependencies](https://github.com/pingdotgg/t3code/pull/13) by [@dependabot\\[bot\\]](https://github.com/apps/dependabot)`, |
| 148 | ); |
| 149 | }); |
| 150 | |
| 151 | it("preserves custom notes and links outside generated GitHub pull request entries", () => { |
| 152 | const releaseNotes = |
| 153 | "## Migration\n* Written by @someone in the docs\n* Commit https://github.com/pingdotgg/t3code/commit/abc123\n[Guide](https://example.com/guide)"; |
| 154 | assert.equal( |
| 155 | payloadDescription(buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, releaseNotes })), |
| 156 | `${intro}\n\n${releaseNotes}`, |
| 157 | ); |
| 158 | }); |
| 159 | |
| 160 | it("links the Full Changelog label instead of displaying the comparison URL", () => { |
| 161 | const url = "https://github.com/pingdotgg/t3code/compare/previous...next"; |
| 162 | const releaseNotes = `**Full Changelog**: ${url}\nFull Changelog: ${url}`; |
| 163 | const description = payloadDescription( |
| 164 | buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, releaseNotes }), |
| 165 | ); |
| 166 | assert.equal(description, `${intro}\n\n[Full Changelog](${url})\n[Full Changelog](${url})`); |
| 167 | }); |
| 168 | |
| 169 | it("keeps inline change attribution whole at a message boundary", () => { |
| 170 | const title = "* [Fix reconnection](https://github.com/pingdotgg/t3code/pull/10)"; |
| 171 | const contributor = "[@contributor](https://github.com/contributor)"; |
| 172 | const filler = "x".repeat(4096 - intro.length - 2 - title.length - 2); |
| 173 | const releaseNotes = `${filler}\n* Fix reconnection by @contributor in https://github.com/pingdotgg/t3code/pull/10`; |
| 174 | const payloads = buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, releaseNotes }); |
| 175 | assert.equal(payloads.length, 2); |
| 176 | assert.equal(payloads[1]?.embeds[0]?.description, `${title} by ${contributor}`); |
| 177 | assert.equal(payloadDescription(payloads), `${intro}\n\n${filler}\n${title} by ${contributor}`); |
| 178 | assertDiscordLimits(payloads); |
| 179 | }); |
| 180 | |
| 181 | it("falls back when notes are missing, empty, or whitespace", () => { |
| 182 | const fallback = buildDiscordReleaseAnnouncement(nightlyAnnouncement); |
| 183 | assert.equal(payloadDescription(fallback), intro); |
| 184 | assert.deepStrictEqual( |
| 185 | buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, releaseNotes: "" }), |
| 186 | fallback, |
| 187 | ); |
| 188 | assert.deepStrictEqual( |
| 189 | buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, releaseNotes: " \r\n\t" }), |
| 190 | fallback, |
| 191 | ); |
| 192 | }); |
| 193 | |
| 194 | it("splits long notes at line boundaries without dropping changes or the comparison link", () => { |
| 195 | const longNotes = |
| 196 | Array.from( |
| 197 | { length: 150 }, |
| 198 | (_, i) => `* Change ${i} in https://github.com/pingdotgg/t3code/pull/${i}\n`, |
| 199 | ).join("") + notes; |
| 200 | const payloads = buildDiscordReleaseAnnouncement({ |
| 201 | ...nightlyAnnouncement, |
| 202 | releaseNotes: longNotes, |
| 203 | }); |
| 204 | assert.ok(payloads.length > 2); |
| 205 | const formattedChanges = Array.from( |
| 206 | { length: 150 }, |
| 207 | (_, i) => `* [Change ${i}](https://github.com/pingdotgg/t3code/pull/${i})\n`, |
| 208 | ).join(""); |
| 209 | assert.equal(payloadDescription(payloads), `${intro}\n\n${formattedChanges}${formattedNotes}`); |
| 210 | assertDiscordLimits(payloads); |
| 211 | for (const [index, payload] of payloads.entries()) { |
| 212 | assert.deepStrictEqual(payload.allowed_mentions, { |
| 213 | parse: [], |
| 214 | roles: index === 0 ? [latestAnnouncement.roleId] : [], |
| 215 | }); |
| 216 | if (index > 0) { |
| 217 | assert.equal(payload.content, ""); |
| 218 | assert.equal( |
| 219 | payload.embeds[0]?.title, |
| 220 | `Changelog continued (${index + 1}/${payloads.length})`, |
| 221 | ); |
| 222 | } |
| 223 | if (index < payloads.length - 1) assert.ok(payload.embeds[0]?.description.endsWith("\n")); |
| 224 | assert.equal(payload.embeds[0]?.url, nightlyAnnouncement.releaseUrl.href); |
| 225 | } |
| 226 | }); |
| 227 | |
| 228 | it("honors the aggregate embed limit when metadata is also large", () => { |
| 229 | const releaseNotes = "a".repeat(16000); |
| 230 | const payloads = buildDiscordReleaseAnnouncement({ |
| 231 | ...nightlyAnnouncement, |
| 232 | releaseName: "n".repeat(256), |
| 233 | version: "x".repeat(2047), |
| 234 | releaseNotes, |
| 235 | }); |
| 236 | assertDiscordLimits(payloads); |
| 237 | assert.equal(payloadDescription(payloads), `${intro}\n\n${releaseNotes}`); |
| 238 | }); |
| 239 | |
| 240 | it("handles oversized lines, words, and Unicode without losing or breaking characters", () => { |
| 241 | const longNotes = |
| 242 | "a".repeat(9000) + "\n" + "🦋".repeat(4500) + "\n" + "word ".repeat(2000).trim(); |
| 243 | const payloads = buildDiscordReleaseAnnouncement({ |
| 244 | ...nightlyAnnouncement, |
| 245 | releaseNotes: longNotes, |
| 246 | }); |
| 247 | assertDiscordLimits(payloads); |
| 248 | assert.equal(payloadDescription(payloads), `${intro}\n\n${longNotes}`); |
| 249 | for (const payload of payloads) assert.ok(payload.embeds[0]?.description.isWellFormed()); |
| 250 | }); |
| 251 | |
| 252 | it("accepts exactly 4096 description characters and splits the next character", () => { |
| 253 | const releaseNotes = "x".repeat(4096 - intro.length - 2); |
| 254 | assert.equal(buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, releaseNotes }).length, 1); |
| 255 | const longer = buildDiscordReleaseAnnouncement({ |
| 256 | ...nightlyAnnouncement, |
| 257 | releaseNotes: releaseNotes + "x", |
| 258 | }); |
| 259 | assert.equal(longer.length, 2); |
| 260 | assert.equal(payloadDescription(longer), `${intro}\n\n${releaseNotes}x`); |
| 261 | assertDiscordLimits(longer); |
| 262 | }); |
| 263 | |
| 264 | it("suppresses everyone, here, user, and role mentions on every message", () => { |
| 265 | const mentions = `@everyone @here <@123> <@!123> <@&456> <@&${nightlyAnnouncement.roleId}>`; |
| 266 | const inert = mentions.replaceAll("@", "@\u200b"); |
| 267 | const payloads = buildDiscordReleaseAnnouncement({ |
| 268 | ...nightlyAnnouncement, |
| 269 | releaseName: mentions, |
| 270 | releaseNotes: `${mentions}\n`.repeat(100) + notes, |
| 271 | }); |
| 272 | assert.ok(payloads.length > 1); |
| 273 | assert.equal( |
| 274 | payloadDescription(payloads), |
| 275 | `${intro}\n\n${`${inert}\n`.repeat(100)}${formattedNotes}`, |
| 276 | ); |
| 277 | for (const [index, payload] of payloads.entries()) { |
| 278 | assert.deepStrictEqual(payload.allowed_mentions, { |
| 279 | parse: [], |
| 280 | roles: index === 0 ? [latestAnnouncement.roleId] : [], |
| 281 | }); |
| 282 | if (index === 0) assert.equal(payload.embeds[0]?.title, inert); |
| 283 | assert.ok(!payload.content.includes("@everyone")); |
| 284 | assert.ok(!payload.content.includes("@here")); |
| 285 | assert.equal(payload.content.match(/<@&\d+>/g)?.length ?? 0, index === 0 ? 1 : 0); |
| 286 | } |
| 287 | }); |
| 288 | |
| 289 | it("rejects invalid metadata before sending", () => { |
| 290 | assert.throws( |
| 291 | () => buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, releaseName: "x".repeat(257) }), |
| 292 | /title exceeds/, |
| 293 | ); |
| 294 | assert.throws( |
| 295 | () => buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, version: "x".repeat(2048) }), |
| 296 | /footer exceeds/, |
| 297 | ); |
| 298 | assert.throws( |
| 299 | () => buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, roleId: "123> @everyone" }), |
| 300 | /Invalid Discord release role/, |
| 301 | ); |
| 302 | }); |
| 303 | |
| 304 | it.effect("retains safe request context without leaking the webhook URL or nested cause", () => |
| 305 | Effect.gen(function* () { |
| 306 | const payload = buildDiscordReleaseAnnouncement(latestAnnouncement)[0]!; |
| 307 | const logs: string[] = []; |
| 308 | const client = Layer.succeed( |
| 309 | HttpClient.HttpClient, |
| 310 | HttpClient.make((request) => |
| 311 | Effect.fail( |
| 312 | new HttpClientError.HttpClientError({ |
| 313 | reason: new HttpClientError.EncodeError({ |
| 314 | request, |
| 315 | cause: new Error(`Encoder failed for ${webhookUrl.href}`), |
| 316 | }), |
| 317 | }), |
| 318 | ), |
| 319 | ), |
| 320 | ); |
| 321 | const error = yield* postDiscordWebhook(webhookUrl, payload, latestAnnouncement).pipe( |
| 322 | Effect.tapCause(Effect.logError), |
| 323 | Effect.provide([ |
| 324 | client, |
| 325 | Logger.layer([ |
| 326 | Logger.make((options) => { |
| 327 | logs.push(Logger.formatJson.log(options)); |
| 328 | }), |
| 329 | ]), |
| 330 | ]), |
| 331 | Effect.flip, |
| 332 | ); |
| 333 | if (error._tag !== "DiscordReleaseWebhookRequestError") |
| 334 | assert.fail(`Unexpected error: ${error._tag}`); |
| 335 | assert.equal(error.reason, "EncodeError"); |
| 336 | assert.equal(error.target, "latest"); |
| 337 | assert.equal(error.tag, latestAnnouncement.tag); |
| 338 | assert.equal(error.webhookOrigin, webhookUrl.origin); |
| 339 | assert.equal(error.webhookPathnameSegmentCount, 4); |
| 340 | assert.equal(error.contentLength, payload.content.length); |
| 341 | assert.equal(error.allowedRoleMentionCount, 1); |
| 342 | assert.ok(!Cause.pretty(Cause.fail(error)).includes("test-secret-token")); |
| 343 | assert.ok(!logs.join("\n").includes("test-secret-token")); |
| 344 | }), |
| 345 | ); |
| 346 | |
| 347 | it.effect( |
| 348 | "retains response status without retaining the secret-bearing request or response body", |
| 349 | () => |
| 350 | Effect.gen(function* () { |
| 351 | const client = Layer.succeed( |
| 352 | HttpClient.HttpClient, |
| 353 | HttpClient.make((request) => |
| 354 | Effect.succeed( |
| 355 | HttpClientResponse.fromWeb(request, new Response(webhookUrl.href, { status: 400 })), |
| 356 | ), |
| 357 | ), |
| 358 | ); |
| 359 | const error = yield* postDiscordWebhook( |
| 360 | webhookUrl, |
| 361 | buildDiscordReleaseAnnouncement(latestAnnouncement)[0]!, |
| 362 | latestAnnouncement, |
| 363 | ).pipe(Effect.provide(client), Effect.flip); |
| 364 | if (error._tag !== "DiscordReleaseWebhookResponseError") |
| 365 | assert.fail(`Unexpected error: ${error._tag}`); |
| 366 | assert.equal(error.status, 400); |
| 367 | assert.equal(error.tag, latestAnnouncement.tag); |
| 368 | assert.ok(!Cause.pretty(Cause.fail(error)).includes("test-secret-token")); |
| 369 | }), |
| 370 | ); |
| 371 | |
| 372 | it.effect("waits for Retry-After before retrying a rate-limited message", () => |
| 373 | Effect.gen(function* () { |
| 374 | const limited = yield* Deferred.make<void>(); |
| 375 | let attempts = 0; |
| 376 | const client = Layer.succeed( |
| 377 | HttpClient.HttpClient, |
| 378 | HttpClient.make((request) => |
| 379 | Effect.gen(function* () { |
| 380 | attempts += 1; |
| 381 | if (attempts === 1) { |
| 382 | yield* Deferred.succeed(limited, undefined); |
| 383 | return HttpClientResponse.fromWeb( |
| 384 | request, |
| 385 | new Response(null, { status: 429, headers: { "retry-after": "2" } }), |
| 386 | ); |
| 387 | } |
| 388 | return HttpClientResponse.fromWeb(request, new Response(null, { status: 204 })); |
| 389 | }), |
| 390 | ), |
| 391 | ); |
| 392 | const fiber = yield* postDiscordWebhook( |
| 393 | webhookUrl, |
| 394 | buildDiscordReleaseAnnouncement(latestAnnouncement)[0]!, |
| 395 | latestAnnouncement, |
| 396 | ).pipe(Effect.provide(client), Effect.forkChild); |
| 397 | yield* Deferred.await(limited); |
| 398 | yield* TestClock.adjust("1 second"); |
| 399 | assert.equal(attempts, 1); |
| 400 | yield* TestClock.adjust("1 second"); |
| 401 | yield* Fiber.join(fiber); |
| 402 | assert.equal(attempts, 2); |
| 403 | }), |
| 404 | ); |
| 405 | |
| 406 | it.effect("bounds total retry time while preserving delays within the deadline", () => |
| 407 | Effect.gen(function* () { |
| 408 | const limited = yield* Deferred.make<void>(); |
| 409 | let attempts = 0; |
| 410 | const client = Layer.succeed( |
| 411 | HttpClient.HttpClient, |
| 412 | HttpClient.make((request) => |
| 413 | Effect.gen(function* () { |
| 414 | attempts += 1; |
| 415 | yield* Deferred.succeed(limited, undefined); |
| 416 | return HttpClientResponse.fromWeb( |
| 417 | request, |
| 418 | new Response(null, { status: 429, headers: { "retry-after": "40" } }), |
| 419 | ); |
| 420 | }), |
| 421 | ), |
| 422 | ); |
| 423 | const fiber = yield* postDiscordWebhook( |
| 424 | webhookUrl, |
| 425 | buildDiscordReleaseAnnouncement(latestAnnouncement)[0]!, |
| 426 | latestAnnouncement, |
| 427 | ).pipe(Effect.provide(client), Effect.result, Effect.forkChild); |
| 428 | yield* Deferred.await(limited); |
| 429 | yield* TestClock.adjust("39 seconds"); |
| 430 | assert.equal(attempts, 1); |
| 431 | yield* TestClock.adjust("1 second"); |
| 432 | assert.equal(attempts, 2); |
| 433 | yield* TestClock.adjust("20 seconds"); |
| 434 | const result = yield* Fiber.join(fiber); |
| 435 | assert.equal(attempts, 2); |
| 436 | assert.equal(result._tag, "Failure"); |
| 437 | if (result._tag !== "Failure") assert.fail("Expected the retry deadline to expire"); |
| 438 | assert.equal(result.failure._tag, "DiscordReleaseWebhookRequestError"); |
| 439 | if (result.failure._tag !== "DiscordReleaseWebhookRequestError") |
| 440 | assert.fail(`Unexpected error: ${result.failure._tag}`); |
| 441 | assert.equal(result.failure.reason, "TimeoutError"); |
| 442 | assert.ok(!Cause.pretty(Cause.fail(result.failure)).includes("test-secret-token")); |
| 443 | }), |
| 444 | ); |
| 445 | |
| 446 | // Run the checked-in shell commands with gh/node stubs, then send their captured |
| 447 | // arguments through the real CLI parser and an injected HTTP client. |
| 448 | function workflowRun(workflow: string, stepName: string) { |
| 449 | const step = workflow.split(` - name: ${stepName}\n`)[1]?.split("\n - name:")[0]; |
| 450 | assert.ok(step, `Missing workflow step: ${stepName}`); |
| 451 | const run = step.split(" run: |\n")[1]; |
| 452 | assert.ok(run, `Missing run block: ${stepName}`); |
| 453 | return run |
| 454 | .split("\n") |
| 455 | .map((line) => line.replace(/^ /, "")) |
| 456 | .join("\n"); |
| 457 | } |
| 458 | |
| 459 | it.layer(NodeServices.layer)("Discord release CLI and workflow", (it) => { |
| 460 | it.effect("passes the published nightly body from the workflow to ordered sends", () => |
| 461 | Effect.gen(function* () { |
| 462 | const fs = yield* FileSystem.FileSystem; |
| 463 | const path = yield* Path.Path; |
| 464 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 465 | const temp = yield* fs.makeTempDirectoryScoped({ prefix: "discord release " }); |
| 466 | const workflow = yield* fs.readFileString( |
| 467 | yield* path.fromFileUrl(new URL("../.github/workflows/release.yml", import.meta.url)), |
| 468 | ); |
| 469 | const publishedNotes = |
| 470 | Array.from({ length: 50 }, () => notes).join("\n\n") + |
| 471 | '\n* Literal $(touch "$RUNNER_TEMP/executed") and `touch "$RUNNER_TEMP/executed"`'; |
| 472 | const read = workflowRun(workflow, "Read published release notes"); |
| 473 | const shellEnv = { |
| 474 | RUNNER_TEMP: temp, |
| 475 | RELEASE_TAG: nightlyAnnouncement.tag, |
| 476 | RELEASE_CHANNEL: "nightly", |
| 477 | FIXTURE_NOTES: publishedNotes, |
| 478 | }; |
| 479 | const readStatus = yield* spawner.exitCode( |
| 480 | ChildProcess.make( |
| 481 | "bash", |
| 482 | [ |
| 483 | "-e", |
| 484 | "-c", |
| 485 | `gh() { [ "$*" = 'release view v1.2.3 --json body --jq .body // ""' ] || return 1; printf '%s' "$FIXTURE_NOTES"; }\n${read}`, |
| 486 | ], |
| 487 | { env: shellEnv }, |
| 488 | ), |
| 489 | ); |
| 490 | assert.equal(readStatus, 0); |
| 491 | assert.equal(yield* fs.readFileString(`${temp}/discord-release-notes.md`), publishedNotes); |
| 492 | const replacements: Record<string, string> = { |
| 493 | "needs.preflight.outputs.release_name": latestAnnouncement.releaseName, |
| 494 | "needs.preflight.outputs.version": latestAnnouncement.version, |
| 495 | "needs.preflight.outputs.tag": latestAnnouncement.tag, |
| 496 | "github.repository": "pingdotgg/t3code", |
| 497 | }; |
| 498 | const announce = workflowRun(workflow, "Announce prerelease on Discord").replace( |
| 499 | /\$\{\{ (.*?) \}\}/g, |
| 500 | (_, key: string) => { |
| 501 | assert.ok(replacements[key], `Unexpected expression: ${key}`); |
| 502 | return replacements[key]!; |
| 503 | }, |
| 504 | ); |
| 505 | const announceStatus = yield* spawner.exitCode( |
| 506 | ChildProcess.make( |
| 507 | "bash", |
| 508 | ["-e", "-c", `node() { printf '%s\\0' "$@" > "$RUNNER_TEMP/args"; }\n${announce}`], |
| 509 | { env: { RUNNER_TEMP: temp, DISCORD_MENTION_ROLE_ID: latestAnnouncement.roleId } }, |
| 510 | ), |
| 511 | ); |
| 512 | assert.equal(announceStatus, 0); |
| 513 | const args = (yield* fs.readFileString(`${temp}/args`)).split("\0").slice(1, -1); |
| 514 | const requests: string[] = []; |
| 515 | yield* runCli(args).pipe(Effect.provide([configLayer, captureClient(requests)])); |
| 516 | const payloads = requests.map((request) => decodePayload(request)); |
| 517 | assert.ok(payloads.length > 2); |
| 518 | assert.equal( |
| 519 | payloads.flatMap((payload) => payload.embeds.map((embed) => embed.description)).join(""), |
| 520 | `${intro}\n\n${Array.from({ length: 50 }, () => formattedNotes).join("\n\n")}\n* Literal $(touch "$RUNNER_TEMP/executed") and \`touch "$RUNNER_TEMP/executed"\``, |
| 521 | ); |
| 522 | for (const [index, payload] of payloads.entries()) |
| 523 | assert.deepStrictEqual(payload.allowed_mentions, { |
| 524 | parse: [], |
| 525 | roles: index === 0 ? [latestAnnouncement.roleId] : [], |
| 526 | }); |
| 527 | assert.equal(yield* fs.exists(`${temp}/executed`), false); |
| 528 | assert.equal( |
| 529 | yield* spawner.exitCode( |
| 530 | ChildProcess.make("bash", ["-e", "-c", `gh() { return 99; }\n${read}`], { |
| 531 | env: { ...shellEnv, RELEASE_CHANNEL: "stable" }, |
| 532 | }), |
| 533 | ), |
| 534 | 0, |
| 535 | ); |
| 536 | assert.equal(yield* fs.readFileString(`${temp}/discord-release-notes.md`), ""); |
| 537 | assert.equal( |
| 538 | yield* spawner.exitCode( |
| 539 | ChildProcess.make( |
| 540 | "bash", |
| 541 | ["-e", "-c", `gh() { printf '%s' 'partial changelog'; return 42; }\n${read}`], |
| 542 | { env: shellEnv }, |
| 543 | ), |
| 544 | ), |
| 545 | 0, |
| 546 | ); |
| 547 | assert.equal(yield* fs.readFileString(`${temp}/discord-release-notes.md`), ""); |
| 548 | const fallbackRequests: string[] = []; |
| 549 | yield* runCli(args).pipe(Effect.provide([configLayer, captureClient(fallbackRequests)])); |
| 550 | assert.equal(fallbackRequests.length, 1); |
| 551 | const fallback = decodePayload(fallbackRequests[0]!); |
| 552 | assert.equal(fallback.embeds[0]?.description, intro); |
| 553 | assert.equal(fallback.content, `-# <@&${latestAnnouncement.roleId}>`); |
| 554 | assert.deepStrictEqual(fallback.allowed_mentions, { |
| 555 | parse: [], |
| 556 | roles: [latestAnnouncement.roleId], |
| 557 | }); |
| 558 | assert.ok( |
| 559 | workflow |
| 560 | .split(" - name: Announce prerelease on Discord\n")[1] |
| 561 | ?.startsWith(" if: needs.preflight.outputs.is_prerelease == 'true'\n"), |
| 562 | ); |
| 563 | assert.ok(workflow.includes("GH_REPO: ${{ github.repository }}")); |
| 564 | assert.ok(workflow.includes("RELEASE_TAG: ${{ needs.preflight.outputs.tag }}")); |
| 565 | assert.ok( |
| 566 | !workflowRun(workflow, "Announce latest release on Discord").includes( |
| 567 | "--release-notes-file", |
| 568 | ), |
| 569 | ); |
| 570 | assert.ok( |
| 571 | workflow |
| 572 | .slice(workflow.indexOf(" announce_discord:")) |
| 573 | .includes("needs.preflight.outputs.release_channel != 'preview'"), |
| 574 | ); |
| 575 | }), |
| 576 | ); |
| 577 | |
| 578 | it.effect("uses the fallback when notes are omitted and ignores a notes file for latest", () => |
| 579 | Effect.gen(function* () { |
| 580 | const requests: string[] = []; |
| 581 | yield* runCli(cliArgs("prerelease")).pipe( |
| 582 | Effect.provide([configLayer, captureClient(requests)]), |
| 583 | ); |
| 584 | assert.equal(decodePayload(requests[0]!).embeds[0]?.description, intro); |
| 585 | yield* runCli([ |
| 586 | ...cliArgs("latest"), |
| 587 | "--release-notes-file", |
| 588 | "/missing-stable-notes.md", |
| 589 | ]).pipe(Effect.provide([configLayer, captureClient(requests)])); |
| 590 | assert.equal(requests.length, 2); |
| 591 | assert.equal( |
| 592 | decodePayload(requests[1]!).embeds[0]?.description, |
| 593 | `[View full release notes on GitHub](${latestAnnouncement.releaseUrl.href})`, |
| 594 | ); |
| 595 | }), |
| 596 | ); |
| 597 | |
| 598 | it.effect("fails before sending when the requested nightly notes file cannot be read", () => |
| 599 | Effect.gen(function* () { |
| 600 | const requests: string[] = []; |
| 601 | const result = yield* runCli([ |
| 602 | ...cliArgs("prerelease"), |
| 603 | "--release-notes-file", |
| 604 | "/missing-nightly-notes.md", |
| 605 | ]).pipe(Effect.provide([configLayer, captureClient(requests)]), Effect.result); |
| 606 | assert.equal(result._tag, "Failure"); |
| 607 | assert.deepStrictEqual(requests, []); |
| 608 | }), |
| 609 | ); |
| 610 | |
| 611 | it.effect("stops on a failed send rather than posting later continuations", () => |
| 612 | Effect.gen(function* () { |
| 613 | const fs = yield* FileSystem.FileSystem; |
| 614 | const temp = yield* fs.makeTempDirectoryScoped(); |
| 615 | const file = `${temp}/notes.md`; |
| 616 | yield* fs.writeFileString(file, notes.repeat(100)); |
| 617 | const requests: string[] = []; |
| 618 | const result = yield* runCli([...cliArgs("prerelease"), "--release-notes-file", file]).pipe( |
| 619 | Effect.provide([configLayer, captureClient(requests, 400)]), |
| 620 | Effect.result, |
| 621 | ); |
| 622 | assert.equal(result._tag, "Failure"); |
| 623 | assert.equal(requests.length, 1); |
| 624 | }), |
| 625 | ); |
| 626 | |
| 627 | it.effect("does not expose an invalid webhook secret in configuration errors", () => |
| 628 | Effect.gen(function* () { |
| 629 | const requests: string[] = []; |
| 630 | const result = yield* runCli(cliArgs("latest")).pipe( |
| 631 | Effect.provide([ |
| 632 | ConfigProvider.layer( |
| 633 | ConfigProvider.fromEnv({ env: { DISCORD_WEBHOOK_URL: "invalid-test-secret-token" } }), |
| 634 | ), |
| 635 | captureClient(requests), |
| 636 | ]), |
| 637 | Effect.result, |
| 638 | ); |
| 639 | assert.equal(result._tag, "Failure"); |
| 640 | if (result._tag === "Failure") |
| 641 | assert.ok(!Cause.pretty(Cause.fail(result.failure)).includes("test-secret-token")); |
| 642 | assert.deepStrictEqual(requests, []); |
| 643 | }), |
| 644 | ); |
| 645 | }); |