|
| 1 | +import { getOctokit } from '@actions/github'; |
| 2 | +import { createServer, type Server } from 'http'; |
| 3 | +import { type AddressInfo } from 'net'; |
| 4 | +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; |
| 5 | +import { GitHubReleaser, release, type Release } from '../src/github'; |
| 6 | +import { parseConfig, type Config } from '../src/util'; |
| 7 | + |
| 8 | +type CapturedRequest = { |
| 9 | + path: string; |
| 10 | + authorization: string | undefined; |
| 11 | + contentType: string | undefined; |
| 12 | + body: Record<string, unknown>; |
| 13 | +}; |
| 14 | + |
| 15 | +const closeServer = async (server: Server): Promise<void> => { |
| 16 | + server.closeIdleConnections(); |
| 17 | + server.closeAllConnections(); |
| 18 | + await new Promise<void>((resolve, reject) => { |
| 19 | + server.close((error) => (error ? reject(error) : resolve())); |
| 20 | + }); |
| 21 | +}; |
| 22 | + |
| 23 | +describe('release creation transport', () => { |
| 24 | + let server: Server; |
| 25 | + let baseUrl: string; |
| 26 | + const requests: CapturedRequest[] = []; |
| 27 | + const releases = new Map<string, Release>(); |
| 28 | + |
| 29 | + beforeAll(async () => { |
| 30 | + server = createServer(async (request, response) => { |
| 31 | + const url = new URL(request.url || '/', 'http://127.0.0.1'); |
| 32 | + const tagMatch = url.pathname.match(/^\/repos\/owner\/remote\/releases\/tags\/(.+)$/); |
| 33 | + if (request.method === 'GET' && tagMatch) { |
| 34 | + const release = releases.get(decodeURIComponent(tagMatch[1])); |
| 35 | + response.writeHead(release ? 200 : 404, { 'content-type': 'application/json' }); |
| 36 | + response.end(JSON.stringify(release ?? { message: 'Not Found' })); |
| 37 | + return; |
| 38 | + } |
| 39 | + |
| 40 | + if (request.method === 'GET' && url.pathname === '/repos/owner/remote/releases') { |
| 41 | + response.writeHead(200, { 'content-type': 'application/json' }); |
| 42 | + response.end(JSON.stringify([...releases.values()])); |
| 43 | + return; |
| 44 | + } |
| 45 | + |
| 46 | + if (request.method === 'POST' && url.pathname === '/repos/owner/remote/releases') { |
| 47 | + const chunks: Buffer[] = []; |
| 48 | + for await (const chunk of request) { |
| 49 | + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); |
| 50 | + } |
| 51 | + const body = JSON.parse(Buffer.concat(chunks).toString('utf8')) as Record<string, unknown>; |
| 52 | + requests.push({ |
| 53 | + path: url.pathname, |
| 54 | + authorization: request.headers.authorization, |
| 55 | + contentType: request.headers['content-type'], |
| 56 | + body, |
| 57 | + }); |
| 58 | + const tag = String(body.tag_name); |
| 59 | + const createdRelease: Release = { |
| 60 | + id: requests.length, |
| 61 | + upload_url: `http://127.0.0.1/uploads/${requests.length}`, |
| 62 | + html_url: `http://127.0.0.1/releases/${requests.length}`, |
| 63 | + tag_name: tag, |
| 64 | + name: String(body.name), |
| 65 | + body: typeof body.body === 'string' ? body.body : null, |
| 66 | + target_commitish: 'main', |
| 67 | + draft: Boolean(body.draft), |
| 68 | + prerelease: Boolean(body.prerelease), |
| 69 | + assets: [], |
| 70 | + }; |
| 71 | + releases.set(tag, createdRelease); |
| 72 | + response.writeHead(201, { 'content-type': 'application/json' }); |
| 73 | + response.end(JSON.stringify(createdRelease)); |
| 74 | + return; |
| 75 | + } |
| 76 | + |
| 77 | + response.writeHead(500, { 'content-type': 'application/json' }); |
| 78 | + response.end( |
| 79 | + JSON.stringify({ message: `Unexpected route ${request.method} ${url.pathname}` }), |
| 80 | + ); |
| 81 | + }); |
| 82 | + |
| 83 | + await new Promise<void>((resolve, reject) => { |
| 84 | + server.once('error', reject); |
| 85 | + server.listen(0, '127.0.0.1', resolve); |
| 86 | + }); |
| 87 | + const address = server.address() as AddressInfo; |
| 88 | + baseUrl = `http://127.0.0.1:${address.port}`; |
| 89 | + }); |
| 90 | + |
| 91 | + afterAll(async () => { |
| 92 | + await closeServer(server); |
| 93 | + }); |
| 94 | + |
| 95 | + it('serializes user-facing category inputs through the real Octokit request path', async () => { |
| 96 | + const parsedEmptyCategory = parseConfig({ |
| 97 | + INPUT_DISCUSSION_CATEGORY_NAME: '', |
| 98 | + }).input_discussion_category_name; |
| 99 | + expect(parsedEmptyCategory).toBeUndefined(); |
| 100 | + |
| 101 | + const cases: Array<{ |
| 102 | + name: string; |
| 103 | + categoryProperty: 'absent' | 'present'; |
| 104 | + category: string | undefined; |
| 105 | + expectedCategory: string | undefined; |
| 106 | + }> = [ |
| 107 | + { |
| 108 | + name: 'absent-category', |
| 109 | + categoryProperty: 'absent', |
| 110 | + category: undefined, |
| 111 | + expectedCategory: undefined, |
| 112 | + }, |
| 113 | + { |
| 114 | + name: 'undefined-category', |
| 115 | + categoryProperty: 'present', |
| 116 | + category: undefined, |
| 117 | + expectedCategory: undefined, |
| 118 | + }, |
| 119 | + { |
| 120 | + name: 'empty-input-category', |
| 121 | + categoryProperty: 'present', |
| 122 | + category: parsedEmptyCategory, |
| 123 | + expectedCategory: undefined, |
| 124 | + }, |
| 125 | + { |
| 126 | + name: 'valid-category', |
| 127 | + categoryProperty: 'present', |
| 128 | + category: 'Announcements', |
| 129 | + expectedCategory: 'Announcements', |
| 130 | + }, |
| 131 | + ]; |
| 132 | + |
| 133 | + for (const testCase of cases) { |
| 134 | + const config: Config = { |
| 135 | + github_token: 'not-a-real-token', |
| 136 | + github_ref: 'refs/heads/main', |
| 137 | + github_repository: 'owner/remote', |
| 138 | + input_tag_name: testCase.name, |
| 139 | + input_name: `Release ${testCase.name}`, |
| 140 | + input_files: [], |
| 141 | + input_draft: false, |
| 142 | + input_prerelease: true, |
| 143 | + input_fail_on_unmatched_files: false, |
| 144 | + input_generate_release_notes: false, |
| 145 | + input_append_body: false, |
| 146 | + input_make_latest: undefined, |
| 147 | + }; |
| 148 | + if (testCase.categoryProperty === 'present') { |
| 149 | + config.input_discussion_category_name = testCase.category; |
| 150 | + } |
| 151 | + |
| 152 | + const releaser = new GitHubReleaser( |
| 153 | + getOctokit(config.github_token, { |
| 154 | + baseUrl, |
| 155 | + }), |
| 156 | + ); |
| 157 | + await expect(release(config, releaser, 1)).resolves.toMatchObject({ |
| 158 | + release: { tag_name: testCase.name }, |
| 159 | + created: true, |
| 160 | + }); |
| 161 | + |
| 162 | + const request = requests.at(-1); |
| 163 | + expect(request).toMatchObject({ |
| 164 | + path: '/repos/owner/remote/releases', |
| 165 | + authorization: 'token not-a-real-token', |
| 166 | + contentType: 'application/json; charset=utf-8', |
| 167 | + body: { |
| 168 | + tag_name: testCase.name, |
| 169 | + name: `Release ${testCase.name}`, |
| 170 | + draft: false, |
| 171 | + prerelease: true, |
| 172 | + generate_release_notes: false, |
| 173 | + }, |
| 174 | + }); |
| 175 | + if (testCase.expectedCategory) { |
| 176 | + expect(request?.body.discussion_category_name).toBe(testCase.expectedCategory); |
| 177 | + } else { |
| 178 | + expect(request?.body).not.toHaveProperty('discussion_category_name'); |
| 179 | + } |
| 180 | + } |
| 181 | + }); |
| 182 | +}); |
0 commit comments