fix(website): restore contracts and focus indicators
This commit is contained in:
@@ -8,6 +8,7 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"audit": "python3 ../scripts/check_bun_audit.py --project . --baseline bun_audit_baseline.json",
|
"audit": "python3 ../scripts/check_bun_audit.py --project . --baseline bun_audit_baseline.json",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
|
"test": "bun test",
|
||||||
"prepare": "svelte-kit sync || echo ''",
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||||
|
|||||||
@@ -163,7 +163,6 @@
|
|||||||
.store-button:focus-visible {
|
.store-button:focus-visible {
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
background: #fff;
|
background: #fff;
|
||||||
outline: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-button :global(svg) {
|
.store-button :global(svg) {
|
||||||
@@ -181,7 +180,6 @@
|
|||||||
.linux-button.active {
|
.linux-button.active {
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
background: var(--color-surface-hover);
|
background: var(--color-surface-hover);
|
||||||
outline: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.desktop-button:hover,
|
.desktop-button:hover,
|
||||||
@@ -271,7 +269,6 @@
|
|||||||
.linux-menu-item:focus-visible {
|
.linux-menu-item:focus-visible {
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
background: rgb(237 237 237 / 0.12);
|
background: rgb(237 237 237 / 0.12);
|
||||||
outline: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 460px) {
|
@media (max-width: 460px) {
|
||||||
|
|||||||
@@ -152,7 +152,7 @@
|
|||||||
|
|
||||||
.faq-toggle:focus-visible {
|
.faq-toggle:focus-visible {
|
||||||
background: rgb(237 237 237 / 0.14);
|
background: rgb(237 237 237 / 0.14);
|
||||||
outline: none;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.faq-question {
|
.faq-question {
|
||||||
@@ -220,6 +220,5 @@
|
|||||||
.faq-answer-content :global(a:hover),
|
.faq-answer-content :global(a:hover),
|
||||||
.faq-answer-content :global(a:focus-visible) {
|
.faq-answer-content :global(a:focus-visible) {
|
||||||
text-decoration-color: var(--color-text);
|
text-decoration-color: var(--color-text);
|
||||||
outline: none;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -67,7 +67,6 @@
|
|||||||
.footer-nav a:focus-visible {
|
.footer-nav a:focus-visible {
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
background: rgb(237 237 237 / 0.12);
|
background: rgb(237 237 237 / 0.12);
|
||||||
outline: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
@media (min-width: 640px) {
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
<div class="noise-overlay" aria-hidden="true"></div>
|
|
||||||
@@ -406,7 +406,6 @@
|
|||||||
.carousel-dot:focus-visible {
|
.carousel-dot:focus-visible {
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
background: rgb(237 237 237 / 0.18);
|
background: rgb(237 237 237 / 0.18);
|
||||||
outline: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.carousel-dot:focus-visible {
|
.carousel-dot:focus-visible {
|
||||||
|
|||||||
@@ -346,7 +346,6 @@
|
|||||||
.device-button:not(.active):focus-visible {
|
.device-button:not(.active):focus-visible {
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
background: rgb(237 237 237 / 0.12);
|
background: rgb(237 237 237 / 0.12);
|
||||||
outline: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-button.active {
|
.device-button.active {
|
||||||
@@ -358,7 +357,6 @@
|
|||||||
.device-button.active:focus-visible {
|
.device-button.active:focus-visible {
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
background: #fff;
|
background: #fff;
|
||||||
outline: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-button :global(svg),
|
.device-button :global(svg),
|
||||||
@@ -401,7 +399,6 @@
|
|||||||
.scroll-arrow.enabled:focus-visible {
|
.scroll-arrow.enabled:focus-visible {
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
background: var(--color-surface-highest);
|
background: var(--color-surface-highest);
|
||||||
outline: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.screenshot-panels {
|
.screenshot-panels {
|
||||||
|
|||||||
@@ -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<Response>;
|
||||||
|
|
||||||
|
export type PlayStoreListing = {
|
||||||
|
available?: boolean;
|
||||||
|
score?: number;
|
||||||
|
ratings?: number;
|
||||||
|
price?: unknown;
|
||||||
|
currency?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HomepageStoreMetadataDependencies = {
|
||||||
|
fetch: HomepageStoreFetch;
|
||||||
|
loadPlayStoreListing: () => Promise<PlayStoreListing>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<HomepageStoreMetadata> {
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -93,6 +93,5 @@
|
|||||||
.home-link:focus-visible {
|
.home-link:focus-visible {
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
background: #fff;
|
background: #fff;
|
||||||
outline: none;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,62 +1,17 @@
|
|||||||
import type { PageServerLoad } from './$types';
|
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 }) => {
|
export const load: PageServerLoad = async ({ fetch }) =>
|
||||||
let appStoreRating: { score: number; count: number } | null = null;
|
loadHomepageStoreMetadata({
|
||||||
let playStoreRating: { score: number; count: number } | null = null;
|
fetch,
|
||||||
let appStorePrice: string | null = null;
|
loadPlayStoreListing: async () => {
|
||||||
let playStorePrice: string | null = null;
|
// Module initialization is optional external data and stays inside the
|
||||||
|
// helper's Play Store failure boundary.
|
||||||
try {
|
const { default: gplay } = await import('google-play-scraper');
|
||||||
const res = await fetch('https://itunes.apple.com/lookup?id=6754315964');
|
return gplay.app({
|
||||||
if (!res.ok) throw new Error(`App Store lookup failed: HTTP ${res.status}`);
|
appId: 'com.edde746.plezy',
|
||||||
const data = await res.json();
|
country: 'us',
|
||||||
const app = data.results?.[0];
|
lang: 'en'
|
||||||
if (app?.averageUserRating && app?.userRatingCount) {
|
});
|
||||||
appStoreRating = {
|
|
||||||
score: app.averageUserRating,
|
|
||||||
count: app.userRatingCount
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
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 };
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -90,6 +90,17 @@ button {
|
|||||||
-webkit-tap-highlight-color: transparent;
|
-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,
|
button,
|
||||||
input,
|
input,
|
||||||
textarea,
|
textarea,
|
||||||
|
|||||||
@@ -164,7 +164,6 @@
|
|||||||
.back-link:focus-visible {
|
.back-link:focus-visible {
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
background: var(--color-surface-highest);
|
background: var(--color-surface-highest);
|
||||||
outline: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-logo :global(img) {
|
.back-logo :global(img) {
|
||||||
@@ -222,6 +221,5 @@
|
|||||||
.prose a:hover,
|
.prose a:hover,
|
||||||
.prose a:focus-visible {
|
.prose a:focus-visible {
|
||||||
text-decoration-color: var(--color-text);
|
text-decoration-color: var(--color-text);
|
||||||
outline: none;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -146,7 +146,6 @@
|
|||||||
.store-button:focus-visible {
|
.store-button:focus-visible {
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
background: #fff;
|
background: #fff;
|
||||||
outline: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-button :global(svg) {
|
.store-button :global(svg) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown> = {}): Response {
|
||||||
|
return Response.json({
|
||||||
|
resultCount: 1,
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
averageUserRating: 4,
|
||||||
|
userRatingCount: 10,
|
||||||
|
price: 4.99,
|
||||||
|
currency: 'USD',
|
||||||
|
...overrides
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function playStoreListing(overrides: Partial<PlayStoreListing> = {}): PlayStoreListing {
|
||||||
|
return {
|
||||||
|
available: true,
|
||||||
|
score: 4.5,
|
||||||
|
ratings: 20,
|
||||||
|
price: 3.99,
|
||||||
|
currency: 'USD',
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectAppleFailureKeepsGoogleMetadata(fetch: HomepageStoreFetch): Promise<void> {
|
||||||
|
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<PlayStoreListing>
|
||||||
|
): Promise<void> {
|
||||||
|
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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user