feat: beta channels for core and plugins with one stable/beta release workflow

This commit is contained in:
LukeGus committed 2026-10-07 16:25:58 -05:00
1 parent 6a855c0fa8
commit f73c4b5ad6
55 files changed
+16200 -460

No files matched your search

@@ -169,6 +169,7 @@ const manageMock = vi.hoisted(() => ({
updateAllPlugins: vi.fn(),
uninstallPlugin: vi.fn(),
setPluginOptions: vi.fn(),
setAllPluginChannels: vi.fn(),
getPluginDataSummary: vi.fn(),
getPluginChangelog: vi.fn(),
deletePluginData: vi.fn(),
@@ -730,6 +731,7 @@ describe("plugins route", () => {
expect(res.status).toBe(200);
expect(manageMock.installPlugin).toHaveBeenCalledWith("docker", {
version: "1.0.0",
channel: "stable",
userId: "admin-1",
capabilities: ["kv:own"],
});
@@ -741,6 +743,68 @@ describe("plugins route", () => {
);
});
it("installs on the beta channel when asked", async () => {
manageMock.installPlugin.mockResolvedValue({
id: "docker",
version: "1.1.0-beta.1",
state: "active",
});
const res = await post("/plugins/docker/install", {
channel: "beta",
capabilities: ["kv:own"],
});
expect(res.status).toBe(200);
expect(manageMock.installPlugin).toHaveBeenCalledWith(
"docker",
expect.objectContaining({ channel: "beta" }),
);
expect(
(
await post("/plugins/docker/install", {
channel: "nightly",
capabilities: [],
})
).status,
).toBe(400);
});
it("moves every plugin to one channel", async () => {
manageMock.setAllPluginChannels.mockResolvedValue({
changed: ["docker"],
});
const res = await post("/plugins/channel", { channel: "beta" });
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ changed: ["docker"] });
expect(manageMock.setAllPluginChannels).toHaveBeenCalledWith("beta");
expect((await post("/plugins/channel", { channel: "x" })).status).toBe(
400,
);
});
it("saves a plugin's channel and refuses an unknown one", async () => {
manageMock.setPluginOptions.mockResolvedValue({
autoUpdate: false,
pinnedVersion: null,
channel: "beta",
});
const patch = (body: unknown) =>
fetch(`${baseUrl}/plugins/docker/options`, {
method: "PATCH",
headers: {
"content-type": "application/json",
"x-test-user-id": "admin-1",
},
body: JSON.stringify(body),
});
expect((await patch({ channel: "beta" })).status).toBe(200);
expect(manageMock.setPluginOptions).toHaveBeenCalledWith("docker", {
autoUpdate: undefined,
pinned: undefined,
channel: "beta",
});
expect((await patch({ channel: "nightly" })).status).toBe(400);
});
it("rejects a bad id or version before touching anything", async () => {
expect((await post("/plugins/..%2Fx/install")).status).toBe(400);
expect(
@@ -865,6 +929,7 @@ describe("plugins route", () => {
post("/plugins/docker/install", {}, user),
post("/plugins/docker/update", {}, user),
post("/plugins/update-all", {}, user),
post("/plugins/channel", { channel: "beta" }, user),
fetch(`${baseUrl}/plugins/docker`, { method: "DELETE", headers }),
fetch(`${baseUrl}/plugins/docker/options`, {
method: "PATCH",
@@ -21,7 +21,10 @@ const route = ast.statements.find(
) as ts.ExpressionStatement;
const callback = (route.expression as ts.CallExpression).arguments[2];
async function invoke(fetchGitHubAPI: ReturnType<typeof vi.fn>) {
async function invoke(
fetchLatestRelease: ReturnType<typeof vi.fn>,
channel = "stable",
) {
const handler = runInNewContext(
ts.transpileModule(`(${callback.getText(ast)})`, {
compilerOptions: { target: ts.ScriptTarget.ES2022 },
@@ -29,10 +32,9 @@ async function invoke(fetchGitHubAPI: ReturnType<typeof vi.fn>) {
{
getLocalVersion: () => "2.7.1",
databaseLogger: { warn: vi.fn(), error: vi.fn() },
fetchGitHubAPI,
REPO_OWNER: "Termix-SSH",
REPO_NAME: "Termix",
compareSemver: () => -1,
fetchLatestRelease,
getCoreChannel: async () => channel,
updateStatus: () => "requires_update",
},
);
const res = { json: vi.fn(), status: vi.fn(), send: vi.fn() };
@@ -50,23 +52,33 @@ it("keeps the installed version when the update service is unreachable", async (
expect(res.status).not.toHaveBeenCalled();
});
it("keeps the installed version if the release has no parseable version", async () => {
const res = await invoke(
vi.fn().mockResolvedValue({ data: { tag_name: "nightly" } }),
);
const res = await invoke(vi.fn().mockResolvedValue(null));
expect(res.json).toHaveBeenCalledWith({
localVersion: "2.7.1",
status: "unknown",
channel: "stable",
});
});
it("still reports a newer release when the check succeeds", async () => {
const res = await invoke(
vi.fn().mockResolvedValue({ data: { tag_name: "2.8.0" } }),
vi.fn().mockResolvedValue({ version: "2.8.0", tagName: "v2.8.0" }),
);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
localVersion: "2.7.1",
status: "requires_update",
remoteVersion: "2.8.0",
channel: "stable",
}),
);
});
it("checks the channel the instance is on", async () => {
const fetchLatest = vi
.fn()
.mockResolvedValue({ version: "2.8.0-beta.1", tagName: "v2.8.0-beta.1" });
const res = await invoke(fetchLatest, "beta");
expect(fetchLatest).toHaveBeenCalledWith("beta");
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ channel: "beta", remoteVersion: "2.8.0-beta.1" }),
);
});
+120 -9
View File
@@ -160,19 +160,22 @@ const DEFAULT_CAPS = ["hosts:read", "kv:own"];
function registryEntry(
id: string,
versions: Array<{ version: string; capabilities?: string[] }>,
prereleases: Array<{ version: string; capabilities?: string[] }> = [],
) {
const toEntry = (v: { version: string; capabilities?: string[] }) => ({
version: v.version,
api: "1",
url: `https://example.com/${id}-${v.version}.tmxplug`,
sha256: "a".repeat(64),
signature: "sig",
size: 1,
capabilities: v.capabilities ?? ["hosts:read", "kv:own"],
});
return {
id,
name: id,
versions: versions.map((v) => ({
version: v.version,
api: "1",
url: `https://example.com/${id}-${v.version}.tmxplug`,
sha256: "a".repeat(64),
signature: "sig",
size: 1,
capabilities: v.capabilities ?? ["hosts:read", "kv:own"],
})),
versions: versions.map(toEntry),
prereleases: prereleases.map(toEntry),
};
}
@@ -407,6 +410,114 @@ describe("updates", () => {
});
});
describe("beta channel", () => {
it("offers betas only to a plugin on the beta channel", async () => {
createFixturePlugin({ id: "docker", root: bundled });
registry.index.plugins = [
registryEntry(
"docker",
[{ version: "1.0.0" }],
[{ version: "1.1.0-beta.1" }],
),
];
await boot();
expect((await manage.listRegistry()).plugins[0]).toMatchObject({
channel: "stable",
latestVersion: "1.0.0",
latestBeta: "1.1.0-beta.1",
updateAvailable: false,
});
expect(
(await manage.listRegistry()).plugins[0].versions.map((v) => [
v.version,
v.prerelease,
]),
).toEqual([
["1.1.0-beta.1", true],
["1.0.0", false],
]);
await manage.setPluginOptions("docker", { channel: "beta" });
expect((await manage.listRegistry()).plugins[0]).toMatchObject({
channel: "beta",
latestVersion: "1.1.0-beta.1",
updateAvailable: true,
});
});
it("keeps an installed beta after going back to stable", async () => {
createFixturePlugin({
id: "docker",
root: bundled,
manifestOverrides: { version: "1.1.0-beta.1" },
});
registry.index.plugins = [
registryEntry(
"docker",
[{ version: "1.0.0" }],
[{ version: "1.1.0-beta.1" }],
),
];
await boot();
expect((await manage.listRegistry()).plugins[0]).toMatchObject({
channel: "stable",
installedVersion: "1.1.0-beta.1",
updateAvailable: false,
});
const result = await manage.updateAllPlugins({ userId: "admin" });
expect(result.updated).toEqual([]);
expect(registry.download).not.toHaveBeenCalled();
registry.index.plugins = [
registryEntry("docker", [{ version: "1.1.0" }, { version: "1.0.0" }]),
];
expect((await manage.listRegistry()).plugins[0]).toMatchObject({
latestVersion: "1.1.0",
updateAvailable: true,
});
});
it("installs the newest beta when asked to follow betas", async () => {
registry.index.plugins = [
registryEntry(
"notes",
[{ version: "1.0.0" }],
[{ version: "1.1.0-beta.2" }],
),
];
registry.download.mockImplementation(() =>
stageArtifact("notes", { version: "1.1.0-beta.2" }),
);
await boot();
const result = await manage.installPlugin("notes", {
userId: "admin",
channel: "beta",
capabilities: DEFAULT_CAPS,
});
expect(result.version).toBe("1.1.0-beta.2");
expect(db.rows.get("notes")).toMatchObject({
channel: "beta",
pinnedVersion: null,
});
});
it("moves every installed plugin to one channel", async () => {
createFixturePlugin({ id: "docker", root: bundled });
createFixturePlugin({ id: "notes", root: bundled });
await boot();
expect((await manage.setAllPluginChannels("beta")).changed.sort()).toEqual([
"docker",
"notes",
]);
expect(db.rows.get("docker")?.channel).toBe("beta");
expect((await manage.setAllPluginChannels("beta")).changed).toEqual([]);
});
});
describe("dependencies", () => {
it("starts a dependency first and stops dependents first", async () => {
createFixturePlugin({ id: "base", root: bundled });
@@ -29,6 +29,7 @@ import {
fetchRegistryIndex,
fetchRegistryStats,
findRelease,
latestOnChannel,
getStatsUrl,
isApiCompatible,
parseRegistryIndex,
@@ -188,6 +189,63 @@ describe("findRelease", () => {
});
});
describe("prereleases", () => {
const index = parseRegistryIndex({
plugins: [
{
id: "docker",
versions: [
version({ version: "1.1.0" }),
version({ version: "1.2.0-beta.1" }),
],
prereleases: [
version({ version: "1.2.0-beta.1" }),
version({ version: "1.2.0-beta.10" }),
version({ version: "1.3.0" }),
],
},
{ id: "only-beta", prereleases: [version({ version: "0.1.0-beta.1" })] },
],
});
const docker = index.plugins[0];
it("keeps betas out of the stable list and stable out of betas", () => {
expect(docker.versions.map((v) => v.version)).toEqual(["1.1.0"]);
expect(docker.prereleases.map((v) => v.version)).toEqual([
"1.2.0-beta.10",
"1.2.0-beta.1",
]);
expect(docker.prereleases.every((v) => v.prerelease)).toBe(true);
expect(index.plugins[1].versions).toEqual([]);
});
it("picks by channel and finds a beta by exact version", () => {
expect(latestOnChannel(docker, "stable")?.version).toBe("1.1.0");
expect(latestOnChannel(docker, "beta")?.version).toBe("1.2.0-beta.10");
expect(findRelease(index, "docker")?.release.version).toBe("1.1.0");
expect(
findRelease(index, "docker", undefined, "beta")?.release.version,
).toBe("1.2.0-beta.10");
expect(findRelease(index, "docker", "1.2.0-beta.1")?.release.version).toBe(
"1.2.0-beta.1",
);
expect(findRelease(index, "only-beta")).toBeNull();
});
it("prefers a newer stable over an older beta", () => {
const later = parseRegistryIndex({
plugins: [
{
id: "docker",
versions: [version({ version: "1.2.0" })],
prereleases: [version({ version: "1.2.0-beta.3" })],
},
],
});
expect(latestOnChannel(later.plugins[0], "beta")?.version).toBe("1.2.0");
});
});
describe("fetchRegistryIndex", () => {
const body = JSON.stringify({
plugins: [{ id: "docker", versions: [version()] }],
@@ -2,8 +2,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const sendCoreAlert = vi.hoisted(() => vi.fn(async () => {}));
const latest = vi.hoisted(() => ({
value: null as null | { version: string; url: string },
value: null as null | { version: string; url: string; prerelease?: boolean },
}));
const channel = vi.hoisted(() => ({ value: "stable", asked: [] as string[] }));
const version = vi.hoisted(() => ({ value: "2.9.0" as string | null }));
const disabled = vi.hoisted(() => ({ value: false }));
@@ -13,7 +14,13 @@ vi.mock("../../utils/app-version.js", () => ({
}));
vi.mock("../../utils/latest-release.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../utils/latest-release.js")>()),
fetchLatestRelease: async () => latest.value,
fetchLatestRelease: async (wanted: string) => {
channel.asked.push(wanted);
return latest.value;
},
}));
vi.mock("../../updates/update-channel.js", () => ({
getCoreChannel: async () => channel.value,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentUserRepository: () => ({
@@ -31,6 +38,8 @@ beforeEach(() => {
sendCoreAlert.mockClear();
version.value = "2.9.0";
disabled.value = false;
channel.value = "stable";
channel.asked = [];
latest.value = {
version: "2.9.1",
url: "https://github.com/Termix-SSH/Termix/releases/tag/v2.9.1",
@@ -60,4 +69,29 @@ describe("desktop update check", () => {
await checkForDesktopUpdate();
expect(sendCoreAlert).not.toHaveBeenCalled();
});
it("follows the beta channel", async () => {
channel.value = "beta";
latest.value = {
version: "2.9.1-beta.2",
url: "https://github.com/Termix-SSH/Termix/releases/tag/v2.9.1-beta.2",
prerelease: true,
};
await checkForDesktopUpdate();
expect(channel.asked).toEqual(["beta"]);
expect(sendCoreAlert).toHaveBeenCalledWith(
expect.objectContaining({
title: "Termix 2.9.1-beta.2 is available",
dedupeKey: "update:2.9.1-beta.2",
}),
);
});
it("does not offer the matching stable as older than its own beta", async () => {
version.value = "2.9.1-beta.2";
await checkForDesktopUpdate();
expect(sendCoreAlert).toHaveBeenCalledWith(
expect.objectContaining({ dedupeKey: "update:2.9.1" }),
);
});
});
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import {
compareSemver,
isPrereleaseVersion,
normalizeVersion,
updateStatus,
} from "../../utils/latest-release.js";
describe("normalizeVersion", () => {
it("reads tags and short versions and keeps the prerelease part", () => {
expect(normalizeVersion("v2.8")).toBe("2.8.0");
expect(normalizeVersion("release-2.9.1-tag")).toBe("2.9.1-tag");
expect(normalizeVersion("v26.11.0-beta.2")).toBe("26.11.0-beta.2");
expect(normalizeVersion("nightly")).toBeNull();
});
});
describe("compareSemver", () => {
it("orders betas below their stable release and by number", () => {
expect(compareSemver("26.11.0-beta.1", "26.11.0")).toBe(-1);
expect(compareSemver("26.11.0-beta.2", "26.11.0-beta.10")).toBe(-1);
expect(compareSemver("26.11.0-beta.1", "26.10.3")).toBe(1);
expect(compareSemver("v2.9.0", "2.9")).toBe(0);
expect(compareSemver("x", "2.9.0")).toBeNull();
});
});
describe("updateStatus", () => {
it("calls a newer release on the channel an update", () => {
expect(updateStatus("26.10.0", "26.10.1")).toBe("requires_update");
expect(updateStatus("26.11.0-beta.1", "26.11.0-beta.2")).toBe(
"requires_update",
);
expect(updateStatus("26.11.0-beta.2", "26.11.0")).toBe("requires_update");
});
it("marks betas and builds ahead of the newest release as beta", () => {
expect(updateStatus("26.11.0-beta.2", "26.11.0-beta.2")).toBe("beta");
expect(updateStatus("26.11.0-beta.2", "26.10.0")).toBe("beta");
expect(updateStatus("26.11.0", "26.10.0")).toBe("beta");
expect(updateStatus("26.10.0", "26.10.0")).toBe("up_to_date");
expect(updateStatus("26.10.0", null)).toBe("up_to_date");
});
it("knows a prerelease", () => {
expect(isPrereleaseVersion("26.11.0-beta.1")).toBe(true);
expect(isPrereleaseVersion("26.11.0")).toBe(false);
});
});