| ... | @@ -1,9 +1,25 @@ | ... | @@ -1,9 +1,25 @@ |
| | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 1 | import { assert, it } from "@effect/vitest"; | 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"; |
| 2 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| | 7 | import * as Fiber from "effect/Fiber"; |
| | 8 | import * as FileSystem from "effect/FileSystem"; |
| 3 | import * as Layer from "effect/Layer"; | 9 | import * as Layer from "effect/Layer"; |
| 4 | import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http"; | 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/unstable/cli"; |
| | 15 | import { HttpClient, HttpClientError, HttpClientResponse, UrlParams } from "effect/unstable/http"; |
| | 16 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 5 | | 17 | |
| 6 | import { buildDiscordReleaseAnnouncement, postDiscordWebhook } from "./notify-discord-release.ts"; | 18 | import { |
| | 19 | buildDiscordReleaseAnnouncement, |
| | 20 | notifyDiscordReleaseCommand, |
| | 21 | postDiscordWebhook, |
| | 22 | } from "./notify-discord-release.ts"; |
| 7 | | 23 | |
| 8 | const latestAnnouncement = { | 24 | const latestAnnouncement = { |
| 9 | target: "latest", | 25 | target: "latest", |
| ... | @@ -11,158 +27,619 @@ const latestAnnouncement = { | ... | @@ -11,158 +27,619 @@ const latestAnnouncement = { |
| 11 | releaseName: "T3 Code v1.2.3", | 27 | releaseName: "T3 Code v1.2.3", |
| 12 | version: "1.2.3", | 28 | version: "1.2.3", |
| 13 | tag: "v1.2.3", | 29 | tag: "v1.2.3", |
| 14 | releaseUrl: new URL("https://github.com/t3dotgg/t3-code/releases/tag/v1.2.3"), | 30 | releaseUrl: new URL("https://github.com/pingdotgg/t3code/releases/tag/v1.2.3"), |
| 15 | timestamp: "2026-05-01T01:41:00.000Z", | 31 | timestamp: "2026-05-01T01:41:00.000Z", |
| 16 | } as const; | 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 | ); |
| 17 | | 82 | |
| 18 | const webhookUrl = new URL("https://discord.com/api/webhooks/123456/secret-token"); | 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 | } |
| 19 | | 97 | |
| 20 | it("builds a prerelease Discord announcement for nightly subscribers", () => { | 98 | it("includes nightly changes in order with their links and existing metadata", () => { |
| 21 | assert.deepStrictEqual( | 99 | const payloads = buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, releaseNotes: notes }); |
| 22 | buildDiscordReleaseAnnouncement({ | 100 | assert.deepStrictEqual(payloads, [ |
| 23 | target: "prerelease", | | |
| 24 | roleId: "111111111111111111", | | |
| 25 | releaseName: "T3 Code Nightly 1.2.4-nightly.20260501.17 (abcdef123456)", | | |
| 26 | version: "1.2.4-nightly.20260501.17", | | |
| 27 | tag: "v1.2.4-nightly.20260501.17", | | |
| 28 | releaseUrl: new URL( | | |
| 29 | "https://github.com/t3dotgg/t3-code/releases/tag/v1.2.4-nightly.20260501.17", | | |
| 30 | ), | | |
| 31 | timestamp: "2026-05-01T01:41:00.000Z", | | |
| 32 | }), | | |
| 33 | { | 101 | { |
| 34 | content: | 102 | content: "-# <@&222222222222222222>", |
| 35 | "<@&111111111111111111> Prerelease published: T3 Code Nightly 1.2.4-nightly.20260501.17 (abcdef123456)", | 103 | allowed_mentions: { parse: [], roles: [latestAnnouncement.roleId] }, |
| 36 | allowed_mentions: { | | |
| 37 | roles: ["111111111111111111"], | | |
| 38 | }, | | |
| 39 | embeds: [ | 104 | embeds: [ |
| 40 | { | 105 | { |
| 41 | title: "T3 Code Nightly 1.2.4-nightly.20260501.17 (abcdef123456)", | 106 | title: latestAnnouncement.releaseName, |
| 42 | url: "https://github.com/t3dotgg/t3-code/releases/tag/v1.2.4-nightly.20260501.17", | 107 | url: latestAnnouncement.releaseUrl.href, |
| 43 | description: "A new T3 Code prerelease is available for nightly testers.", | 108 | description: `${intro}\n\n${formattedNotes}`, |
| 44 | color: 0x5865f2, | 109 | color: 0x5865f2, |
| 45 | fields: [ | 110 | footer: { text: "v1.2.3" }, |
| 46 | { | 111 | timestamp: latestAnnouncement.timestamp, |
| 47 | name: "Version", | | |
| 48 | value: "1.2.4-nightly.20260501.17", | | |
| 49 | inline: true, | | |
| 50 | }, | | |
| 51 | { | | |
| 52 | name: "Tag", | | |
| 53 | value: "v1.2.4-nightly.20260501.17", | | |
| 54 | inline: true, | | |
| 55 | }, | | |
| 56 | ], | | |
| 57 | timestamp: "2026-05-01T01:41:00.000Z", | | |
| 58 | }, | 112 | }, |
| 59 | ], | 113 | ], |
| 60 | }, | 114 | }, |
| 61 | ); | 115 | ]); |
| | 116 | assertDiscordLimits(payloads); |
| 62 | }); | 117 | }); |
| 63 | | 118 | |
| 64 | it("builds a latest Discord announcement for stable subscribers", () => { | 119 | it("keeps stable announcements short even when a long changelog is provided", () => { |
| 65 | assert.deepStrictEqual(buildDiscordReleaseAnnouncement(latestAnnouncement), { | 120 | assert.deepStrictEqual( |
| 66 | content: "<@&222222222222222222> Latest published: T3 Code v1.2.3", | 121 | buildDiscordReleaseAnnouncement({ ...latestAnnouncement, releaseNotes: notes.repeat(200) }), |
| 67 | allowed_mentions: { | 122 | [ |
| 68 | roles: ["222222222222222222"], | | |
| 69 | }, | | |
| 70 | embeds: [ | | |
| 71 | { | 123 | { |
| 72 | title: "T3 Code v1.2.3", | 124 | content: "-# <@&222222222222222222>", |
| 73 | url: "https://github.com/t3dotgg/t3-code/releases/tag/v1.2.3", | 125 | allowed_mentions: { parse: [], roles: [latestAnnouncement.roleId] }, |
| 74 | description: "A new T3 Code latest release is available.", | 126 | embeds: [ |
| 75 | color: 0x2ecc71, | | |
| 76 | fields: [ | | |
| 77 | { | | |
| 78 | name: "Version", | | |
| 79 | value: "1.2.3", | | |
| 80 | inline: true, | | |
| 81 | }, | | |
| 82 | { | 127 | { |
| 83 | name: "Tag", | 128 | title: latestAnnouncement.releaseName, |
| 84 | value: "v1.2.3", | 129 | url: latestAnnouncement.releaseUrl.href, |
| 85 | inline: true, | 130 | description: `[View full release notes on GitHub](${latestAnnouncement.releaseUrl.href})`, |
| | 131 | color: 0x2ecc71, |
| | 132 | footer: { text: "v1.2.3" }, |
| | 133 | timestamp: latestAnnouncement.timestamp, |
| 86 | }, | 134 | }, |
| 87 | ], | 135 | ], |
| 88 | timestamp: "2026-05-01T01:41:00.000Z", | | |
| 89 | }, | 136 | }, |
| 90 | ], | 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, |
| 91 | }); | 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 | } |
| 92 | }); | 226 | }); |
| 93 | | 227 | |
| 94 | it.effect("preserves webhook request context and the full client cause chain", () => { | 228 | it("honors the aggregate embed limit when metadata is also large", () => { |
| 95 | const payload = buildDiscordReleaseAnnouncement(latestAnnouncement); | 229 | const releaseNotes = "a".repeat(16000); |
| 96 | const requestCause = new Error("request encoder unavailable"); | 230 | const payloads = buildDiscordReleaseAnnouncement({ |
| 97 | let clientError: HttpClientError.HttpClientError | undefined; | 231 | ...nightlyAnnouncement, |
| 98 | const httpClientLayer = Layer.succeed( | 232 | releaseName: "n".repeat(256), |
| 99 | HttpClient.HttpClient, | 233 | version: "x".repeat(2047), |
| 100 | HttpClient.make((request) => { | 234 | releaseNotes, |
| 101 | clientError = new HttpClientError.HttpClientError({ | 235 | }); |
| 102 | reason: new HttpClientError.EncodeError({ | 236 | assertDiscordLimits(payloads); |
| 103 | request, | 237 | assert.equal(payloadDescription(payloads), `${intro}\n\n${releaseNotes}`); |
| 104 | cause: requestCause, | 238 | }); |
| 105 | }), | 239 | |
| 106 | }); | 240 | it("handles oversized lines, words, and Unicode without losing or breaking characters", () => { |
| 107 | return Effect.fail(clientError); | 241 | const longNotes = |
| 108 | }), | 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}`, |
| 109 | ); | 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 | }); |
| 110 | | 288 | |
| 111 | return Effect.gen(function* () { | 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 | ); |
| 112 | const error = yield* postDiscordWebhook(webhookUrl, payload, latestAnnouncement).pipe( | 321 | const error = yield* postDiscordWebhook(webhookUrl, payload, latestAnnouncement).pipe( |
| 113 | Effect.provide(httpClientLayer), | 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 | ]), |
| 114 | Effect.flip, | 331 | Effect.flip, |
| 115 | ); | 332 | ); |
| 116 | | 333 | if (error._tag !== "DiscordReleaseWebhookRequestError") |
| 117 | if (error._tag !== "DiscordReleaseWebhookRequestError") { | | |
| 118 | assert.fail(`Unexpected error: ${error._tag}`); | 334 | assert.fail(`Unexpected error: ${error._tag}`); |
| 119 | } | 335 | assert.equal(error.reason, "EncodeError"); |
| 120 | assert.equal(error.target, "latest"); | 336 | assert.equal(error.target, "latest"); |
| 121 | assert.equal(error.releaseName, latestAnnouncement.releaseName); | | |
| 122 | assert.equal(error.version, latestAnnouncement.version); | | |
| 123 | assert.equal(error.tag, latestAnnouncement.tag); | 337 | assert.equal(error.tag, latestAnnouncement.tag); |
| 124 | assert.equal(error.releaseUrl, latestAnnouncement.releaseUrl.href); | | |
| 125 | assert.equal(error.webhookOrigin, webhookUrl.origin); | 338 | assert.equal(error.webhookOrigin, webhookUrl.origin); |
| 126 | assert.equal(error.webhookPathnameSegmentCount, 4); | 339 | assert.equal(error.webhookPathnameSegmentCount, 4); |
| 127 | assert.equal(error.contentLength, payload.content.length); | 340 | assert.equal(error.contentLength, payload.content.length); |
| 128 | assert.equal(error.embedCount, 1); | | |
| 129 | assert.equal(error.allowedRoleMentionCount, 1); | 341 | assert.equal(error.allowedRoleMentionCount, 1); |
| 130 | assert.equal(error.hasRoleMentionSyntax, true); | 342 | assert.ok(!Cause.pretty(Cause.fail(error)).includes("test-secret-token")); |
| 131 | assert.equal(error.cause, clientError); | 343 | assert.ok(!logs.join("\n").includes("test-secret-token")); |
| 132 | assert.equal((error.cause as HttpClientError.HttpClientError).cause, requestCause); | 344 | }), |
| 133 | assert.ok(!error.message.includes(requestCause.message)); | 345 | ); |
| 134 | }); | | |
| 135 | }); | | |
| 136 | | 346 | |
| 137 | it.effect("preserves a non-success response error with structured status context", () => { | 347 | it.effect( |
| 138 | const payload = buildDiscordReleaseAnnouncement(latestAnnouncement); | 348 | "retains response status without retaining the secret-bearing request or response body", |
| 139 | const httpClientLayer = Layer.succeed( | 349 | () => |
| 140 | HttpClient.HttpClient, | 350 | Effect.gen(function* () { |
| 141 | HttpClient.make((request) => | 351 | const client = Layer.succeed( |
| 142 | Effect.succeed( | 352 | HttpClient.HttpClient, |
| 143 | HttpClientResponse.fromWeb(request, new Response("invalid webhook", { status: 400 })), | 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 | }), |
| 144 | ), | 390 | ), |
| 145 | ), | 391 | ); |
| 146 | ); | 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 | ); |
| 147 | | 405 | |
| 148 | return Effect.gen(function* () { | 406 | it.effect("bounds total retry time while preserving delays within the deadline", () => |
| 149 | const error = yield* postDiscordWebhook(webhookUrl, payload, latestAnnouncement).pipe( | 407 | Effect.gen(function* () { |
| 150 | Effect.provide(httpClientLayer), | 408 | const limited = yield* Deferred.make<void>(); |
| 151 | Effect.flip, | 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 | ), |
| 152 | ); | 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 | ); |
| 153 | | 445 | |
| 154 | if (error._tag !== "DiscordReleaseWebhookResponseError") { | 446 | // Run the checked-in shell commands with gh/node stubs, then send their captured |
| 155 | assert.fail(`Unexpected error: ${error._tag}`); | 447 | // arguments through the real CLI parser and an injected HTTP client. |
| 156 | } | 448 | function workflowRun(workflow: string, stepName: string) { |
| 157 | assert.equal(error.target, "latest"); | 449 | const step = workflow.split(` - name: ${stepName}\n`)[1]?.split("\n - name:")[0]; |
| 158 | assert.equal(error.tag, latestAnnouncement.tag); | 450 | assert.ok(step, `Missing workflow step: ${stepName}`); |
| 159 | assert.equal(error.webhookOrigin, webhookUrl.origin); | 451 | const run = step.split(" run: |\n")[1]; |
| 160 | assert.equal(error.webhookPathnameSegmentCount, 4); | 452 | assert.ok(run, `Missing run block: ${stepName}`); |
| 161 | assert.equal(error.status, 400); | 453 | return run |
| 162 | if (!HttpClientError.isHttpClientError(error.cause)) { | 454 | .split("\n") |
| 163 | assert.fail("Expected HttpClientError cause"); | 455 | .map((line) => line.replace(/^ /, "")) |
| 164 | } | 456 | .join("\n"); |
| 165 | assert.equal(error.cause.reason._tag, "StatusCodeError"); | 457 | } |
| 166 | assert.ok(!error.message.includes(error.cause.message)); | 458 | |
| 167 | }); | 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 | ); |
| 168 | }); | 645 | }); |