|
| 1 | +import test from 'node:test'; |
| 2 | +import assert from 'node:assert/strict'; |
| 3 | +import fs from 'node:fs'; |
| 4 | +import os from 'node:os'; |
| 5 | +import path from 'node:path'; |
| 6 | +import { spawnSync } from 'node:child_process'; |
| 7 | + |
| 8 | +const repoRoot = path.resolve(import.meta.dirname, '..', '..'); |
| 9 | +const scriptPath = path.join(repoRoot, 'scripts', 'pipeline', 'github', 'promote-branch.mjs'); |
| 10 | + |
| 11 | +function writeExecutable(filePath, content) { |
| 12 | + fs.writeFileSync(filePath, content, { encoding: 'utf8', mode: 0o700 }); |
| 13 | +} |
| 14 | + |
| 15 | +function writeGhStub(binDir) { |
| 16 | + const ghPath = path.join(binDir, 'gh'); |
| 17 | + writeExecutable( |
| 18 | + ghPath, |
| 19 | + [ |
| 20 | + '#!/usr/bin/env node', |
| 21 | + "import fs from 'node:fs';", |
| 22 | + '', |
| 23 | + 'const logPath = process.env.GH_STUB_LOG;', |
| 24 | + 'if (logPath) fs.appendFileSync(logPath, `${JSON.stringify(process.argv.slice(2))}\\n`, \"utf8\");', |
| 25 | + '', |
| 26 | + 'const args = process.argv.slice(2);', |
| 27 | + "if (args[0] !== 'api') process.exit(0);", |
| 28 | + '', |
| 29 | + "let method = 'GET';", |
| 30 | + 'let endpoint = "";', |
| 31 | + 'const rawFields = [];', |
| 32 | + 'const typedFields = [];', |
| 33 | + '', |
| 34 | + 'for (let i = 1; i < args.length; i++) {', |
| 35 | + ' const a = args[i];', |
| 36 | + " if (a === '-X' || a === '--method') { method = args[i + 1] ?? method; i++; continue; }", |
| 37 | + ' if ((a === "-f" || a === "--raw-field") && args[i + 1]) { rawFields.push(args[i + 1]); i++; continue; }', |
| 38 | + ' if ((a === "-F" || a === "--field") && args[i + 1]) { typedFields.push(args[i + 1]); i++; continue; }', |
| 39 | + ' if (!endpoint && !a.startsWith("-")) endpoint = a;', |
| 40 | + '}', |
| 41 | + '', |
| 42 | + 'function hasTypedForceTrue() {', |
| 43 | + ' return typedFields.some((f) => f === "force=true");', |
| 44 | + '}', |
| 45 | + '', |
| 46 | + 'function write422(message) {', |
| 47 | + ' process.stdout.write(JSON.stringify({ message, status: "422" }));', |
| 48 | + ' process.stderr.write(`gh: ${message} (HTTP 422)\\n`);', |
| 49 | + ' process.exit(1);', |
| 50 | + '}', |
| 51 | + '', |
| 52 | + 'function write403(message) {', |
| 53 | + ' process.stdout.write(JSON.stringify({ message, status: "403" }));', |
| 54 | + ' process.stderr.write(`gh: ${message} (HTTP 403)\\n`);', |
| 55 | + ' process.exit(1);', |
| 56 | + '}', |
| 57 | + '', |
| 58 | + 'if (method === "GET") {', |
| 59 | + ' if (endpoint.includes("/git/ref/heads/dev")) { process.stdout.write("SOURCE_SHA\\n"); process.exit(0); }', |
| 60 | + ' if (endpoint.includes("/git/ref/heads/main")) { process.stdout.write("TARGET_SHA\\n"); process.exit(0); }', |
| 61 | + ' if (endpoint.includes("/compare/main...dev")) {', |
| 62 | + ' process.stdout.write(JSON.stringify({ status: "ahead", ahead_by: 1, behind_by: 0, files: [] }));', |
| 63 | + ' process.exit(0);', |
| 64 | + ' }', |
| 65 | + ' process.stdout.write("");', |
| 66 | + ' process.exit(0);', |
| 67 | + '}', |
| 68 | + '', |
| 69 | + 'if (method === "PATCH" && endpoint.includes("/git/refs/heads/main")) {', |
| 70 | + ' const outcome = process.env.GH_STUB_PATCH_OUTCOME ?? "require_typed_force";', |
| 71 | + ' if (outcome === "forbidden") write403("Forbidden");', |
| 72 | + ' if (!hasTypedForceTrue()) write422("Update is not a fast forward");', |
| 73 | + ' process.exit(0);', |
| 74 | + '}', |
| 75 | + '', |
| 76 | + 'if (method === "POST" && endpoint.endsWith("/git/refs")) {', |
| 77 | + ' const message = process.env.GH_STUB_CREATE_MESSAGE ?? "Reference already exists";', |
| 78 | + ' write422(message);', |
| 79 | + '}', |
| 80 | + '', |
| 81 | + 'process.exit(0);', |
| 82 | + '', |
| 83 | + ].join('\n'), |
| 84 | + ); |
| 85 | + return ghPath; |
| 86 | +} |
| 87 | + |
| 88 | +function runPromoteBranch({ patchOutcome }) { |
| 89 | + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'happier-promote-branch-script-')); |
| 90 | + const binDir = path.join(dir, 'bin'); |
| 91 | + fs.mkdirSync(binDir, { recursive: true }); |
| 92 | + |
| 93 | + const logPath = path.join(dir, 'gh.log'); |
| 94 | + writeGhStub(binDir); |
| 95 | + |
| 96 | + const env = { |
| 97 | + ...process.env, |
| 98 | + PATH: `${binDir}:${process.env.PATH ?? ''}`, |
| 99 | + GH_REPO: 'happier-dev/happier', |
| 100 | + GH_TOKEN: 'test-token', |
| 101 | + GH_STUB_LOG: logPath, |
| 102 | + GH_STUB_PATCH_OUTCOME: patchOutcome ?? 'require_typed_force', |
| 103 | + }; |
| 104 | + |
| 105 | + const res = spawnSync( |
| 106 | + process.execPath, |
| 107 | + [ |
| 108 | + scriptPath, |
| 109 | + '--source', |
| 110 | + 'dev', |
| 111 | + '--target', |
| 112 | + 'main', |
| 113 | + '--mode', |
| 114 | + 'reset', |
| 115 | + '--allow-reset', |
| 116 | + 'true', |
| 117 | + '--confirm', |
| 118 | + 'reset main from dev', |
| 119 | + ], |
| 120 | + { cwd: repoRoot, env, encoding: 'utf8' }, |
| 121 | + ); |
| 122 | + |
| 123 | + const calls = fs |
| 124 | + .readFileSync(logPath, 'utf8') |
| 125 | + .trim() |
| 126 | + .split('\n') |
| 127 | + .filter(Boolean) |
| 128 | + .map((line) => JSON.parse(line)); |
| 129 | + |
| 130 | + return { res, calls }; |
| 131 | +} |
| 132 | + |
| 133 | +test('promote-branch reset uses typed force update (no fallback create)', () => { |
| 134 | + const { res, calls } = runPromoteBranch({ patchOutcome: 'require_typed_force' }); |
| 135 | + |
| 136 | + assert.equal(res.status, 0, `expected success (stderr: ${res.stderr})`); |
| 137 | + assert.ok(calls.some((c) => c.includes('-X') && c.includes('PATCH')), 'expected PATCH call to update ref'); |
| 138 | + assert.ok(calls.some((c) => c.includes('-F') && c.includes('force=true')), 'expected typed force=true field'); |
| 139 | + assert.ok(!calls.some((c) => c.includes('-X') && c.includes('POST')), 'expected no POST fallback create call'); |
| 140 | +}); |
| 141 | + |
| 142 | +test('promote-branch does not mask PATCH failures by attempting create', () => { |
| 143 | + const { res, calls } = runPromoteBranch({ patchOutcome: 'forbidden' }); |
| 144 | + |
| 145 | + assert.notEqual(res.status, 0, 'expected failure'); |
| 146 | + assert.match(res.stderr, /\bForbidden\b/); |
| 147 | + assert.ok(!calls.some((c) => c.includes('-X') && c.includes('POST')), 'expected no POST fallback create call'); |
| 148 | +}); |
| 149 | + |
0 commit comments