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:
Brandon Oxendine
2026-10-08 18:21:48 +02:00
committed by Rūdolfs Ošiņš
co-authored by Daniel Norman Rūdolfs Ošiņš
parent 837766212e
commit 595a81150b
41 changed files with 6235 additions and 137 deletions
+493
View File
@@ -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();
});
+4 -4
View File
@@ -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,
+1
View File
@@ -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);
});
+39 -1
View File
@@ -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);
});
+309
View File
@@ -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);
});
});
+66
View File
@@ -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",
+278
View File
@@ -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");
});
});
+74
View File
@@ -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);
});
});