import { expect } from "@playwright/test";
import {
button,
commitRow,
type Ctx,
link,
mergeButton,
not,
pullId,
target,
testid,
text,
unique,
where
} from "../harness/flow";
import { spindle } from "../harness/services";
import {
answerWith,
branches,
forkedAhead,
merges,
opensForkPull,
reloadsUntil,
resubmitsForkPatch,
waitsForApproval,
targetTookTheFeature,
upstreamPull
} from "../harness/shared";
import { delivers, dropsHook, hookOn, nothingMore } from "../harness/webhooks";
const forkTitle = unique("a fix from my fork");
const forkHead = (ctx: Ctx) => ctx.repo.heads[branches.from];
const upstreamDid = (ctx: Ctx) => ctx.upstream.did;
const completed = testid("ticketStatePill", { state: "completed" });
const upstreamMerged = where(
"the upstream's merged pulls",
(ctx) => `${ctx.upstream.path}/pulls?state=completed`
);
forkedAhead("merging a fork pull request moves the upstream's target to the fork's commit")
.use(opensForkPull(forkTitle))
.actor("owner")
.open(upstreamPull)
.use(merges)
.knotStored(pullId, "completed")
.step(
"the upstream's target branch took the fork's commit",
targetTookTheFeature((ctx) => ctx.actor, { fromFork: true })
)
.checkpoint("the merge moved the upstream's target branch")
.reload()
.sees(completed)
.open(upstreamMerged)
.sees(link(forkTitle));
// the mirror misses a fork's head for well under a second, too short to hit on purpose
const mergeCheckRoute = "**/xrpc/sh.tangled.git.temp2.mergeCheck*";
const mergeCommitRoute = "**/xrpc/sh.tangled.git.mergeCommit";
const lacksTheHead = (route: string) => (ctx: Ctx) =>
answerWith(route, 404, "RevisionNotFound", `commit not found: ${forkHead(ctx)}`)(ctx);
const mirrorWait = testid("mirrorWait");
forkedAhead("a fork pull request waits to merge until the mirror has the fork's head")
.use(opensForkPull(forkTitle))
.actor("owner")
.open(upstreamPull)
.step("the mirror doesn't have the fork's head yet", lacksTheHead(mergeCheckRoute))
.reload()
.sees(testid("mirrorWait", { state: "waiting" }))
.step("the merge waits for the mirror", (ctx) =>
expect(mergeButton.locate(ctx.page, ctx)).toBeDisabled()
)
.step("the mirror catches up", (ctx) => ctx.page.unroute(mergeCheckRoute))
.step("the check tries again by itself", (ctx) =>
expect(mirrorWait.locate(ctx.page, ctx)).toHaveCount(0, { timeout: 10_000 })
)
.step("the merge loses the race once", lacksTheHead(mergeCommitRoute))
.click(mergeButton)
.sees(
testid("errorAlert"),
"The mirror hasn't caught up with this pull yet, try again in a moment."
)
.step("the mirror catches up again", (ctx) => ctx.page.unroute(mergeCommitRoute))
.use(merges)
.knotStored(pullId, "completed")
.step(
"the upstream's target branch took the fork's commit",
targetTookTheFeature((ctx) => ctx.actor, { fromFork: true })
);
const pushedFile = testid("diffFileName", { path: "e2e/more.txt" });
// no round labels here, so the commit rows tell the rounds apart
forkedAhead("resubmitting a fork pull request after pushing to the fork's branch")
.use(opensForkPull(forkTitle))
.push({ "e2e/more.txt": "more\n" }, { branch: branches.from })
.mirrored()
.open(upstreamPull)
.sees(not(pushedFile))
.sees(testid("resubmit"))
.use(resubmitsForkPatch)
.reload()
.step("the new file shows up", reloadsUntil(pushedFile))
.sees(pushedFile)
.sees(commitRow(2))
.sees(not(commitRow(3)));
const approveCi = button("Approve CI");
forkedAhead("a fork pull request from someone who isn't a collaborator waits for approval", {
ci: true
})
.use(opensForkPull(forkTitle))
.step("the opened pull's pipeline is blocked", waitsForApproval(forkHead, upstreamDid))
.push({ "e2e/more.txt": "more\n" }, { branch: branches.from })
.mirrored()
.open(upstreamPull)
.use(resubmitsForkPatch)
.reload()
.changeState(pullId, "closed", testid("requestClose"), testid("changeState"))
.changeState(pullId, "open", testid("changeState"), testid("requestClose"))
.step(
"still blocked after the resubmit, the close and the reopen",
waitsForApproval(forkHead, upstreamDid)
)
.sees(not(approveCi))
.actor("owner")
.open(upstreamPull)
.click(approveCi)
.click(button("Approve CI run"))
.sees(text("CI approved"))
.step("spindle runs a workflow at the fork's head", (ctx) =>
spindle.finishedWorkflow(ctx.upstream.did, forkHead(ctx))
);
// the hook belongs to the upstream, but the flow's repo is the fork
const onUpstream = (step: (ctx: Ctx) => Promise<unknown>) => async (ctx: Ctx) => {
const fork = ctx.repo;
ctx.repo = ctx.upstream;
try {
await step(ctx);
} finally {
ctx.repo = fork;
}
};
const forks = new WeakMap<Ctx, { did: string; sha: string; forker: string }>();
const rememberFork = async (ctx: Ctx) => {
forks.set(ctx, { did: ctx.repo.did, sha: forkHead(ctx), forker: ctx.actor.did });
};
const pullEvents = ["pull_request:created", "pull_request:merged"];
const forkPullDelivered = (action: string, state: string) =>
onUpstream(
delivers(`pull_request:${action}`, "Tangled-Hook/pull_request", (body, ctx) => {
const fork = forks.get(ctx);
if (!fork) throw new Error("this step needs the fork remembered first");
expect(body).toMatchObject({
action,
sender: { did: ctx.actor.did },
pull_request: {
title: forkTitle,
state,
target_branch: branches.into,
round_number: 1,
owner: { did: fork.forker },
source: { repo: fork.did, branch: branches.from, sha: fork.sha }
}
});
})
);
forkedAhead("fork pull request webhooks name the fork as the source", { ci: true })
.step("remember the fork", rememberFork)
.actor("owner")
.step("spindle has a pipeline for the first push", (ctx) =>
spindle.hasPipeline(ctx.upstream.did, ctx.upstream.heads[branches.into], "push")
)
.step(
"point a webhook at the upstream",
onUpstream(hookOn(pullEvents, { secret: "e2e-webhook-secret" }))
)
.actor("forker")
.use(opensForkPull(forkTitle))
.step("the receiver got the new pull", forkPullDelivered("created", "open"))
.actor("owner")
.open(upstreamPull)
.use(merges)
.step("the receiver got the merge", forkPullDelivered("merged", "merged"))
.step("nothing else arrived", onUpstream(nothingMore))
.step("take the webhook away", onUpstream(dropsHook));