Files
radicle-explorer/explorer/tests/unit/compat.test.ts
T
Daniel Norman d8f363ca99 Replace API version checks with feature detection
httpd:
- Serve the API at /api, not only under /v1
- Keep serving it at /api/v1, without a redirect, until the first
  major release after explorer and the Open Graph worker switch
  to /api
- Remove apiVersion: released explorers do not read it
- Add `httpd.releases` to `/info`, set by the artifacts feature

explorer:
- Remove the API version check, its config and the unused
  compare-versions dependency
- Keep the node version in a plain parse error message
- Add `minimumSupportedVersion`, set to 0.28.0, and warn on nodes
  whose major is older, treating 0.y as the major before 1.0
- Do not warn on builds without a release number
- Show a warning on the node page of an outdated node
- Mark an outdated node in the seed picker on every page
- Name the node version and the floor in parse errors
- Drop the `/node` fallback for httpd without `/info`, which
  predates the floor
- Check the status, not `ResponseError`, so the Open Graph worker
  can import compat without the fetcher
- Name the httpd release each shim serves, not a date
- Drop the mime shim for httpd 0.18.1 and the unused `lib/file.ts`
- Read the node from `/info` in repo views
- Pass the node and its features to repo views as one `node` param
- Show the releases tab and pages when the node supports releases,
  and the counter only when there is a count
- Read a release count as release support on older httpd
- Test the flag and the fallback

docs:
- Restructure docs/api-changes.md: the supported versions, one
  table of removals, fixed fields per change, and a link to ADR
  0001 for the rules
- Say in ADR 0001 that each compat comment names what the other
  side does
- Say in ADR 0001 when a capability needs a flag in /info

tests:
- Run e2e tests on httpd 0.30.0, above the floor, instead of 0.26.0
- Run the release tests on it too, so they cover the compat shims
- Skip only those that need release counts per scope on it
- Test a node without release support through a mocked `/info`
  flag, instead of an httpd that predates the floor
2026-10-29 18:32:03 +01:00

369 lines
10 KiB
TypeScript

import type { Release } from "@http-client";
import { describe, expect, test } from "vitest";
import {
isOutdated,
legacyHasOtherReleases,
legacyReleasesQuery,
legacyScopeReleases,
releaseNumber,
releasesSupported,
} 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);
});
});
describe("releaseNumber", () => {
test("reads the release number of a release build", () => {
expect(releaseNumber("0.31.0-abc123")).toBe("0.31.0");
expect(releaseNumber("1.2.3")).toBe("1.2.3");
});
test("has none for builds without one", () => {
expect(releaseNumber("pre-release-abc123")).toBeUndefined();
expect(releaseNumber("nix-abc123-abc123")).toBeUndefined();
});
});
describe("isOutdated", () => {
test("compares 0.y as the major before 1.0", () => {
expect(isOutdated("0.27.0", "0.28.0")).toBe(true);
expect(isOutdated("0.28.0-unknown", "0.28.0")).toBe(false);
expect(isOutdated("0.31.2", "0.28.0")).toBe(false);
expect(isOutdated("1.2.0", "0.28.0")).toBe(false);
});
test("compares the major from 1.0 on", () => {
expect(isOutdated("0.40.0", "1.0.0")).toBe(true);
expect(isOutdated("1.9.0", "2.0.0")).toBe(true);
expect(isOutdated("2.0.0", "2.0.0")).toBe(false);
expect(isOutdated("2.5.1-abc123", "2.0.0")).toBe(false);
expect(isOutdated("2.0.0", "2.3.0")).toBe(false);
expect(isOutdated("1.9.0", "2.3.0")).toBe(true);
});
test("does not warn on builds without a release number", () => {
expect(isOutdated("pre-release-abc123", "2.0.0")).toBe(false);
expect(isOutdated("nix-abc123-abc123", "2.0.0")).toBe(false);
});
});
describe("releasesSupported", () => {
test("follows the node's flag, with or without a count", () => {
expect(releasesSupported(true, { releaseCount: undefined })).toBe(true);
expect(releasesSupported(false, { releaseCount: 2 })).toBe(false);
});
test("reads a count as support on older nodes", () => {
expect(releasesSupported(undefined, { releaseCount: 0 })).toBe(true);
expect(releasesSupported(undefined, { releaseCount: undefined })).toBe(
false,
);
});
});