- Or pick another build
+ Or pick another architecture build
{alts.map((v) => (
@@ -143,6 +143,7 @@ function Chip({ v }: { v: { key: ArchKey; asset: GithubReleaseAsset } }) {
{m.short}
+ {m.desc && (
+
+ · {m.desc}
+
+ )}
- {formatFileSize(v.asset.size)}
+ · {formatFileSize(v.asset.size)}
);
@@ -164,6 +170,7 @@ function VariantRow({ v }: { v: { key: ArchKey; asset: GithubReleaseAsset } }) {
diff --git a/src/components/downloads/DownloadCard.tsx b/src/components/downloads/DownloadCard.tsx
index a5242c8..ede9eec 100644
--- a/src/components/downloads/DownloadCard.tsx
+++ b/src/components/downloads/DownloadCard.tsx
@@ -1,7 +1,16 @@
"use client";
import { useState } from "react";
-import { Download, Calendar, Tag, AlertTriangle } from "lucide-react";
+import Link from "next/link";
+import {
+ Download,
+ Calendar,
+ Tag,
+ AlertTriangle,
+ ArrowRight,
+ BookOpen,
+ ExternalLink,
+} from "lucide-react";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { ArchDownload } from "@/components/downloads/ArchDownload";
@@ -13,6 +22,8 @@ import {
} from "@/hooks/useGithubRelease";
import type { GithubRelease } from "@/types/github";
import { formatDate, formatNumber } from "@passcodes/passalgo";
+import { USER_GUIDE_URL } from "@/lib/constants";
+import { getChangelogEntryByTagName } from "@/lib/changelog";
export function DownloadCard({
release,
@@ -29,6 +40,7 @@ export function DownloadCard({
const channel = classifyRelease(release);
const isYanked = isYankedRelease(release);
const isDeprecated = isDeprecatedRelease(release);
+ const changelogEntry = getChangelogEntryByTagName(release.tag_name);
return (
Deprecated
)}
- {isYanked && Yanked }
+ {isYanked && (
+
+ Yanked
+
+ )}
{channel === "beta" && (
Beta
)}
@@ -58,6 +74,12 @@ export function DownloadCard({
+ {changelogEntry?.summary && (
+
+ {changelogEntry.summary}
+
+ )}
+
@@ -93,6 +115,21 @@ export function DownloadCard({
)}
+ {isYanked && (
+
+
+
+
Withdrawn / Yanked Release
+
+
+ This release was withdrawn from active distribution. Preserved for archive documentation and project history only.
+
+
+ )}
+
{release.body && (
+ {isYanked ? (
+
+
+ Downloads are withdrawn for this release. Please use the recommended production build above.
+
+
+ ) : (
+
+ )}
+
+ {/* Discovery & Navigation Links */}
+
+
+
View in Project Updates
+
+
+
+
+
+
Setup Guide
+
+
+
);
}
diff --git a/src/components/downloads/DownloadsContent.tsx b/src/components/downloads/DownloadsContent.tsx
index b20273d..1388f15 100644
--- a/src/components/downloads/DownloadsContent.tsx
+++ b/src/components/downloads/DownloadsContent.tsx
@@ -1,7 +1,7 @@
"use client";
import { useMemo, useState } from "react";
-import { Search, XCircle, AlertTriangle, Cpu, Layers } from "lucide-react";
+import { Search, XCircle, AlertTriangle, Cpu, Layers, ArrowRight } from "lucide-react";
import { SectionHeader } from "@/components/shared/SectionHeader";
import { DeviceWarning } from "@/components/downloads/DeviceWarning";
import { DownloadCard } from "@/components/downloads/DownloadCard";
@@ -13,23 +13,43 @@ import {
isRateLimitError,
getLatestStableRelease,
classifyRelease,
+ isYankedRelease,
+ isDeprecatedRelease,
} from "@/hooks/useGithubRelease";
import {
KOMI_STORE_URL,
KOMI_BADGE_SRC,
KOMI_BADGE_WEBP,
+ USER_GUIDE_URL,
} from "@/lib/constants";
import Link from "next/link";
-type FilterChannel = "all" | "stable" | "beta" | "alpha";
+type FilterChannel =
+ | "all"
+ | "stable"
+ | "beta"
+ | "alpha"
+ | "deprecated"
+ | "yanked";
const FILTER_CHANNELS: { id: FilterChannel; label: string }[] = [
{ id: "all", label: "All Releases" },
{ id: "stable", label: "Stable" },
{ id: "beta", label: "Beta" },
{ id: "alpha", label: "Alpha" },
+ { id: "deprecated", label: "Deprecated" },
+ { id: "yanked", label: "Yanked" },
];
+const CHANNEL_EXPLANATIONS: Record
= {
+ all: "Displaying all recorded releases across active, preview, and historical channels.",
+ stable: "Verified production releases thoroughly tested and recommended for general use.",
+ beta: "Pre-release builds for testing upcoming features and bug fixes before production.",
+ alpha: "Early preview builds containing experimental features and rapid architectural changes.",
+ deprecated: "Legacy releases (v1.x and v2.x) superseded by the modern v3 database architecture.",
+ yanked: "Releases withdrawn from active distribution due to known issues; preserved for historical records.",
+};
+
function KomiSection() {
const [badgeFailed, setBadgeFailed] = useState(false);
return (
@@ -97,13 +117,30 @@ export function DownloadsContent() {
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return (releases ?? []).filter((r) => {
+ const isDep = isDeprecatedRelease(r);
+ const isYank = isYankedRelease(r);
+ const channel = classifyRelease(r);
+
const matchQ =
!q ||
(r.name || "").toLowerCase().includes(q) ||
r.tag_name.toLowerCase().includes(q) ||
- (r.body || "").toLowerCase().includes(q);
- const matchS =
- status === "all" || classifyRelease(r) === status;
+ (r.body || "").toLowerCase().includes(q) ||
+ channel.includes(q) ||
+ (isDep && "deprecated".includes(q)) ||
+ (isYank && "yanked".includes(q));
+
+ const matchS = (() => {
+ if (status === "all") return true;
+ if (status === "deprecated") return isDep;
+ if (status === "yanked") return isYank;
+ if (status === "stable")
+ return channel === "stable" && !isDep && !isYank;
+ if (status === "beta") return channel === "beta" && !isYank;
+ if (status === "alpha") return channel === "alpha" && !isYank;
+ return channel === status;
+ })();
+
return matchQ && matchS;
});
}, [releases, query, status]);
@@ -121,7 +158,7 @@ export function DownloadsContent() {
as="h1"
badge="Verified Binaries"
title="Downloads & Releases"
- subtitle="Production builds engineered for Android devices with split ABI support. Always free and open source."
+ subtitle="Verified, privacy-first Android builds with optimized packages for your device architecture. Always free and open source."
/>
@@ -156,18 +193,37 @@ export function DownloadsContent() {
latestRelease && (
-
+
Recommended Production Build
+
+
Release notes & milestones
+
+
+
+ First time installing an Android APK? Follow our{" "}
+
+ Installation & Setup Guide
+
+ .
+
)
@@ -221,11 +277,16 @@ export function DownloadsContent() {
-
+
{FILTER_CHANNELS.map((item) => (
setStatus(item.id)}
>
@@ -234,6 +295,10 @@ export function DownloadsContent() {
))}
+
+ {CHANNEL_EXPLANATIONS[status]}
+
+
{hasControls && (
Showing{" "}
diff --git a/src/components/downloads/ReleaseList.tsx b/src/components/downloads/ReleaseList.tsx
index 6078b85..cb1c87f 100644
--- a/src/components/downloads/ReleaseList.tsx
+++ b/src/components/downloads/ReleaseList.tsx
@@ -1,6 +1,13 @@
"use client";
-import { Download, Calendar, ExternalLink, AlertTriangle } from "lucide-react";
+import {
+ Download,
+ Calendar,
+ ExternalLink,
+ AlertTriangle,
+ Search,
+ ArrowRight,
+} from "lucide-react";
import { ArchDownload } from "@/components/downloads/ArchDownload";
import {
classifyRelease,
@@ -9,7 +16,8 @@ import {
} from "@/hooks/useGithubRelease";
import type { GithubRelease } from "@/types/github";
import Link from "next/link";
-import { formatDate } from "@passcodes/passalgo";
+import { formatDate, formatNumber } from "@passcodes/passalgo";
+import { getChangelogEntryByTagName } from "@/lib/changelog";
export function ReleaseList({
releases,
@@ -21,17 +29,21 @@ export function ReleaseList({
if (releases.length === 0) {
return (
-
+
+
No releases match your filter
-
- Try adjusting your search query or channel filter.
+
+ Try adjusting your search query or channel filter to view available builds.
{onResetFilters && (
Reset filters
@@ -50,6 +62,7 @@ export function ReleaseList({
const channel = classifyRelease(release);
const isYanked = isYankedRelease(release);
const isDeprecated = isDeprecatedRelease(release);
+ const changelogEntry = getChangelogEntryByTagName(release.tag_name);
return (
{isDeprecated && (
- deprecated
+ Deprecated
)}
{isYanked && (
-
- yanked
+
+ Yanked
)}
- {channel}
+ {channel === "stable" ? "Stable" : channel === "beta" ? "Beta" : "Alpha"}
@@ -91,7 +104,7 @@ export function ReleaseList({
className="mr-1 inline h-3 w-3"
aria-hidden="true"
/>
- {totalDownloads}
+ {formatNumber(totalDownloads)}
@@ -113,21 +126,56 @@ export function ReleaseList({
)}
-
-
-
-
-
GitHub Manifest
-
+ {isYanked && (
+
+
+
+ This release was withdrawn from active distribution.
+ Preserved for documentation and project history only.
+
+
+ )}
+
+
+ {isYanked ? (
+
+ Downloads withdrawn for this release
+
+ ) : (
+
+ )}
+
+
+
Project Updates
+
+
+
+
+
GitHub
+
+
);
diff --git a/src/components/home/Hero.tsx b/src/components/home/Hero.tsx
index b3337c6..99563bf 100644
--- a/src/components/home/Hero.tsx
+++ b/src/components/home/Hero.tsx
@@ -17,14 +17,18 @@ import { GithubIcon, DiscordIcon } from "@/components/ui/BrandIcons";
import { ScrollReveal } from "@/components/motion/ScrollReveal";
import { useDownloadCount } from "@/hooks/useDownloadCount";
import { useLatestRelease } from "@/hooks/useGithubRelease";
-import { getLatestChangelogEntry } from "@/lib/changelog";
+import {
+ getLatestProductionRelease,
+ getLatestChangelogEntry,
+} from "@/lib/changelog";
import { formatNumber } from "@passcodes/passalgo";
import { GITHUB_REPO_URL, DISCORD_URL, USER_GUIDE_URL } from "@/lib/constants";
export function Hero() {
const { data: downloadStats } = useDownloadCount();
const { data: latestRelease } = useLatestRelease();
- const latestEntry = getLatestChangelogEntry();
+ const latestEntry =
+ getLatestProductionRelease() || getLatestChangelogEntry();
const displayVersion = latestRelease
? `v${latestRelease.tag_name.replace(/^v/, "")}`
@@ -72,7 +76,7 @@ export function Hero() {
{displayVersion}
- Passcodes {latestEntry.version} is live
+ Passcodes {displayVersion} is live
entry.slug === slug);
}
+export function getChangelogEntryByTagName(
+ tagName: string
+): ChangelogEntry | undefined {
+ const cleanTag = tagName.trim().toLowerCase();
+ const tagWithoutV = cleanTag.startsWith("v") ? cleanTag.slice(1) : cleanTag;
+ const tagWithV = cleanTag.startsWith("v") ? cleanTag : `v${cleanTag}`;
+
+ return CHANGELOG_ENTRIES.find((entry) => {
+ const v = entry.version.toLowerCase();
+ return (
+ v === cleanTag ||
+ v === tagWithV ||
+ v === tagWithoutV ||
+ entry.slug === cleanTag ||
+ entry.slug.startsWith(cleanTag)
+ );
+ });
+}
+
export function getLatestChangelogEntry(): ChangelogEntry {
return (
CHANGELOG_ENTRIES.find((e) => !e.isMilestone) || CHANGELOG_ENTRIES[0]
@@ -106,3 +125,158 @@ export function isDeprecatedEntry(entry: ChangelogEntry): boolean {
const major = parseInt(match[1], 10);
return major === 1 || major === 2;
}
+
+export type ReleaseState =
+ | "Stable"
+ | "Beta"
+ | "Alpha"
+ | "Deprecated"
+ | "Yanked"
+ | "Milestone";
+
+/**
+ * Classify a changelog entry into its primary semantic release state.
+ */
+export function getReleaseState(entry: ChangelogEntry): ReleaseState {
+ if (entry.isMilestone) return "Milestone";
+ if (entry.isYanked) return "Yanked";
+ if (isDeprecatedEntry(entry)) return "Deprecated";
+ return entry.releaseType;
+}
+
+/**
+ * Get the current stable production release.
+ * Excludes milestones, yanked releases, deprecated releases, and non-stable channels.
+ */
+export function getLatestProductionRelease(): ChangelogEntry | undefined {
+ return CHANGELOG_ENTRIES.find(
+ (entry) =>
+ !entry.isMilestone &&
+ !entry.isYanked &&
+ !isDeprecatedEntry(entry) &&
+ entry.releaseType === "Stable"
+ );
+}
+
+/**
+ * Get the most recent meaningful updates (active, non-milestone releases).
+ */
+export function getRecentUpdates(count: number = 3): ChangelogEntry[] {
+ return CHANGELOG_ENTRIES.filter(
+ (entry) =>
+ !entry.isMilestone &&
+ !isDeprecatedEntry(entry) &&
+ !entry.isYanked
+ ).slice(0, count);
+}
+
+/**
+ * Get documented major architectural milestones.
+ * Includes the explicit architecture transition milestone and major version baseline releases.
+ */
+export function getProjectMilestones(): ChangelogEntry[] {
+ return CHANGELOG_ENTRIES.filter(
+ (entry) =>
+ entry.isMilestone ||
+ (entry.isMajor &&
+ ["v3.0.0", "v2.0.0", "v1.0.0"].includes(entry.version))
+ );
+}
+
+export const RELEASE_CHANNEL_FILTERS = [
+ "All",
+ "Stable",
+ "Beta",
+ "Alpha",
+ "Milestones",
+ "Deprecated",
+ "Yanked",
+] as const;
+
+export type ReleaseChannelFilter = (typeof RELEASE_CHANNEL_FILTERS)[number];
+
+/**
+ * Search across version, title, summary, highlights, technical details, and release state.
+ */
+export function matchesReleaseSearch(
+ entry: ChangelogEntry,
+ query: string
+): boolean {
+ const searchLower = query.trim().toLowerCase();
+ if (!searchLower) return true;
+
+ const state = getReleaseState(entry).toLowerCase();
+ const releaseType = entry.releaseType.toLowerCase();
+ const isDep = isDeprecatedEntry(entry) ? "deprecated" : "";
+ const isYank = entry.isYanked ? "yanked" : "";
+ const isMs = entry.isMilestone ? "milestone" : "";
+
+ // Version, title, summary, tldr
+ if (entry.version.toLowerCase().includes(searchLower)) return true;
+ if (entry.title.toLowerCase().includes(searchLower)) return true;
+ if (entry.summary.toLowerCase().includes(searchLower)) return true;
+ if (entry.tldr?.toLowerCase().includes(searchLower)) return true;
+
+ // Highlights
+ if (entry.highlights?.some((h) => h.toLowerCase().includes(searchLower))) {
+ return true;
+ }
+
+ // Sections
+ if (
+ entry.sections?.some(
+ (s) =>
+ s.title.toLowerCase().includes(searchLower) ||
+ s.items.some((item) =>
+ item.toLowerCase().includes(searchLower)
+ )
+ )
+ ) {
+ return true;
+ }
+
+ // Release states & channels
+ if (
+ state.includes(searchLower) ||
+ releaseType.includes(searchLower) ||
+ isDep.includes(searchLower) ||
+ isYank.includes(searchLower) ||
+ isMs.includes(searchLower)
+ ) {
+ return true;
+ }
+
+ // Technical build & runtime details
+ if (entry.internalDetails) {
+ const details = entry.internalDetails;
+ if (details.packageName?.toLowerCase().includes(searchLower)) {
+ return true;
+ }
+ if (details.versionName?.toLowerCase().includes(searchLower)) {
+ return true;
+ }
+ if (details.masterDbVersion?.toLowerCase().includes(searchLower)) {
+ return true;
+ }
+ if (details.minAndroid?.toLowerCase().includes(searchLower)) {
+ return true;
+ }
+ if (details.maxAndroid?.toLowerCase().includes(searchLower)) {
+ return true;
+ }
+ if (
+ details.expoSdk &&
+ String(details.expoSdk).toLowerCase().includes(searchLower)
+ ) {
+ return true;
+ }
+ if (
+ details.versionCode &&
+ String(details.versionCode).includes(searchLower)
+ ) {
+ return true;
+ }
+ }
+
+ return false;
+}
diff --git a/tests/functional/changelog-search.spec.ts b/tests/functional/changelog-search.spec.ts
index e9ca501..c35d58a 100644
--- a/tests/functional/changelog-search.spec.ts
+++ b/tests/functional/changelog-search.spec.ts
@@ -44,6 +44,24 @@ test.describe("Changelog Search Functional Tests", () => {
expect(restoredCount).toBeGreaterThan(5);
});
+ test("searching by release state filters to deprecated or yanked entries", async ({ page }) => {
+ const searchInput = page.getByLabel("Search changelog entries");
+
+ // Search for "yanked"
+ await searchInput.fill("yanked");
+ const yankedArticles = page.locator("article").filter({
+ has: page.locator(".tag").filter({ hasText: /yanked/i }),
+ });
+ await expect(yankedArticles.first()).toBeVisible();
+
+ // Search for "deprecated"
+ await searchInput.fill("deprecated");
+ const deprecatedArticles = page.locator("article").filter({
+ has: page.locator(".tag.deprecated"),
+ });
+ await expect(deprecatedArticles.first()).toBeVisible();
+ });
+
test("no results message appears on unmatched query and reset works", async ({ page }) => {
const searchInput = page.getByLabel("Search changelog entries");
await searchInput.fill("nonexistent-release-query-xyz");
diff --git a/tests/functional/release-filters.spec.ts b/tests/functional/release-filters.spec.ts
index 02904fd..af8928f 100644
--- a/tests/functional/release-filters.spec.ts
+++ b/tests/functional/release-filters.spec.ts
@@ -39,6 +39,20 @@ test.describe("Downloads Channel Filter & Search Functional Tests", () => {
await expect(archiveCards.locator(".tag.alpha").first()).toBeVisible();
await expect(archiveCards.locator(".tag.stable")).toHaveCount(0);
+ // Filter by Deprecated
+ const deprecatedFilterBtn = page.getByRole("button", { name: "Deprecated", exact: true });
+ await deprecatedFilterBtn.click();
+ await expect(deprecatedFilterBtn).toHaveClass(/active/);
+ await expect(archiveCards.first()).toBeVisible();
+ await expect(archiveCards.locator(".tag.deprecated").first()).toBeVisible();
+
+ // Filter by Yanked
+ const yankedFilterBtn = page.getByRole("button", { name: "Yanked", exact: true });
+ await yankedFilterBtn.click();
+ await expect(yankedFilterBtn).toHaveClass(/active/);
+ await expect(archiveCards.first()).toBeVisible();
+ await expect(archiveCards.locator(".tag").filter({ hasText: /yanked/i }).first()).toBeVisible();
+
// Reset to All
await allFilterBtn.click();
await expect(allFilterBtn).toHaveClass(/active/);
@@ -67,4 +81,20 @@ test.describe("Downloads Channel Filter & Search Functional Tests", () => {
await expect(searchInput).toHaveValue("");
await expect(archiveCards.count()).resolves.toBeGreaterThan(1);
});
+
+ test("unmatched query shows friendly empty state with working reset button", async ({ page }) => {
+ const searchInput = page.getByLabel("Search releases");
+ await searchInput.fill("nonexistentquery9999");
+
+ const emptyMessage = page.getByText(/No releases match your/i);
+ await expect(emptyMessage).toBeVisible();
+
+ const resetBtn = page.getByRole("button", { name: "Reset filters" });
+ await expect(resetBtn).toBeVisible();
+ await resetBtn.click();
+
+ await expect(searchInput).toHaveValue("");
+ const archiveCards = page.locator(".release-card:not(.latest)");
+ await expect(archiveCards.first()).toBeVisible();
+ });
});
diff --git a/tests/smoke/changelog.spec.ts b/tests/smoke/changelog.spec.ts
index f3085b7..44c4ea7 100644
--- a/tests/smoke/changelog.spec.ts
+++ b/tests/smoke/changelog.spec.ts
@@ -7,7 +7,12 @@ test.describe("Changelog Smoke & Detail Page Tests", () => {
test("changelog index loads and displays timeline stream", async ({ page }) => {
await expect(page).toHaveTitle(/Passcodes/i);
- await expect(page.getByRole("heading", { name: "Release Highlights & Milestones", level: 1 })).toBeVisible();
+ await expect(
+ page.getByRole("heading", {
+ name: "Release Highlights & Milestones",
+ level: 1,
+ })
+ ).toBeVisible();
// Articles/timeline entries render
const articles = page.locator("article");
@@ -17,6 +22,59 @@ test.describe("Changelog Smoke & Detail Page Tests", () => {
await expect(page.getByText("v3.2.1").first()).toBeVisible();
});
+ test("current production release hero renders prominently with actions", async ({ page }) => {
+ // Current production badge
+ await expect(page.getByText(/Current Production Release/i)).toBeVisible();
+
+ // Top production heading
+ const heroHeading = page.locator("#current-release");
+ await expect(heroHeading).toBeVisible();
+ await expect(heroHeading.getByRole("heading", { level: 2 })).toContainText("v3.2.1");
+
+ // Action links
+ const downloadBtn = heroHeading.getByRole("link", { name: /Get v3\.2\.1 APK/i });
+ await expect(downloadBtn).toBeVisible();
+ await expect(downloadBtn).toHaveAttribute("href", /\/downloads\/?/);
+
+ const detailsBtn = heroHeading.getByRole("link", { name: /Release Details/i });
+ await expect(detailsBtn).toBeVisible();
+ });
+
+ test("recent updates and architecture milestones sections render properly", async ({ page }) => {
+ // Recent Updates
+ await expect(
+ page.getByRole("heading", { name: "Recent Updates", level: 2 })
+ ).toBeVisible();
+
+ // Major Architectural Milestones
+ await expect(
+ page.getByRole("heading", {
+ name: "Major Architectural Milestones",
+ level: 2,
+ })
+ ).toBeVisible();
+ await expect(
+ page.getByText(/End of Android-Only Codebase/i).first()
+ ).toBeVisible();
+ });
+
+ test("release channel filter pills update visible timeline entries", async ({ page }) => {
+ const betaChannelBtn = page.getByRole("button", { name: "Beta", exact: true });
+ await expect(betaChannelBtn).toBeVisible();
+ await betaChannelBtn.click();
+
+ // Beta releases should be visible
+ const articles = page.locator("article");
+ await expect(articles.first()).toBeVisible();
+ await expect(articles.locator(".tag.beta").first()).toBeVisible();
+ // Should not show active stable in beta filter
+ await expect(articles.locator(".tag.stable")).toHaveCount(0);
+
+ // Reset by clicking All
+ const allChannelBtn = page.getByRole("button", { name: "All", exact: true }).filter({ hasText: /^All$/ }).last();
+ await allChannelBtn.click();
+ });
+
test("historical releases display deprecated and yanked indicators appropriately", async ({ page }) => {
// v2.1.1 should have Deprecated tag
const v2Entry = page.locator("article").filter({ hasText: /v2\.1\.1/i }).first();
@@ -24,10 +82,15 @@ test.describe("Changelog Smoke & Detail Page Tests", () => {
await expect(v2Entry.locator(".tag.deprecated")).toBeVisible();
// v1.2.0 should display Yanked tag and Deprecated tag
- const v120Entry = page.locator("article").filter({ has: page.getByRole("heading", { name: /v1\.2\.0/i }) }).first();
+ const v120Entry = page
+ .locator("article")
+ .filter({ has: page.getByRole("heading", { name: /v1\.2\.0/i }) })
+ .first();
await expect(v120Entry).toBeVisible();
await expect(v120Entry.locator(".tag.deprecated")).toBeVisible();
- await expect(v120Entry.locator(".tag").filter({ hasText: /yanked/i })).toBeVisible();
+ await expect(
+ v120Entry.locator(".tag").filter({ hasText: /yanked/i })
+ ).toBeVisible();
});
test("release detail page loads with metadata and navigates back", async ({ page }) => {
@@ -42,6 +105,22 @@ test.describe("Changelog Smoke & Detail Page Tests", () => {
// Release Highlights or sections are visible
await expect(page.getByText(/Release Highlights/i)).toBeVisible();
+ // Documentation resources are visible
+ await expect(page.getByText(/Relevant Documentation/i)).toBeVisible();
+
+ // Breadcrumb navigation is visible
+ await expect(page.getByRole("navigation", { name: "Breadcrumb" })).toBeVisible();
+
+ // Next Steps journey card is visible
+ await expect(page.getByRole("heading", { name: "Ready to run Passcodes?" })).toBeVisible();
+ await expect(page.getByRole("link", { name: "Download APK" })).toHaveAttribute("href", /\/downloads\/?/);
+ await expect(page.getByRole("link", { name: "All Updates", exact: true })).toHaveAttribute("href", /\/changelog\/?/);
+
+ // Download APK button is visible for stable release
+ await expect(
+ page.getByRole("link", { name: /Get v3\.2\.1 APK/i })
+ ).toBeVisible();
+
// Back link works
const backLink = page.getByRole("link", { name: /Back to all updates/i });
await expect(backLink).toBeVisible();
@@ -57,4 +136,16 @@ test.describe("Changelog Smoke & Detail Page Tests", () => {
await expect(page.locator(".tag.deprecated")).toContainText(/Deprecated Release/i);
await expect(page.getByText(/Deprecated Release Notice/i)).toBeVisible();
});
+
+ test("yanked release detail page displays yanked notice banner without download button", async ({ page }) => {
+ // Navigate to yanked release detail page
+ await page.goto("/changelog/v1-2-0-alpha-yanked/");
+
+ await expect(page).toHaveTitle(/v1\.2\.0.*Passcodes Changelog/i);
+ await expect(page.getByText(/Yanked Release Notice/i)).toBeVisible();
+ // Should not offer a direct get APK button for yanked release
+ await expect(
+ page.getByRole("link", { name: /Get v1\.2\.0 APK/i })
+ ).toHaveCount(0);
+ });
});
diff --git a/tests/smoke/downloads.spec.ts b/tests/smoke/downloads.spec.ts
index ec41e53..d91d639 100644
--- a/tests/smoke/downloads.spec.ts
+++ b/tests/smoke/downloads.spec.ts
@@ -21,6 +21,23 @@ test.describe("Downloads Page Smoke & Regression Tests", () => {
// Download controls are rendered
const downloadOptions = page.locator(".arch-download-btn, .arch-dropdown, a[href*='.apk']");
await expect(downloadOptions.first()).toBeVisible();
+
+ // Link to release notes & milestones in /changelog
+ const releaseNotesLink = page.getByRole("link", { name: /Release notes & milestones/i });
+ await expect(releaseNotesLink).toBeVisible();
+ await expect(releaseNotesLink).toHaveAttribute("href", /\/changelog\/?/);
+
+ // Download cards contain "View in Project Updates" link
+ const updatesLink = page.getByRole("link", { name: /View in Project Updates/i }).first();
+ await expect(updatesLink).toBeVisible();
+ await expect(updatesLink).toHaveAttribute("href", /\/changelog/);
+
+ // Setup guide link prompt is visible below recommended build
+ const setupLink = page.getByRole("link", { name: "Installation & Setup Guide" });
+ await expect(setupLink).toBeVisible();
+
+ // Channel explanation text is visible
+ await expect(page.getByText(/Displaying all recorded releases across active/i)).toBeVisible();
});
test("deprecated v1.x and v2.x releases display deprecation indicators while v3.x does not", async ({ page }) => {
@@ -49,4 +66,16 @@ test.describe("Downloads Page Smoke & Regression Tests", () => {
await expect(v120Card.locator(".tag.deprecated")).toBeVisible();
await expect(v120Card.locator(".tag").filter({ hasText: /yanked/i })).toBeVisible();
});
+
+ test("archive release item allows expanding architecture variants", async ({ page }) => {
+ const buildsToggle = page.locator(".release-card:not(.latest) button", { hasText: /builds/i }).first();
+ if (await buildsToggle.isVisible()) {
+ await expect(buildsToggle).toHaveAttribute("aria-expanded", "false");
+ await buildsToggle.click();
+ await expect(buildsToggle).toHaveAttribute("aria-expanded", "true");
+ // Expanded variants list should now be visible
+ const variantLinks = page.locator(".release-card:not(.latest) ul a[href*='.apk']");
+ await expect(variantLinks.first()).toBeVisible();
+ }
+ });
});
diff --git a/tests/smoke/mobile.spec.ts b/tests/smoke/mobile.spec.ts
index 8ea8988..783504c 100644
--- a/tests/smoke/mobile.spec.ts
+++ b/tests/smoke/mobile.spec.ts
@@ -47,4 +47,28 @@ test.describe("Mobile Responsive Usability Tests", () => {
expect(hasHorizontalOverflow, "Changelog has unexpected horizontal overflow").toBe(false);
});
+
+ test("homepage does not horizontally overflow viewport on mobile", async ({ page, isMobile }) => {
+ test.skip(!isMobile, "Horizontal overflow check targeted for mobile viewports");
+
+ await page.goto("/");
+
+ const hasHorizontalOverflow = await page.evaluate(() => {
+ return document.documentElement.scrollWidth > window.innerWidth;
+ });
+
+ expect(hasHorizontalOverflow, "Homepage has unexpected horizontal overflow").toBe(false);
+ });
+
+ test("release detail page does not horizontally overflow viewport on mobile", async ({ page, isMobile }) => {
+ test.skip(!isMobile, "Horizontal overflow check targeted for mobile viewports");
+
+ await page.goto("/changelog/v3-2-1-stable/");
+
+ const hasHorizontalOverflow = await page.evaluate(() => {
+ return document.documentElement.scrollWidth > window.innerWidth;
+ });
+
+ expect(hasHorizontalOverflow, "Release detail page has unexpected horizontal overflow").toBe(false);
+ });
});