diff --git a/src/app/changelog/[slug]/page.tsx b/src/app/changelog/[slug]/page.tsx index 54a69f3..972f0e6 100644 --- a/src/app/changelog/[slug]/page.tsx +++ b/src/app/changelog/[slug]/page.tsx @@ -234,7 +234,7 @@ export default function ChangelogEntryPage({ {entry.deprecatedReason || "This release is no longer supported. Superseded by the newer database architecture introduced in later releases. Please use a current release instead."}

-
+
View current production downloads
)} diff --git a/src/app/upgrade/page.tsx b/src/app/upgrade/page.tsx new file mode 100644 index 0000000..e746616 --- /dev/null +++ b/src/app/upgrade/page.tsx @@ -0,0 +1,21 @@ +import type { Metadata } from "next"; +import { UpgradeContent } from "@/components/upgrade/UpgradeContent"; +import { SITE_META } from "@/lib/constants"; + +export const metadata: Metadata = { + title: "Upgrade & Migration Guide | Passcodes", + description: + "Find the right next step for your Passcodes installation. Step-by-step guidance for first-time installs, version updates, legacy migrations, device changes, and backups.", + openGraph: { + title: "Upgrade & Migration Guide | Passcodes", + description: + "Find the right next step for your Passcodes installation. Guidance for first-time installs, version updates, legacy migrations, device changes, and backups.", + url: `${SITE_META.url}/upgrade/`, + siteName: "Passcodes", + type: "website", + }, +}; + +export default function UpgradePage() { + return ; +} diff --git a/src/components/downloads/DownloadCard.tsx b/src/components/downloads/DownloadCard.tsx index ede9eec..d2569c7 100644 --- a/src/components/downloads/DownloadCard.tsx +++ b/src/components/downloads/DownloadCard.tsx @@ -112,6 +112,15 @@ export function DownloadCard({ newer database architecture introduced in later releases. Please use a current release instead.

+
+ + Upgrade & Migration Guidance +
)} diff --git a/src/components/downloads/DownloadsContent.tsx b/src/components/downloads/DownloadsContent.tsx index 1388f15..58ebbd6 100644 --- a/src/components/downloads/DownloadsContent.tsx +++ b/src/components/downloads/DownloadsContent.tsx @@ -222,6 +222,13 @@ export function DownloadsContent() { > Installation & Setup Guide + {" "}or visit the{" "} + + Upgrade & Migration Center + .

@@ -229,7 +236,35 @@ export function DownloadsContent() { ) )} - {/* 3. Alternative Distribution */} + {/* 3. Upgrade & Migration Guidance */} + +
+
+
+
+ + Planning an Update? + +
+

+ Upgrade & Migration Center +

+

+ Need help deciding how to update safely, switching phones, or upgrading from a deprecated v1.x/v2.x release? Follow our guided decision flow before installing. +

