Files
radicle-explorer/explorer/tests/unit/compat.test.ts
T
Daniel Norman 0dc1e557a5 Move repo facts outside the project payload
Repos need not carry a `xyz.radicle.project` payload; the default
branch can come from `xyz.radicle.crefs` alone. Such repos got no
counts and no default branch, and failed schema validation in
explorer, which broke the parse of a whole listing.

httpd:
- Add top-level `cobs` with issue, patch and bucketed release counts
- Add top-level `defaultBranch`, qualified like the crefs payload
- Omit a count the node cannot determine instead of reporting zero
- Drop `head`: the tip is `refs[defaultBranch]`
- Keep counts in the project payload for older clients until a
  major release
- Keep the project payload when a COB read fails
- Keep `meta.releases` a single count: delegate + other
- Log and skip canonical ref rules and refs that fail to read,
  so one bad ref doesn't drop the default branch tip

explorer:
- Model `cobs` and `defaultBranch`, adapting older nodes to one shape
- Resolve the default branch from the crefs HEAD on older nodes
- Collect the shims for older httpd in http-client/lib/compat.ts,
  each naming the httpd release that the client can drop it with
- Add `defaultBranchTip`, replacing `head`, with a fallback to the
  legacy `meta.head`
- Make the project payload and its legacy meta optional
- Fall back to the RID for the repo name, and show a placeholder
  for a missing name in repo cards and the header
- Hide a counter instead of reading a missing count as zero
- Fall back to a full-page check for "More" without counts
- Add a `repo.sourceUnavailable` route for repos without a
  default branch or tip
- Browse explicit revisions and offer the branch selector without
  a default branch
- List these repos in repo grids and on the landing page
- Say the node is older or built without releases
- Scope release pages in the client, so views never call the shims
- Read OG worker repos through the same compat shims
- Share the repo name and default branch helpers with the OG worker

CI:
- Add check:open-graph, a wrangler deploy dry run
- Run it in GitHub CI, Buildkite and check:ci
- Install explorer's dependencies first: the worker
  resolves compat.ts imports, such as zod, from there

Tests:
- Test that a build without releases omits the count
- Test that one failing rule keeps the others
- Test the repo shape across httpd versions and the release shims
- Test `hasMorePages` and `repoName`
- Test repos without a project payload end to end
2026-10-29 18:21:09 +01:00

317 lines
8.6 KiB
TypeScript

