|
1 | | -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; |
| 1 | +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; |
2 | 2 | import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from "fs"; |
3 | 3 | import { tmpdir } from "os"; |
4 | 4 | import { join } from "path"; |
@@ -1302,7 +1302,114 @@ function ocannlProject(opts: { enabled?: boolean | undefined; path?: string }): |
1302 | 1302 | return base; |
1303 | 1303 | } |
1304 | 1304 |
|
| 1305 | +// task-35e74651: RunGit driver that reaches step (E)'s push and returns a |
| 1306 | +// given push result. remote/status/branch-detect/fetch/local-ff/ancestry all |
| 1307 | +// succeed so the push is the only failure point. |
| 1308 | +function pushPathRunGit(push: { stdout?: string; stderr?: string; exitCode?: number }): RunGit { |
| 1309 | + return (args) => { |
| 1310 | + const key = args[0] ?? ""; |
| 1311 | + if (key === "remote") return { stdout: "origin\nupstream\n", exitCode: 0 }; |
| 1312 | + if (key === "status") return { stdout: "", exitCode: 0 }; |
| 1313 | + if (key === "symbolic-ref") { |
| 1314 | + const ref = args[1] ?? ""; |
| 1315 | + if (ref.endsWith("/origin/HEAD")) return { stdout: "refs/remotes/origin/master\n", exitCode: 0 }; |
| 1316 | + if (ref.endsWith("/upstream/HEAD")) return { stdout: "refs/remotes/upstream/master\n", exitCode: 0 }; |
| 1317 | + return { stdout: "", exitCode: 128 }; |
| 1318 | + } |
| 1319 | + if (key === "rev-parse" && args[1] === "--abbrev-ref") return { stdout: "master\n", exitCode: 0 }; |
| 1320 | + if (key === "checkout") return { stdout: "", exitCode: 0 }; |
| 1321 | + if (key === "fetch") return { stdout: "", exitCode: 0 }; |
| 1322 | + if (key === "merge" && args[1] === "--ff-only") return { stdout: "Already up to date.\n", exitCode: 0 }; |
| 1323 | + if (key === "rev-list" && args[1] === "--count") return { stdout: "5\n", exitCode: 0 }; |
| 1324 | + if (key === "merge-base" && args[1] === "--is-ancestor") return { stdout: "", exitCode: 0 }; |
| 1325 | + if (key === "push") return { stdout: push.stdout ?? "", stderr: push.stderr, exitCode: push.exitCode ?? 0 }; |
| 1326 | + return { stdout: "", exitCode: 0 }; |
| 1327 | + }; |
| 1328 | +} |
| 1329 | + |
| 1330 | +const WORKFLOW_SCOPE_STDERR = |
| 1331 | + "! [remote rejected] origin/master -> master (refusing to allow an OAuth App to create or update workflow `.github/workflows/gh-pages-docs.yml` without `workflow` scope)"; |
| 1332 | + |
1305 | 1333 | describe("runStagingOutboundPushTick", () => { |
| 1334 | + test("task-35e74651: workflow-scope push rejection persists event with structured project + remedy", () => { |
| 1335 | + const harnessRoot = mkdtempSync("/tmp/mag-outbound-wfscope-harness-"); |
| 1336 | + const checkoutDir = mkdtempSync("/tmp/mag-outbound-wfscope-checkout-"); |
| 1337 | + const ORIGINAL_HARNESS_DIR = process.env.LUDICS_HARNESS_DIR; |
| 1338 | + process.env.LUDICS_HARNESS_DIR = harnessRoot; |
| 1339 | + try { |
| 1340 | + const cfg = { |
| 1341 | + projects: [{ |
| 1342 | + name: "ocannl", |
| 1343 | + repo: "lukstafi/ocannl-staging", |
| 1344 | + upstream_repo: "ahrefs/ocannl", |
| 1345 | + outbound_sync_enabled: true, |
| 1346 | + path: checkoutDir, |
| 1347 | + }], |
| 1348 | + } as unknown as LudicsFullConfig; |
| 1349 | + const results = runStagingOutboundPushTick({ |
| 1350 | + isController: () => true, |
| 1351 | + runGit: pushPathRunGit({ stderr: WORKFLOW_SCOPE_STDERR, exitCode: 128 }), |
| 1352 | + config: cfg, |
| 1353 | + now: new Date(), |
| 1354 | + // sentinelDir omitted → emitEvent writes under env-overridden harnessRoot. |
| 1355 | + }); |
| 1356 | + expect(results).toHaveLength(1); |
| 1357 | + expect(results[0]!.outcome).toBe("skipped-no-workflow-scope"); |
| 1358 | + |
| 1359 | + const eventsFile = join(harnessRoot, "journal", "events.jsonl"); |
| 1360 | + expect(existsSync(eventsFile)).toBe(true); |
| 1361 | + const lines = readFileSync(eventsFile, "utf-8").trim().split("\n").filter(Boolean); |
| 1362 | + const wf = lines |
| 1363 | + .map((l) => JSON.parse(l) as Record<string, unknown>) |
| 1364 | + .filter((e) => e.event_type === "staging_outbound_workflow_scope_missing"); |
| 1365 | + expect(wf).toHaveLength(1); |
| 1366 | + // Mutation guard for the new `project: ev.project` adapter line: drop it |
| 1367 | + // and this assertion fails (the annotation lookup would then have to |
| 1368 | + // parse the message prefix). |
| 1369 | + expect(wf[0]!.project).toBe("ocannl"); |
| 1370 | + expect(String(wf[0]!.message)).toContain("gh auth refresh -h github.com -s workflow"); |
| 1371 | + } finally { |
| 1372 | + if (ORIGINAL_HARNESS_DIR === undefined) delete process.env.LUDICS_HARNESS_DIR; |
| 1373 | + else process.env.LUDICS_HARNESS_DIR = ORIGINAL_HARNESS_DIR; |
| 1374 | + rmSync(harnessRoot, { recursive: true, force: true }); |
| 1375 | + rmSync(checkoutDir, { recursive: true, force: true }); |
| 1376 | + } |
| 1377 | + }); |
| 1378 | + |
| 1379 | + test("task-35e74651: skipped-no-workflow-scope outcome is logged to stderr", () => { |
| 1380 | + const sentinelDir = mkdtempSync("/tmp/outbound-wfscope-stderr-"); |
| 1381 | + const checkoutDir = mkdtempSync("/tmp/outbound-wfscope-stderr-checkout-"); |
| 1382 | + const cfg = { |
| 1383 | + projects: [{ |
| 1384 | + name: "ocannl", |
| 1385 | + repo: "lukstafi/ocannl-staging", |
| 1386 | + upstream_repo: "ahrefs/ocannl", |
| 1387 | + outbound_sync_enabled: true, |
| 1388 | + path: checkoutDir, |
| 1389 | + }], |
| 1390 | + } as unknown as LudicsFullConfig; |
| 1391 | + const spy = spyOn(console, "error").mockImplementation(() => {}); |
| 1392 | + let logged: string[]; |
| 1393 | + try { |
| 1394 | + const results = runStagingOutboundPushTick({ |
| 1395 | + isController: () => true, |
| 1396 | + runGit: pushPathRunGit({ stderr: WORKFLOW_SCOPE_STDERR, exitCode: 128 }), |
| 1397 | + config: cfg, |
| 1398 | + sentinelDir, |
| 1399 | + now: new Date(), |
| 1400 | + }); |
| 1401 | + expect(results[0]!.outcome).toBe("skipped-no-workflow-scope"); |
| 1402 | + // Capture before restore (bun:test mockRestore wipes call history). |
| 1403 | + logged = spy.mock.calls.map((c) => String(c[0])); |
| 1404 | + } finally { |
| 1405 | + spy.mockRestore(); |
| 1406 | + rmSync(sentinelDir, { recursive: true, force: true }); |
| 1407 | + rmSync(checkoutDir, { recursive: true, force: true }); |
| 1408 | + } |
| 1409 | + expect(logged.some((l) => l.includes("outbound-staging-ff ocannl: skipped-no-workflow-scope"))).toBe(true); |
| 1410 | + }); |
| 1411 | + |
| 1412 | + |
1306 | 1413 | test("controller-gate: short-circuits with zero git invocations when isController() returns false", () => { |
1307 | 1414 | const { run, calls } = recordingRunGit(); |
1308 | 1415 | const sentinelDir = mkdtempSync("/tmp/outbound-gate-"); |
|
0 commit comments