diff --git a/website/package.json b/website/package.json index c2e3f92e..5960945a 100644 --- a/website/package.json +++ b/website/package.json @@ -8,6 +8,7 @@ "build": "vite build", "audit": "python3 ../scripts/check_bun_audit.py --project . --baseline bun_audit_baseline.json", "preview": "vite preview", + "test": "bun test", "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" diff --git a/website/src/lib/components/DownloadButtons.svelte b/website/src/lib/components/DownloadButtons.svelte index 460d3741..89cd821d 100644 --- a/website/src/lib/components/DownloadButtons.svelte +++ b/website/src/lib/components/DownloadButtons.svelte @@ -163,7 +163,6 @@ .store-button:focus-visible { border-radius: var(--radius-md); background: #fff; - outline: none; } .store-button :global(svg) { @@ -181,7 +180,6 @@ .linux-button.active { color: var(--color-text); background: var(--color-surface-hover); - outline: none; } .desktop-button:hover, @@ -271,7 +269,6 @@ .linux-menu-item:focus-visible { color: var(--color-text); background: rgb(237 237 237 / 0.12); - outline: none; } @media (max-width: 460px) { diff --git a/website/src/lib/components/FAQ.svelte b/website/src/lib/components/FAQ.svelte index e880fcd0..dae6a720 100644 --- a/website/src/lib/components/FAQ.svelte +++ b/website/src/lib/components/FAQ.svelte @@ -152,7 +152,7 @@ .faq-toggle:focus-visible { background: rgb(237 237 237 / 0.14); - outline: none; + outline-offset: -2px; } .faq-question { @@ -220,6 +220,5 @@ .faq-answer-content :global(a:hover), .faq-answer-content :global(a:focus-visible) { text-decoration-color: var(--color-text); - outline: none; } diff --git a/website/src/lib/components/Footer.svelte b/website/src/lib/components/Footer.svelte index aca54c75..803c7b11 100644 --- a/website/src/lib/components/Footer.svelte +++ b/website/src/lib/components/Footer.svelte @@ -67,7 +67,6 @@ .footer-nav a:focus-visible { color: var(--color-text); background: rgb(237 237 237 / 0.12); - outline: none; } @media (min-width: 640px) { diff --git a/website/src/lib/components/NoiseOverlay.svelte b/website/src/lib/components/NoiseOverlay.svelte deleted file mode 100644 index 06dffa9a..00000000 --- a/website/src/lib/components/NoiseOverlay.svelte +++ /dev/null @@ -1 +0,0 @@ - diff --git a/website/src/lib/components/Reviews.svelte b/website/src/lib/components/Reviews.svelte index 66267d18..21d66426 100644 --- a/website/src/lib/components/Reviews.svelte +++ b/website/src/lib/components/Reviews.svelte @@ -406,7 +406,6 @@ .carousel-dot:focus-visible { color: var(--color-text); background: rgb(237 237 237 / 0.18); - outline: none; } .carousel-dot:focus-visible { diff --git a/website/src/lib/components/Screenshots.svelte b/website/src/lib/components/Screenshots.svelte index 7be3d5cd..2f72b600 100644 --- a/website/src/lib/components/Screenshots.svelte +++ b/website/src/lib/components/Screenshots.svelte @@ -346,7 +346,6 @@ .device-button:not(.active):focus-visible { color: var(--color-text); background: rgb(237 237 237 / 0.12); - outline: none; } .device-button.active { @@ -358,7 +357,6 @@ .device-button.active:focus-visible { border-radius: var(--radius-md); background: #fff; - outline: none; } .device-button :global(svg), @@ -401,7 +399,6 @@ .scroll-arrow.enabled:focus-visible { border-radius: var(--radius-md); background: var(--color-surface-highest); - outline: none; } .screenshot-panels { diff --git a/website/src/lib/server/homepage_store_metadata.ts b/website/src/lib/server/homepage_store_metadata.ts new file mode 100644 index 00000000..286fcd0f --- /dev/null +++ b/website/src/lib/server/homepage_store_metadata.ts @@ -0,0 +1,104 @@ +import { normalizeUsdStorePrice } from '../content/software_app_offers'; + +export type HomepageAggregateRating = { + ratingValue: string; + ratingCount: number; +}; + +export type HomepageStoreMetadata = { + aggregateRating: HomepageAggregateRating | null; + appStorePrice: string | null; + playStorePrice: string | null; +}; + +export type HomepageStoreFetch = (url: string) => Promise; + +export type PlayStoreListing = { + available?: boolean; + score?: number; + ratings?: number; + price?: unknown; + currency?: unknown; +}; + +export type HomepageStoreMetadataDependencies = { + fetch: HomepageStoreFetch; + loadPlayStoreListing: () => Promise; +}; + +type StoreRating = { + score: number; + count: number; +}; + +type AppStoreLookupResult = { + averageUserRating?: number; + userRatingCount?: number; + price?: unknown; + currency?: unknown; +}; + +type AppStoreLookupResponse = { + results?: AppStoreLookupResult[]; +}; + +const APP_STORE_LOOKUP_URL = 'https://itunes.apple.com/lookup?id=6754315964'; + +export async function loadHomepageStoreMetadata({ + fetch, + loadPlayStoreListing +}: HomepageStoreMetadataDependencies): Promise { + let appStoreRating: StoreRating | null = null; + let playStoreRating: StoreRating | null = null; + let appStorePrice: string | null = null; + let playStorePrice: string | null = null; + + try { + const response = await fetch(APP_STORE_LOOKUP_URL); + if (!response.ok) throw new Error(`App Store lookup failed: HTTP ${response.status}`); + + const data = (await response.json()) as AppStoreLookupResponse; + const app = data.results?.[0]; + if (app?.averageUserRating && app.userRatingCount) { + appStoreRating = { + score: app.averageUserRating, + count: app.userRatingCount + }; + } + appStorePrice = normalizeUsdStorePrice(app?.price, app?.currency); + } catch { + // App Store metadata is optional and must not suppress Play Store metadata. + } + + try { + const app = await loadPlayStoreListing(); + if (app.available === false) throw new Error('Google Play listing unavailable'); + if (app.score && app.ratings) { + playStoreRating = { + score: app.score, + count: app.ratings + }; + } + playStorePrice = normalizeUsdStorePrice(app.price, app.currency); + } catch { + // Play Store metadata is optional and must not suppress App Store metadata. + } + + const ratings = [appStoreRating, playStoreRating].filter( + (rating): rating is StoreRating => rating !== null + ); + const aggregateRating = aggregateStoreRatings(ratings); + + return { aggregateRating, appStorePrice, playStorePrice }; +} + +function aggregateStoreRatings(ratings: readonly StoreRating[]): HomepageAggregateRating | null { + if (ratings.length === 0) return null; + + const ratingCount = ratings.reduce((sum, rating) => sum + rating.count, 0); + const weightedSum = ratings.reduce((sum, rating) => sum + rating.score * rating.count, 0); + return { + ratingValue: (weightedSum / ratingCount).toFixed(1), + ratingCount + }; +} diff --git a/website/src/routes/+error.svelte b/website/src/routes/+error.svelte index 1853cb81..2869835d 100644 --- a/website/src/routes/+error.svelte +++ b/website/src/routes/+error.svelte @@ -93,6 +93,5 @@ .home-link:focus-visible { border-radius: var(--radius-md); background: #fff; - outline: none; } diff --git a/website/src/routes/+page.server.ts b/website/src/routes/+page.server.ts index 16c820b6..d488440e 100644 --- a/website/src/routes/+page.server.ts +++ b/website/src/routes/+page.server.ts @@ -1,62 +1,17 @@ import type { PageServerLoad } from './$types'; -import { normalizeUsdStorePrice } from '$lib/content/software_app_offers'; +import { loadHomepageStoreMetadata } from '$lib/server/homepage_store_metadata'; -export const load: PageServerLoad = async ({ fetch }) => { - let appStoreRating: { score: number; count: number } | null = null; - let playStoreRating: { score: number; count: number } | null = null; - let appStorePrice: string | null = null; - let playStorePrice: string | null = null; - - try { - const res = await fetch('https://itunes.apple.com/lookup?id=6754315964'); - if (!res.ok) throw new Error(`App Store lookup failed: HTTP ${res.status}`); - const data = await res.json(); - const app = data.results?.[0]; - if (app?.averageUserRating && app?.userRatingCount) { - appStoreRating = { - score: app.averageUserRating, - count: app.userRatingCount - }; +export const load: PageServerLoad = async ({ fetch }) => + loadHomepageStoreMetadata({ + fetch, + loadPlayStoreListing: async () => { + // Module initialization is optional external data and stays inside the + // helper's Play Store failure boundary. + const { default: gplay } = await import('google-play-scraper'); + return gplay.app({ + appId: 'com.edde746.plezy', + country: 'us', + lang: 'en' + }); } - appStorePrice = normalizeUsdStorePrice(app?.price, app?.currency); - } catch { - // App Store fetch failed, continue without it - } - - try { - // Module initialization is optional external data and must stay inside this failure boundary. - const { default: gplay } = await import('google-play-scraper'); - const app = await gplay.app({ - appId: 'com.edde746.plezy', - country: 'us', - lang: 'en' - }); - if (app.available === false) throw new Error('Google Play listing unavailable'); - if (app.score && app.ratings) { - playStoreRating = { - score: app.score, - count: app.ratings - }; - } - playStorePrice = normalizeUsdStorePrice(app.price, app.currency); - } catch { - // Play Store fetch failed, continue without it - } - - // Compute combined weighted average - let aggregateRating: { ratingValue: string; ratingCount: number } | null = null; - const ratings = [appStoreRating, playStoreRating].filter(Boolean) as { - score: number; - count: number; - }[]; - if (ratings.length > 0) { - const totalCount = ratings.reduce((sum, r) => sum + r.count, 0); - const weightedSum = ratings.reduce((sum, r) => sum + r.score * r.count, 0); - aggregateRating = { - ratingValue: (weightedSum / totalCount).toFixed(1), - ratingCount: totalCount - }; - } - - return { aggregateRating, appStorePrice, playStorePrice }; -}; + }); diff --git a/website/src/routes/layout.css b/website/src/routes/layout.css index 0065db6d..e6a2b413 100644 --- a/website/src/routes/layout.css +++ b/website/src/routes/layout.css @@ -90,6 +90,17 @@ button { -webkit-tap-highlight-color: transparent; } +:focus-visible { + outline: 2px solid var(--color-text); + outline-offset: 2px; +} + +@media (forced-colors: active) { + :focus-visible { + outline-color: Highlight; + } +} + button, input, textarea, diff --git a/website/src/routes/privacy/+page.svelte b/website/src/routes/privacy/+page.svelte index cc10d56a..e4f7f8ab 100644 --- a/website/src/routes/privacy/+page.svelte +++ b/website/src/routes/privacy/+page.svelte @@ -164,7 +164,6 @@ .back-link:focus-visible { color: var(--color-text); background: var(--color-surface-highest); - outline: none; } .back-logo :global(img) { @@ -222,6 +221,5 @@ .prose a:hover, .prose a:focus-visible { text-decoration-color: var(--color-text); - outline: none; } diff --git a/website/src/routes/scan/+page.svelte b/website/src/routes/scan/+page.svelte index 76646559..929d551a 100644 --- a/website/src/routes/scan/+page.svelte +++ b/website/src/routes/scan/+page.svelte @@ -146,7 +146,6 @@ .store-button:focus-visible { border-radius: var(--radius-md); background: #fff; - outline: none; } .store-button :global(svg) { diff --git a/website/tests/content_contracts.test.ts b/website/tests/content_contracts.test.ts new file mode 100644 index 00000000..ea686b04 --- /dev/null +++ b/website/tests/content_contracts.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, test } from 'bun:test'; +import { + detectMobileStorePlatform, + linuxArchitectures, + storeOptionsForPlatform +} from '../src/lib/content/downloads'; +import { + faqSchemaMainEntity, + faqs, + watchTogetherFaqAnswer +} from '../src/lib/content/faqs'; +import { + buildSoftwareApplicationOffers, + normalizeUsdStorePrice +} from '../src/lib/content/software_app_offers'; +import { csr as privacyCsr } from '../src/routes/privacy/+page'; + +describe('mobile store selection', () => { + test('treats missing and unrecognized client evidence as unknown', () => { + expect(detectMobileStorePlatform()).toBe('unknown'); + expect( + detectMobileStorePlatform({ + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + platform: 'Win32', + maxTouchPoints: 0 + }) + ).toBe('unknown'); + }); + + test('detects iOS and Android user agents', () => { + expect( + detectMobileStorePlatform({ + userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)' + }) + ).toBe('ios'); + expect( + detectMobileStorePlatform({ + userAgent: 'Mozilla/5.0 (Linux; Android 15; Pixel 9)' + }) + ).toBe('android'); + }); + + test('distinguishes desktop-mode iPadOS from a non-touch Mac', () => { + const desktopSafari = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)'; + expect( + detectMobileStorePlatform({ + userAgent: desktopSafari, + platform: 'MacIntel', + maxTouchPoints: 5 + }) + ).toBe('ios'); + expect( + detectMobileStorePlatform({ + userAgent: desktopSafari, + platform: 'MacIntel', + maxTouchPoints: 0 + }) + ).toBe('unknown'); + }); + + test('shows only the matching store when known and both stores when unknown', () => { + expect(storeOptionsForPlatform('ios').map((option) => option.id)).toEqual(['app-store']); + expect(storeOptionsForPlatform('android').map((option) => option.id)).toEqual([ + 'play-store' + ]); + expect(storeOptionsForPlatform('unknown').map((option) => option.id)).toEqual([ + 'app-store', + 'play-store' + ]); + }); +}); + +describe('Linux download inventory', () => { + test('keeps a unique four-format artifact matrix for x64 and ARM64', () => { + const artifactNames = linuxArchitectures.map((architecture) => + architecture.formats.map(({ url }) => url.slice(url.lastIndexOf('/') + 1)) + ); + + expect(linuxArchitectures.map(({ label }) => label)).toEqual(['x64 (Intel/AMD)', 'ARM64']); + expect(artifactNames).toEqual([ + [ + 'plezy-linux-x64.deb', + 'plezy-linux-x64.rpm', + 'plezy-linux-x64.pkg.tar.zst', + 'plezy-linux-x64.tar.gz' + ], + [ + 'plezy-linux-arm64.deb', + 'plezy-linux-arm64.rpm', + 'plezy-linux-arm64.pkg.tar.zst', + 'plezy-linux-arm64.tar.gz' + ] + ]); + + const urls = linuxArchitectures.flatMap((architecture) => + architecture.formats.map(({ url }) => url) + ); + expect(new Set(urls).size).toBe(8); + expect( + urls.every((url) => + url.startsWith('https://github.com/edde746/plezy/releases/latest/download/') + ) + ).toBe(true); + }); +}); + +describe('software application offers', () => { + test('accepts only finite nonnegative numeric USD prices', () => { + expect(normalizeUsdStorePrice(0, 'USD')).toBe('0'); + expect(normalizeUsdStorePrice(4.99, 'USD')).toBe('4.99'); + + for (const value of [-1, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, '4.99', null]) { + expect(normalizeUsdStorePrice(value, 'USD')).toBeNull(); + } + for (const currency of ['EUR', 'usd', '', null, undefined]) { + expect(normalizeUsdStorePrice(4.99, currency)).toBeNull(); + } + }); + + test('keeps unavailable paid-store links without describing them as free', () => { + const offers = buildSoftwareApplicationOffers({ + appStorePrice: null, + playStorePrice: null + }); + + expect(offers.find(({ category }) => category === 'App Store')).toEqual({ + '@type': 'Offer', + url: 'https://apps.apple.com/us/app/id6754315964', + category: 'App Store' + }); + expect(offers.find(({ category }) => category === 'Google Play')).toEqual({ + '@type': 'Offer', + url: 'https://play.google.com/store/apps/details?id=com.edde746.plezy', + category: 'Google Play' + }); + expect(offers.filter(({ price }) => price === '0').map(({ category }) => category)).toEqual([ + 'GitHub' + ]); + }); + + test('attaches valid USD prices to each paid mobile store', () => { + const offers = buildSoftwareApplicationOffers({ + appStorePrice: '4.99', + playStorePrice: '3.99' + }); + + expect(offers.find(({ category }) => category === 'App Store')).toMatchObject({ + price: '4.99', + priceCurrency: 'USD' + }); + expect(offers.find(({ category }) => category === 'Google Play')).toMatchObject({ + price: '3.99', + priceCurrency: 'USD' + }); + }); +}); + +describe('route content contracts', () => { + test('uses the same Watch Together answer in the visible FAQ and FAQ schema', () => { + const visibleFaq = faqs.find(({ id }) => id === 'watch-together'); + expect(visibleFaq).toBeDefined(); + expect(visibleFaq?.answer).toBe(watchTogetherFaqAnswer); + + const schemaFaq = faqSchemaMainEntity.find(({ name }) => name === visibleFaq?.question); + expect(schemaFaq).toBeDefined(); + expect(schemaFaq?.acceptedAnswer.text).toBe(watchTogetherFaqAnswer); + }); + + test('keeps the privacy route server-rendered without client hydration', () => { + expect(privacyCsr).toBe(false); + }); +}); diff --git a/website/tests/homepage_store_metadata.test.ts b/website/tests/homepage_store_metadata.test.ts new file mode 100644 index 00000000..8f092fc6 --- /dev/null +++ b/website/tests/homepage_store_metadata.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, test } from 'bun:test'; +import { + loadHomepageStoreMetadata, + type HomepageStoreFetch, + type PlayStoreListing +} from '../src/lib/server/homepage_store_metadata'; + +const APP_STORE_LOOKUP_URL = 'https://itunes.apple.com/lookup?id=6754315964'; + +function appStoreResponse(overrides: Record = {}): Response { + return Response.json({ + resultCount: 1, + results: [ + { + averageUserRating: 4, + userRatingCount: 10, + price: 4.99, + currency: 'USD', + ...overrides + } + ] + }); +} + +function playStoreListing(overrides: Partial = {}): PlayStoreListing { + return { + available: true, + score: 4.5, + ratings: 20, + price: 3.99, + currency: 'USD', + ...overrides + }; +} + +async function expectAppleFailureKeepsGoogleMetadata(fetch: HomepageStoreFetch): Promise { + const metadata = await loadHomepageStoreMetadata({ + fetch, + loadPlayStoreListing: async () => playStoreListing() + }); + + expect(metadata).toEqual({ + aggregateRating: { ratingValue: '4.5', ratingCount: 20 }, + appStorePrice: null, + playStorePrice: '3.99' + }); +} + +async function expectGoogleFailureKeepsAppleMetadata( + loadPlayStoreListing: () => Promise +): Promise { + const metadata = await loadHomepageStoreMetadata({ + fetch: async () => appStoreResponse(), + loadPlayStoreListing + }); + + expect(metadata).toEqual({ + aggregateRating: { ratingValue: '4.0', ratingCount: 10 }, + appStorePrice: '4.99', + playStorePrice: null + }); +} + +describe('loadHomepageStoreMetadata failure isolation', () => { + test('an App Store fetch exception does not discard Google Play metadata', async () => { + await expectAppleFailureKeepsGoogleMetadata(async () => { + throw new Error('offline'); + }); + }); + + test('a non-OK App Store response does not discard Google Play metadata', async () => { + await expectAppleFailureKeepsGoogleMetadata( + async () => new Response(null, { status: 503 }) + ); + }); + + test('malformed App Store JSON does not discard Google Play metadata', async () => { + await expectAppleFailureKeepsGoogleMetadata( + async () => new Response('{', { headers: { 'content-type': 'application/json' } }) + ); + }); + + test('a Google Play exception does not discard App Store metadata', async () => { + await expectGoogleFailureKeepsAppleMetadata(async () => { + throw new Error('offline'); + }); + }); + + test('an unavailable Google Play listing does not discard App Store metadata', async () => { + await expectGoogleFailureKeepsAppleMetadata(async () => + playStoreListing({ available: false }) + ); + }); +}); + +describe('loadHomepageStoreMetadata prices and ratings', () => { + test('normalizes each store price independently', async () => { + const malformedApplePrice = await loadHomepageStoreMetadata({ + fetch: async () => appStoreResponse({ price: '4.99' }), + loadPlayStoreListing: async () => playStoreListing() + }); + expect(malformedApplePrice.appStorePrice).toBeNull(); + expect(malformedApplePrice.playStorePrice).toBe('3.99'); + + const malformedGooglePrice = await loadHomepageStoreMetadata({ + fetch: async () => appStoreResponse(), + loadPlayStoreListing: async () => playStoreListing({ currency: 'EUR' }) + }); + expect(malformedGooglePrice.appStorePrice).toBe('4.99'); + expect(malformedGooglePrice.playStorePrice).toBeNull(); + }); + + test('computes the count-weighted aggregate to one decimal place', async () => { + const requestedUrls: string[] = []; + const metadata = await loadHomepageStoreMetadata({ + fetch: async (url) => { + requestedUrls.push(url); + return appStoreResponse({ averageUserRating: 4, userRatingCount: 10 }); + }, + loadPlayStoreListing: async () => + playStoreListing({ score: 5, ratings: 30 }) + }); + + expect(requestedUrls).toEqual([APP_STORE_LOOKUP_URL]); + expect(metadata.aggregateRating).toEqual({ + ratingValue: '4.8', + ratingCount: 40 + }); + }); +});