From 39ddfd2bd9790a081686e9450ff78c39ae240e42 Mon Sep 17 00:00:00 2001
From: edde746 <86283021+edde746@users.noreply.github.com>
Date: Sat, 25 Jul 2026 16:17:05 +0200
Subject: [PATCH] fix(website): restore contracts and focus indicators
---
website/package.json | 1 +
.../src/lib/components/DownloadButtons.svelte | 3 -
website/src/lib/components/FAQ.svelte | 3 +-
website/src/lib/components/Footer.svelte | 1 -
.../src/lib/components/NoiseOverlay.svelte | 1 -
website/src/lib/components/Reviews.svelte | 1 -
website/src/lib/components/Screenshots.svelte | 3 -
.../src/lib/server/homepage_store_metadata.ts | 104 +++++++++++
website/src/routes/+error.svelte | 1 -
website/src/routes/+page.server.ts | 73 ++------
website/src/routes/layout.css | 11 ++
website/src/routes/privacy/+page.svelte | 2 -
website/src/routes/scan/+page.svelte | 1 -
website/tests/content_contracts.test.ts | 172 ++++++++++++++++++
website/tests/homepage_store_metadata.test.ts | 130 +++++++++++++
15 files changed, 433 insertions(+), 74 deletions(-)
delete mode 100644 website/src/lib/components/NoiseOverlay.svelte
create mode 100644 website/src/lib/server/homepage_store_metadata.ts
create mode 100644 website/tests/content_contracts.test.ts
create mode 100644 website/tests/homepage_store_metadata.test.ts
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
+ });
+ });
+});