import type { Release } from "@http-client";
import { describe, expect, test } from "vitest";
import {
legacyHasOtherReleases,
legacyReleasesQuery,
legacyScopeReleases,
} from "@http-client/lib/compat";
import { repoSchema } from "@http-client/lib/repo";
const delegate = { id: "did:key:z6MkDelegate" };
const other = { id: "did:key:z6MkOther" };
const issues = { open: 1, closed: 2 };
const patches = { open: 3, draft: 0, archived: 0, merged: 4 };
const buckets = {
delegate: 2,
delegateRedacted: 1,
other: 3,
otherRedacted: 0,
};
const project = {
data: { name: "heartwood", description: "", defaultBranch: "master" },
};
function repo(fields: Record<string, unknown>) {
return repoSchema.parse({
rid: "rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5",
payloads: {},
delegates: [delegate],
threshold: 1,
visibility: { type: "public" },
seeding: 1,
...fields,
});
}
function release(creator: { id: string }): Release {
return { creator } as Release;
}
describe("repo shape", () => {
test("reads the top-level fields of current httpd", () => {
const parsed = repo({
payloads: {
"xyz.radicle.project": {
...project,
meta: { head: "abc", issues, patches, releases: 5 },
},
},
cobs: { issues, patches, releases: buckets },
defaultBranch: "refs/heads/main",
});
expect(parsed.cobs).toEqual({ issues, patches, releases: buckets });
expect(parsed.defaultBranch).toBe("refs/heads/main");
expect(parsed.releaseCount).toBe(buckets.delegate);
});
test("adapts the project payload of httpd 0.28.0 and 0.29.0", () => {
const parsed = repo({
payloads: {
"xyz.radicle.project": {
...project,
meta: { head: "abc", issues, patches, releases: 7 },
},
},
});
expect(parsed.cobs).toEqual({ issues, patches, releases: undefined });
expect(parsed.defaultBranch).toBe("refs/heads/master");
expect(parsed.releaseCount).toBe(7);
});
test("has no release count on httpd 0.27.0", () => {
const parsed = repo({
payloads: {
"xyz.radicle.project": {
...project,
meta: { head: "abc", issues, patches },
},
},
});
expect(parsed.cobs?.issues).toEqual(issues);
expect(parsed.releaseCount).toBeUndefined();
});
test("has no release count on a build without releases", () => {
const parsed = repo({ cobs: { issues, patches } });
expect(parsed.releaseCount).toBeUndefined();
});
test("takes no default branch from a project payload without meta", () => {
const parsed = repo({
payloads: { "xyz.radicle.project": project },
cobs: { patches },
});
expect(parsed.cobs).toEqual({ patches });
expect(parsed.defaultBranch).toBeUndefined();
});
test("keeps a newer node's missing default branch missing", () => {
const parsed = repo({
payloads: {
"xyz.radicle.project": {
...project,
meta: { head: "abc", issues, patches, releases: 5 },
},
},
cobs: { issues, patches, releases: buckets },
});
expect(parsed.defaultBranch).toBeUndefined();
expect(parsed.releaseCount).toBe(buckets.delegate);
});
test.each([
["a direct HEAD", { HEAD: "refs/heads/main" }, "refs/heads/main"],
[
"a chained HEAD",
{ HEAD: "MAIN", MAIN: "refs/heads/trunk" },
"refs/heads/trunk",
],
["a HEAD at a tag", { HEAD: "refs/tags/v1" }, undefined],
["a dangling HEAD", { HEAD: "MAIN" }, undefined],
["a cycle", { HEAD: "A", A: "B", B: "A" }, undefined],
])(
"resolves the crefs default branch of older httpd from %s",
(_, symbolic, expected) => {
const parsed = repo({
payloads: {
"xyz.radicle.crefs": { data: { rules: {}, symbolic } },
},
});
expect(parsed.defaultBranch).toBe(expected);
},
);
test("prefers the crefs HEAD of older httpd to the project payload", () => {
const parsed = repo({
payloads: {
"xyz.radicle.project": {
...project,
meta: { head: "abc", issues, patches },
},
"xyz.radicle.crefs": {
data: { symbolic: { HEAD: "refs/heads/main" } },
},
},
});
expect(parsed.defaultBranch).toBe("refs/heads/main");
});
test("falls back to the project payload without a crefs HEAD", () => {
const parsed = repo({
payloads: {
"xyz.radicle.project": {
...project,
meta: { head: "abc", issues, patches },
},
"xyz.radicle.crefs": { data: { rules: {} } },
},
});
expect(parsed.defaultBranch).toBe("refs/heads/master");
});
test("takes the default branch tip from meta without canonical refs", () => {
const parsed = repo({
payloads: {
"xyz.radicle.project": {
...project,
meta: { head: "abc", issues, patches },
},
},
});
expect(parsed.refs).toEqual({
tags: {},
refs: { "refs/heads/master": "abc" },
});
});
test("adds the default branch tip from meta to canonical refs", () => {
const parsed = repo({
payloads: {
"xyz.radicle.project": {
...project,
meta: { head: "abc", issues, patches },
},
},
refs: { tags: {}, refs: { "refs/heads/dev": "def" } },
});
expect(parsed.refs?.refs).toEqual({
"refs/heads/dev": "def",
"refs/heads/master": "abc",
});
});
test("prefers the canonical default branch tip to meta", () => {
const parsed = repo({
payloads: {
"xyz.radicle.project": {
...project,
meta: { head: "abc", issues, patches },
},
},
refs: { tags: {}, refs: { "refs/heads/master": "def" } },
});
expect(parsed.refs?.refs["refs/heads/master"]).toBe("def");
});
test("takes no tip from meta for a crefs HEAD on another branch", () => {
const parsed = repo({
payloads: {
"xyz.radicle.project": {
...project,
meta: { head: "abc", issues, patches },
},
"xyz.radicle.crefs": {
data: { symbolic: { HEAD: "refs/heads/main" } },
},
},
});
expect(parsed.refs).toBeUndefined();
});
test("takes no tip from meta on current httpd", () => {
const parsed = repo({
payloads: {
"xyz.radicle.project": {
...project,
meta: { head: "abc", issues, patches },
},
},
cobs: { issues, patches },
defaultBranch: "refs/heads/master",
refs: { tags: {}, refs: {} },
});
expect(parsed.refs?.refs).toEqual({});
});
test("parses a repo with a malformed crefs payload", () => {
const parsed = repo({
payloads: { "xyz.radicle.crefs": { data: { symbolic: 1 } } },
});
expect(parsed.defaultBranch).toBeUndefined();
});
test("parses a repo without a project payload", () => {
const parsed = repo({});
expect(parsed.cobs).toBeUndefined();
expect(parsed.defaultBranch).toBeUndefined();
expect(parsed.releaseCount).toBeUndefined();
});
});
describe("legacyReleasesQuery", () => {
test("asks older nodes for every author in the untrusted scope", () => {
expect(legacyReleasesQuery("untrusted")).toEqual({ allAuthors: true });
});
test.each(["trusted" as const, undefined])("adds nothing for %s", scope => {
expect(legacyReleasesQuery(scope)).toEqual({});
});
});
describe("legacyScopeReleases", () => {
const releases = [release(delegate), release(other)];
test("keeps every release in the trusted scope", () => {
expect(legacyScopeReleases(releases, [delegate], "trusted")).toEqual(
releases,
);
});
test("drops delegate releases from the untrusted scope", () => {
expect(legacyScopeReleases(releases, [delegate], "untrusted")).toEqual([
release(other),
]);
});
});
describe("legacyHasOtherReleases", () => {
test("finds releases by others", async () => {
const page = async () => [release(delegate), release(other)];
expect(await legacyHasOtherReleases(page, [delegate], 10)).toBe(true);
});
test("finds none when only delegates released", async () => {
const page = async () => [release(delegate)];
expect(await legacyHasOtherReleases(page, [delegate], 10)).toBe(false);
});
test("assumes others after a full page", async () => {
const page = async () => [release(delegate)];
expect(await legacyHasOtherReleases(page, [delegate], 1)).toBe(true);
});
test("assumes others when the fetch fails", async () => {
const page = () => Promise.reject(new Error("offline"));
expect(await legacyHasOtherReleases(page, [delegate], 10)).toBe(true);
});
});