mirror of
https://seed.radicle.dev/rad:z4D5UCArafTzTQpDZNQRuqswh3ury.git
synced 2026-10-18 19:10:45 +08:00
Add Releases and Release views
- Add the Releases list and Release page - Add modals to create, delete and redact releases - Register artifacts and add or remove their locations - Download, seed and unseed artifacts from the release page - Share one artifact node state and show its status - Pick the release commit from a list - Rename Stop seeding to Unseed Co-authored-by: Daniel Norman <daniel@norman.life> Co-authored-by: Rūdolfs Ošiņš <rudolfs@osins.org>
This commit is contained in:
committed by
Rūdolfs Ošiņš
co-authored by
Daniel Norman
Rūdolfs Ošiņš
parent
837766212e
commit
595a81150b
@@ -0,0 +1,493 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import type { PeerManager, RadiclePeer } from "@tests/support/peerManager.js";
|
||||
|
||||
import * as Fs from "node:fs/promises";
|
||||
import * as Path from "node:path";
|
||||
|
||||
import {
|
||||
clone,
|
||||
createCollaborators,
|
||||
createProject,
|
||||
} from "@tests/support/collaboration.js";
|
||||
import {
|
||||
expect,
|
||||
goto,
|
||||
reload,
|
||||
test,
|
||||
useBackend,
|
||||
} from "@tests/support/fixtures.js";
|
||||
|
||||
test("the commit picker lists commits and rejects unknown SHAs", async ({
|
||||
page,
|
||||
peer,
|
||||
}) => {
|
||||
const { rid, repoFolder } = await createProject(peer);
|
||||
const { stdout: head } = await peer.git(["rev-parse", "main"], {
|
||||
cwd: repoFolder,
|
||||
});
|
||||
|
||||
await page.goto(`/repos/${rid}/releases`);
|
||||
await page.getByRole("button", { name: "New release" }).click();
|
||||
|
||||
const input = page.getByPlaceholder("Pick a commit or paste a SHA");
|
||||
await input.click();
|
||||
await page.getByText("Add notes").click();
|
||||
await expect(input).toHaveValue(head.trim());
|
||||
|
||||
await input.fill("cafecafecafecafecafecafecafecafecafecafe");
|
||||
await expect(
|
||||
page.getByText("Commit cafecaf is not in this repo."),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Create release" }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
// Fills the New release modal with one artifact, without submitting it.
|
||||
async function fillRelease(page: Page, peer: RadiclePeer) {
|
||||
const { rid, repoFolder } = await createProject(peer);
|
||||
const file = Path.join(repoFolder, "build.tar");
|
||||
await Fs.writeFile(file, "artifact bytes");
|
||||
// The native file dialog only exists under Tauri.
|
||||
await page.route(/\/pick_artifact_files$/, route =>
|
||||
route.fulfill({ json: [file] }),
|
||||
);
|
||||
|
||||
await page.goto(`/repos/${rid}/releases`);
|
||||
await page.getByRole("button", { name: "New release" }).click();
|
||||
await page.getByPlaceholder("Pick a commit or paste a SHA").click();
|
||||
await page.getByText("Add notes").click();
|
||||
await page.getByRole("button", { name: "Choose files" }).click();
|
||||
return rid;
|
||||
}
|
||||
|
||||
test("creating a release opens it and lists it", async ({ page, peer }) => {
|
||||
const rid = await fillRelease(page, peer);
|
||||
await page.getByRole("button", { name: "Create release" }).click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`));
|
||||
await expect(page.getByRole("button", { name: /build\.tar/ })).toBeVisible();
|
||||
// The test peer runs no artifact node, so there is nothing to seed with.
|
||||
await expect(
|
||||
page.getByTitle("Your artifact node is not running"),
|
||||
).toBeDisabled();
|
||||
|
||||
await goto(page, `/repos/${rid}/releases`);
|
||||
await expect(page.getByText("Add notes")).toBeVisible();
|
||||
});
|
||||
|
||||
test("a release whose seeding failed still opens", async ({ page, peer }) => {
|
||||
await page.route(/\/artifact_node_running$/, route =>
|
||||
route.fulfill({ json: true }),
|
||||
);
|
||||
await page.route(/\/seed_artifact$/, route =>
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
json: { code: "UnknownError", message: "seeding failed" },
|
||||
}),
|
||||
);
|
||||
const rid = await fillRelease(page, peer);
|
||||
await page.getByRole("button", { name: "Create release" }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText("Registered, but 1 artifact could not be seeded."),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Retry" })).toBeEnabled();
|
||||
await page.getByRole("button", { name: "Open release" }).click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`));
|
||||
await expect(page.getByRole("button", { name: /build\.tar/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test("a release can be deleted", async ({ page, peer }) => {
|
||||
const rid = await fillRelease(page, peer);
|
||||
await page.getByRole("button", { name: "Create release" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`));
|
||||
|
||||
await page.getByRole("button", { name: "Delete" }).click();
|
||||
await page
|
||||
.locator("[data-modal-content]")
|
||||
.getByRole("button", { name: "Delete" })
|
||||
.click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases$`));
|
||||
await expect(page.getByText("No releases")).toBeVisible();
|
||||
});
|
||||
|
||||
test("re-registering an artifact only signs what changes", async ({
|
||||
page,
|
||||
peer,
|
||||
}) => {
|
||||
const rid = await fillRelease(page, peer);
|
||||
await page.getByRole("button", { name: "Create release" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`));
|
||||
const modal = page.locator("[data-modal-content]");
|
||||
|
||||
// The same bytes under the same name change nothing.
|
||||
await page.getByRole("button", { name: "Register artifacts" }).click();
|
||||
await page.getByRole("button", { name: "Files…" }).click();
|
||||
await expect(modal.getByText("Nothing will be registered.")).toBeVisible();
|
||||
await expect(
|
||||
modal.getByRole("button", { name: "icon-plus Register" }),
|
||||
).toBeHidden();
|
||||
await modal.getByRole("button", { name: "Close", exact: true }).click();
|
||||
|
||||
// The same bytes under a new name rename your own artifact.
|
||||
const renamed = Path.join(peer.checkoutPath, "renamed.tar");
|
||||
await Fs.writeFile(renamed, "artifact bytes");
|
||||
await page.route(/\/pick_artifact_files$/, route =>
|
||||
route.fulfill({ json: [renamed] }),
|
||||
);
|
||||
await page.getByRole("button", { name: "Register artifacts" }).click();
|
||||
await page.getByRole("button", { name: "Files…" }).click();
|
||||
await expect(modal.getByText("renames “build.tar”")).toBeVisible();
|
||||
await modal.getByRole("button", { name: "icon-plus Update" }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: /renamed\.tar/ }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("picks with the same contents are registered once", async ({
|
||||
page,
|
||||
peer,
|
||||
}) => {
|
||||
const rid = await fillRelease(page, peer);
|
||||
const copy = Path.join(peer.checkoutPath, "copy.tar");
|
||||
await Fs.writeFile(copy, "artifact bytes");
|
||||
await page.route(/\/pick_artifact_files$/, route =>
|
||||
route.fulfill({ json: [copy] }),
|
||||
);
|
||||
await page.getByRole("button", { name: "Choose files" }).click();
|
||||
await expect(page.getByText("same as “build.tar”")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Create release" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`));
|
||||
await expect(page.getByRole("button", { name: /build\.tar/ })).toBeVisible();
|
||||
|
||||
// Two new picks with the same contents in one go.
|
||||
const first = Path.join(peer.checkoutPath, "a.tar");
|
||||
const second = Path.join(peer.checkoutPath, "b.tar");
|
||||
await Fs.writeFile(first, "other bytes");
|
||||
await Fs.writeFile(second, "other bytes");
|
||||
await page.route(/\/pick_artifact_files$/, route =>
|
||||
route.fulfill({ json: [first, second] }),
|
||||
);
|
||||
const modal = page.locator("[data-modal-content]");
|
||||
await page.getByRole("button", { name: "Register artifacts" }).click();
|
||||
await page.getByRole("button", { name: "Files…" }).click();
|
||||
await expect(modal.getByText("same as “a.tar” · skipped")).toBeVisible();
|
||||
await modal.getByRole("button", { name: "icon-plus Register" }).click();
|
||||
await expect(modal).toBeHidden();
|
||||
await expect(page.getByRole("button", { name: /a\.tar/ })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /b\.tar/ })).toBeHidden();
|
||||
});
|
||||
|
||||
test("a location can be added and removed", async ({ page, peer }) => {
|
||||
const rid = await fillRelease(page, peer);
|
||||
await page.getByRole("button", { name: "Create release" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`));
|
||||
await page.getByRole("button", { name: /build\.tar/ }).click();
|
||||
|
||||
const add = page.getByTitle("Add a URL others can download");
|
||||
const input = page.getByPlaceholder("https://…");
|
||||
await add.click();
|
||||
await input.fill("iroh://abc");
|
||||
await input.press("Enter");
|
||||
await expect(
|
||||
page.getByText("This is not a URL peers can fetch from."),
|
||||
).toBeVisible();
|
||||
|
||||
const url = "https://example.com/build.tar";
|
||||
await input.fill(url);
|
||||
await input.press("Enter");
|
||||
await expect(page.getByText(url)).toBeVisible();
|
||||
|
||||
await page.getByTitle("Remove location").click();
|
||||
await page.getByRole("button", { name: "icon-trash Remove" }).click();
|
||||
await expect(page.getByText(url)).toBeHidden();
|
||||
await expect(page.getByText("No locations")).toBeVisible();
|
||||
});
|
||||
|
||||
test("a location added by someone else cannot be removed", async ({
|
||||
page,
|
||||
peer,
|
||||
}) => {
|
||||
const rid = await fillRelease(page, peer);
|
||||
await page.getByRole("button", { name: "Create release" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`));
|
||||
|
||||
// The test peers run no artifact node, so another user's location is
|
||||
// injected into the release the view reads.
|
||||
const url = "https://example.com/theirs.tar";
|
||||
await page.route(/\/release_by_id$/, async route => {
|
||||
const release = await (await route.fetch()).json();
|
||||
for (const artifact of release.artifacts) {
|
||||
artifact.locations.push({
|
||||
user: {
|
||||
did: "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5",
|
||||
},
|
||||
url,
|
||||
});
|
||||
}
|
||||
await route.fulfill({ json: release });
|
||||
});
|
||||
await reload(page);
|
||||
await page.getByRole("button", { name: /build\.tar/ }).click();
|
||||
|
||||
await expect(page.getByText(url)).toBeVisible();
|
||||
await expect(page.getByTitle("Remove location")).toBeHidden();
|
||||
});
|
||||
|
||||
test("removing your node's location while seeding stops seeding", async ({
|
||||
page,
|
||||
peer,
|
||||
}) => {
|
||||
const rid = await fillRelease(page, peer);
|
||||
await page.getByRole("button", { name: "Create release" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`));
|
||||
await page.getByRole("button", { name: /build\.tar/ }).click();
|
||||
await page.getByTitle("Add a URL others can download").click();
|
||||
await page.getByPlaceholder("https://…").fill("radiroh://");
|
||||
const added = page.waitForRequest(/\/add_artifact_location$/);
|
||||
await page.getByPlaceholder("https://…").press("Enter");
|
||||
const { cid, releaseId } = (await added).postDataJSON();
|
||||
await expect(page.getByText("radiroh://")).toBeVisible();
|
||||
|
||||
// The test peers run no artifact node, so it is made to report the
|
||||
// artifact as seeded and to accept the unseed.
|
||||
await page.route(/\/seeded_artifacts$/, route =>
|
||||
route.fulfill({ json: [cid] }),
|
||||
);
|
||||
const unseed = page.waitForRequest(/\/unseed_artifact$/);
|
||||
await page.route(/\/unseed_artifact$/, route =>
|
||||
route.fulfill({ json: null }),
|
||||
);
|
||||
await reload(page);
|
||||
await page.getByRole("button", { name: /build\.tar/ }).click();
|
||||
|
||||
await page.getByTitle("Remove location").click();
|
||||
await expect(page.getByText("Unseed this artifact?")).toBeVisible();
|
||||
await page.getByRole("button", { name: "icon-trash Unseed" }).click();
|
||||
expect((await unseed).postDataJSON()).toMatchObject({ releaseId, cid });
|
||||
});
|
||||
|
||||
test("metadata can be added, edited and removed", async ({ page, peer }) => {
|
||||
const rid = await fillRelease(page, peer);
|
||||
await page.getByRole("button", { name: "Create release" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`));
|
||||
await page.getByRole("button", { name: /build\.tar/ }).click();
|
||||
const metadata = page.locator(".section").filter({ hasText: /^Metadata/ });
|
||||
|
||||
await metadata.getByRole("button", { name: "Add" }).click();
|
||||
await metadata.getByPlaceholder("Key").fill("arch");
|
||||
await metadata.getByPlaceholder("Value").fill("x86_64");
|
||||
await metadata.getByPlaceholder("Value").press("Enter");
|
||||
await expect(metadata.getByText("x86_64")).toBeVisible();
|
||||
await reload(page);
|
||||
await page.getByRole("button", { name: /build\.tar/ }).click();
|
||||
await expect(metadata.getByText("x86_64")).toBeVisible();
|
||||
|
||||
await metadata.getByTitle("Edit").click();
|
||||
await metadata.getByPlaceholder("Value").fill("aarch64");
|
||||
await metadata.getByRole("button", { name: "Save" }).click();
|
||||
await expect(metadata.getByText("aarch64")).toBeVisible();
|
||||
|
||||
await metadata.getByTitle("Remove").click();
|
||||
await page.getByRole("button", { name: "icon-trash Remove" }).click();
|
||||
await expect(metadata.getByText("No metadata")).toBeVisible();
|
||||
});
|
||||
|
||||
test("an artifact can be redacted", async ({ page, peer }) => {
|
||||
const rid = await fillRelease(page, peer);
|
||||
await page.getByRole("button", { name: "Create release" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`));
|
||||
await page.getByRole("button", { name: /build\.tar/ }).click();
|
||||
|
||||
await page.getByRole("button", { name: "Redact" }).click();
|
||||
const modal = page.locator("[data-modal-content]");
|
||||
await modal
|
||||
.getByPlaceholder("Why should this no longer be used?")
|
||||
.fill("bad");
|
||||
await modal.getByRole("button", { name: "Redact" }).click();
|
||||
await expect(modal).toBeHidden();
|
||||
|
||||
// A redaction by the author hides the artifact by default.
|
||||
await expect(page.getByText("No artifacts")).toBeVisible();
|
||||
await page.getByRole("button", { name: /Show redacted/ }).click();
|
||||
await page.getByRole("button", { name: /build\.tar/ }).click();
|
||||
await expect(page.getByText("bad", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
// Calls a command on the peer's test backend directly, bypassing the app.
|
||||
function backend(peer: RadiclePeer) {
|
||||
return async (cmd: string, args: object) => {
|
||||
const { hostname, port } = peer.httpdBaseUrl;
|
||||
const response = await fetch(`http://${hostname}:${port}/${cmd}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(args),
|
||||
});
|
||||
expect(response.ok).toBe(true);
|
||||
return response.json();
|
||||
};
|
||||
}
|
||||
|
||||
test("opening another release starts from a fresh page", async ({
|
||||
page,
|
||||
peer,
|
||||
}) => {
|
||||
const { rid, repoFolder } = await createProject(peer);
|
||||
const file = Path.join(repoFolder, "build.tar");
|
||||
await Fs.writeFile(file, "artifact bytes");
|
||||
const call = backend(peer);
|
||||
const releaseIds: string[] = [];
|
||||
// Both releases carry the same bytes, so their artifacts share a CID.
|
||||
for (const revision of ["main~1", "main"]) {
|
||||
const { stdout: oid } = await peer.git(["rev-parse", revision], {
|
||||
cwd: repoFolder,
|
||||
});
|
||||
const releaseId: string = await call("create_or_open_release", {
|
||||
rid,
|
||||
oid: oid.trim(),
|
||||
tag: null,
|
||||
});
|
||||
const { cid, sizeBytes } = await call("compute_artifact_cid", {
|
||||
path: file,
|
||||
});
|
||||
await call("register_artifact", {
|
||||
rid,
|
||||
releaseId,
|
||||
cid,
|
||||
name: "build.tar",
|
||||
sizeBytes,
|
||||
});
|
||||
releaseIds.push(releaseId);
|
||||
}
|
||||
const [first, second] = releaseIds;
|
||||
|
||||
await page.goto(`/repos/${rid}/releases/${first}`);
|
||||
await page.getByRole("button", { name: /build\.tar/ }).click();
|
||||
const attestations = page
|
||||
.locator(".section")
|
||||
.filter({ hasText: /^Attestations/ });
|
||||
await expect(attestations).toBeVisible();
|
||||
|
||||
// A link to another release, as a mention renders one, navigates in place.
|
||||
await page.evaluate(href => {
|
||||
const link = document.createElement("a");
|
||||
link.href = href;
|
||||
link.textContent = "Next release";
|
||||
document.body.append(link);
|
||||
}, `/repos/${rid}/releases/${second}`);
|
||||
await page.getByRole("link", { name: "Next release" }).click();
|
||||
|
||||
await expect(page.locator(".title").getByText("Add notes")).toBeVisible();
|
||||
await expect(attestations).toBeHidden();
|
||||
});
|
||||
|
||||
test("a failed refresh after registering is not reported as a failure", async ({
|
||||
page,
|
||||
peer,
|
||||
}) => {
|
||||
const rid = await fillRelease(page, peer);
|
||||
await page.getByRole("button", { name: "Create release" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`));
|
||||
|
||||
const other = Path.join(peer.checkoutPath, "other.tar");
|
||||
await Fs.writeFile(other, "other bytes");
|
||||
await page.route(/\/pick_artifact_files$/, route =>
|
||||
route.fulfill({ json: [other] }),
|
||||
);
|
||||
await page.route(/\/release_by_id$/, route =>
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
json: { code: "UnknownError", message: "refresh failed" },
|
||||
}),
|
||||
);
|
||||
const modal = page.locator("[data-modal-content]");
|
||||
await page.getByRole("button", { name: "Register artifacts" }).click();
|
||||
await page.getByRole("button", { name: "Files…" }).click();
|
||||
await modal.getByRole("button", { name: "icon-plus Register" }).click();
|
||||
|
||||
await expect(modal).toBeHidden();
|
||||
await expect(page.getByText("Registering failed.")).toBeHidden();
|
||||
});
|
||||
|
||||
// Bob publishes an artifact; the app runs as eve, who isn't a delegate.
|
||||
async function openBobsArtifact(page: Page, peerManager: PeerManager) {
|
||||
const { bob, eve } = await createCollaborators(peerManager);
|
||||
const { rid, repoFolder } = await createProject(bob);
|
||||
const { stdout: head } = await bob.git(["rev-parse", "main"], {
|
||||
cwd: repoFolder,
|
||||
});
|
||||
const file = Path.join(bob.checkoutPath, "build.tar");
|
||||
await Fs.writeFile(file, "artifact bytes");
|
||||
|
||||
await bob.startHttpd();
|
||||
const call = backend(bob);
|
||||
const releaseId: string = await call("create_or_open_release", {
|
||||
rid,
|
||||
oid: head.trim(),
|
||||
tag: null,
|
||||
});
|
||||
const { cid, sizeBytes } = await call("compute_artifact_cid", { path: file });
|
||||
await call("register_artifact", {
|
||||
rid,
|
||||
releaseId,
|
||||
cid,
|
||||
name: "build.tar",
|
||||
sizeBytes,
|
||||
});
|
||||
|
||||
await clone(eve, rid);
|
||||
await eve.startHttpd();
|
||||
await useBackend(page, eve);
|
||||
await page.goto(`/repos/${rid}/releases/${releaseId}`);
|
||||
await page.getByRole("button", { name: /build\.tar/ }).click();
|
||||
return eve;
|
||||
}
|
||||
|
||||
test("a non-delegate can attest an artifact by reproducing it", async ({
|
||||
page,
|
||||
peerManager,
|
||||
}) => {
|
||||
const eve = await openBobsArtifact(page, peerManager);
|
||||
const other = Path.join(eve.checkoutPath, "other.tar");
|
||||
const same = Path.join(eve.checkoutPath, "same.tar");
|
||||
await Fs.writeFile(other, "other bytes");
|
||||
await Fs.writeFile(same, "artifact bytes");
|
||||
const attestations = page
|
||||
.locator(".section")
|
||||
.filter({ hasText: /^Attestations/ });
|
||||
|
||||
await page.route(/\/pick_artifact_files$/, route =>
|
||||
route.fulfill({ json: [other] }),
|
||||
);
|
||||
await attestations.getByRole("button", { name: /Attest/ }).click();
|
||||
await expect(
|
||||
page.getByText("Your build has a different CID, so it was not attested."),
|
||||
).toBeVisible();
|
||||
|
||||
await page.route(/\/pick_artifact_files$/, route =>
|
||||
route.fulfill({ json: [same] }),
|
||||
);
|
||||
await attestations.getByRole("button", { name: /Attest/ }).click();
|
||||
await expect(attestations.getByText("eve")).toBeVisible();
|
||||
await expect(
|
||||
attestations.getByRole("button", { name: /Attest/ }),
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
test("a non-delegate cannot redact, edit or delete someone else's release", async ({
|
||||
page,
|
||||
peerManager,
|
||||
}) => {
|
||||
await openBobsArtifact(page, peerManager);
|
||||
|
||||
await expect(
|
||||
page.locator(".section").filter({ hasText: /^Attestations/ }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Redact" })).toBeHidden();
|
||||
await expect(page.getByRole("button", { name: "Delete" })).toBeHidden();
|
||||
const metadata = page.locator(".section").filter({ hasText: /^Metadata/ });
|
||||
await expect(metadata.getByRole("button", { name: "Add" })).toBeHidden();
|
||||
});
|
||||
@@ -47,12 +47,12 @@ async function stopSeeding(page: Page, name: string, deleteFiles: boolean) {
|
||||
await page.getByRole("link", { name: new RegExp(name) }).click({
|
||||
button: "right",
|
||||
});
|
||||
await page.getByRole("menuitem", { name: /Stop seeding/ }).click();
|
||||
await page.getByRole("menuitem", { name: /Unseed/ }).click();
|
||||
if (deleteFiles) {
|
||||
await page.getByText("Delete the files from local storage").click();
|
||||
}
|
||||
await waitForCommand(page, deleteFiles ? "clean" : "unseed", () =>
|
||||
page.getByRole("button", { name: /Stop seeding$/ }).click(),
|
||||
page.getByRole("button", { name: /Unseed$/ }).click(),
|
||||
);
|
||||
await expect(page.getByRole("link", { name: new RegExp(name) })).toBeHidden();
|
||||
}
|
||||
@@ -85,7 +85,7 @@ async function exists(path: string) {
|
||||
);
|
||||
}
|
||||
|
||||
test("stopping seeding keeps the repo's files", async ({
|
||||
test("unseeding keeps the repo's files", async ({
|
||||
page,
|
||||
peerManager,
|
||||
stateDir,
|
||||
@@ -97,7 +97,7 @@ test("stopping seeding keeps the repo's files", async ({
|
||||
expect(await exists(storedRepo(stateDir, rid))).toBe(true);
|
||||
});
|
||||
|
||||
test("stopping seeding can delete the repo's files", async ({
|
||||
test("unseeding can delete the repo's files", async ({
|
||||
page,
|
||||
peerManager,
|
||||
stateDir,
|
||||
|
||||
@@ -13,6 +13,7 @@ describe("basename", () => {
|
||||
["/home/alice/cat.png", "cat.png"],
|
||||
["C:\\Users\\alice\\cat.png", "cat.png"],
|
||||
["cat.png", "cat.png"],
|
||||
["/home/alice/build/", "build"],
|
||||
])("of %j is %j", (path, expected) => {
|
||||
expect(basename(path)).toBe(expected);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { dynamicInterval, resetDynamicInterval } from "@app/lib/interval";
|
||||
import { dynamicInterval, poll, resetDynamicInterval } from "@app/lib/interval";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
@@ -72,3 +72,41 @@ test("keys run independently", () => {
|
||||
expect(a).toHaveBeenCalledTimes(1);
|
||||
expect(b).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("poll runs at once and again a period after each run settles", async () => {
|
||||
let release: (() => void) | undefined;
|
||||
const task = vi.fn(() => new Promise<void>(resolve => (release = resolve)));
|
||||
const stop = poll(task, 100);
|
||||
expect(task).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A slow run never overlaps the next one.
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(task).toHaveBeenCalledTimes(1);
|
||||
|
||||
release?.();
|
||||
await vi.advanceTimersByTimeAsync(99);
|
||||
expect(task).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(task).toHaveBeenCalledTimes(2);
|
||||
stop();
|
||||
});
|
||||
|
||||
test("stopping a poll ends it and marks a pending run inactive", async () => {
|
||||
let active: (() => boolean) | undefined;
|
||||
let release: (() => void) | undefined;
|
||||
const task = vi.fn(
|
||||
(isActive: () => boolean) =>
|
||||
new Promise<void>(resolve => {
|
||||
active = isActive;
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
const stop = poll(task, 100);
|
||||
expect(active?.()).toBe(true);
|
||||
|
||||
stop();
|
||||
expect(active?.()).toBe(false);
|
||||
release?.();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(task).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
import type { Artifact } from "@bindings/cob/release/Artifact";
|
||||
import type { ReleaseCounts } from "@bindings/cob/release/ReleaseCounts";
|
||||
import type { Commit } from "@bindings/repo/Commit";
|
||||
import type { RepoRefs } from "@bindings/repo/RepoRefs";
|
||||
import type { Tag } from "@bindings/repo/Tag";
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
artifactView,
|
||||
attestedBy,
|
||||
canAttest,
|
||||
canEditMetadata,
|
||||
canonicalTags,
|
||||
delegatesFirst,
|
||||
displayMetadataValue,
|
||||
locationsByNode,
|
||||
matchCommits,
|
||||
parseMetadataValue,
|
||||
redactedByDelegate,
|
||||
releaseListScope,
|
||||
webLocations,
|
||||
} from "@app/lib/releases";
|
||||
|
||||
const me = "did:key:z6MkMe";
|
||||
const them = "did:key:z6MkThem";
|
||||
const delegate = "did:key:z6MkDelegate";
|
||||
const delegates = new Set([delegate]);
|
||||
|
||||
function artifact(cid: string, overrides: Partial<Artifact> = {}): Artifact {
|
||||
return {
|
||||
cid,
|
||||
name: cid,
|
||||
directory: false,
|
||||
author: { did: delegate },
|
||||
locations: [],
|
||||
attestations: [],
|
||||
redactions: [],
|
||||
redacted: false,
|
||||
metadata: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function counts(delegate: number, other: number): ReleaseCounts {
|
||||
return { delegate, delegateRedacted: 0, other, otherRedacted: 0 };
|
||||
}
|
||||
|
||||
describe("releaseListScope", () => {
|
||||
test("keeps a requested scope", () => {
|
||||
expect(releaseListScope("trusted", counts(0, 3))).toBe("trusted");
|
||||
});
|
||||
|
||||
test("opens on untrusted when only others released", () => {
|
||||
expect(releaseListScope(undefined, counts(0, 3))).toBe("untrusted");
|
||||
});
|
||||
|
||||
test.each([
|
||||
[1, 3],
|
||||
[0, 0],
|
||||
])("opens on trusted with %d delegate and %d other", (d, o) => {
|
||||
expect(releaseListScope(undefined, counts(d, o))).toBe("trusted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("artifactView", () => {
|
||||
const ours = artifact("ours");
|
||||
const ourRedacted = artifact("ours-redacted", { redacted: true });
|
||||
const theirs = artifact("theirs", { author: { did: them } });
|
||||
|
||||
test("shows the wanted scope", () => {
|
||||
const view = artifactView([ours, theirs], delegates, "untrusted", false);
|
||||
expect(view.scope).toBe("untrusted");
|
||||
expect(view.shown).toEqual([theirs]);
|
||||
expect(view.showFilters).toBe(true);
|
||||
});
|
||||
|
||||
test("falls back to the other scope when the wanted one is empty", () => {
|
||||
const view = artifactView([theirs], delegates, "trusted", false);
|
||||
expect(view.scope).toBe("untrusted");
|
||||
expect(view.shown).toEqual([theirs]);
|
||||
expect(view.showFilters).toBe(false);
|
||||
});
|
||||
|
||||
test("hides redacted artifacts unless asked", () => {
|
||||
const hidden = artifactView(
|
||||
[ours, ourRedacted],
|
||||
delegates,
|
||||
"trusted",
|
||||
false,
|
||||
);
|
||||
expect(hidden.shown).toEqual([ours]);
|
||||
expect(hidden.redactedCount).toBe(1);
|
||||
expect(hidden.counts.trusted).toBe(1);
|
||||
|
||||
const shown = artifactView([ours, ourRedacted], delegates, "trusted", true);
|
||||
expect(shown.shown).toEqual([ours, ourRedacted]);
|
||||
expect(shown.counts.trusted).toBe(2);
|
||||
});
|
||||
|
||||
test("keeps a scope whose artifacts are all redacted", () => {
|
||||
const view = artifactView(
|
||||
[ourRedacted, theirs],
|
||||
delegates,
|
||||
"trusted",
|
||||
false,
|
||||
);
|
||||
expect(view.scope).toBe("trusted");
|
||||
expect(view.shown).toEqual([]);
|
||||
expect(view.counts).toEqual({ trusted: 0, untrusted: 1 });
|
||||
});
|
||||
|
||||
test("counts redactions within the current scope only", () => {
|
||||
const theirRedacted = artifact("theirs-redacted", {
|
||||
author: { did: them },
|
||||
redacted: true,
|
||||
});
|
||||
const view = artifactView(
|
||||
[ours, theirs, theirRedacted],
|
||||
delegates,
|
||||
"trusted",
|
||||
false,
|
||||
);
|
||||
expect(view.redactedCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test("redactedByDelegate tells a delegate's redaction from others'", () => {
|
||||
const byAuthor = artifact("a", {
|
||||
author: { did: them },
|
||||
redactions: [{ user: { did: them }, reason: "" }],
|
||||
});
|
||||
const byDelegate = artifact("b", {
|
||||
redactions: [
|
||||
{ user: { did: them }, reason: "" },
|
||||
{ user: { did: delegate }, reason: "" },
|
||||
],
|
||||
});
|
||||
expect(redactedByDelegate(byAuthor, delegates)).toBe(false);
|
||||
expect(redactedByDelegate(byDelegate, delegates)).toBe(true);
|
||||
});
|
||||
|
||||
test("locationsByNode groups by node in first-seen order", () => {
|
||||
expect(
|
||||
locationsByNode([
|
||||
{ user: { did: them }, url: "a" },
|
||||
{ user: { did: me }, url: "b" },
|
||||
{ user: { did: them }, url: "c" },
|
||||
]),
|
||||
).toEqual([
|
||||
{ user: { did: them }, urls: ["a", "c"] },
|
||||
{ user: { did: me }, urls: ["b"] },
|
||||
]);
|
||||
});
|
||||
|
||||
test("delegatesFirst moves delegates up and keeps order within groups", () => {
|
||||
const items = [them, delegate, me, `${delegate}2`];
|
||||
const result = delegatesFirst(
|
||||
items,
|
||||
did => did,
|
||||
new Set([delegate, `${delegate}2`]),
|
||||
);
|
||||
expect(result).toEqual([delegate, `${delegate}2`, them, me]);
|
||||
expect(items[0]).toBe(them);
|
||||
});
|
||||
|
||||
test("webLocations keeps http(s) URLs, delegates first", () => {
|
||||
const theirs = { user: { did: them }, url: "https://them.example/a" };
|
||||
const ours = { user: { did: delegate }, url: "HTTP://us.example/a" };
|
||||
expect(
|
||||
webLocations(
|
||||
[theirs, { user: { did: delegate }, url: "radiroh://" }, ours],
|
||||
delegates,
|
||||
),
|
||||
).toEqual([ours, theirs]);
|
||||
});
|
||||
|
||||
describe("attestedBy", () => {
|
||||
test("is zero without attestations", () => {
|
||||
expect(attestedBy([], delegates)).toBe(0);
|
||||
});
|
||||
|
||||
test("counts only delegates", () => {
|
||||
expect(
|
||||
attestedBy([{ did: them }, { did: delegate }, { did: me }], delegates),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
test("ignores attestations by others", () => {
|
||||
expect(attestedBy([{ did: them }, { did: me }], delegates)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canAttest", () => {
|
||||
test("lets others attest", () => {
|
||||
expect(canAttest(artifact("a"), me)).toBe(true);
|
||||
});
|
||||
|
||||
test.each<[string, Partial<Artifact>]>([
|
||||
["its author", { author: { did: me } }],
|
||||
["a trusted redaction", { redacted: true }],
|
||||
["an earlier attestation", { attestations: [{ did: me }] }],
|
||||
["your own redaction", { redactions: [{ user: { did: me }, reason: "" }] }],
|
||||
])("is barred by %s", (_, overrides) => {
|
||||
expect(canAttest(artifact("a", overrides), me)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canEditMetadata", () => {
|
||||
const theirs = artifact("a", { author: { did: them } });
|
||||
|
||||
test.each([
|
||||
[them, true],
|
||||
[delegate, true],
|
||||
[me, false],
|
||||
])("%s can edit: %s", (did, expected) => {
|
||||
expect(canEditMetadata(theirs, did, delegates)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("metadata values", () => {
|
||||
test.each<[string, unknown]>([
|
||||
["42", 42],
|
||||
["true", true],
|
||||
['{"a":1}', { a: 1 }],
|
||||
["x86_64", "x86_64"],
|
||||
['"quoted"', "quoted"],
|
||||
])("%s parses to %j and displays back", (input, value) => {
|
||||
expect(parseMetadataValue(input)).toEqual(value);
|
||||
expect(parseMetadataValue(displayMetadataValue(value))).toEqual(value);
|
||||
});
|
||||
|
||||
test("shows a string as it is, without JSON quotes", () => {
|
||||
expect(displayMetadataValue("x86_64")).toBe("x86_64");
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchCommits", () => {
|
||||
function commit(id: string, summary: string): Commit {
|
||||
const person = { name: "", email: "", time: 0 };
|
||||
return {
|
||||
id,
|
||||
summary,
|
||||
message: summary,
|
||||
author: person,
|
||||
committer: person,
|
||||
parents: [],
|
||||
};
|
||||
}
|
||||
const first = commit("abc123", "Add notes");
|
||||
const second = commit("def456", "Fix build");
|
||||
const pasted = commit("fff999", "Old change");
|
||||
|
||||
test.each([
|
||||
["", [first, second]],
|
||||
["ABC", [first]],
|
||||
[" fix ", [second]],
|
||||
["123", []],
|
||||
])("%j matches", (query, expected) => {
|
||||
expect(matchCommits([first, second], query, undefined)).toEqual(expected);
|
||||
});
|
||||
|
||||
test("offers a resolved commit outside the loaded history", () => {
|
||||
expect(matchCommits([first, second], "fff999", pasted)).toEqual([pasted]);
|
||||
});
|
||||
|
||||
test("keeps the matches when the resolved commit is among them", () => {
|
||||
expect(matchCommits([first, second], "", first)).toEqual([first, second]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canonicalTags", () => {
|
||||
function tag(timestamp: number, tagOid?: string): Tag {
|
||||
return { oid: "c", timestamp, tagOid };
|
||||
}
|
||||
function refs(
|
||||
canonical: Record<string, Tag>,
|
||||
...remotes: Record<string, Tag>[]
|
||||
): RepoRefs {
|
||||
return {
|
||||
canonical: { branches: {}, tags: canonical },
|
||||
remotes: remotes.map((tags, i) => ({
|
||||
id: `${i}`,
|
||||
delegate: false,
|
||||
branches: {},
|
||||
tags,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
test("lists canonical tags, newest first", () => {
|
||||
expect(
|
||||
canonicalTags(refs({ v1: tag(1), v2: tag(2) })).map(t => t.name),
|
||||
).toEqual(["v2", "v1"]);
|
||||
});
|
||||
|
||||
test("ignores tags only a peer has", () => {
|
||||
expect(canonicalTags(refs({ v1: tag(1) }, { v2: tag(2) }))).toEqual([
|
||||
{ name: "v1", tag: tag(1) },
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps the canonical tag over a peer's annotated one", () => {
|
||||
const canonical = tag(1);
|
||||
expect(
|
||||
canonicalTags(refs({ v1: canonical }, { v1: tag(2, "evil") }))[0].tag,
|
||||
).toBe(canonical);
|
||||
});
|
||||
});
|
||||
@@ -82,6 +82,48 @@ describe("repoRouteToPath and repoUrlToRoute round trip", () => {
|
||||
view: "changes",
|
||||
},
|
||||
],
|
||||
[
|
||||
"/repos/RID/releases",
|
||||
{ resource: "repo.releases", rid, scope: undefined },
|
||||
],
|
||||
[
|
||||
"/repos/RID/releases?scope=untrusted",
|
||||
{ resource: "repo.releases", rid, scope: "untrusted" },
|
||||
],
|
||||
[
|
||||
"/repos/RID/releases?scope=trusted",
|
||||
{ resource: "repo.releases", rid, scope: "trusted" },
|
||||
],
|
||||
[
|
||||
"/repos/RID/releases/abc",
|
||||
{
|
||||
resource: "repo.release",
|
||||
rid,
|
||||
release: "abc",
|
||||
scope: undefined,
|
||||
artifactScope: undefined,
|
||||
},
|
||||
],
|
||||
[
|
||||
"/repos/RID/releases/abc?scope=trusted",
|
||||
{
|
||||
resource: "repo.release",
|
||||
rid,
|
||||
release: "abc",
|
||||
scope: "trusted",
|
||||
artifactScope: undefined,
|
||||
},
|
||||
],
|
||||
[
|
||||
"/repos/RID/releases/abc?scope=untrusted&artifacts=untrusted",
|
||||
{
|
||||
resource: "repo.release",
|
||||
rid,
|
||||
release: "abc",
|
||||
scope: "untrusted",
|
||||
artifactScope: "untrusted",
|
||||
},
|
||||
],
|
||||
])("%s", (template, route) => {
|
||||
const path = template
|
||||
.replace("RID", rid)
|
||||
@@ -131,6 +173,30 @@ describe("repoUrlToRoute", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("leaves the trusted artifact scope out of the URL", () => {
|
||||
expect(
|
||||
repoRouteToPath({
|
||||
resource: "repo.release",
|
||||
rid,
|
||||
release: "abc",
|
||||
scope: undefined,
|
||||
artifactScope: "trusted",
|
||||
}),
|
||||
).toBe(`/repos/${rid}/releases/abc`);
|
||||
});
|
||||
|
||||
test("ignores an unknown release scope", () => {
|
||||
expect(
|
||||
parse(`/repos/${rid}/releases/abc?scope=bogus&artifacts=bogus`),
|
||||
).toEqual({
|
||||
resource: "repo.release",
|
||||
rid,
|
||||
release: "abc",
|
||||
scope: undefined,
|
||||
artifactScope: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("treats a sha as a revision when a peer is given", () => {
|
||||
expect(parse(`/repos/${rid}/commits/remotes/${peer}/${sha}`)).toEqual({
|
||||
resource: "repo.commits",
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import type { Artifact } from "@bindings/cob/release/Artifact";
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { StagedArtifact } from "@app/lib/stageArtifacts";
|
||||
import {
|
||||
duplicatePicks,
|
||||
effective,
|
||||
planDetail,
|
||||
planSummary,
|
||||
planTitle,
|
||||
registerPlan,
|
||||
stageArtifacts,
|
||||
} from "@app/lib/stageArtifacts";
|
||||
|
||||
const me = "did:key:z6MkMe";
|
||||
const them = "did:key:z6MkThem";
|
||||
const delegate = "did:key:z6MkDelegate";
|
||||
|
||||
function picked(name: string, cid = "cid") {
|
||||
return {
|
||||
path: `/tmp/${name}`,
|
||||
name,
|
||||
digest: { cid, sizeBytes: 3, fileCount: 1, directory: false },
|
||||
};
|
||||
}
|
||||
|
||||
function artifact(overrides: Partial<Artifact> = {}): Artifact {
|
||||
return {
|
||||
cid: "cid",
|
||||
name: "build.tar",
|
||||
directory: false,
|
||||
author: { did: me },
|
||||
locations: [],
|
||||
attestations: [],
|
||||
redactions: [],
|
||||
redacted: false,
|
||||
metadata: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function stage(
|
||||
name: string,
|
||||
artifacts: Artifact[],
|
||||
{ nodeRunning = true, seeded = new Set<string>() } = {},
|
||||
) {
|
||||
const [item] = stageArtifacts([picked(name)], artifacts, {
|
||||
ownDid: me,
|
||||
delegates: new Set([delegate]),
|
||||
nodeRunning,
|
||||
seeded,
|
||||
});
|
||||
return item;
|
||||
}
|
||||
|
||||
describe("stageArtifacts", () => {
|
||||
test("registers and seeds a new artifact", () => {
|
||||
const item = stage("build.tar", []);
|
||||
expect(item).toMatchObject({ register: true, seed: true });
|
||||
expect(item.existing).toBeUndefined();
|
||||
});
|
||||
|
||||
test("registers a new artifact without seeding when the node is down", () => {
|
||||
expect(stage("build.tar", [], { nodeRunning: false })).toMatchObject({
|
||||
register: true,
|
||||
seed: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("registers a rename of your own artifact", () => {
|
||||
expect(stage("renamed.tar", [artifact()])).toMatchObject({
|
||||
register: true,
|
||||
existing: { name: "build.tar", keepsName: false },
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps the name of someone else's artifact and only seeds", () => {
|
||||
const item = stage("renamed.tar", [artifact({ author: { did: them } })]);
|
||||
expect(item).toMatchObject({
|
||||
register: false,
|
||||
seed: true,
|
||||
existing: { name: "build.tar", keepsName: true },
|
||||
});
|
||||
});
|
||||
|
||||
test("skips an artifact your node already seeds", () => {
|
||||
expect(
|
||||
stage("build.tar", [artifact()], { seeded: new Set(["cid"]) }),
|
||||
).toMatchObject({ register: false, seed: false });
|
||||
});
|
||||
|
||||
test("skips an existing artifact when the node is down", () => {
|
||||
expect(
|
||||
stage("build.tar", [artifact()], { nodeRunning: false }),
|
||||
).toMatchObject({ register: false, seed: false });
|
||||
});
|
||||
|
||||
test("carries the redaction reason of a redacted artifact", () => {
|
||||
const item = stage("build.tar", [
|
||||
artifact({
|
||||
author: { did: them },
|
||||
redacted: true,
|
||||
redactions: [
|
||||
{ user: { did: "did:key:z6MkOther" }, reason: "ignored" },
|
||||
{ user: { did: delegate }, reason: "malware" },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(item.existing?.redaction).toBe("malware");
|
||||
expect(effective(item, false)).toEqual({ register: false, seed: false });
|
||||
expect(effective(item, true)).toEqual({ register: false, seed: true });
|
||||
});
|
||||
|
||||
test("skips a later pick with the same contents", () => {
|
||||
const [first, second] = stageArtifacts(
|
||||
[picked("a.tar"), picked("b.tar")],
|
||||
[],
|
||||
{
|
||||
ownDid: me,
|
||||
delegates: new Set(),
|
||||
nodeRunning: true,
|
||||
seeded: new Set(),
|
||||
},
|
||||
);
|
||||
expect(first).toMatchObject({ register: true, seed: true });
|
||||
expect(first.duplicateOf).toBeUndefined();
|
||||
expect(second).toMatchObject({
|
||||
register: false,
|
||||
seed: false,
|
||||
duplicateOf: "a.tar",
|
||||
});
|
||||
expect(effective(second, true)).toEqual({ register: false, seed: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("duplicatePicks", () => {
|
||||
test("maps later picks to the first with the same contents", () => {
|
||||
expect(
|
||||
duplicatePicks([
|
||||
picked("a.tar", "x"),
|
||||
picked("b.tar", "y"),
|
||||
picked("c.tar", "x"),
|
||||
picked("d.tar", "x"),
|
||||
]),
|
||||
).toEqual(
|
||||
new Map([
|
||||
["/tmp/c.tar", "a.tar"],
|
||||
["/tmp/d.tar", "a.tar"],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test("ignores picks still hashing", () => {
|
||||
expect(
|
||||
duplicatePicks([
|
||||
{ path: "/tmp/a.tar", name: "a.tar" },
|
||||
{ path: "/tmp/b.tar", name: "b.tar" },
|
||||
]).size,
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
function staged(overrides: Partial<StagedArtifact> = {}): StagedArtifact {
|
||||
return { ...picked("build.tar"), register: true, seed: false, ...overrides };
|
||||
}
|
||||
|
||||
const renamed = staged({
|
||||
existing: { name: "old.tar", keepsName: false },
|
||||
});
|
||||
const kept = staged({
|
||||
register: false,
|
||||
existing: { name: "old.tar", keepsName: true },
|
||||
});
|
||||
const redacted = staged({
|
||||
register: false,
|
||||
existing: { name: "old.tar", keepsName: false, redaction: "bad" },
|
||||
});
|
||||
const reseeded = staged({
|
||||
register: false,
|
||||
seed: true,
|
||||
existing: { name: "build.tar", keepsName: false },
|
||||
});
|
||||
const duplicate = staged({ register: false, duplicateOf: "a.tar" });
|
||||
|
||||
describe("registerPlan", () => {
|
||||
test("counts what each pick does", () => {
|
||||
const plan = registerPlan(
|
||||
[staged(), renamed, reseeded, kept, duplicate],
|
||||
false,
|
||||
);
|
||||
expect(plan).toMatchObject({
|
||||
newCount: 1,
|
||||
renamedCount: 1,
|
||||
seededCount: 1,
|
||||
skippedCount: 2,
|
||||
});
|
||||
expect(plan.acting).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("skips a redacted match unless it is included", () => {
|
||||
expect(registerPlan([redacted], false).skippedCount).toBe(1);
|
||||
expect(
|
||||
registerPlan([{ ...redacted, register: true }], true).acting,
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("is empty for no picks", () => {
|
||||
expect(registerPlan([], false)).toMatchObject({
|
||||
newCount: 0,
|
||||
skippedCount: 0,
|
||||
acting: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("planTitle", () => {
|
||||
test.each([
|
||||
[[kept], "Already registered"],
|
||||
[[staged()], "Register 1 artifact"],
|
||||
[[staged(), renamed], "Register 2 artifacts"],
|
||||
[[renamed], "Update 1 artifact"],
|
||||
])("%#", (items, expected) => {
|
||||
expect(planTitle(registerPlan(items, false))).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("planSummary", () => {
|
||||
test("lists only the counts that are not zero", () => {
|
||||
expect(planSummary(registerPlan([staged(), kept, duplicate], false))).toBe(
|
||||
"1 registered, 2 skipped",
|
||||
);
|
||||
});
|
||||
|
||||
test("is empty when nothing is staged", () => {
|
||||
expect(planSummary(registerPlan([], false))).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("planDetail", () => {
|
||||
function detail(item: StagedArtifact, includeRedacted = false) {
|
||||
const [planned] = registerPlan([item], includeRedacted).items;
|
||||
return planDetail(planned, includeRedacted);
|
||||
}
|
||||
|
||||
test.each([
|
||||
["a new file", staged(), "3 B"],
|
||||
[
|
||||
"a new folder",
|
||||
staged({
|
||||
digest: { cid: "c", sizeBytes: 3, fileCount: 2, directory: true },
|
||||
}),
|
||||
"2 files · 3 B",
|
||||
],
|
||||
[
|
||||
"a folder with one file",
|
||||
staged({
|
||||
digest: { cid: "c", sizeBytes: 3, fileCount: 1, directory: true },
|
||||
}),
|
||||
"1 file · 3 B",
|
||||
],
|
||||
["a repeated pick", duplicate, "same as “a.tar” · skipped"],
|
||||
["a redacted match", redacted, "redacted · skipped"],
|
||||
["a rename", renamed, "renames “old.tar”"],
|
||||
["someone else's name", kept, "stays “old.tar” · skipped"],
|
||||
[
|
||||
"an existing artifact to seed",
|
||||
reseeded,
|
||||
"already registered · will seed",
|
||||
],
|
||||
])("%s", (_, item, expected) => {
|
||||
expect(detail(item)).toBe(expected);
|
||||
});
|
||||
|
||||
test("describes an included redacted match like any other", () => {
|
||||
expect(detail(redacted, true)).toBe("already registered · skipped");
|
||||
});
|
||||
});
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
creditedCoAuthors,
|
||||
explorerHost,
|
||||
explorerLink,
|
||||
formatBytes,
|
||||
formatGitIdentity,
|
||||
formatRepositoryId,
|
||||
formatTimestamp,
|
||||
formatUptime,
|
||||
identityKey,
|
||||
isPublishableReview,
|
||||
parseNodeId,
|
||||
@@ -17,6 +19,7 @@ import {
|
||||
pluralize,
|
||||
revisionPosition,
|
||||
safeHttpUrl,
|
||||
shortenCids,
|
||||
truncateDid,
|
||||
unqualifyBranch,
|
||||
} from "@app/lib/utils";
|
||||
@@ -308,3 +311,74 @@ describe("commit credits", () => {
|
||||
).toEqual([carol]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatBytes", () => {
|
||||
test.each([
|
||||
[0, "0 B"],
|
||||
[1023, "1023 B"],
|
||||
[1024, "1.0 KiB"],
|
||||
[1536, "1.5 KiB"],
|
||||
[10 * 1024 - 1, "10.0 KiB"],
|
||||
[10 * 1024, "10 KiB"],
|
||||
[1024 ** 2, "1.0 MiB"],
|
||||
[1024 ** 4, "1.0 TiB"],
|
||||
[1024 ** 5, "1024 TiB"],
|
||||
])("%d is %j", (bytes, expected) => {
|
||||
expect(formatBytes(bytes)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatUptime", () => {
|
||||
test.each([
|
||||
[0, "0s"],
|
||||
[59.9, "59s"],
|
||||
[60, "1m"],
|
||||
[3599, "59m"],
|
||||
[3600, "1h"],
|
||||
[86399, "23h"],
|
||||
[86400, "1d"],
|
||||
[86400 * 400, "400d"],
|
||||
])("%d is %j", (seconds, expected) => {
|
||||
expect(formatUptime(seconds)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shortenCids", () => {
|
||||
const prefix = "bafkr4i";
|
||||
|
||||
test("returns nothing for no ids", () => {
|
||||
expect(shortenCids([]).size).toBe(0);
|
||||
});
|
||||
|
||||
test("keeps the minimum head when ids differ early", () => {
|
||||
const a = "abcdefghij0123456789";
|
||||
const b = "zbcdefghij9876543210";
|
||||
const labels = shortenCids([a, b]);
|
||||
expect(labels.get(a)).toBe("abcdefg…456789");
|
||||
expect(labels.get(b)).toBe("zbcdefg…543210");
|
||||
});
|
||||
|
||||
test("grows the head past a shared prefix until ids differ", () => {
|
||||
const a = `${prefix}aaaa${"x".repeat(20)}111111`;
|
||||
const b = `${prefix}aaab${"x".repeat(20)}111111`;
|
||||
const labels = shortenCids([a, b]);
|
||||
expect(labels.get(a)).toBe(`${prefix}aaaa…111111`);
|
||||
expect(labels.get(b)).toBe(`${prefix}aaab…111111`);
|
||||
});
|
||||
|
||||
test("stops growing the head at the maximum", () => {
|
||||
const a = `${"p".repeat(20)}a${"x".repeat(10)}`;
|
||||
const b = `${"p".repeat(20)}b${"x".repeat(10)}`;
|
||||
expect(shortenCids([a, b]).get(a)).toBe(`${"p".repeat(16)}…xxxxxx`);
|
||||
});
|
||||
|
||||
test("shows an id in full when head and tail cover it", () => {
|
||||
const short = "abcdefghijklm";
|
||||
expect(shortenCids([short]).get(short)).toBe(short);
|
||||
});
|
||||
|
||||
test("labels repeated ids once", () => {
|
||||
const a = "abcdefghij0123456789";
|
||||
expect(shortenCids([a, a]).size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user