feat: changelog.md release notes, v tags, plugin release notes and videos

This commit is contained in:
LukeGus committed 2026-10-06 18:13:39 -05:00
1 parent b9dd3488a5
commit 9bc2be721b
45 files changed
+1462 -816

No files matched your search

@@ -0,0 +1,107 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import i18next from "i18next";
import { I18nextProvider, initReactI18next } from "react-i18next";
import type { PluginEntry } from "@/plugins/plugin-model";
const getPluginChangelog = vi.hoisted(() => vi.fn());
vi.mock("@/api/plugins-api", () => ({ getPluginChangelog }));
const { PluginReleaseNotes, PluginVideo } =
await import("@/plugins/PluginReleaseNotes");
afterEach(() => {
cleanup();
getPluginChangelog.mockReset();
});
async function i18n() {
const instance = i18next.createInstance();
await instance.use(initReactI18next).init({
lng: "en",
resources: {
en: {
translation: {
plugins: {
manager: {
releaseNotes: "Release notes",
installedBadge: "Installed",
changeTypes: { added: "Added", fixed: "Fixed" },
videoTitle: "{{name}} video",
},
},
},
},
},
});
return instance;
}
function plugin(overrides: Partial<PluginEntry> = {}): PluginEntry {
return {
id: "docker",
name: "Docker",
installed: true,
version: "1.0.0",
versions: [],
...overrides,
} as PluginEntry;
}
describe("PluginReleaseNotes", () => {
it("shows the installed copy's changelog grouped by type", async () => {
getPluginChangelog.mockResolvedValue([
{
version: "1.0.0",
changes: [
{ type: "added", text: "Runs `docker ps`" },
{ type: "fixed", text: "A crash" },
],
},
]);
render(
<I18nextProvider i18n={await i18n()}>
<PluginReleaseNotes plugin={plugin()} />
</I18nextProvider>,
);
await waitFor(() => expect(screen.getByText("A crash")).toBeTruthy());
expect(getPluginChangelog).toHaveBeenCalledWith("docker");
expect(screen.getByText("Added")).toBeTruthy();
expect(screen.getByText("docker ps").tagName).toBe("CODE");
expect(screen.getByText("Installed")).toBeTruthy();
});
it("renders nothing for a plugin with no versions", async () => {
const { container } = render(
<I18nextProvider i18n={await i18n()}>
<PluginReleaseNotes plugin={plugin({ installed: false })} />
</I18nextProvider>,
);
expect(container.innerHTML).toBe("");
expect(getPluginChangelog).not.toHaveBeenCalled();
});
});
describe("PluginVideo", () => {
it("embeds a YouTube id", async () => {
render(
<I18nextProvider i18n={await i18n()}>
<PluginVideo videoId="dQw4w9WgXcQ" name="Docker" />
</I18nextProvider>,
);
const frame = screen.getByTitle("Docker video") as HTMLIFrameElement;
expect(frame.src).toBe(
"https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?rel=0",
);
});
it("loads nothing without a valid id", async () => {
const { container } = render(
<I18nextProvider i18n={await i18n()}>
<PluginVideo videoId="https://evil.example" name="Docker" />
<PluginVideo name="Docker" />
</I18nextProvider>,
);
expect(container.querySelector("iframe")).toBeNull();
});
});
+88
View File
@@ -5,7 +5,9 @@ import {
describeContributions,
formatBytes,
formatCount,
groupChanges,
matchesQuery,
mergeReleaseNotes,
mergePlugins,
orderByRisk,
pluginSource,
@@ -13,6 +15,7 @@ import {
reportIssueUrl,
sortPlugins,
uploadEntry,
youtubeEmbedUrl,
} from "@/plugins/plugin-model";
function summary(overrides: Partial<PluginSummary> = {}): PluginSummary {
@@ -283,3 +286,88 @@ describe("reportIssueUrl", () => {
).toBeNull();
});
});
describe("mergeReleaseNotes", () => {
const registryVersion = (version: string, extra = {}) => ({
version,
compatible: true,
capabilities: [],
size: 1,
publishedAt: "2026-10-01T00:00:00Z",
...extra,
});
it("joins registry and local notes newest first, local winning", () => {
const rows = mergeReleaseNotes(
[
registryVersion("1.2.0", {
compatible: false,
notes: { changes: [{ type: "added", text: "Registry" }] },
}),
registryVersion("1.0.0", {
releaseNotesUrl: "https://github.com/x/y/releases/tag/v1.0.0",
notes: { changes: [{ type: "added", text: "Old" }] },
}),
],
[
{
version: "1.0.0",
date: "2026-09-01",
summary: "First.",
changes: [{ type: "added", text: "Local" }],
},
{ version: "0.9.0", changes: [{ type: "fixed", text: "Beta" }] },
],
);
expect(rows.map((r) => r.version)).toEqual(["1.2.0", "1.0.0", "0.9.0"]);
expect(rows[0]).toMatchObject({ compatible: false });
expect(rows[1]).toEqual({
version: "1.0.0",
date: "2026-09-01",
summary: "First.",
changes: [{ type: "added", text: "Local" }],
compatible: true,
releaseNotesUrl: "https://github.com/x/y/releases/tag/v1.0.0",
});
expect(rows[2]).toMatchObject({ date: null, compatible: true });
});
it("is empty with nothing to show", () => {
expect(mergeReleaseNotes([], [])).toEqual([]);
});
});
describe("groupChanges", () => {
it("groups by type in a fixed order", () => {
expect(
groupChanges([
{ type: "fixed", text: "b" },
{ type: "added", text: "a" },
{ type: "fixed", text: "c" },
]),
).toEqual([
{ type: "added", items: ["a"] },
{ type: "fixed", items: ["b", "c"] },
]);
});
});
describe("youtubeEmbedUrl", () => {
it("builds a privacy embed from a video id only", () => {
expect(youtubeEmbedUrl("dQw4w9WgXcQ")).toBe(
"https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?rel=0",
);
});
it("refuses anything that is not an id", () => {
for (const value of [
undefined,
"",
"https://evil.example/x",
"dQw4w9WgXcQ/../../x",
"javascript:alert(1)",
]) {
expect(youtubeEmbedUrl(value)).toBeNull();
}
});
});