Files
paseo/scripts/sync-fdroid-changelogs.test.mjs
T
Anton LazarevandMohamed Boudra f6288629ff Fastlane metadata format for F-Droid releases (#2501)
* add f-droid metadata support

* share version code calculation with website

* preserve blockquoted sections in changelogs

* refactor(android): simplify stable F-Droid metadata

Generate store changelogs only for stable releases and keep native version-code ownership in the existing release module.

* test(android): run F-Droid changelog suite in CI

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-25 23:37:54 +02:00

328 lines
11 KiB
JavaScript

import assert from "node:assert/strict";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
import {
getFdroidVersionCodes,
getNativeReleaseVersion,
} from "../packages/app/native-release-version.js";
import { formatFdroidChangelog, syncFdroidChangelogs } from "./sync-fdroid-changelogs.mjs";
const CHANGELOG_CHARACTER_LIMIT = 500;
function withTempRepo(fn, { changelog, version = "0.2.3" }) {
const tempDir = mkdtempSync(path.join(tmpdir(), "paseo-fdroid-changelogs-"));
try {
writeFileSync(path.join(tempDir, "package.json"), JSON.stringify({ version }));
writeFileSync(path.join(tempDir, "CHANGELOG.md"), changelog);
fn(tempDir);
} finally {
rmSync(tempDir, { force: true, recursive: true });
}
}
function readChangelogFile(dir, versionCode, locale = "en-US") {
return readFileSync(
path.join(dir, "fastlane", "metadata", "android", locale, "changelogs", `${versionCode}.txt`),
"utf8",
);
}
// The whole point of the shared module: these are the numbers the APKs actually
// ship with, so the filenames have to track them exactly.
test("derives one version code per published ABI", () => {
assert.equal(getNativeReleaseVersion("0.2.2").androidVersionCode, 2002);
assert.deepEqual(getFdroidVersionCodes("0.2.2"), [
{ abi: "armeabi-v7a", versionCode: 20021 },
{ abi: "arm64-v8a", versionCode: 20022 },
{ abi: "x86", versionCode: 20023 },
{ abi: "x86_64", versionCode: 20024 },
]);
});
test("does not write or validate F-Droid changelogs for beta releases", () => {
withTempRepo(
(dir) => {
const result = syncFdroidChangelogs([], { cwd: dir });
assert.deepEqual(result, {
contents: null,
version: "0.2.3-beta.1",
versionCodes: [],
written: [],
});
assert.equal(existsSync(path.join(dir, "fastlane")), false);
},
{ changelog: "", version: "0.2.3-beta.1" },
);
});
test("rejects versions that would collide or overflow", () => {
assert.throws(() => getNativeReleaseVersion("0.1000.0"), /collision-free/);
assert.throws(() => getNativeReleaseVersion("not-a-version"), /unsupported version/);
assert.throws(() => getNativeReleaseVersion("210.0.0"), /out of range/);
});
test("writes an identical changelog for every ABI version code", () => {
const changelog = "# Changelog\n\n## 0.2.3 - 2026-07-27\n\n### Fixed\n\n- Something broke.\n";
withTempRepo(
(dir) => {
const result = syncFdroidChangelogs([], { cwd: dir });
assert.equal(result.written.length, 4);
for (const { versionCode } of getFdroidVersionCodes("0.2.3")) {
assert.equal(readChangelogFile(dir, versionCode), result.contents);
}
assert.match(result.contents, /- Something broke\./);
},
{ changelog },
);
});
test("writes the four stable 0.5.0 changelog files", () => {
const changelog = "## 0.5.0 - 2026-08-21\n\n### Added\n\n- Stable F-Droid metadata.\n";
withTempRepo(
(dir) => {
const result = syncFdroidChangelogs([], { cwd: dir });
assert.deepEqual(
result.written.map((entry) => entry.replace(/ \(.+\)$/, "")),
[
"fastlane/metadata/android/en-US/changelogs/50001.txt",
"fastlane/metadata/android/en-US/changelogs/50002.txt",
"fastlane/metadata/android/en-US/changelogs/50003.txt",
"fastlane/metadata/android/en-US/changelogs/50004.txt",
],
);
},
{ changelog, version: "0.5.0" },
);
});
test("strips PR references and contributor attributions", () => {
const changelog = [
"## 0.2.3 - 2026-07-27",
"",
"### Added",
"",
"- Support for `GitLab` merge requests ([#1913](https://x/1913) by [@nllptrx](https://y))",
"",
].join("\n");
withTempRepo(
(dir) => {
const { contents } = syncFdroidChangelogs([], { cwd: dir });
assert.equal(contents.includes("#1913"), false);
assert.equal(contents.includes("nllptrx"), false);
assert.equal(contents.includes("`"), false);
assert.match(contents, /- Support for GitLab merge requests\n/);
},
{ changelog },
);
});
test("stays inside the 500 character limit and keeps the full-notes link", () => {
const bullets = Array.from(
{ length: 40 },
(_, index) => `- Shipped improvement number ${index}.`,
);
const changelog = ["## 0.2.3 - 2026-07-27", "", "### Added", "", ...bullets, ""].join("\n");
withTempRepo(
(dir) => {
const { contents } = syncFdroidChangelogs([], { cwd: dir });
assert.ok(
contents.length <= CHANGELOG_CHARACTER_LIMIT,
`expected <= ${CHANGELOG_CHARACTER_LIMIT}, got ${contents.length}`,
);
assert.match(contents, /Full notes: https:\/\/paseo\.sh\/changelog\n$/);
// Truncation happens at a bullet boundary, never mid-sentence.
assert.equal(contents.includes("Shipped improvement number 0."), true);
assert.equal(/- Shipped improvement number \d+\.\.\.$/m.test(contents), false);
},
{ changelog },
);
});
test("never leaves an orphan section heading when bullets get cut", () => {
const filler = Array.from({ length: 20 }, (_, index) => `- Improvement ${index} with some text.`);
const changelog = [
"## 0.2.3 - 2026-07-27",
"",
"### Improved",
"",
...filler,
"",
"### Fixed",
"",
"- A late fix that will not fit.",
"",
].join("\n");
withTempRepo(
(dir) => {
const { contents } = syncFdroidChangelogs([], { cwd: dir });
assert.equal(contents.includes("Fixed"), false);
assert.equal(contents.trimEnd().endsWith("Improved"), false);
},
{ changelog },
);
});
test("salvages a truncated first bullet rather than emitting only a link", () => {
const contents = formatFdroidChangelog(["### Added", `- ${"x".repeat(600)}`]);
assert.ok(contents.length <= CHANGELOG_CHARACTER_LIMIT);
assert.match(contents, /^- x+\.\.\.\n\nFull notes:/);
});
// Releases use blockquotes for action-required notices. Dropping one silently is
// worse than dropping any bullet, so these pin the behaviour.
test("preserves a blockquoted notice ahead of the bullets", () => {
const contents = formatFdroidChangelog([
"> **Important update notice**",
">",
"> If you installed Paseo Desktop 0.1.108, you need to [reinstall manually](https://paseo.sh/download).",
"",
"### Fixed",
"",
"- Desktop no longer gets stuck connecting.",
]);
assert.match(
contents,
/^Important update notice\nIf you installed Paseo Desktop 0\.1\.108, you need to reinstall manually\.\n\nFixed\n- Desktop no longer gets stuck connecting\./,
);
assert.ok(contents.length <= CHANGELOG_CHARACTER_LIMIT);
});
test("keeps the notice whole and cuts bullets when the budget runs out", () => {
const notice = `- ${"y".repeat(10)}`;
const bullets = Array.from({ length: 30 }, () => `- ${"b".repeat(60)}`);
const contents = formatFdroidChangelog([
"> Reinstall Paseo manually before updating, the old updater cannot install this build.",
"",
"### Fixed",
"",
...bullets,
notice,
]);
assert.ok(contents.length <= CHANGELOG_CHARACTER_LIMIT);
assert.match(
contents,
/^Reinstall Paseo manually before updating, the old updater cannot install this build\.\n/,
);
// The notice survived intact, so some bullets must have been dropped.
assert.ok(contents.split("\n").filter((line) => line.startsWith("- b")).length < bullets.length);
});
test("truncates an over-long notice instead of dropping it", () => {
const contents = formatFdroidChangelog([`> ${"n".repeat(900)}`, "", "### Fixed", "", "- A fix."]);
assert.ok(contents.length <= CHANGELOG_CHARACTER_LIMIT);
assert.match(contents, /^n+\.\.\.\n\nFull notes:/);
assert.equal(contents.includes("A fix."), false);
});
test("captures multiple blockquotes and blockquotes that follow sections", () => {
const contents = formatFdroidChangelog([
"> First notice.",
"",
"### Fixed",
"",
"- A fix.",
"",
"> Second notice.",
]);
assert.match(contents, /^First notice\.\n\nSecond notice\.\n\nFixed\n- A fix\./);
});
test("renders the real 0.1.109 notice, which is the entry's whole point", () => {
const changelog = [
"## 0.1.109 - 2026-07-16",
"",
"> **Important update notice**",
">",
"> If you installed Paseo Desktop 0.1.108, you need to [download and reinstall Paseo manually](https://paseo.sh/download) to get this fix. The bug in 0.1.108 prevents its automatic updater from installing 0.1.109. Users on 0.1.107 or earlier can update normally.",
"",
"### Fixed",
"",
"- Paseo Desktop no longer gets stuck connecting or loses native window controls after updating ([#2111](https://github.com/getpaseo/paseo/pull/2111) by [@cleiter](https://github.com/cleiter))",
"",
].join("\n");
withTempRepo(
(dir) => {
const { contents } = syncFdroidChangelogs([], { cwd: dir });
assert.ok(contents.length <= CHANGELOG_CHARACTER_LIMIT);
assert.match(contents, /^Important update notice\n/);
assert.match(contents, /download and reinstall Paseo manually/);
assert.match(contents, /Users on 0\.1\.107 or earlier can update normally\./);
assert.match(contents, /\nFixed\n- Paseo Desktop no longer gets stuck connecting/);
assert.equal(contents.includes("#2111"), false);
},
{ changelog, version: "0.1.109" },
);
});
test("preserves the standalone 0.1.96 Android notice", () => {
const changelog = readFileSync(path.resolve("CHANGELOG.md"), "utf8");
const entry = changelog.match(/## 0\.1\.96 - 2026-06-13\n([\s\S]*?)(?=\n## |$)/)?.[1];
assert.ok(entry);
const contents = formatFdroidChangelog(entry.split("\n"));
assert.match(contents, /This release only fixes an Android issue/);
assert.equal(contents.includes("_This release"), false);
});
test("--check fails when the generated files are missing or stale", () => {
const changelog = "## 0.2.3 - 2026-07-27\n\n### Fixed\n\n- Something broke.\n";
withTempRepo(
(dir) => {
assert.throws(() => syncFdroidChangelogs(["--check"], { cwd: dir }), /out of date/);
syncFdroidChangelogs([], { cwd: dir });
assert.doesNotThrow(() => syncFdroidChangelogs(["--check"], { cwd: dir }));
const [{ versionCode }] = getFdroidVersionCodes("0.2.3");
writeFileSync(
path.join(
dir,
"fastlane",
"metadata",
"android",
"en-US",
"changelogs",
`${versionCode}.txt`,
),
"drifted\n",
);
assert.throws(() => syncFdroidChangelogs(["--check"], { cwd: dir }), /out of date/);
},
{ changelog },
);
});
test("fails loudly when the release has no changelog entry", () => {
const changelog = "## 0.2.2 - 2026-07-25\n\n### Fixed\n\n- Older release.\n";
withTempRepo(
(dir) => {
assert.throws(() => syncFdroidChangelogs([], { cwd: dir }), /No CHANGELOG\.md entry/);
},
{ changelog, version: "0.2.3" },
);
});
test("honours a non-default locale directory", () => {
const changelog = "## 0.2.3 - 2026-07-27\n\n### Fixed\n\n- Something broke.\n";
withTempRepo(
(dir) => {
mkdirSync(path.join(dir, "fastlane", "metadata", "android", "ja"), { recursive: true });
syncFdroidChangelogs(["--locale", "ja"], { cwd: dir });
const [{ versionCode }] = getFdroidVersionCodes("0.2.3");
assert.match(readChangelogFile(dir, versionCode, "ja"), /- Something broke\./);
},
{ changelog },
);
});