import { expect } from "@playwright/test";
import { execFile } from "node:child_process";
import { readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { gunzipSync } from "node:zlib";
import { fetch as undiciFetch } from "undici";
import { decodeRefs, pdsCall, sessionOf } from "../harness/api";
import { type Account, config } from "../harness/config";
import {
bobbin,
branchesTab,
confirmDelete,
type Ctx,
exactLink,
flow,
git,
heading,
hydrant,
knot,
knotCopy,
link,
not,
page,
pds,
record,
refsId,
target,
text,
testid,
through,
where,
within
} from "../harness/flow";
import { cloneOverHttps, lsRemote, lsRemoteOverHttps, sshAs } from "../harness/git";
import { dispatcher, until } from "../harness/net";
import { base, deletesBranch, learnRefs, mirrorLists, pushesTag } from "../harness/shared";
import { type Artifact } from "../harness/lexicons";
const files = { "README.md": "hello from e2e\n", "src/main.txt": "x\n" };
flow("pushing a commit to a new repo")
.actor("owner")
.repo()
.push(files)
.follow(refsId, through(git, knotCopy, hydrant, bobbin))
.step("the record names the pushed commit and the default branch", async (ctx) => {
const value = await knot.has(ctx.get(refsId));
expect(decodeRefs(value)).toContainEqual([ctx.pushed.sha, "refs/heads/main"]);
expect(value.defaultBranch).toBe("main");
});
flow("a pushed repo shows its files")
.actor("owner")
.repo()
.push(files)
.mirrored()
.open(page.repo)
.sees(link("README.md"))
.sees(link("src"));
const exec = promisify(execFile);
// encoded as one segment, so a slash in a branch name survives
const treeAt = (ref: string, path = "") =>
where(`the tree of ${ref}${path && ` at ${path}`}`, (ctx) =>
[`${ctx.repo.path}/tree/${encodeURIComponent(ref)}`, path].filter(Boolean).join("/")
);
const blobOf = (ref: string, path: string) =>
where(
`the blob ${path} on ${ref}`,
(ctx) => `${ctx.repo.path}/blob/${encodeURIComponent(ref)}/${path}`
);
const tagsTab = where("the tags tab", (ctx) => `${ctx.repo.path}/tags`);
flow("browsing a branch with a slash in its name down to one file")
.actor("owner")
.repo()
.push(base)
.push({ "docs/guide/intro.txt": "browse me\n" }, { branch: "feature/browse", base: "main" })
.mirrored()
.open(treeAt("feature/browse"))
.sees(exactLink("docs"))
.click(exactLink("docs"))
.at(treeAt("feature/browse", "docs"))
.click(exactLink("guide"))
.at(treeAt("feature/browse", "docs/guide"))
.click(exactLink("intro.txt"))
.at(blobOf("feature/browse", "docs/guide/intro.txt"))
.sees(text("browse me"))
.step("the knot has the branch at the commit the page read", async (ctx) => {
const heads = await lsRemote(ctx.actor, ctx.repo.did, "refs/heads/");
expect(heads["refs/heads/feature/browse"]).toBe(ctx.repo.heads["feature/browse"]);
});
const readme = "# readme from a branch\n\nsee [the code](src/main.txt) for more\n";
// the heading has its own anchor link in it, so text alone won't match
const renderedReadmeHeading = target("the readme's heading", (tab) =>
tab.locator('[data-testid="renderedMarkdownBody"] h1:has(a[href="#readme-from-a-branch"])')
);
flow("a readme on a branch renders and its relative link goes to the file")
.actor("owner")
.repo()
.push(base)
.push(
{ "README.md": readme, "src/main.txt": "linked file\n" },
{ branch: "feature/readme", base: "main" }
)
.mirrored()
.open(treeAt("feature/readme"))
.sees(renderedReadmeHeading)
.click(exactLink("the code"))
.sees(text("linked file"));
const onlyMain = async (ctx: Ctx) =>
expect(Object.keys(await lsRemote(ctx.actor, ctx.repo.did, "refs/heads/"))).toEqual([
"refs/heads/main"
]);
flow("deleting a branch over git takes it off the branches tab")
.actor("owner")
.repo()
.push(base)
.push({ "e2e/doomed.txt": "gone soon\n" }, { branch: "doomed", base: "main" })
.mirrored()
.open(branchesTab)
.sees(exactLink("doomed"))
.sees(exactLink("main"))
.step("delete the branch over git", deletesBranch("doomed"))
.step("the knot's record drops it", learnRefs("refs/heads/doomed", false))
.follow(refsId, through(git, knotCopy, hydrant, bobbin))
.step("git lists no doomed branch", onlyMain)
.step("bobbin has caught up", mirrorLists("Branches", "doomed", false))
.reload()
.sees(not(exactLink("doomed")))
.sees(exactLink("main"));
flow("an annotated tag shows under tags with its message")
.actor("owner")
.repo()
.push(base)
.step("push the tag over git", pushesTag("v1.0", "the first release"))
.step("the knot's record names it", learnRefs("refs/tags/v1.0", true))
.follow(refsId, through(knotCopy, hydrant, bobbin))
.step("git lists the tag", async (ctx) => {
const tags = await lsRemote(ctx.actor, ctx.repo.did, "refs/tags/");
expect(Object.keys(tags)).toContain("refs/tags/v1.0");
})
.step("bobbin has caught up", mirrorLists("Tags", "v1.0", true))
.open(tagsTab)
.sees(exactLink("v1.0"))
.sees(within(testid("tagMessageTitle"), text("the first release")))
.click(exactLink("v1.0"))
.at(where("the tag's page", (ctx) => `${ctx.repo.path}/tags/v1.0`))
.sees(text("the first release"));
const cloneUrl = (label: "SSH" | "HTTPS") => testid("cloneUrlValue", { label });
const codeMenu = within(testid("cloneThisRepository"), testid("toggleMenu"));
flow("the ssh clone url in the code menu is one git accepts")
.actor("owner")
.repo()
.push(base)
.mirrored()
.open(page.repo)
.click(codeMenu)
.sees(cloneUrl("SSH"))
.step("git lists the repo's branches at the url the page shows", async (ctx) => {
const url = await cloneUrl("SSH").locate(ctx.page, ctx).innerText();
// the page shows the host with no port, so the port is set by hand
const [host, port] = config.knotSsh.split(":");
const listed = await until(`git should accept ${url}`, 60_000, async () => {
try {
const { stdout } = await exec("git", ["ls-remote", "--heads", url], {
env: {
...process.env,
GIT_SSH_COMMAND: `${await sshAs(ctx.actor)} -p ${port} -o HostName=${host}`
},
timeout: 30_000
});
return [true, stdout, stdout];
} catch (cause: any) {
return [false, String(cause.stderr ?? cause).trim()];
}
});
expect(listed).toContain(`${ctx.repo.heads["main"]}\trefs/heads/main`);
});
const redirectOf = async (url: string) => {
const res = await undiciFetch(url, { dispatcher, redirect: "manual" });
await res.arrayBuffer();
return { status: res.status, location: res.headers.get("location") };
};
flow("the https clone url in the code menu clones through the web app")
.actor("owner")
.repo()
.push(base)
.mirrored()
.open(page.repo)
.click(codeMenu)
.sees(cloneUrl("HTTPS"))
.step("info/refs at that url redirects to the repo did on the knot", async (ctx) => {
const url = await cloneUrl("HTTPS").locate(ctx.page, ctx).innerText();
const service = "info/refs?service=git-upload-pack";
expect(await redirectOf(`${url}/${service}`)).toEqual({
status: 307,
location: `${config.knotUrl}/${ctx.repo.did}/${service}`
});
})
.step("git lists and clones the repo at that url", async (ctx) => {
const url = await cloneUrl("HTTPS").locate(ctx.page, ctx).innerText();
expect(await lsRemoteOverHttps(url)).toContain(
`${ctx.repo.heads["main"]}\trefs/heads/main`
);
expect(await cloneOverHttps(`${url}.git`)).toBe(ctx.repo.heads["main"]);
})
.step("git lists the repo at its did permalink", async (ctx) => {
const url = `${config.webUrl}/${ctx.repo.did}`;
expect(await lsRemoteOverHttps(url)).toContain(
`${ctx.repo.heads["main"]}\trefs/heads/main`
);
});
const atHandle = where(
"the repo under its @handle",
(ctx) => `/@${ctx.actor.handle}/${ctx.repo.name}`
);
flow("an old @handle repo link opens the repo and clones over https")
.actor("owner")
.repo()
.push(base)
.mirrored()
.open(atHandle)
.at(page.repo)
.step("git lists the repo at the @handle url", async (ctx) => {
const url = `${config.webUrl}${atHandle.path(ctx)}`;
expect(await lsRemoteOverHttps(url)).toContain(
`${ctx.repo.heads["main"]}\trefs/heads/main`
);
});
const tarFiles = (tgz: Buffer) => {
const tar = gunzipSync(tgz);
const found: Record<string, string> = {};
for (let at = 0; at + 512 <= tar.length && tar[at] !== 0;) {
const cstr = (from: number, to: number) => tar.toString("utf8", from, to).split("\0")[0];
const size = parseInt(cstr(at + 124, at + 136).trim() || "0", 8);
const kind = String.fromCharCode(tar[at + 156]);
const name = [cstr(at + 345, at + 500), cstr(at, at + 100)].filter(Boolean).join("/");
if (kind === "0" || kind === "\0")
found[name] = tar.toString("utf8", at + 512, at + 512 + size);
at += 512 + Math.ceil(size / 512) * 512;
}
return found;
};
const zipNames = (zip: Buffer) => {
const names: string[] = [];
for (let at = zip.indexOf("PK\x03\x04"); at !== -1; at = zip.indexOf("PK\x03\x04", at + 4)) {
const length = zip.readUInt16LE(at + 26);
names.push(zip.toString("utf8", at + 30, at + 30 + length));
}
return names;
};
const downloadOf = async (ctx: Ctx, name: string) => {
const format = name === ".zip" ? "zip" : name;
const [download] = await Promise.all([
ctx.page.waitForEvent("download"),
testid("downloadArchiveLink", { format }).locate(ctx.page, ctx).click()
]);
const saved = join(tmpdir(), `e2e-archive-${Date.now()}-${download.suggestedFilename()}`);
await download.saveAs(saved);
try {
return await readFile(saved);
} finally {
await rm(saved, { force: true });
}
};
const archiveFiles = { "e2e/base.txt": "in the archive\n", "docs/readme.txt": "second file\n" };
flow("the tar.gz in the code menu downloads the default branch")
.actor("owner")
.repo()
.push(archiveFiles)
.mirrored()
.open(page.repo)
.click(codeMenu)
.step("the download unpacks to the pushed files under the repo's name", async (ctx) => {
const files = tarFiles(await downloadOf(ctx, "tar.gz"));
for (const [path, contents] of Object.entries(archiveFiles))
expect(files[`${ctx.repo.name}-main/${path}`]).toBe(contents);
});
flow("the zip in the code menu downloads the default branch")
.actor("owner")
.repo()
.push(archiveFiles)
.mirrored()
.open(page.repo)
.click(codeMenu)
.step("the download lists the pushed files under the repo's name", async (ctx) => {
const names = zipNames(await downloadOf(ctx, ".zip"));
for (const path of Object.keys(archiveFiles))
expect(names).toContain(`${ctx.repo.name}-main/${path}`);
});
const artifact = record("sh.tangled.repo.artifact");
const tagPage = where("the tag's page", (ctx) => `${ctx.repo.path}/tags/v1.0`);
const releaseName = "release.txt";
const releaseFile = target("the release file input", (tab) => tab.getByLabel("Upload artifact"));
const releaseLink = exactLink(releaseName);
flow("uploading a release file to a tag lists it on the tag page")
.actor("owner")
.repo()
.push(base)
.step("push an annotated tag", pushesTag("v1.0", "the first release"))
.step("bobbin lists the tag", mirrorLists("Tags", "v1.0", true))
.open(tagPage)
.sees(releaseFile)
.step("choose the file", async (ctx) => {
await releaseFile.locate(ctx.page, ctx).setInputFiles({
name: releaseName,
mimeType: "text/plain",
buffer: Buffer.from("the release\n")
});
})
.sends(artifact, pds, () => ({ record: { $type: artifact.nsid, name: releaseName } }), "create")
.trace(artifact, through(pds, hydrant, bobbin))
.sees(releaseLink)
.reload()
.sees(releaseLink);
// no helper for a binary upload here, so this dials the pds directly
const uploadBlob = async (who: Account, bytes: Buffer, mimeType: string) => {
const sent = await undiciFetch(`${config.pdsUrl}/xrpc/com.atproto.repo.uploadBlob`, {
dispatcher,
method: "POST",
headers: { authorization: `Bearer ${await sessionOf(who)}`, "content-type": mimeType },
body: bytes
});
const body: any = await sent.json();
expect(sent.status, `the pds refused the blob: ${JSON.stringify(body)}`).toBe(200);
return body.blob;
};
// a sha256 repo's tags can't give the 20 byte hash the lexicon wants, so this is made up
const makeRelease = async (ctx: Ctx) => {
const blob = await uploadBlob(ctx.actor, Buffer.from("the release\n"), "text/plain");
const made = await pdsCall(ctx.actor, "com.atproto.repo.createRecord", {
collection: artifact.nsid,
record: {
$type: artifact.nsid,
name: releaseName,
repoDid: ctx.repo.did,
tag: { $bytes: Buffer.alloc(20, 7).toString("base64") },
createdAt: new Date().toISOString(),
artifact: blob
} satisfies Artifact
});
ctx.adopt(artifact, { uri: made.uri, cid: made.cid });
};
const deleteRelease = target("the delete release button", (tab) =>
tab.getByRole("button", { name: `Delete ${releaseName}`, exact: true })
);
flow("deleting a release file takes it off the tag page and off every service")
.actor("owner")
.repo()
.push(base)
.step("push an annotated tag", pushesTag("v1.0", "the first release"))
.step("bobbin lists the tag", mirrorLists("Tags", "v1.0", true))
.step("upload a release file", makeRelease)
.open(tagPage)
.sees(releaseLink)
.sees(deleteRelease)
.click(deleteRelease)
.click(confirmDelete)
.sends(artifact, pds, () => ({ collection: artifact.nsid }), "delete")
.trace(artifact, through(pds, hydrant, bobbin))
.sees(not(releaseLink))
.reload()
.sees(not(releaseLink));
const deleteBranchButton = target("the delete branch button", (tab) =>
tab.getByRole("button", { name: "Delete branch doomed", exact: true })
);
flow("deleting a branch from the branches tab removes it from git")
.actor("owner")
.repo()
.push(base)
.push({ "e2e/doomed.txt": "gone soon\n" }, { branch: "doomed", base: "main" })
.mirrored()
.open(branchesTab)
.sees(exactLink("doomed"))
.sees(deleteBranchButton)
.click(deleteBranchButton)
.click(confirmDelete)
.step("the knot's record drops it", learnRefs("refs/heads/doomed", false))
.follow(refsId, through(git, knotCopy, hydrant, bobbin))
.step("git lists only main", onlyMain)
.reload()
.sees(not(exactLink("doomed")))
.sees(exactLink("main"));