| 1 | import { mkdirSync, writeFileSync } from 'node:fs'; |
| 2 | import { tmpdir } |
| 3 | import path from 'node:path |
| 4 | import type { NextFunction, |
| 5 | import { expect } |
| 6 | |
| 7 | /** Where the gh suite records every request gh and the git it runs make; nothing is recorded without it. Fixtures are refreshed from CI's `gh-capture` artifact. */ |
| 8 | export const GH_CAPTURE_DIR = |
| 9 | |
| 10 | const HEADERS = [ |
| 11 | 'accept', |
| 12 | 'content-type', |
| 13 | 'graphql-features', |
| 14 | 'x-github-api-version', |
| 15 | ]; |
| 16 | const SECRET_FIELD = / |
| 17 | |
| 18 | type CapturedRequest |
| 19 | method: string |
| 20 | path: string |
| 21 | query: Record |
| 22 | headers: Record |
| 23 | body: unknown |
| 24 | status: number |
| 25 | }; |
| 26 | |
| 27 | const commands: { |
| 28 | test: string |
| 29 | command: string |
| 30 | requests: CapturedRequest |
| 31 | }[] = []; |
| 32 | let running: ( |
| 33 | |
| 34 | /** Attributes the requests that follow to `gh <args>` until the returned function is called. */ |
| 35 | export function capturing(args |
| 36 | if (!GH_CAPTURE_DIR) |
| 37 | const command = |
| 38 | test: expect.getState |
| 39 | command: `gh ${args |
| 40 | requests: [], |
| 41 | }; |
| 42 | commands.push(command |
| 43 | running = command; |
| 44 | return () => |
| 45 | if (running === |
| 46 | }; |
| 47 | } |
| 48 | |
| 49 | function redact(value: |
| 50 | if (Array.isArray |
| 51 | if (typeof value |
| 52 | return Object.fromEntries |
| 53 | Object.entries(value |
| 54 | key, |
| 55 | SECRET_FIELD.test(key |
| 56 | ]), |
| 57 | ); |
| 58 | } |
| 59 | |
| 60 | /** Express middleware recording each gh or git request once its response is sent, when the body parsers have run. Requests the suite makes itself are not gh's and are left out. */ |
| 61 | export function recordGhRequests( |
| 62 | request: Request |
| 63 | response: Response |
| 64 | next: NextFunction |
| 65 | ) { |
| 66 | const command = |
| 67 | // Read now: routers mounted under a prefix, such as Apollo's, rewrite the path before the response finishes. |
| 68 | const requestPath = |
| 69 | const agent = |
| 70 | if (command && |
| 71 | response.on('finish |
| 72 | command.requests.push |
| 73 | method: request.method |
| 74 | path: requestPath, |
| 75 | query: redact(request |
| 76 | headers: Object.fromEntries |
| 77 | HEADERS.flatMap((name |
| 78 | const value = |
| 79 | return typeof value |
| 80 | }), |
| 81 | ), |
| 82 | // git's bodies are pack data, not parsed; only what the API parsed is kept. |
| 83 | body: |
| 84 | typeof request.body |
| 85 | request.body !== |
| 86 | !Buffer.isBuffer(request |
| 87 | ? redact(request. |
| 88 | : null, |
| 89 | status: response.statusCode |
| 90 | }); |
| 91 | }); |
| 92 | next(); |
| 93 | } |
| 94 | |
| 95 | const escape = ( |
| 96 | |
| 97 | /** What changes from run to run, or must not be written down, and the placeholder that replaces it, so a refresh only shows what gh changed. */ |
| 98 | const placeholders = ( |
| 99 | [ |
| 100 | [new RegExp(escape |
| 101 | [new RegExp(escape |
| 102 | [new RegExp(` |
| 103 | [/\/\/[^/\s |
| 104 | [/ghost_pat_\w+/g, |
| 105 | [/\b((?:ssh|ecdsa| |
| 106 | [/\b(U|O|R |
| 107 | [/issues\/comments\/ |
| 108 | [/releases\/(assets\/ |
| 109 | ] as const; |
| 110 | |
| 111 | /** Orders a command's requests by what they ask, since gh sends some at once and their arrival order varies. */ |
| 112 | const byRequest = ( |
| 113 | JSON.stringify([a |
| 114 | JSON.stringify([b |
| 115 | ); |
| 116 | |
| 117 | /** Writes one file per test, with `placeholders` applied. */ |
| 118 | export function writeGhCapture({ |
| 119 | login, |
| 120 | host, |
| 121 | }: { |
| 122 | login: string |
| 123 | host: string |
| 124 | }) { |
| 125 | if (!GH_CAPTURE_DIR) |
| 126 | mkdirSync(GH_CAPTURE_DIR, { |
| 127 | for (const test |
| 128 | const json = |
| 129 | commands |
| 130 | .filter((command) |
| 131 | .map(({ command |
| 132 | command, |
| 133 | requests: requests.toSorted |
| 134 | })), |
| 135 | null, |
| 136 | 2, |
| 137 | ); |
| 138 | const replaced = |
| 139 | (text, [pattern |
| 140 | json, |
| 141 | ); |
| 142 | const name = |
| 143 | .split(' > ') |
| 144 | .at(-1)! |
| 145 | .toLowerCase() |
| 146 | .replace(/[^a-z0-9 |
| 147 | .replace(/^-| |
| 148 | writeFileSync(path.join |
| 149 | } |
| 150 | } |