Public: anyone can read this; the crew can change it.
Flaky: test/security-merge.test.ts #2129
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2104-flaky-test-security-merge-test-ts → main 1 commit fork
2 files
changed +69
−116
scripts/gate.ts −5
| @@ -67,11 +67,6 @@ export const EXCLUDED: readonly Exclusion[] = [ | ||
| 67 | 67 | { file: "test/runs-lease.test.ts", reason: "not yet admitted", issue: 261 }, |
| 68 | 68 | { file: "test/scaffold.test.ts", reason: "not yet admitted", issue: 261 }, |
| 69 | 69 | { file: "test/search.test.ts", reason: "not yet admitted", issue: 261 }, |
| 70 | { | |
| 71 | file: "test/security-merge.test.ts", | |
| 72 | reason: "not yet admitted", | |
| 73 | issue: 261, | |
| 74 | }, | |
| 75 | 70 | { file: "test/security-xss.test.ts", reason: "not yet admitted", issue: 261 }, |
| 76 | 71 | { |
| 77 | 72 | file: "test/snapshot-destination.test.ts", |
test/security-merge.test.ts +69 −111
| @@ -309,85 +309,81 @@ describe("merge correctness (clean results, recorded for regression)", () => { | ||
| 309 | 309 | await rm(work, { recursive: true, force: true }); |
| 310 | 310 | }); |
| 311 | 311 | |
| 312 | test("squash merge produces exactly one parent; regular merge produces exactly two", async () => { | |
| 312 | // Each test builds its own branches and pull requests from the current | |
| 313 | // main, and reads the pull number the forge hands back, so no test depends | |
| 314 | // on what another left behind or on the order they run in. | |
| 315 | async function branchFromMain( | |
| 316 | name: string, | |
| 317 | file: string, | |
| 318 | content: string, | |
| 319 | ): Promise<void> { | |
| 313 | 320 | const dir = join(work, "app"); |
| 321 | await gitOk(["fetch", "origin"], dir); | |
| 322 | await gitOk(["checkout", "-B", "main", "origin/main"], dir); | |
| 323 | await gitOk(["checkout", "-b", name], dir); | |
| 324 | await Bun.write(join(dir, file), content); | |
| 325 | await gitOk(["add", "."], dir); | |
| 326 | await gitOk(["commit", "-m", `change on ${name}`], dir); | |
| 327 | await gitOk(["push", "origin", name], dir); | |
| 328 | } | |
| 329 | ||
| 330 | async function openPull(head: string): Promise<number> { | |
| 331 | const res = await fetch(`${base}/bob/app/pulls/new`, { | |
| 332 | method: "POST", | |
| 333 | headers: { cookie }, | |
| 334 | body: form({ base: "main", head, title: `PR ${head}`, body: "" }), | |
| 335 | redirect: "manual", | |
| 336 | }); | |
| 337 | const m = /\/pulls\/(\d+)$/.exec(res.headers.get("location") ?? ""); | |
| 338 | if (!m) throw new Error(`pull for ${head} not opened: ${res.status}`); | |
| 339 | return Number(m[1]); | |
| 340 | } | |
| 341 | ||
| 342 | async function mergePull(n: number, strategy: string): Promise<void> { | |
| 343 | await fetch(`${base}/bob/app/pulls/${n}/merge`, { | |
| 344 | method: "POST", | |
| 345 | headers: { cookie }, | |
| 346 | body: form({ strategy }), | |
| 347 | }); | |
| 348 | } | |
| 349 | ||
| 350 | async function parentCount(sha: string, gitDir: string): Promise<number> { | |
| 351 | return (await gitCapture(["cat-file", "-p", sha], gitDir)) | |
| 352 | .split("\n") | |
| 353 | .filter((l) => l.startsWith("parent ")).length; | |
| 354 | } | |
| 355 | ||
| 356 | test("squash merge produces exactly one parent; regular merge produces exactly two", async () => { | |
| 314 | 357 | const repoGitDir = join(dataDir, "repos/bob/app.git"); |
| 315 | 358 | |
| 316 | await gitOk(["checkout", "-b", "squash-feature"], dir); | |
| 317 | await Bun.write(join(dir, "code.txt"), "alpha\nBETA\ngamma\n"); | |
| 318 | await gitOk(["commit", "-am", "squash change"], dir); | |
| 319 | await gitOk(["push", "origin", "squash-feature"], dir); | |
| 320 | await fetch(`${base}/bob/app/pulls/new`, { | |
| 321 | method: "POST", | |
| 322 | headers: { cookie }, | |
| 323 | body: form({ | |
| 324 | base: "main", | |
| 325 | head: "squash-feature", | |
| 326 | title: "Squash PR", | |
| 327 | body: "", | |
| 328 | }), | |
| 329 | }); | |
| 330 | await fetch(`${base}/bob/app/pulls/1/merge`, { | |
| 331 | method: "POST", | |
| 332 | headers: { cookie }, | |
| 333 | body: form({ strategy: "squash" }), | |
| 334 | }); | |
| 359 | await branchFromMain("squash-feature", "code.txt", "alpha\nBETA\ngamma\n"); | |
| 360 | const squashPr = await openPull("squash-feature"); | |
| 361 | await mergePull(squashPr, "squash"); | |
| 335 | 362 | const squashSha = await gitCapture( |
| 336 | ["rev-parse", "refs/pull/1/merged"], | |
| 363 | ["rev-parse", `refs/pull/${squashPr}/merged`], | |
| 337 | 364 | repoGitDir, |
| 338 | 365 | ); |
| 339 | const squashParents = ( | |
| 340 | await gitCapture(["cat-file", "-p", squashSha], repoGitDir) | |
| 341 | ) | |
| 342 | .split("\n") | |
| 343 | .filter((l) => l.startsWith("parent ")).length; | |
| 344 | expect(squashParents).toBe(1); | |
| 366 | expect(await parentCount(squashSha, repoGitDir)).toBe(1); | |
| 345 | 367 | |
| 346 | await gitOk(["checkout", "main"], dir); | |
| 347 | await gitOk(["pull", "origin", "main"], dir); | |
| 348 | await gitOk(["checkout", "-b", "merge-feature"], dir); | |
| 349 | await Bun.write(join(dir, "other.txt"), "new file\n"); | |
| 350 | await gitOk(["add", "."], dir); | |
| 351 | await gitOk(["commit", "-m", "merge change"], dir); | |
| 352 | await gitOk(["push", "origin", "merge-feature"], dir); | |
| 353 | await fetch(`${base}/bob/app/pulls/new`, { | |
| 354 | method: "POST", | |
| 355 | headers: { cookie }, | |
| 356 | body: form({ | |
| 357 | base: "main", | |
| 358 | head: "merge-feature", | |
| 359 | title: "Merge PR", | |
| 360 | body: "", | |
| 361 | }), | |
| 362 | }); | |
| 363 | await fetch(`${base}/bob/app/pulls/2/merge`, { | |
| 364 | method: "POST", | |
| 365 | headers: { cookie }, | |
| 366 | body: form({ strategy: "merge" }), | |
| 367 | }); | |
| 368 | await branchFromMain("merge-feature", "other.txt", "new file\n"); | |
| 369 | const mergePr = await openPull("merge-feature"); | |
| 370 | await mergePull(mergePr, "merge"); | |
| 368 | 371 | const mergeSha = await gitCapture( |
| 369 | ["rev-parse", "refs/pull/2/merged"], | |
| 372 | ["rev-parse", `refs/pull/${mergePr}/merged`], | |
| 370 | 373 | repoGitDir, |
| 371 | 374 | ); |
| 372 | const mergeParents = ( | |
| 373 | await gitCapture(["cat-file", "-p", mergeSha], repoGitDir) | |
| 374 | ) | |
| 375 | .split("\n") | |
| 376 | .filter((l) => l.startsWith("parent ")).length; | |
| 377 | expect(mergeParents).toBe(2); | |
| 375 | expect(await parentCount(mergeSha, repoGitDir)).toBe(2); | |
| 378 | 376 | }); |
| 379 | 377 | |
| 380 | 378 | test("refs/pull/N/merged survives `git gc --prune=now`", async () => { |
| 381 | 379 | const repoGitDir = join(dataDir, "repos/bob/app.git"); |
| 382 | const before = await gitCapture( | |
| 383 | ["rev-parse", "refs/pull/1/merged"], | |
| 384 | repoGitDir, | |
| 385 | ); | |
| 380 | await branchFromMain("gc-feature", "gc.txt", "gc me\n"); | |
| 381 | const pr = await openPull("gc-feature"); | |
| 382 | await mergePull(pr, "squash"); | |
| 383 | const ref = `refs/pull/${pr}/merged`; | |
| 384 | const before = await gitCapture(["rev-parse", ref], repoGitDir); | |
| 386 | 385 | await gitOk(["gc", "--prune=now"], repoGitDir); |
| 387 | const after = await gitCapture( | |
| 388 | ["rev-parse", "refs/pull/1/merged"], | |
| 389 | repoGitDir, | |
| 390 | ); | |
| 386 | const after = await gitCapture(["rev-parse", ref], repoGitDir); | |
| 391 | 387 | expect(after).toBe(before); |
| 392 | 388 | // The object itself must still resolve, not just the ref. |
| 393 | 389 | const catOut = await gitCapture(["cat-file", "-t", after], repoGitDir); |
| @@ -395,66 +391,28 @@ describe("merge correctness (clean results, recorded for regression)", () => { | ||
| 395 | 391 | }); |
| 396 | 392 | |
| 397 | 393 | test("a conflicting merge attempt leaves the base ref completely unchanged", async () => { |
| 398 | const dir = join(work, "app"); | |
| 399 | 394 | const repoGitDir = join(dataDir, "repos/bob/app.git"); |
| 400 | 395 | |
| 401 | await gitOk(["checkout", "main"], dir); | |
| 402 | await gitOk(["pull", "origin", "main"], dir); | |
| 403 | await gitOk(["checkout", "-b", "conflict-a"], dir); | |
| 404 | await Bun.write(join(dir, "code.txt"), "ALPHA-A\nbeta\ngamma\n"); | |
| 405 | await gitOk(["commit", "-am", "conflict a"], dir); | |
| 406 | await gitOk(["push", "origin", "conflict-a"], dir); | |
| 407 | ||
| 408 | await gitOk(["checkout", "main"], dir); | |
| 409 | await gitOk(["checkout", "-b", "conflict-b"], dir); | |
| 410 | await Bun.write(join(dir, "code.txt"), "ALPHA-B\nbeta\ngamma\n"); | |
| 411 | await gitOk(["commit", "-am", "conflict b"], dir); | |
| 412 | await gitOk(["push", "origin", "conflict-b"], dir); | |
| 413 | ||
| 414 | await fetch(`${base}/bob/app/pulls/new`, { | |
| 415 | method: "POST", | |
| 416 | headers: { cookie }, | |
| 417 | body: form({ | |
| 418 | base: "main", | |
| 419 | head: "conflict-a", | |
| 420 | title: "Conflict A", | |
| 421 | body: "", | |
| 422 | }), | |
| 423 | }); | |
| 424 | await fetch(`${base}/bob/app/pulls/new`, { | |
| 425 | method: "POST", | |
| 426 | headers: { cookie }, | |
| 427 | body: form({ | |
| 428 | base: "main", | |
| 429 | head: "conflict-b", | |
| 430 | title: "Conflict B", | |
| 431 | body: "", | |
| 432 | }), | |
| 433 | }); | |
| 434 | // Merge conflict-a first, so conflict-b's merge attempt against the new | |
| 435 | // main will genuinely conflict. | |
| 436 | await fetch(`${base}/bob/app/pulls/3/merge`, { | |
| 437 | method: "POST", | |
| 438 | headers: { cookie }, | |
| 439 | body: form({ strategy: "merge" }), | |
| 440 | }); | |
| 396 | // Both branches rewrite the same file from the same base, so whichever | |
| 397 | // merges second genuinely conflicts. | |
| 398 | await branchFromMain("conflict-a", "conflict.txt", "A\n"); | |
| 399 | await branchFromMain("conflict-b", "conflict.txt", "B\n"); | |
| 400 | const a = await openPull("conflict-a"); | |
| 401 | const b = await openPull("conflict-b"); | |
| 402 | await mergePull(a, "merge"); | |
| 441 | 403 | |
| 442 | 404 | const before = await gitCapture( |
| 443 | 405 | ["rev-parse", "refs/heads/main"], |
| 444 | 406 | repoGitDir, |
| 445 | 407 | ); |
| 446 | await fetch(`${base}/bob/app/pulls/4/merge`, { | |
| 447 | method: "POST", | |
| 448 | headers: { cookie }, | |
| 449 | body: form({ strategy: "merge" }), | |
| 450 | }); | |
| 408 | await mergePull(b, "merge"); | |
| 451 | 409 | const after = await gitCapture( |
| 452 | 410 | ["rev-parse", "refs/heads/main"], |
| 453 | 411 | repoGitDir, |
| 454 | 412 | ); |
| 455 | 413 | expect(after).toBe(before); |
| 456 | 414 | |
| 457 | const page = await (await fetch(`${base}/bob/app/pulls/4/files`)).text(); | |
| 415 | const page = await (await fetch(`${base}/bob/app/pulls/${b}/files`)).text(); | |
| 458 | 416 | expect(page).toContain("Can't merge."); |
| 459 | 417 | }); |
| 460 | 418 | }); |
new above
offline