From 595a81150ba2dbfc8378c70dbfa41f52f8ab8e93 Mon Sep 17 00:00:00 2001 From: Brandon Oxendine Date: Mon, 14 Sep 2026 11:14:12 +0200 Subject: [PATCH] Add Releases and Release views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Co-authored-by: Rūdolfs Ošiņš --- src/App.svelte | 28 +- src/components/AppSidebar.svelte | 2 + src/components/ArtifactDownloadButton.svelte | 410 ++++ .../ArtifactNodeStatusButton.svelte | 267 +++ src/components/DelegateBadge.svelte | 26 + src/components/DropdownList.svelte | 13 +- src/components/Icon.svelte | 4 + src/components/Popover.svelte | 7 +- src/components/ReleaseMetadata.svelte | 149 ++ src/components/ReleaseTeaser.svelte | 117 ++ src/components/SidebarRepoList.svelte | 55 +- src/components/TooltipBadge.svelte | 49 + src/components/YouBadge.svelte | 26 + src/lib/artifactPickers.ts | 15 + src/lib/embeds.ts | 3 +- src/lib/events.ts | 3 + src/lib/interval.ts | 23 + src/lib/releases.ts | 181 ++ src/lib/router.ts | 4 +- src/lib/router/definitions.ts | 10 +- src/lib/stageArtifacts.ts | 188 ++ src/lib/startup.svelte.ts | 33 +- src/lib/utils.ts | 61 + src/modals/ConfirmDeleteRelease.svelte | 57 + src/modals/ConfirmModal.svelte | 138 ++ src/modals/ConfirmRedact.svelte | 100 + src/modals/ConfirmRegisterArtifacts.svelte | 211 ++ src/modals/ConfirmUnseed.svelte | 108 +- src/modals/CreateRelease.svelte | 681 +++++++ src/views/auth/Auth.svelte | 4 +- src/views/repo/Release.svelte | 1766 +++++++++++++++++ src/views/repo/Releases.svelte | 182 ++ src/views/repo/router.ts | 182 +- tests/e2e/repo/createRelease.spec.ts | 493 +++++ tests/e2e/seeding.spec.ts | 8 +- tests/unit/embeds.test.ts | 1 + tests/unit/interval.test.ts | 40 +- tests/unit/releases.test.ts | 309 +++ tests/unit/repoRouter.test.ts | 66 + tests/unit/stageArtifacts.test.ts | 278 +++ tests/unit/utils.test.ts | 74 + 41 files changed, 6235 insertions(+), 137 deletions(-) create mode 100644 src/components/ArtifactDownloadButton.svelte create mode 100644 src/components/ArtifactNodeStatusButton.svelte create mode 100644 src/components/DelegateBadge.svelte create mode 100644 src/components/ReleaseMetadata.svelte create mode 100644 src/components/ReleaseTeaser.svelte create mode 100644 src/components/TooltipBadge.svelte create mode 100644 src/components/YouBadge.svelte create mode 100644 src/lib/artifactPickers.ts create mode 100644 src/lib/releases.ts create mode 100644 src/lib/stageArtifacts.ts create mode 100644 src/modals/ConfirmDeleteRelease.svelte create mode 100644 src/modals/ConfirmModal.svelte create mode 100644 src/modals/ConfirmRedact.svelte create mode 100644 src/modals/ConfirmRegisterArtifacts.svelte create mode 100644 src/modals/CreateRelease.svelte create mode 100644 src/views/repo/Release.svelte create mode 100644 src/views/repo/Releases.svelte create mode 100644 tests/e2e/repo/createRelease.spec.ts create mode 100644 tests/unit/releases.test.ts create mode 100644 tests/unit/stageArtifacts.test.ts diff --git a/src/App.svelte b/src/App.svelte index 944a72aed..ae7663568 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -3,7 +3,6 @@ import type { ErrorWrapper } from "@bindings/error/ErrorWrapper"; import type { RepoInfo } from "@bindings/repo/RepoInfo"; - import { listen } from "@tauri-apps/api/event"; import { onDestroy, onMount } from "svelte"; import { get } from "svelte/store"; @@ -15,7 +14,6 @@ } from "@app/lib/appearance.svelte"; import { checkAuth, startup } from "@app/lib/auth.svelte"; import { deepLinksReady, listenForDeepLinks } from "@app/lib/deepLink"; - import { nodeRunning } from "@app/lib/events"; import { dynamicInterval } from "@app/lib/interval"; import { invoke } from "@app/lib/invoke"; import { hide, modalStore, show, toggle } from "@app/lib/modal"; @@ -23,6 +21,7 @@ import { isLoadedRepoRoute } from "@app/lib/router/definitions"; import { listenForShortcuts, useShortcuts } from "@app/lib/shortcuts.svelte"; import { + createEventEmittersOnce, setUnlistenNodeEvents, unlistenNodeEvents, } from "@app/lib/startup.svelte"; @@ -48,6 +47,8 @@ import Issues from "@app/views/repo/Issues.svelte"; import Patch from "@app/views/repo/Patch.svelte"; import Patches from "@app/views/repo/Patches.svelte"; + import Release from "@app/views/repo/Release.svelte"; + import Releases from "@app/views/repo/Releases.svelte"; import RepoCommit from "@app/views/repo/RepoCommit.svelte"; import RepoCommits from "@app/views/repo/RepoCommits.svelte"; import RepoHome from "@app/views/repo/RepoHome.svelte"; @@ -181,22 +182,7 @@ return; } - if (window.__TAURI_INTERNALS__) { - setUnlistenNodeEvents( - await listen("node_running", event => { - nodeRunning.set(event.payload); - }), - ); - } else { - // The test backend can't emit events. - const pollNodeRunning = () => - invoke("node_running") - .then(running => nodeRunning.set(running)) - .catch(console.error); - void pollNodeRunning(); - const interval = setInterval(pollNodeRunning, 2_000); - setUnlistenNodeEvents(() => clearInterval(interval)); - } + setUnlistenNodeEvents(await createEventEmittersOnce()); void listenForDeepLinks(); try { @@ -299,6 +285,12 @@ {:else if $activeRouteStore.resource === "repo.patches"} + {:else if $activeRouteStore.resource === "repo.release"} + {#key $activeRouteStore.params.release.id} + + {/key} + {:else if $activeRouteStore.resource === "repo.releases"} + {:else} {unreachable($activeRouteStore)} {/if} diff --git a/src/components/AppSidebar.svelte b/src/components/AppSidebar.svelte index dfb7dcd81..a4937f77b 100644 --- a/src/components/AppSidebar.svelte +++ b/src/components/AppSidebar.svelte @@ -37,6 +37,7 @@ import useLocalStorage from "@app/lib/useLocalStorage.svelte"; import { isMac } from "@app/lib/utils"; + import ArtifactNodeStatusButton from "@app/components/ArtifactNodeStatusButton.svelte"; import { badgeCounter } from "@app/components/BadgeCounterSwitch.svelte"; import Button from "@app/components/Button.svelte"; import Icon from "@app/components/Icon.svelte"; @@ -718,6 +719,7 @@ New Update {/if} + diff --git a/src/components/ArtifactDownloadButton.svelte b/src/components/ArtifactDownloadButton.svelte new file mode 100644 index 000000000..07e75540d --- /dev/null +++ b/src/components/ArtifactDownloadButton.svelte @@ -0,0 +1,410 @@ + + + + + + {#snippet toggle(onclick)} + + {/snippet} + + {#snippet popover()} +
+
+ + + +
+ + {#if activeTab === "app"} + + {#if nodeRunning === false} +
+ +
+ {/if} +
+ + {#if !seeding} + + {/if} +
+ + {#if running} +
+
+
+
+ {phaseLabel} + {#if progress?.offset !== undefined} + + {formatBytes(progress.offset)}{progress.total + ? ` / ${formatBytes(progress.total)}` + : ""} + + {/if} +
+ {:else if finished} +
+ + {finished === "download" ? "Saved" : "Fetched"}{seeding + ? " and seeding" + : ""}. +
+ {/if} + + {#if downloadError} +
{downloadError}
+ {/if} + + {#if !seeding} +
+ Seed after fetching +
+ {/if} + {:else if activeTab === "cli"} + {#if nodeRunning === false} + +
+ +
+ {/if} + + + {:else} +
+ + These downloads are not checked against the CID. +
+
+ {#each webLocations as location (`${location.user.did}:${location.url}`)} +
+ {#if delegateIds.has(location.user.did)} + + {/if} + {location.url} + +
+ {/each} +
+ {/if} +
+ {/snippet} +
diff --git a/src/components/ArtifactNodeStatusButton.svelte b/src/components/ArtifactNodeStatusButton.svelte new file mode 100644 index 000000000..38889fd02 --- /dev/null +++ b/src/components/ArtifactNodeStatusButton.svelte @@ -0,0 +1,267 @@ + + + + + + {#snippet toggle(onclick)} + + {/snippet} + {#snippet popover()} +
+ {#if status} + {#if status.relayUnreachable} +
+ + No relay connected: peers may not reach this node. +
+ {/if} +
+ Endpoint + + + + Uptime + {uptime} + Seeding + + {status.seededCount} + {status.seededCount === 1 ? "artifact" : "artifacts"} + ({formatBytes(status.seededBytesLogical)}) + + Store + + + + Connections + {status.connectionsActive} active + Traffic + + {formatBytes(status.inBytes)} in, {formatBytes(status.outBytes)} out + + Relay + {#if status.relays[0]} + {@const relay = status.relays[0]} + + {relay.url} + + {#if relay.connected} + Connected{relay.latencyMs !== undefined + ? `, ${relay.latencyMs}ms` + : ""} + {:else} + Disconnected + {/if} + + + {:else} + None + {/if} + {#if status.pkarrUri} + Pkarr key + + + + {/if} + {#if status.pkarrRelays.length > 0} + Pkarr relays + + {#each status.pkarrRelays as relay (relay)} + + + + {/each} + + {/if} +
+ {:else if running === false && installed !== undefined} +
+ {#if installed} + The artifact node is not running, so artifacts cannot be downloaded + or seeded from this app. + {:else if binaries?.cli} + The artifact seeding daemon is not installed, so artifacts cannot be + downloaded or seeded from this app. + {:else} + The artifact tools are not installed, so artifacts cannot be + downloaded or seeded from this app. + {/if} +
+ {installed ? "Start it with:" : "Install them with:"} +
+ {#if installed} + + {:else} + + {/if} +
+
+
+ {:else} + Checking the artifact node… + {/if} +
+ {/snippet} +
diff --git a/src/components/DelegateBadge.svelte b/src/components/DelegateBadge.svelte new file mode 100644 index 000000000..a7d4abac3 --- /dev/null +++ b/src/components/DelegateBadge.svelte @@ -0,0 +1,26 @@ + + + + + + {#snippet badge()} + + + + {/snippet} + diff --git a/src/components/DropdownList.svelte b/src/components/DropdownList.svelte index 8ec886960..267a63297 100644 --- a/src/components/DropdownList.svelte +++ b/src/components/DropdownList.svelte @@ -8,9 +8,16 @@ empty?: Snippet; items: T[]; styleDropdownMinWidth?: string; + styleDropdownMaxHeight?: string; } - const { item, empty, items, styleDropdownMinWidth }: Props = $props(); + const { + item, + empty, + items, + styleDropdownMinWidth, + styleDropdownMaxHeight = "60vh", + }: Props = $props(); let dropdownElement: HTMLDivElement | undefined = undefined; @@ -34,7 +41,6 @@ + +
+
+ + {#if delegateIds.has(release.creator.did)} + + {/if} +
+ + + + {#if release.tagName} + + + {release.tagName} + + {/if} + + + + {formatOid(release.oid)} + + + + + Created {formatTimestamp(release.createdAt)} + +
diff --git a/src/components/ReleaseTeaser.svelte b/src/components/ReleaseTeaser.svelte new file mode 100644 index 000000000..407ee6db5 --- /dev/null +++ b/src/components/ReleaseTeaser.svelte @@ -0,0 +1,117 @@ + + + + + +
{ + void push({ + resource: "repo.release", + rid, + release: release.id, + scope, + }); + }}> +
+ +
+
+
+ + {#if release.tagName} + {release.tagName} + {/if} +
+
+ + {#if delegateIds.has(release.creator.did)} + + {/if} + created + + + {formatTimestamp(release.createdAt)} + +
+
+ +
+
+ + {release.artifacts.length} +
+
+
diff --git a/src/components/SidebarRepoList.svelte b/src/components/SidebarRepoList.svelte index b29758c96..cd495d07e 100644 --- a/src/components/SidebarRepoList.svelte +++ b/src/components/SidebarRepoList.svelte @@ -52,6 +52,7 @@ + + + + {#snippet toggle()} + {@render badge()} + {/snippet} + {#snippet popover()} + {tooltip} + {/snippet} + + diff --git a/src/components/YouBadge.svelte b/src/components/YouBadge.svelte new file mode 100644 index 000000000..ec137dda3 --- /dev/null +++ b/src/components/YouBadge.svelte @@ -0,0 +1,26 @@ + + + + + + {#snippet badge()} + You + {/snippet} + diff --git a/src/lib/artifactPickers.ts b/src/lib/artifactPickers.ts new file mode 100644 index 000000000..ab963f84b --- /dev/null +++ b/src/lib/artifactPickers.ts @@ -0,0 +1,15 @@ +import { invoke } from "@app/lib/invoke"; + +export async function pickFiles(): Promise { + return invoke("pick_artifact_files"); +} + +export async function pickFolder(): Promise { + return (await invoke("pick_artifact_directory")) ?? undefined; +} + +export async function pickLike( + directory: boolean, +): Promise { + return directory ? pickFolder() : (await pickFiles())[0]; +} diff --git a/src/lib/embeds.ts b/src/lib/embeds.ts index b36178405..aade10274 100644 --- a/src/lib/embeds.ts +++ b/src/lib/embeds.ts @@ -1,5 +1,6 @@ export function basename(path: string): string { - return path.split(/[\\/]/).pop() ?? path; + const parts = path.split(/[\\/]/).filter(p => p.length > 0); + return parts[parts.length - 1] ?? path; } export function embedPreviewKind( diff --git a/src/lib/events.ts b/src/lib/events.ts index ba98b139b..5cc207eb4 100644 --- a/src/lib/events.ts +++ b/src/lib/events.ts @@ -1,3 +1,6 @@ import { writable } from "svelte/store"; export const nodeRunning = writable(false); +// Undefined until the first probe, so nothing reports the node as down before +// it has been asked. +export const artifactNodeRunning = writable(undefined); diff --git a/src/lib/interval.ts b/src/lib/interval.ts index 6f4804644..dbc1e4406 100644 --- a/src/lib/interval.ts +++ b/src/lib/interval.ts @@ -23,3 +23,26 @@ export function resetDynamicInterval(key: string) { clearTimeout(dynamicIntervals.get(key)); dynamicIntervals.delete(key); } + +// Unlike setInterval, a slow run never overlaps the next one. +export function poll( + task: (active: () => boolean) => Promise, + period: number, +): () => void { + let stopped = false; + let timer: ReturnType | undefined; + const active = () => !stopped; + + const run = async () => { + await task(active); + if (!stopped) { + timer = setTimeout(() => void run(), period); + } + }; + void run(); + + return () => { + stopped = true; + clearTimeout(timer); + }; +} diff --git a/src/lib/releases.ts b/src/lib/releases.ts new file mode 100644 index 000000000..0ca162502 --- /dev/null +++ b/src/lib/releases.ts @@ -0,0 +1,181 @@ +import type { Author } from "@bindings/cob/Author"; +import type { Artifact } from "@bindings/cob/release/Artifact"; +import type { Location } from "@bindings/cob/release/Location"; +import type { ReleaseCounts } from "@bindings/cob/release/ReleaseCounts"; +import type { ReleaseScope } from "@bindings/cob/release/ReleaseScope"; +import type { Commit } from "@bindings/repo/Commit"; +import type { RepoRefs } from "@bindings/repo/RepoRefs"; +import type { Tag } from "@bindings/repo/Tag"; + +// With no scope asked for and no delegate releases, open on the untrusted +// scope rather than on an empty list. +export function releaseListScope( + requested: ReleaseScope | undefined, + counts: ReleaseCounts, +): ReleaseScope { + if (requested !== undefined) { + return requested; + } + return counts.delegate === 0 && counts.other > 0 ? "untrusted" : "trusted"; +} + +export interface ArtifactView { + scope: ReleaseScope; + shown: Artifact[]; + // Redacted artifacts within the current author scope. + redactedCount: number; + // What each scope would show, so an artifact hidden as redacted never + // counts towards a scope. + counts: Record; + // Filter only when both scopes hold something. + showFilters: boolean; +} + +// Split a release's artifacts into two disjoint scopes by author. Show the +// scope asked for unless it is empty, and hide artifacts redacted by their +// author or a delegate unless asked to show them. +export function artifactView( + artifacts: Artifact[], + delegates: Set, + wanted: ReleaseScope, + showRedacted: boolean, +): ArtifactView { + const byScope: Record = { + trusted: artifacts.filter(a => delegates.has(a.author.did)), + untrusted: artifacts.filter(a => !delegates.has(a.author.did)), + }; + const visible = (list: Artifact[]) => + showRedacted ? list : list.filter(a => !a.redacted); + const other = wanted === "trusted" ? "untrusted" : "trusted"; + const scope = byScope[wanted].length > 0 ? wanted : other; + + return { + scope, + shown: visible(byScope[scope]), + redactedCount: byScope[scope].filter(a => a.redacted).length, + counts: { + trusted: visible(byScope.trusted).length, + untrusted: visible(byScope.untrusted).length, + }, + showFilters: byScope.trusted.length > 0 && byScope.untrusted.length > 0, + }; +} + +// Delegate takes precedence over author, since the author may also be a +// delegate. +export function redactedByDelegate( + artifact: Artifact, + delegates: Set, +): boolean { + return artifact.redactions.some(r => delegates.has(r.user.did)); +} + +// Group locations by the contributing node, preserving order. +export function locationsByNode( + locations: Location[], +): { user: Author; urls: string[] }[] { + const groups = new Map(); + for (const { user, url } of locations) { + const group = groups.get(user.did) ?? { user, urls: [] }; + group.urls.push(url); + groups.set(user.did, group); + } + return [...groups.values()]; +} + +// Order a node-keyed list so delegates come first. The backend returns these +// sorted by DID (arbitrary to a reader); sort is stable, so the original +// order is preserved within each group. +export function delegatesFirst( + items: T[], + did: (item: T) => string, + delegates: Set, +): T[] { + return [...items].sort( + (a, b) => Number(delegates.has(did(b))) - Number(delegates.has(did(a))), + ); +} + +// Locations a browser can open, as opposed to ones fetched from a seeder +// over the radicle-artifact protocol. Delegate locations come first: they +// are the ones a reader can trust most. +export function webLocations( + locations: Location[], + delegates: Set, +): Location[] { + return delegatesFirst( + locations.filter(l => /^https?:\/\//i.test(l.url)), + l => l.user.did, + delegates, + ); +} + +// The trust signal worth carrying on the collapsed row. Only delegates count: +// anyone can make keys that attest their own artifact. +export function attestedBy( + attestations: Author[], + delegates: Set, +): number { + return attestations.filter(a => delegates.has(a.did)).length; +} + +// Only metadata by the author or a delegate is shown, so only they get an +// editor. +export function canEditMetadata( + artifact: Artifact, + ownDid: string, + delegates: Set, +): boolean { + return artifact.author.did === ownDid || delegates.has(ownDid); +} + +// The author vouches by registering, so the COB ignores their attestation, +// and a redaction bars the same user from attesting. +export function canAttest(artifact: Artifact, ownDid: string): boolean { + return ( + artifact.author.did !== ownDid && + !artifact.redacted && + !artifact.attestations.some(a => a.did === ownDid) && + !artifact.redactions.some(r => r.user.did === ownDid) + ); +} + +// The COB stores values as free-form JSON. Text that parses as JSON is sent +// as that value, so numbers and booleans round-trip; anything else is sent +// as a plain string. +export function parseMetadataValue(input: string): unknown { + try { + return JSON.parse(input); + } catch { + return input; + } +} + +export function displayMetadataValue(value: unknown): string { + return typeof value === "string" ? value : JSON.stringify(value); +} + +// Commits whose SHA starts with, or whose summary contains, the query. A +// pasted commit outside the loaded history still shows as a choice. +export function matchCommits( + commits: Commit[], + query: string, + resolved: Commit | undefined, +): Commit[] { + const q = query.trim().toLowerCase(); + const matches = commits.filter( + c => c.id.startsWith(q) || c.summary.toLowerCase().includes(q), + ); + if (resolved && !matches.some(c => c.id === resolved.id)) { + return [resolved]; + } + return matches; +} + +// Only canonical tags: a peer's tag of the same name could point a release +// at a commit the delegates never tagged. +export function canonicalTags(refs: RepoRefs): { name: string; tag: Tag }[] { + return Object.entries(refs.canonical.tags) + .map(([name, tag]) => ({ name, tag })) + .sort((a, b) => b.tag.timestamp - a.tag.timestamp); +} diff --git a/src/lib/router.ts b/src/lib/router.ts index b3c35d5c6..2ba46de13 100644 --- a/src/lib/router.ts +++ b/src/lib/router.ts @@ -239,7 +239,9 @@ export function routeToPath(route: Route): string { route.resource === "repo.issue" || route.resource === "repo.issues" || route.resource === "repo.patch" || - route.resource === "repo.patches" + route.resource === "repo.patches" || + route.resource === "repo.release" || + route.resource === "repo.releases" ) { return repoRouteToPath(route); } else if (route.resource === "booting") { diff --git a/src/lib/router/definitions.ts b/src/lib/router/definitions.ts index dce0fc40e..ad6fde19e 100644 --- a/src/lib/router/definitions.ts +++ b/src/lib/router/definitions.ts @@ -9,6 +9,8 @@ import { loadIssues, loadPatch, loadPatches, + loadRelease, + loadReleases, loadRepoCommit, loadRepoCommits, loadRepoHome, @@ -64,7 +66,9 @@ export function isLoadedRepoRoute( route.resource === "repo.issue" || route.resource === "repo.issues" || route.resource === "repo.patch" || - route.resource === "repo.patches" + route.resource === "repo.patches" || + route.resource === "repo.release" || + route.resource === "repo.releases" ); } @@ -121,6 +125,10 @@ export async function loadRoute( return loadPatch(route, previousLoaded); } else if (route.resource === "repo.patches") { return loadPatches(route); + } else if (route.resource === "repo.release") { + return loadRelease(route); + } else if (route.resource === "repo.releases") { + return loadReleases(route); } return route; } diff --git a/src/lib/stageArtifacts.ts b/src/lib/stageArtifacts.ts new file mode 100644 index 000000000..f9e229b01 --- /dev/null +++ b/src/lib/stageArtifacts.ts @@ -0,0 +1,188 @@ +import type { Artifact } from "@bindings/cob/release/Artifact"; +import type { ArtifactDigest } from "@bindings/cob/release/ArtifactDigest"; + +import { formatBytes } from "@app/lib/utils"; + +export interface Picked { + path: string; + name: string; + digest: ArtifactDigest; +} + +// Identity is the content hash, so picking bytes the release already holds +// lands on the existing entry. +export interface StagedArtifact extends Picked { + register: boolean; + seed: boolean; + duplicateOf?: string; + existing?: { + name: string; + keepsName: boolean; + redaction?: string; + }; +} + +export function stageArtifacts( + picked: Picked[], + artifacts: Artifact[], + options: { + ownDid: string; + delegates: Set; + nodeRunning: boolean; + seeded: Set; + }, +): StagedArtifact[] { + const byCid = new Map(artifacts.map(a => [a.cid, a])); + const firstPick = new Map(); + return picked.map(item => { + const duplicateOf = firstPick.get(item.digest.cid); + if (duplicateOf !== undefined) { + return { ...item, register: false, seed: false, duplicateOf }; + } + firstPick.set(item.digest.cid, item.name); + const match = byCid.get(item.digest.cid); + const seed = options.nodeRunning && !options.seeded.has(item.digest.cid); + if (!match) { + return { ...item, register: true, seed }; + } + const own = match.author.did === options.ownDid; + const renamed = match.name !== item.name; + const redaction = match.redacted + ? (match.redactions.find( + r => + r.user.did === match.author.did || + options.delegates.has(r.user.did), + )?.reason ?? "") + : undefined; + return { + ...item, + register: own && renamed, + seed, + existing: { + name: match.name, + keepsName: renamed && !own, + redaction, + }, + }; + }); +} + +// A redacted match is left alone unless you confirm registering it again. +export function effective( + item: StagedArtifact, + includeRedacted: boolean, +): { register: boolean; seed: boolean } { + if (item.existing?.redaction !== undefined && !includeRedacted) { + return { register: false, seed: false }; + } + return { register: item.register, seed: item.seed }; +} + +export interface PlannedArtifact { + item: StagedArtifact; + register: boolean; + seed: boolean; +} + +export interface RegisterPlan { + items: PlannedArtifact[]; + acting: PlannedArtifact[]; + registering: PlannedArtifact[]; + newCount: number; + renamedCount: number; + seededCount: number; + skippedCount: number; +} + +export function registerPlan( + staged: StagedArtifact[], + includeRedacted: boolean, +): RegisterPlan { + const items = staged.map(item => ({ + item, + ...effective(item, includeRedacted), + })); + const acting = items.filter(p => p.register || p.seed); + const registering = items.filter(p => p.register); + const newCount = registering.filter(p => !p.item.existing).length; + return { + items, + acting, + registering, + newCount, + renamedCount: registering.length - newCount, + seededCount: acting.length - registering.length, + skippedCount: items.length - acting.length, + }; +} + +export function planTitle(plan: RegisterPlan): string { + const count = plan.acting.length; + if (count === 0) { + return "Already registered"; + } + const verb = plan.newCount > 0 ? "Register" : "Update"; + return `${verb} ${count} ${count === 1 ? "artifact" : "artifacts"}`; +} + +export function planSummary(plan: RegisterPlan): string { + return ( + [ + [plan.newCount, "registered"], + [plan.renamedCount, "renamed"], + [plan.seededCount, "seeded"], + [plan.skippedCount, "skipped"], + ] as const + ) + .filter(([count]) => count !== 0) + .map(([count, label]) => `${count} ${label}`) + .join(", "); +} + +export function planDetail( + { item, register, seed }: PlannedArtifact, + includeRedacted: boolean, +): string { + const existing = item.existing; + if (item.duplicateOf !== undefined) { + return `same as “${item.duplicateOf}” · skipped`; + } + if (!existing) { + const size = formatBytes(item.digest.sizeBytes); + const files = item.digest.fileCount; + return item.digest.directory + ? `${files} ${files === 1 ? "file" : "files"} · ${size}` + : size; + } + if (existing.redaction !== undefined && !includeRedacted) { + return "redacted · skipped"; + } + const name = register + ? `renames “${existing.name}”` + : existing.keepsName + ? `stays “${existing.name}”` + : "already registered"; + if (seed) { + return `${name} · will seed`; + } + return register ? name : `${name} · skipped`; +} + +export function duplicatePicks( + picks: { path: string; name: string; digest?: { cid: string } }[], +): Map { + const first = new Map(); + const duplicates = new Map(); + for (const pick of picks) { + if (!pick.digest) { + continue; + } + const earlier = first.get(pick.digest.cid); + if (earlier === undefined) { + first.set(pick.digest.cid, pick.name); + } else { + duplicates.set(pick.path, earlier); + } + } + return duplicates; +} diff --git a/src/lib/startup.svelte.ts b/src/lib/startup.svelte.ts index bdbb814c1..651b187e9 100644 --- a/src/lib/startup.svelte.ts +++ b/src/lib/startup.svelte.ts @@ -3,7 +3,9 @@ import type { UnlistenFn } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event"; import once from "lodash/once"; -import { nodeRunning } from "./events"; +import { invoke } from "@app/lib/invoke"; + +import { artifactNodeRunning, nodeRunning } from "./events"; export let unlistenNodeEvents: UnlistenFn | undefined = undefined; @@ -12,8 +14,29 @@ export function setUnlistenNodeEvents(unlisten: UnlistenFn) { } // Will be called once in the startup of the app -export const createEventEmittersOnce = once(async () => { - return await listen("node_running", event => { - nodeRunning.set(event.payload); - }); +export const createEventEmittersOnce = once(async (): Promise => { + if (!window.__TAURI_INTERNALS__) { + // The test backend can't emit events, so poll at the backend's pace. + const pollNodes = () => { + invoke("node_running") + .then(running => nodeRunning.set(running)) + .catch(console.error); + invoke("artifact_node_running") + .then(running => artifactNodeRunning.set(running)) + .catch(console.error); + }; + pollNodes(); + const interval = setInterval(pollNodes, 2_000); + return () => clearInterval(interval); + } + + const unlisteners = await Promise.all([ + listen("node_running", event => { + nodeRunning.set(event.payload); + }), + listen("artifact_node_running", event => { + artifactNodeRunning.set(event.payload); + }), + ]); + return () => unlisteners.forEach(unlisten => unlisten()); }); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 02517185a..417b98fb3 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -453,3 +453,64 @@ export function creditedCoAuthors(commit: { return true; }); } + +export function formatBytes(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B`; + } + const units = ["KiB", "MiB", "GiB", "TiB"]; + let value = bytes / 1024; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`; +} + +export function formatUptime(seconds: number): string { + if (seconds < 60) { + return `${Math.floor(seconds)}s`; + } + if (seconds < 3600) { + return `${Math.floor(seconds / 60)}m`; + } + if (seconds < 86400) { + return `${Math.floor(seconds / 3600)}h`; + } + return `${Math.floor(seconds / 86400)}d`; +} + +// CIDs in a release share a long prefix, so the head grows until each label is +// distinct. +export function shortenCids( + cids: string[], + minHead = 7, + tailLength = 6, + maxHead = 16, +): Map { + const display = new Map(); + const unique = [...new Set(cids)]; + if (unique.length === 0) { + return display; + } + + let head = minHead; + while ( + head < maxHead && + new Set(unique.map(cid => cid.slice(0, head))).size < unique.length + ) { + head += 1; + } + + for (const cid of unique) { + display.set( + cid, + head + tailLength >= cid.length + ? cid + : `${cid.slice(0, head)}…${cid.slice(-tailLength)}`, + ); + } + + return display; +} diff --git a/src/modals/ConfirmDeleteRelease.svelte b/src/modals/ConfirmDeleteRelease.svelte new file mode 100644 index 000000000..c0f5445a6 --- /dev/null +++ b/src/modals/ConfirmDeleteRelease.svelte @@ -0,0 +1,57 @@ + + + + + "Could not delete the release."} + {confirm}> + {#snippet body()} +
+ + {title} +
+ + This removes the release and its artifact list, and your node stops + seeding its artifacts. Peers that already fetched the release keep their + copy until they sync. + + {#if shared} + + Others registered artifacts in this release, so it stays visible with + their changes. + + {/if} + {/snippet} +
diff --git a/src/modals/ConfirmModal.svelte b/src/modals/ConfirmModal.svelte new file mode 100644 index 000000000..0efe16127 --- /dev/null +++ b/src/modals/ConfirmModal.svelte @@ -0,0 +1,138 @@ + + + + + diff --git a/src/modals/ConfirmRedact.svelte b/src/modals/ConfirmRedact.svelte new file mode 100644 index 000000000..f46bbb26b --- /dev/null +++ b/src/modals/ConfirmRedact.svelte @@ -0,0 +1,100 @@ + + + + + confirm(reason.trim())}> + {#snippet body({ working, run })} + {name} + + + This marks the artifact as one that should no longer be used, and hides it + from the release by default. + + + + +
+ +
+ + Nothing is deleted. The CID stays in the release history, and anyone + who already downloaded it keeps their copy. + + {#if seeding} + + Your node stops seeding it and removes the location it added. + + {/if} + + This cannot be undone, and you will not be able to attest to this + artifact afterwards. + +
+
+ {/snippet} +
diff --git a/src/modals/ConfirmRegisterArtifacts.svelte b/src/modals/ConfirmRegisterArtifacts.svelte new file mode 100644 index 000000000..d08278f00 --- /dev/null +++ b/src/modals/ConfirmRegisterArtifacts.svelte @@ -0,0 +1,211 @@ + + + + + 0 ? "Register" : "Update"} + busyLabel={newCount > 0 ? "Registering…" : "Updating…"} + failure="Unable to register these artifacts." + canConfirm={acting.length > 0} + confirm={() => confirm(staged, includeRedacted)}> + {#snippet body({ working })} +
+ {#each plan.items as p} +
+ + + + {p.item.name} + {planDetail(p, includeRedacted)} +
+ {/each} +
+ + {#if acting.length > 0 && (acting.length > 1 || acting.some(p => p.item.digest.directory))} +
+ {totalFiles} + {totalFiles === 1 ? "file" : "files"} in total, {formatBytes( + totalBytes, + )} +
+ {/if} + + {#if anyExisting || anyDuplicate} +
+ An artifact is identified by the hash of its contents, not by its name, + so anything already in this release stays the single entry it is. + {#if anyDuplicate} + Picks with the same contents are registered once. + {/if} + {#if acting.length === 0} + Nothing will be registered. + {:else} + {summary}. + {/if} + {#if anyKeepsName} + A name marked “stays” is kept: only its author can rename it. + {/if} +
+ {/if} + + {#if redacted.length > 0} +
+ +
+ {#each redacted as item (item.digest.cid)} + + “{item.existing?.name}” was redacted{item.existing?.redaction + ? `: ${item.existing.redaction}` + : "."} + + {/each} + + Register {redacted.length === 1 ? "it" : "them"} again + +
+
+ {/if} + + {#if acting.length > 0} +
+ +
+ {#if registering.length > 0} + + {#if registersFolder} + A folder is registered whole, as one artifact. + {:else} + These names and CIDs are signed into the release and reach + everyone who has this repository. + {/if} + + {/if} + {#if willSeed} + + Your artifact node is running, so it seeds the artifact and adds + your node's location. + + {/if} +
+
+ {/if} + {/snippet} +
diff --git a/src/modals/ConfirmUnseed.svelte b/src/modals/ConfirmUnseed.svelte index c354fc70e..77405f192 100644 --- a/src/modals/ConfirmUnseed.svelte +++ b/src/modals/ConfirmUnseed.svelte @@ -1,10 +1,9 @@ - - -
- - -
- + {/snippet} + diff --git a/src/modals/CreateRelease.svelte b/src/modals/CreateRelease.svelte new file mode 100644 index 000000000..ddbc62d0b --- /dev/null +++ b/src/modals/CreateRelease.svelte @@ -0,0 +1,681 @@ + + + + + diff --git a/src/views/auth/Auth.svelte b/src/views/auth/Auth.svelte index c07da170d..7b6d8d3cc 100644 --- a/src/views/auth/Auth.svelte +++ b/src/views/auth/Auth.svelte @@ -31,9 +31,7 @@ error = undefined; try { await invoke("authenticate", { passphrase }); - if (window.__TAURI_INTERNALS__) { - setUnlistenNodeEvents(await createEventEmittersOnce()); - } + setUnlistenNodeEvents(await createEventEmittersOnce()); passphrase = " ".repeat(passphrase.length); await router.push({ resource: "inbox" }); } catch (err) { diff --git a/src/views/repo/Release.svelte b/src/views/repo/Release.svelte new file mode 100644 index 000000000..4a6598770 --- /dev/null +++ b/src/views/repo/Release.svelte @@ -0,0 +1,1766 @@ + + + + + +
+ + +
+ {#if release.creator.did === ownDid} + + {/if} + + + {#snippet toggle(onclick)} + + {/snippet} + {#snippet popover()} +
+ + +
+ {/snippet} +
+
+
+ + +
+
+
+ +
+ +
+ + + + {#if view.showFilters || view.redactedCount > 0} +
+ {#if view.showFilters} + + + {/if} + {#if view.redactedCount > 0} +
+ +
+ {/if} +
+ {/if} + + {#if registerError} +
{registerError}
+ {/if} + + {#if view.shown.length === 0} +
+ + No artifacts +
+ {/if} + +
+ {#each view.shown as artifact (artifact.cid)} + {@const size = artifactSize(artifact)} + {@const locations = delegatesFirst( + locationsByNode(artifact.locations), + g => g.user.did, + delegateIds, + )} + {@const metadata = otherMetadata(artifact)} + {@const locationCount = artifact.locations.length} + {@const redactions = delegatesFirst( + artifact.redactions, + r => r.user.did, + delegateIds, + )} + {@const attestations = delegatesFirst( + artifact.attestations, + n => n.did, + delegateIds, + )} + {@const isOpen = expanded[artifact.cid] === true} + {@const editable = canEditMetadata(artifact, ownDid, delegateIds)} + {@const artifactName = artifactTitle(artifact)} + {@const trust = attestationLabel(attestations)} +
+
+ + + {#if size} + {size} + {/if} +
+ {#if seededCids.has(artifact.cid)} + + {:else if !artifact.redacted} + + {/if} + +
+
+ +
+ + + + + + {#if delegateIds.has(artifact.author.did)} + + {:else if artifact.author.did === ownDid} + + {/if} + + {#if trust} + + + {trust} + + {/if} + {#if reseedError?.cid === artifact.cid} + {reseedError.message} + {/if} + {#if unseedFailed === artifact.cid} + Could not unseed. + {/if} +
+ + {#if isOpen} +
+
+
+ Metadata + {metadata.length} + {#if editable && editing !== editorId(artifact.cid, "")} + + {/if} +
+ + {#if metadata.length === 0 && editing !== editorId(artifact.cid, "")} +
No metadata
+ {/if} + + {#each metadata as [key, value] (key)} + {#if editing === editorId(artifact.cid, key)} +
+ + + + + saveMetadata(artifact, key)} /> + + + +
+ {:else} +
+ {key} + + {displayMetadataValue(value)} + + {#if editable} + + + + {#snippet toggle(onclick)} + + {/snippet} + {#snippet popover()} +
+
+
+ Remove "{key}"? +
+
+ The entry is dropped from the release + for everyone who replicates it. You can + set it again afterwards. +
+
+
+ + +
+
+ {/snippet} +
+
+ {/if} +
+ {/if} + {/each} + + {#if editing === editorId(artifact.cid, "")} +
+ + + + + saveMetadata(artifact, "")} /> + + + +
+ {/if} + + {#if metadataError} +
{metadataError}
+ {/if} +
+ +
+
+ Attestations + {attestations.length} + {#if canAttest(artifact, ownDid)} + + {/if} +
+ {#if attestError?.cid === artifact.cid} +
{attestError.message}
+ {/if} + {#if attestations.length === 0} +
+ Nobody has attested to this artifact +
+ {:else} +
+ {#each attestations as node (node.did)} +
+ + + {#if delegateIds.has(node.did)} + + {/if} +
+ {/each} +
+ {/if} +
+ +
+
+ Redactions + {redactions.length} + + {#if canEditMetadata(artifact, ownDid, delegateIds) && !redactions.some(r => r.user.did === ownDid)} + + {/if} +
+ {#if redactions.length === 0} +
No redactions
+ {:else} +
+ {#each redactions as redaction (redaction.user.did)} +
+ + {#if delegateIds.has(redaction.user.did)} + + {/if} + + {redaction.reason || "No reason"} + +
+ {/each} +
+ {/if} +
+ +
+
+ Locations + {locationCount} + {#if !artifact.redacted && addingLocation !== artifact.cid} + + {/if} +
+ {#if addingLocation === artifact.cid} +
+ + addLocation(artifact.cid)} /> + + + +
+ {/if} + {#if locationError?.cid === artifact.cid} +
{locationError.message}
+ {/if} + {#if locationCount === 0} + {#if addingLocation !== artifact.cid} +
No locations
+ {/if} + {:else} +
+ {#each locations as group (group.user.did)} +
+
+ + {#if delegateIds.has(group.user.did)} + + {/if} +
+ {#each group.urls as url (url)} +
+ + + + {#if group.user.did === ownDid} + + + {#snippet toggle(onclick)} + + {/snippet} + {#snippet popover()} + + {@const seeding = + url.startsWith("radiroh:") && + seededCids.has(artifact.cid)} + {@const busy = + savingLocation || + unseeding.has(artifact.cid)} +
+
+
+ {seeding + ? "Unseed this artifact?" + : "Remove this location?"} +
+
+ {#if seeding} + This is your node's location. + Removing it also unseeds it from + your node seeding the artifact. + {:else} + Peers stop downloading the + artifact from it. + {/if} +
+
+
+ + +
+
+ {/snippet} +
+
+ {/if} +
+ {/each} +
+ {/each} +
+ {/if} +
+
+ {/if} +
+ {/each} +
+
+
+
+
diff --git a/src/views/repo/Releases.svelte b/src/views/repo/Releases.svelte new file mode 100644 index 000000000..4542de6ce --- /dev/null +++ b/src/views/repo/Releases.svelte @@ -0,0 +1,182 @@ + + + + + { + const auxiliarKey = isMac() ? e.metaKey : e.ctrlKey; + if (auxiliarKey && e.key.toLowerCase() === "n" && !$modalStore) { + e.preventDefault(); + openCreateRelease(); + } + }} /> + + +
+ + Releases + {#if showFilters} + + {/if} +
+ +
+
+ + {#if list.items.length === 0} +
+
+ {!showFilters + ? "No releases" + : scope === "trusted" + ? "No releases by delegates" + : "No releases by others"} +
+
+ {:else} + + list.loadMore()} + estimatedItemSize={80} + getKey={release => release.id} + initialCache={list.initialCache} + initialScrollOffset={list.initialScrollOffset} + onRestored={list.consumeRestoredScroll} + onState={list.persistScroll}> + {#snippet row(release)} +
+ +
+ {/snippet} +
+
+ {/if} +
+
diff --git a/src/views/repo/router.ts b/src/views/repo/router.ts index 860891f96..01aab3f0c 100644 --- a/src/views/repo/router.ts +++ b/src/views/repo/router.ts @@ -6,6 +6,10 @@ import type { Action as PatchAction } from "@bindings/cob/patch/Action"; import type { Patch } from "@bindings/cob/patch/Patch"; import type { Review } from "@bindings/cob/patch/Review"; import type { Revision } from "@bindings/cob/patch/Revision"; +import type { Release } from "@bindings/cob/release/Release"; +import type { ReleaseCounts } from "@bindings/cob/release/ReleaseCounts"; +import type { ReleaseFilter } from "@bindings/cob/release/ReleaseFilter"; +import type { ReleaseScope } from "@bindings/cob/release/ReleaseScope"; import type { Thread } from "@bindings/cob/thread/Thread"; import type { Config } from "@bindings/config/Config"; import type { Diff } from "@bindings/diff/Diff"; @@ -20,6 +24,7 @@ import { cachedGetDiffText, invoke, } from "@app/lib/invoke"; +import { releaseListScope } from "@app/lib/releases"; import type { LoadedRoute, SidebarData } from "@app/lib/router/definitions"; import { loadSidebarData } from "@app/lib/router/definitions"; import { unreachable } from "@app/lib/utils"; @@ -28,6 +33,7 @@ export type IssueStatus = "all" | Issue["state"]["status"]; export const DEFAULT_TAKE = 20; export const COMMITS_PAGE_SIZE = 300; +export const RELEASES_PER_PAGE = 30; export interface RepoHomeRoute { resource: "repo.home"; @@ -187,6 +193,45 @@ export interface LoadedRepoPatchesRoute { }; } +export interface RepoReleasesRoute { + resource: "repo.releases"; + rid: string; + scope?: ReleaseScope; +} + +export interface LoadedRepoReleasesRoute { + resource: "repo.releases"; + params: { + repo: RepoInfo; + releases: PaginatedQuery; + releaseCounts: ReleaseCounts; + scope: ReleaseScope; + showFilters: boolean; + sidebarData: SidebarData; + }; +} + +export interface RepoReleaseRoute { + resource: "repo.release"; + rid: string; + release: string; + // The release list the user came from, shown in the breadcrumb. + scope?: ReleaseScope; + artifactScope?: ReleaseScope; +} + +export interface LoadedRepoReleaseRoute { + resource: "repo.release"; + params: { + repo: RepoInfo; + config: Config; + release: Release; + scope?: ReleaseScope; + artifactScope?: ReleaseScope; + sidebarData: SidebarData; + }; +} + export type RepoRoute = | RepoHomeRoute | RepoCommitsRoute @@ -195,7 +240,9 @@ export type RepoRoute = | RepoIssueRoute | RepoIssuesRoute | RepoPatchRoute - | RepoPatchesRoute; + | RepoPatchesRoute + | RepoReleaseRoute + | RepoReleasesRoute; export type LoadedRepoRoute = | LoadedRepoHomeRoute | LoadedRepoCommitsRoute @@ -204,7 +251,9 @@ export type LoadedRepoRoute = | LoadedRepoIssueRoute | LoadedRepoIssuesRoute | LoadedRepoPatchRoute - | LoadedRepoPatchesRoute; + | LoadedRepoPatchesRoute + | LoadedRepoReleaseRoute + | LoadedRepoReleasesRoute; export async function loadPatch( route: RepoPatchRoute, @@ -503,6 +552,94 @@ export async function loadIssues( }; } +export function listReleases( + rid: string, + scope: ReleaseScope, + skip: number, + // Undefined lists every release. + take: number | undefined, +) { + return invoke>("list_releases", { + rid, + filter: { scope, showRedacted: false } satisfies ReleaseFilter, + skip, + take, + }); +} + +export async function loadReleases( + route: RepoReleasesRoute, +): Promise { + let scope = route.scope ?? "trusted"; + const [sidebarData, repo, firstPage, releaseCounts] = await Promise.all([ + loadSidebarData(), + invoke("repo_by_id", { + rid: route.rid, + }), + listReleases(route.rid, scope, 0, RELEASES_PER_PAGE), + invoke("release_counts", { + rid: route.rid, + }), + ]); + let releases = firstPage; + + const fallback = releaseListScope(route.scope, releaseCounts); + if (fallback !== scope) { + scope = fallback; + releases = await listReleases(route.rid, scope, 0, RELEASES_PER_PAGE); + } + + const showFilters = releaseCounts.delegate > 0 && releaseCounts.other > 0; + + return { + resource: "repo.releases", + params: { + sidebarData, + repo, + releases, + releaseCounts, + scope, + showFilters, + }, + }; +} + +export async function loadRelease( + route: RepoReleaseRoute, +): Promise { + const [sidebarData, repo, release] = await Promise.all([ + loadSidebarData(), + invoke("repo_by_id", { + rid: route.rid, + }), + invoke("release_by_id", { + rid: route.rid, + id: route.release, + }), + ]); + + // A redacted or stale release falls back to the list. + if (!release) { + return loadReleases({ + resource: "repo.releases", + rid: route.rid, + scope: route.scope, + }); + } + + return { + resource: "repo.release", + params: { + sidebarData, + repo, + config: sidebarData.config, + release, + scope: route.scope, + artifactScope: route.artifactScope, + }, + }; +} + export function repoRouteToPath(route: RepoRoute): string { const pathSegments = ["/repos", route.rid]; const searchParams = new URLSearchParams(); @@ -565,11 +702,34 @@ export function repoRouteToPath(route: RepoRoute): string { url += `?${searchParams}`; } return url; + } else if (route.resource === "repo.release") { + let url = [...pathSegments, "releases", route.release].join("/"); + if (route.scope) { + searchParams.set("scope", route.scope); + } + if (route.artifactScope === "untrusted") { + searchParams.set("artifacts", "untrusted"); + } + if (searchParams.size > 0) { + url += `?${searchParams}`; + } + return url; + } else if (route.resource === "repo.releases") { + let url = [...pathSegments, "releases"].join("/"); + if (route.scope) { + searchParams.set("scope", route.scope); + url += `?${searchParams}`; + } + return url; } else { return unreachable(route); } } +function parseReleaseScope(value: string | null): ReleaseScope | undefined { + return value === "trusted" || value === "untrusted" ? value : undefined; +} + export function repoUrlToRoute( segments: string[], searchParams: URLSearchParams, @@ -654,6 +814,24 @@ export function repoUrlToRoute( } else { return { resource: "repo.patches", rid, status }; } + } else if (resource === "releases") { + const id = segments.shift(); + const scope = parseReleaseScope(searchParams.get("scope")); + if (id) { + const artifactScope = + searchParams.get("artifacts") === "untrusted" + ? "untrusted" + : undefined; + return { + resource: "repo.release", + rid, + release: id, + scope, + artifactScope, + }; + } else { + return { resource: "repo.releases", rid, scope }; + } } else { return null; } diff --git a/tests/e2e/repo/createRelease.spec.ts b/tests/e2e/repo/createRelease.spec.ts new file mode 100644 index 000000000..5c9bc4348 --- /dev/null +++ b/tests/e2e/repo/createRelease.spec.ts @@ -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(); +}); diff --git a/tests/e2e/seeding.spec.ts b/tests/e2e/seeding.spec.ts index 79b271696..033f32850 100644 --- a/tests/e2e/seeding.spec.ts +++ b/tests/e2e/seeding.spec.ts @@ -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, diff --git a/tests/unit/embeds.test.ts b/tests/unit/embeds.test.ts index c9b7eb408..8d3e78e96 100644 --- a/tests/unit/embeds.test.ts +++ b/tests/unit/embeds.test.ts @@ -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); }); diff --git a/tests/unit/interval.test.ts b/tests/unit/interval.test.ts index 0ee411f0f..e76e2d9cf 100644 --- a/tests/unit/interval.test.ts +++ b/tests/unit/interval.test.ts @@ -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(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(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); +}); diff --git a/tests/unit/releases.test.ts b/tests/unit/releases.test.ts new file mode 100644 index 000000000..52cbc5639 --- /dev/null +++ b/tests/unit/releases.test.ts @@ -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 { + 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]>([ + ["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, + ...remotes: Record[] + ): 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); + }); +}); diff --git a/tests/unit/repoRouter.test.ts b/tests/unit/repoRouter.test.ts index 5806e1720..cbe24385a 100644 --- a/tests/unit/repoRouter.test.ts +++ b/tests/unit/repoRouter.test.ts @@ -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", diff --git a/tests/unit/stageArtifacts.test.ts b/tests/unit/stageArtifacts.test.ts new file mode 100644 index 000000000..aa798cf10 --- /dev/null +++ b/tests/unit/stageArtifacts.test.ts @@ -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 { + 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() } = {}, +) { + 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 { + 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"); + }); +}); diff --git a/tests/unit/utils.test.ts b/tests/unit/utils.test.ts index 926dfbbb1..e2af74140 100644 --- a/tests/unit/utils.test.ts +++ b/tests/unit/utils.test.ts @@ -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); + }); +});