+
+ + Get Guidance +
+
+
+ + {/* 4. Alternative Distribution */} diff --git a/src/components/downloads/ReleaseList.tsx b/src/components/downloads/ReleaseList.tsx index cb1c87f..6401727 100644 --- a/src/components/downloads/ReleaseList.tsx +++ b/src/components/downloads/ReleaseList.tsx @@ -123,6 +123,15 @@ export function ReleaseList({ architecture introduced in later releases. Please use a current release instead.

+
+ + Upgrade & Migration Guidance +
)} diff --git a/src/components/layout/Footer.tsx b/src/components/layout/Footer.tsx index f063c3d..1553cf0 100644 --- a/src/components/layout/Footer.tsx +++ b/src/components/layout/Footer.tsx @@ -53,6 +53,14 @@ export function Footer() { Changelog +
  • + + Upgrade & Migration + +
  • (null); + const [followUp, setFollowUp] = useState(null); + + const latestProd = useMemo(() => getLatestProductionRelease(), []); + + const situationRefs = useRef<(HTMLButtonElement | null)[]>([]); + const updateFollowupRefs = useRef<(HTMLButtonElement | null)[]>([]); + const legacyFollowupRefs = useRef<(HTMLButtonElement | null)[]>([]); + + const resetSelection = () => { + setSituation(null); + setFollowUp(null); + }; + + const handleSelectSituation = (id: SituationKey) => { + setSituation(id); + setFollowUp(null); + }; + + const handleSituationKeyDown = ( + e: React.KeyboardEvent, + index: number + ) => { + let nextIndex = -1; + if (e.key === "ArrowDown" || e.key === "ArrowRight") { + e.preventDefault(); + nextIndex = (index + 1) % SITUATION_OPTIONS.length; + } else if (e.key === "ArrowUp" || e.key === "ArrowLeft") { + e.preventDefault(); + nextIndex = (index - 1 + SITUATION_OPTIONS.length) % SITUATION_OPTIONS.length; + } else if (e.key === "Home") { + e.preventDefault(); + nextIndex = 0; + } else if (e.key === "End") { + e.preventDefault(); + nextIndex = SITUATION_OPTIONS.length - 1; + } + + if (nextIndex >= 0) { + const nextOpt = SITUATION_OPTIONS[nextIndex]; + handleSelectSituation(nextOpt.id); + situationRefs.current[nextIndex]?.focus(); + } + }; + + const handleUpdateFollowupKeyDown = ( + e: React.KeyboardEvent, + index: number + ) => { + let nextIndex = -1; + if (e.key === "ArrowDown" || e.key === "ArrowRight") { + e.preventDefault(); + nextIndex = (index + 1) % UPDATE_FOLLOWUP_OPTIONS.length; + } else if (e.key === "ArrowUp" || e.key === "ArrowLeft") { + e.preventDefault(); + nextIndex = (index - 1 + UPDATE_FOLLOWUP_OPTIONS.length) % UPDATE_FOLLOWUP_OPTIONS.length; + } else if (e.key === "Home") { + e.preventDefault(); + nextIndex = 0; + } else if (e.key === "End") { + e.preventDefault(); + nextIndex = UPDATE_FOLLOWUP_OPTIONS.length - 1; + } + + if (nextIndex >= 0) { + const nextOpt = UPDATE_FOLLOWUP_OPTIONS[nextIndex]; + setFollowUp(nextOpt.id); + updateFollowupRefs.current[nextIndex]?.focus(); + } + }; + + const handleLegacyFollowupKeyDown = ( + e: React.KeyboardEvent, + index: number + ) => { + let nextIndex = -1; + if (e.key === "ArrowDown" || e.key === "ArrowRight") { + e.preventDefault(); + nextIndex = (index + 1) % LEGACY_FOLLOWUP_OPTIONS.length; + } else if (e.key === "ArrowUp" || e.key === "ArrowLeft") { + e.preventDefault(); + nextIndex = (index - 1 + LEGACY_FOLLOWUP_OPTIONS.length) % LEGACY_FOLLOWUP_OPTIONS.length; + } else if (e.key === "Home") { + e.preventDefault(); + nextIndex = 0; + } else if (e.key === "End") { + e.preventDefault(); + nextIndex = LEGACY_FOLLOWUP_OPTIONS.length - 1; + } + + if (nextIndex >= 0) { + const nextOpt = LEGACY_FOLLOWUP_OPTIONS[nextIndex]; + setFollowUp(nextOpt.id); + legacyFollowupRefs.current[nextIndex]?.focus(); + } + }; + + // Determine current active result key + const activeResult = useMemo(() => { + if (!situation) return null; + if (situation === "first-time") return "first-time"; + if (situation === "device-switch") return "device-switch"; + if (situation === "check-version") return "check-version"; + + if (situation === "update") { + if (!followUp) return null; + if (followUp === "v3") return "update-v3"; + if (followUp === "legacy") return "legacy-general"; + if (followUp === "not-sure") return "check-version"; + } + + if (situation === "legacy") { + if (!followUp) return null; + if (followUp === "v2") return "legacy-v2"; + if (followUp === "v1") return "legacy-v1"; + if (followUp === "yanked") return "yanked"; + if (followUp === "not-sure") return "check-version"; + } + + return null; + }, [situation, followUp]); + + return ( +
    +
    + {/* 1. Breadcrumbs */} + + + + + + + {/* 2. Step 1: Primary Situation Selection */} + +
    +
    +
    + + Step 1 of {situation === "update" || situation === "legacy" ? "2" : "1"} + +

    + What are you trying to do? +

    +

    + Select the scenario that best describes your current setup. +

    +
    + + {situation && ( + + )} +
    + +
    + {SITUATION_OPTIONS.map((opt, index) => { + const isSelected = situation === opt.id; + const isFirstOption = index === 0; + const tabIndex = situation ? (isSelected ? 0 : -1) : (isFirstOption ? 0 : -1); + const Icon = opt.icon; + return ( + + ); + })} +
    +
    +
    + + {/* 3. Step 2: Follow-up question if situation is Update */} + {situation === "update" && ( +
    +
    +
    + + Step 2 of 2 + +

    + Which version family is currently installed on your phone? +

    +

    + Updating within the same major version is straightforward; jumping across legacy architectures requires special precautions. +

    +
    + +
    + {UPDATE_FOLLOWUP_OPTIONS.map((opt, index) => { + const isSelected = followUp === opt.id; + const isFirstOption = index === 0; + const tabIndex = followUp ? (isSelected ? 0 : -1) : (isFirstOption ? 0 : -1); + return ( + + ); + })} +
    +
    +
    + )} + + {/* 4. Step 2: Follow-up question if situation is Legacy */} + {situation === "legacy" && ( +
    +
    +
    + + Step 2 of 2 + +

    + Which legacy release family are you using? +

    +

    + Legacy releases differ fundamentally in database schema and Android architecture from current v3.x releases. +

    +
    + +
    + {LEGACY_FOLLOWUP_OPTIONS.map((opt, index) => { + const isSelected = followUp === opt.id; + const isFirstOption = index === 0; + const tabIndex = followUp ? (isSelected ? 0 : -1) : (isFirstOption ? 0 : -1); + return ( + + ); + })} +
    +
    +
    + )} + + {/* 5. Recommendation Result Panel */} + {activeResult && ( +
    +
    + {/* Result: First-Time Installation */} + {activeResult === "first-time" && ( + + )} + + {/* Result: Standard v3.x Update */} + {activeResult === "update-v3" && ( + + )} + + {/* Result: Legacy Migration (v1 or v2) */} + {(activeResult === "legacy-general" || + activeResult === "legacy-v1" || + activeResult === "legacy-v2") && ( + + )} + + {/* Result: Device Switch / Reinstall / Restore */} + {activeResult === "device-switch" && ( + + )} + + {/* Result: How to identify version */} + {activeResult === "check-version" && ( + { + setSituation("update"); + setFollowUp("v3"); + }} + onSelectLegacy={() => { + setSituation("legacy"); + setFollowUp("v2"); + }} + onSelectFresh={() => { + setSituation("first-time"); + setFollowUp(null); + }} + /> + )} + + {/* Result: Explicitly Yanked Release */} + {activeResult === "yanked" && ( + + )} +
    +
    + )} +
    +
    + ); +} + +/* ========================================================================= */ +/* Result Component: First-Time Installation */ +/* ========================================================================= */ +function ResultPanelFirstTime({ + latestProd, +}: { + latestProd: ReturnType; +}) { + return ( +
    +
    + + + Production Build +
    + +

    + Installing Passcodes for the First Time +

    + + {/* Recommended next step callout */} +
    +
    +
    +
    + + {/* Before You Continue */} +
    +

    + Before You Continue — Pre-Flight Checklist +

    +
      +
    • +
    • +
    • +
    • +
    • +
    • +
    • +
    • +
    +
    + + {/* Target Release Info */} + {latestProd && ( +
    +
    +
    +
    + + +
    +

    + {latestProd.summary} +

    +
    + )} + + {/* CTAs */} +
    + +
    +
    + ); +} + +/* ========================================================================= */ +/* Result Component: Standard v3.x Update */ +/* ========================================================================= */ +function ResultPanelUpdateV3({ + latestProd, +}: { + latestProd: ReturnType; +}) { + return ( +
    +
    + + + v3.x Series +
    + +

    + Updating Within the v3.x Series +

    + + {/* Recommended next step callout */} +
    +
    +
    +
    + + {/* Before You Continue */} +
    +

    + Before You Continue — Pre-Update Checklist +

    +
      +
    • +
    • +
    • +
    • +
    • +
    • +
    • +
    • +
    • +
    • +
    +
    + + {/* Target Release Info */} + {latestProd && ( +
    +
    + + Target Update: {latestProd.version} (Stable) + + + Published {formatDate(latestProd.date)} + +
    +

    + {latestProd.summary} +

    +
    + )} + + {/* CTAs */} +
    + +
    +
    + ); +} + +/* ========================================================================= */ +/* Result Component: Legacy Upgrade (v1.x or v2.x to v3.x) */ +/* ========================================================================= */ +function ResultPanelLegacy({ + tier, + latestProd, +}: { + tier: "v1" | "v2" | "general"; + latestProd: ReturnType; +}) { + return ( +
    +
    + + Deprecated Release Family + + + +
    + +

    + Upgrading from Legacy {tier === "v1" ? "v1.x" : tier === "v2" ? "v2.x" : "v1.x / v2.x"} +

    + + {/* Warning Callout */} +
    +
    +
    +
    + + {/* Recommended next step callout */} +
    +

    + Recommended Next Step +

    +

    + Manually export your passwords to CSV from your current app, do not uninstall Passcodes, and review official guidance or ask the maintainers before attempting an upgrade. +

    +
    + + {/* Before You Continue */} +
    +

    + Mandatory Precautions Before Proceeding +

    +
      +
    • +
    • +
    • +
    • +
    • +
    • +
    • +
    • +
    +
    + + {/* Official Resources & Target */} +
    +
    + + Latest Production Target{latestProd ? `: ${latestProd.version}` : ""} + + + Read Architecture Transition Milestone + +
    +

    + Refer to the v3.0.0 and v3.1.0 release notes for technical background on the Drizzle DB migration and the troubleshooting recovery workflow. +

    +
    + + {/* CTAs */} +
    + +
    +
    + ); +} + +/* ========================================================================= */ +/* Result Component: Moving Phones / Reinstalling / Restoring Data */ +/* ========================================================================= */ +function ResultPanelDeviceSwitch({ + latestProd, +}: { + latestProd: ReturnType; +}) { + return ( +
    +
    + + + Local Storage Only +
    + +

    + Moving to Another Phone or Restoring Data +

    + + {/* Recommended next step callout */} +
    +
    +
    +
    + + {/* CSV Data Safety Warning */} +
    +
    +
    +
    + + {/* Before You Continue */} +
    +

    + Important Facts & Step-by-Step Procedure +

    +
      +
    • +
    • +
    • +
    • +
    • +
    • +
    • +
    • +
    • +
    • +
    • +
    • +
    +
    + + {/* CTAs */} +
    + +
    +
    + ); +} + +/* ========================================================================= */ +/* Result Component: How to identify installed version */ +/* ========================================================================= */ +function ResultPanelCheckVersion({ + onSelectV3, + onSelectLegacy, + onSelectFresh, +}: { + onSelectV3: () => void; + onSelectLegacy: () => void; + onSelectFresh: () => void; +}) { + return ( +
    +
    + + + Inspection Guide +
    + +

    + How to Check Which Version You Have +

    + + {/* Recommended next step callout */} +
    +

    + Recommended Next Step +

    +

    + Check your installed version number on the app's home screen or under Android system App Info, then select your matching path below. +

    +
    + + {/* Methods */} +
    +
    +
    +
    +
      +
    1. Open the Passcodes application.
    2. +
    3. Look at the bottom of the main passwords list or dashboard.
    4. +
    5. The version label will display the release tag (for example, v3.x-Stable or v2.x-Beta).
    6. +
    +
    + +
    +
    +
    +
      +
    1. Open your phone's Settings app.
    2. +
    3. Tap Apps → find Passcodes.
    4. +
    5. Scroll to the very bottom of the App Info page to view the installed version number.
    6. +
    +
    +
    + + {/* Quick action buttons based on findings */} +
    +

    + Once You Find Your Version, Continue Here: +

    +
    + + + +
    +
    + + {/* CTAs */} +
    + +
    +
    + ); +} + +/* ========================================================================= */ +/* Result Component: Explicitly Yanked Release */ +/* ========================================================================= */ +function ResultPanelYanked({ + latestProd, +}: { + latestProd: ReturnType; +}) { + return ( +
    +
    + + Yanked / Withdrawn Release + + Do Not Install +
    + +

    + Handling a Withdrawn or Yanked Release +

    + + {/* Warning Box */} +
    +
    +
    +
    + + {/* Recommended next step callout */} +
    +

    + Recommended Next Step +

    +

    + Export any credentials currently stored in the yanked build and upgrade to the recommended Stable production build. +

    +
    + + {/* Checklist */} +
    +

    + Important Checks for Yanked Releases +

    +
      +
    • +
    • +
    • +
    • +
    • +
    • +
    +
    + + {/* CTAs */} +
    + +
    +
    + ); +} diff --git a/tests/functional/upgrade-center.spec.ts b/tests/functional/upgrade-center.spec.ts new file mode 100644 index 0000000..40cf36d --- /dev/null +++ b/tests/functional/upgrade-center.spec.ts @@ -0,0 +1,279 @@ +import { test, expect } from "../fixtures/base-test"; +import { getLatestProductionRelease } from "../../src/lib/changelog"; + +test.describe("Upgrade & Migration Center Functional Tests", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/upgrade/"); + }); + + test("1. route loads with expected title, heading, breadcrumbs and accessible landmark", async ({ page }) => { + await expect(page).toHaveTitle(/Upgrade & Migration Guide \| Passcodes/i); + await expect( + page.getByRole("heading", { name: "Upgrade & Migration Center", level: 1 }) + ).toBeVisible(); + + // Semantic Breadcrumbs + const breadcrumb = page.getByRole("navigation", { name: /breadcrumb/i }); + await expect(breadcrumb).toBeVisible(); + const breadcrumbList = breadcrumb.locator("ol"); + await expect(breadcrumbList).toBeVisible(); + const currentItem = breadcrumb.locator("[aria-current='page']"); + await expect(currentItem).toHaveText("Upgrade & Migration Center"); + + // Step 1 question + await expect( + page.getByRole("heading", { name: /What are you trying to do\?/i, level: 2 }) + ).toBeVisible(); + }); + + test("2. displays all 5 primary situation cards in radiogroup", async ({ page }) => { + const situationGroup = page.getByRole("radiogroup", { name: /What are you trying to do\?/i }); + await expect(situationGroup).toBeVisible(); + + const options = situationGroup.getByRole("radio"); + await expect(options).toHaveCount(5); + + await expect(situationGroup.getByText(/Install Passcodes for the first time/i)).toBeVisible(); + await expect(situationGroup.getByText(/Update an existing installation/i)).toBeVisible(); + await expect(situationGroup.getByText(/Move to another phone or restore data/i)).toBeVisible(); + await expect(situationGroup.getByText(/I am on an older v1\.x or v2\.x release/i)).toBeVisible(); + await expect(situationGroup.getByText(/I am not sure which version I have/i)).toBeVisible(); + }); + + test("3. first-time installation pathway shows checklist and canonical downloads CTA", async ({ page }) => { + await page.getByRole("radio", { name: /Install Passcodes for the first time/i }).click(); + + // Heading in result panel + await expect( + page.getByRole("heading", { name: /Installing Passcodes for the First Time/i, level: 3 }) + ).toBeVisible(); + + // Recommended next step callout + await expect(page.getByText(/Recommended Next Step/i)).toBeVisible(); + await expect(page.getByText(/minimum requirement/i)).toBeVisible(); + + // Pre-flight checklist checks - verified minimum Android 8.0 without invented testing claims + await expect(page.getByText(/Passcodes requires Android 8\.0 \(API level 26\) or higher\./i)).toBeVisible(); + await expect(page.getByText(/Officially tested on Android 8 through Android 14\/17/i)).toHaveCount(0); + await expect(page.getByText(/Select the right package/i)).toBeVisible(); + await expect(page.getByText(/Local-only architecture/i)).toBeVisible(); + + // Dynamic latest release info + const latestProd = getLatestProductionRelease(); + if (latestProd) { + await expect(page.getByText(`Recommended Release: ${latestProd.version}`)).toBeVisible(); + } + + // Primary download CTA points to /downloads + const downloadsCta = page.getByRole("link", { name: /Go to Downloads/i }); + await expect(downloadsCta).toBeVisible(); + await expect(downloadsCta).toHaveAttribute("href", /\/downloads\/?/); + + // Official setup guide link + const setupGuideLink = page.getByRole("link", { name: /Official Setup Guide/i }); + await expect(setupGuideLink).toBeVisible(); + await expect(setupGuideLink).toHaveAttribute("target", "_blank"); + }); + + test("4. update pathway triggers step 2 and v3.x path displays standard update cautions", async ({ page }) => { + await page.getByRole("radio", { name: /Update an existing installation/i }).click(); + + // Step 2 appears + await expect( + page.getByRole("heading", { name: /Which version family is currently installed on your phone\?/i, level: 2 }) + ).toBeVisible(); + + // Choose v3.x + await page.getByRole("radio", { name: /v3\.x Series/i }).click(); + + // Result panel for v3 update + await expect( + page.getByRole("heading", { name: /Updating Within the v3\.x Series/i, level: 3 }) + ).toBeVisible(); + + // Checklist items with verified labels and warnings + await expect(page.getByText(/Export your passwords first/i)).toBeVisible(); + await expect(page.getByText(/Settings → Export passwords/i)).toBeVisible(); + await expect(page.getByText(/CSV data safety/i)).toBeVisible(); + await expect(page.getByText(/credentials in unencrypted readable plaintext/i)).toBeVisible(); + await expect(page.getByText(/Never uninstall the app/i)).toBeVisible(); + await expect(page.getByText(/Documented update path/i)).toBeVisible(); + await expect(page.getByText(/Optional troubleshooting mechanism/i)).toBeVisible(); + await expect(page.getByText(/Settings → Troubleshooting → Data Recovery/i)).toBeVisible(); + + // Dynamic target update version + const latestProd = getLatestProductionRelease(); + if (latestProd) { + await expect(page.getByText(`Target Update: ${latestProd.version} (Stable)`)).toBeVisible(); + const changelogLink = page.getByRole("link", { name: /View Release Notes/i }); + await expect(changelogLink).toBeVisible(); + await expect(changelogLink).toHaveAttribute("href", new RegExp(`/changelog/${latestProd.slug}/?`)); + } + }); + + test("5. legacy pathway displays prominent deprecation warning and no false migration guarantees", async ({ page }) => { + await page.getByRole("radio", { name: /I am on an older v1\.x or v2\.x release/i }).click(); + + // Step 2 appears + await expect( + page.getByRole("heading", { name: /Which legacy release family are you using\?/i, level: 2 }) + ).toBeVisible(); + + // Choose v2.x + await page.getByRole("radio", { name: /v2\.x releases/i }).click(); + + // Legacy result + await expect( + page.getByRole("heading", { name: /Upgrading from Legacy v2\.x/i, level: 3 }) + ).toBeVisible(); + + // Deprecation & Safety Notice + await expect(page.getByText(/Important Safety Notice/i)).toBeVisible(); + await expect(page.getByText(/v1\.x and v2\.x releases are Deprecated and have reached End of Support/i)).toBeVisible(); + await expect(page.getByText(/No guaranteed automated migration/i)).toBeVisible(); + await expect(page.getByText(/Export your passwords immediately/i)).toBeVisible(); + await expect(page.getByText(/Do NOT delete or uninstall the old app/i)).toBeVisible(); + await expect(page.getByText(/not a guaranteed recovery process or a substitute for an exported backup/i)).toBeVisible(); + + // Links to community support + const discordCta = page.getByRole("link", { name: /Ask Maintainers on Discord/i }); + await expect(discordCta).toBeVisible(); + await expect(discordCta).toHaveAttribute("href", /discord\.gg/); + + // Architecture milestone link + const milestoneLink = page.getByRole("link", { name: /Read Architecture Transition Milestone/i }); + await expect(milestoneLink).toBeVisible(); + await expect(milestoneLink).toHaveAttribute("href", /\/changelog\/end-of-android-only\/?/); + }); + + test("6. device-switch pathway explains offline nature, CSV security, and verified navigation labels", async ({ page }) => { + await page.getByRole("radio", { name: /Move to another phone or restore data/i }).click(); + + await expect( + page.getByRole("heading", { name: /Moving to Another Phone or Restoring Data/i, level: 3 }) + ).toBeVisible(); + + // Plaintext CSV Warning + await expect(page.getByText(/CSV Data Safety Warning/i)).toBeVisible(); + await expect(page.getByText(/unencrypted plaintext/i)).toBeVisible(); + + // Highlights no cloud sync and verified step labels + await expect(page.getByText(/No cloud synchronization/i)).toBeVisible(); + await expect(page.getByText(/Step 1 — Export on old phone/i)).toBeVisible(); + await expect(page.getByText(/Settings → Export passwords/i)).toBeVisible(); + await expect(page.getByText(/Step 2 — Transfer the file securely/i)).toBeVisible(); + await expect(page.getByText(/Step 3 — Install on new phone/i)).toBeVisible(); + await expect(page.getByText(/Step 4 — Import credentials/i)).toBeVisible(); + await expect(page.getByText(/Settings → Import passwords/i)).toBeVisible(); + await expect(page.getByText(/Do NOT wipe your old phone yet/i)).toBeVisible(); + }); + + test("7. version identification pathway gives clear in-app and Android settings steps", async ({ page }) => { + await page.getByRole("radio", { name: /I am not sure which version I have/i }).click(); + + await expect( + page.getByRole("heading", { name: /How to Check Which Version You Have/i, level: 3 }) + ).toBeVisible(); + + await expect(page.getByText(/Method 1: Inside the App/i)).toBeVisible(); + await expect(page.getByText(/Method 2: Android System Settings/i)).toBeVisible(); + + // Quick jump action buttons work + const v3JumpBtn = page.getByRole("button", { name: /I have v3\.x/i }); + await expect(v3JumpBtn).toBeVisible(); + await v3JumpBtn.click(); + + // Transitioned to v3 update result! + await expect( + page.getByRole("heading", { name: /Updating Within the v3\.x Series/i, level: 3 }) + ).toBeVisible(); + }); + + test("8. yanked release selection warns that build is withdrawn and does not offer direct download of yanked APK", async ({ page }) => { + await page.getByRole("radio", { name: /I am on an older v1\.x or v2\.x release/i }).click(); + await page.getByRole("radio", { name: /Explicitly Yanked/i }).click(); + + await expect( + page.getByRole("heading", { name: /Handling a Withdrawn or Yanked Release/i, level: 3 }) + ).toBeVisible(); + + await expect(page.getByText(/Withdrawn from Active Distribution/i)).toBeVisible(); + await expect(page.getByText(/Do not download or redistribute/i)).toBeVisible(); + + // CTA directs to current recommended build, never yanked build + const cta = page.getByRole("link", { name: /Get Recommended Release/i }); + await expect(cta).toBeVisible(); + await expect(cta).toHaveAttribute("href", /\/downloads\/?/); + }); + + test("9. 'Start over' reset button returns to clean initial state", async ({ page }) => { + await page.getByRole("radio", { name: /Install Passcodes for the first time/i }).click(); + await expect(page.getByText(/Recommended Next Step/i)).toBeVisible(); + + const startOverBtn = page.getByRole("button", { name: /Start over/i }); + await expect(startOverBtn).toBeVisible(); + await startOverBtn.click(); + + // Result panel disappears, Step 1 reset + await expect(page.getByText(/Recommended Next Step/i)).toHaveCount(0); + const radio = page.getByRole("radio", { name: /Install Passcodes for the first time/i }); + await expect(radio).toHaveAttribute("aria-checked", "false"); + }); + + test("10. keyboard navigation supports roving tabindex and arrow keys across choices", async ({ page }) => { + const radios = page.getByRole("radiogroup", { name: /What are you trying to do\?/i }).getByRole("radio"); + + // Initial roving tabindex: first radio is tabindex 0, others are -1 + await expect(radios.nth(0)).toHaveAttribute("tabindex", "0"); + await expect(radios.nth(1)).toHaveAttribute("tabindex", "-1"); + await expect(radios.nth(2)).toHaveAttribute("tabindex", "-1"); + + // Focus first radio + await radios.nth(0).focus(); + + // Press ArrowDown to advance to situation 2 (Update) + await page.keyboard.press("ArrowDown"); + await expect(radios.nth(1)).toBeFocused(); + await expect(radios.nth(1)).toHaveAttribute("aria-checked", "true"); + await expect(radios.nth(1)).toHaveAttribute("tabindex", "0"); + await expect(radios.nth(0)).toHaveAttribute("tabindex", "-1"); + + // Step 2 should now be visible because situation is 'update' + await expect( + page.getByRole("heading", { name: /Which version family is currently installed on your phone\?/i, level: 2 }) + ).toBeVisible(); + + // Press ArrowUp to move back to situation 1 (First-time install) + await page.keyboard.press("ArrowUp"); + await expect(radios.nth(0)).toBeFocused(); + await expect(radios.nth(0)).toHaveAttribute("aria-checked", "true"); + await expect(radios.nth(0)).toHaveAttribute("tabindex", "0"); + + // Heading for first-time installation should now be visible + await expect( + page.getByRole("heading", { name: /Installing Passcodes for the First Time/i, level: 3 }) + ).toBeVisible(); + }); + + test("11. flow does not include sensitive input fields or file upload prompts", async ({ page }) => { + // No input[type=password], input[type=file], or form submitting credentials + await expect(page.locator("input[type='password']")).toHaveCount(0); + await expect(page.locator("input[type='file']")).toHaveCount(0); + }); + + test("12. contextual navigation from other pages to /upgrade/ works", async ({ page }) => { + // From Downloads page guidance banner + await page.goto("/downloads/"); + const downloadsBannerCta = page.getByRole("link", { name: /Get Guidance/i }); + await expect(downloadsBannerCta).toBeVisible(); + await downloadsBannerCta.click(); + await expect(page).toHaveURL(/\/upgrade\/?$/); + + // From Footer Product links + await page.goto("/"); + const footerUpgradeLink = page.locator("footer.site-footer").getByRole("link", { name: /Upgrade & Migration/i }); + await expect(footerUpgradeLink).toBeVisible(); + await footerUpgradeLink.click(); + await expect(page).toHaveURL(/\/upgrade\/?$/); + }); +}); diff --git a/tests/smoke/mobile.spec.ts b/tests/smoke/mobile.spec.ts index 783504c..3736638 100644 --- a/tests/smoke/mobile.spec.ts +++ b/tests/smoke/mobile.spec.ts @@ -71,4 +71,16 @@ test.describe("Mobile Responsive Usability Tests", () => { expect(hasHorizontalOverflow, "Release detail page has unexpected horizontal overflow").toBe(false); }); + + test("upgrade page does not horizontally overflow viewport on mobile", async ({ page, isMobile }) => { + test.skip(!isMobile, "Horizontal overflow check targeted for mobile viewports"); + + await page.goto("/upgrade/"); + + const hasHorizontalOverflow = await page.evaluate(() => { + return document.documentElement.scrollWidth > window.innerWidth; + }); + + expect(hasHorizontalOverflow, "Upgrade page has unexpected horizontal overflow").toBe(false); + }); });