Public Access
Compare commits
2
Commits
444b03acee
...
6edb53162e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6edb53162e | ||
|
|
f8e6c2c970 |
No files matched your search
+3
-1
@@ -9,6 +9,8 @@ RUN npm run build
|
|||||||
# Serve the static files using a lightweight web server
|
# Serve the static files using a lightweight web server
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
# nginx.conf is a template. An env var that is not set stays in it as ${...}, and nginx then fails to start.
|
||||||
|
ENV AUTH_HOST="" API_HOST=""
|
||||||
|
COPY nginx.conf /etc/nginx/templates/default.conf.template
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -26,6 +26,8 @@ To Be Played is split into three repositories:
|
|||||||
priority first, reroll without repeats, and "Start playing" in one click.
|
priority first, reroll without repeats, and "Start playing" in one click.
|
||||||
- **Library**: status filters, "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest
|
- **Library**: status filters, "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest
|
||||||
in library, rating or priority, paging. Each row shows the unlocked achievements.
|
in library, rating or priority, paging. Each row shows the unlocked achievements.
|
||||||
|
- **Add a game by name** for games outside Steam (PlayStation, Switch, Xbox, physical): search IGDB, pick a hit,
|
||||||
|
a platform and optional hours. Works without a Steam ID.
|
||||||
- **Game detail**: status, priority, 1–5 star rating, notes, achievements, time to beat (story / extras / 100 %),
|
- **Game detail**: status, priority, 1–5 star rating, notes, achievements, time to beat (story / extras / 100 %),
|
||||||
a hint when Steam shows recent playtime for a game still in the backlog, and one to set the status to 100 % when
|
a hint when Steam shows recent playtime for a game still in the backlog, and one to set the status to 100 % when
|
||||||
every achievement is unlocked.
|
every achievement is unlocked.
|
||||||
@@ -34,6 +36,7 @@ To Be Played is split into three repositories:
|
|||||||
- **Appearance**: accent color, corner style, wide layout, page background and banner from your own game art.
|
- **Appearance**: accent color, corner style, wide layout, page background and banner from your own game art.
|
||||||
Saved in Auth, so it follows you to every device.
|
Saved in Auth, so it follows you to every device.
|
||||||
- German and English, dark mode, mobile layout, session-expired message instead of a silent logout.
|
- German and English, dark mode, mobile layout, session-expired message instead of a silent logout.
|
||||||
|
- **Installable** as an app on phone and desktop (web app manifest, no service worker, so no offline mode).
|
||||||
- **Session**: the tokens are HttpOnly cookies that no script can read. `apiFetch` renews an expired access token
|
- **Session**: the tokens are HttpOnly cookies that no script can read. `apiFetch` renews an expired access token
|
||||||
once and sends the request again; parallel requests and other tabs share one refresh.
|
once and sends the request again; parallel requests and other tabs share one refresh.
|
||||||
|
|
||||||
@@ -43,7 +46,8 @@ To Be Played is split into three repositories:
|
|||||||
- Tailwind CSS 4, Radix UI primitives (shadcn/ui style components), Lucide icons
|
- Tailwind CSS 4, Radix UI primitives (shadcn/ui style components), Lucide icons
|
||||||
- react-hook-form + zod for forms, i18next for translations (`public/i18n`)
|
- react-hook-form + zod for forms, i18next for translations (`public/i18n`)
|
||||||
- Vitest + React Testing Library + jsdom
|
- Vitest + React Testing Library + jsdom
|
||||||
- Production image: nginx (Alpine), SPA fallback and long-term caching for hashed assets
|
- Production image: nginx (Alpine), SPA fallback, long-term caching for hashed assets, Content Security Policy
|
||||||
|
(report-only for now) and security headers
|
||||||
- CI: Forgejo Actions (`npm ci`, `npm test`, `npm run build`, Docker image)
|
- CI: Forgejo Actions (`npm ci`, `npm test`, `npm run build`, Docker image)
|
||||||
|
|
||||||
## Project structure
|
## Project structure
|
||||||
@@ -87,8 +91,7 @@ npm run build
|
|||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
- Mood filter for "What to play next".
|
- Mood filter for "What to play next".
|
||||||
- A Content Security Policy in `nginx.conf`.
|
- Enforce the Content Security Policy (it runs in report-only mode now).
|
||||||
- Installable PWA.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
+8
-1
@@ -3,8 +3,15 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
<meta name="theme-color" content="#09090b" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="TBP" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Game Log</title>
|
<title>ToBePlayed</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
+15
@@ -2,11 +2,26 @@ server {
|
|||||||
listen 80;
|
listen 80;
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
|
|
||||||
|
# An nginx template: AUTH_HOST and API_HOST come from the container env at start (see Dockerfile).
|
||||||
|
# Report-Only until production shows no violation in the browser console; then rename the header.
|
||||||
|
add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://*.steamstatic.com https://steamcdn-a.akamaihd.net https://media.steampowered.com https://images.igdb.com; font-src 'self' data:; connect-src 'self' https://${AUTH_HOST} https://${API_HOST}; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
|
# Keeps the headers above in the locations that add their own Cache-Control (nginx 1.29.3 and later).
|
||||||
|
add_header_inherit merge;
|
||||||
|
|
||||||
# Vite puts a content hash in these file names, so they never change.
|
# Vite puts a content hash in these file names, so they never change.
|
||||||
location /assets/ {
|
location /assets/ {
|
||||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Not in every nginx mime.types; browsers expect this type for the PWA manifest.
|
||||||
|
location = /manifest.webmanifest {
|
||||||
|
types { application/manifest+json webmanifest; }
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
|
}
|
||||||
|
|
||||||
# index.html and /i18n/*.json keep their names across deploys. Without
|
# index.html and /i18n/*.json keep their names across deploys. Without
|
||||||
# no-cache the browser shows old translation keys or loads an old bundle.
|
# no-cache the browser shows old translation keys or loads an old bundle.
|
||||||
location / {
|
location / {
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
+10
-1
@@ -1 +1,10 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" rx="112" fill="#7f22fe"/>
|
||||||
|
<g transform="translate(88 88) scale(14)" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="6" x2="10" y1="11" y2="11"/>
|
||||||
|
<line x1="8" x2="8" y1="9" y2="13"/>
|
||||||
|
<line x1="15" x2="15.01" y1="12" y2="12"/>
|
||||||
|
<line x1="18" x2="18.01" y1="10" y2="10"/>
|
||||||
|
<path d="M17.32 5H6.68a4 4 0 0 0-3.978 3.59c-.006.052-.01.101-.017.152C2.604 9.416 2 14.456 2 16a3 3 0 0 0 3 3c1 0 1.5-.5 2-1l1.414-1.414A2 2 0 0 1 9.828 16h4.344a2 2 0 0 1 1.414.586L17 18c.5.5 1 1 2 1a3 3 0 0 0 3-3c0-1.545-.604-6.584-.685-7.258-.007-.05-.011-.1-.017-.151A4 4 0 0 0 17.32 5z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 773 B |
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"id": "/",
|
||||||
|
"name": "ToBePlayed",
|
||||||
|
"short_name": "TBP",
|
||||||
|
"description": "Your game backlog: Steam library, games from other platforms, and what to play next.",
|
||||||
|
"start_url": "/dashboard",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#09090b",
|
||||||
|
"theme_color": "#09090b",
|
||||||
|
"icons": [
|
||||||
|
{"src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any"},
|
||||||
|
{"src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any"},
|
||||||
|
{"src": "/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable"}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
} from '../utils/appearance.js';
|
} from '../utils/appearance.js';
|
||||||
|
|
||||||
const DARK_MODE_STORAGE_KEY = 'tbp_dark_mode';
|
const DARK_MODE_STORAGE_KEY = 'tbp_dark_mode';
|
||||||
|
// The --background of style.css; the installed app paints its title bar in this color.
|
||||||
|
const THEME_COLORS = {dark: '#09090b', light: '#fafafa'};
|
||||||
|
|
||||||
const PreferencesContext = createContext(null);
|
const PreferencesContext = createContext(null);
|
||||||
|
|
||||||
@@ -28,6 +30,8 @@ export function PreferencesProvider({children}) {
|
|||||||
} else {
|
} else {
|
||||||
document.documentElement.classList.remove('dark');
|
document.documentElement.classList.remove('dark');
|
||||||
}
|
}
|
||||||
|
document.querySelector('meta[name="theme-color"]')
|
||||||
|
?.setAttribute('content', isDarkMode ? THEME_COLORS.dark : THEME_COLORS.light);
|
||||||
localStorage.setItem(DARK_MODE_STORAGE_KEY, String(isDarkMode));
|
localStorage.setItem(DARK_MODE_STORAGE_KEY, String(isDarkMode));
|
||||||
}, [isDarkMode]);
|
}, [isDarkMode]);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {act, cleanup, render} from '@testing-library/react';
|
||||||
|
import {PreferencesProvider, usePreferences} from './PreferencesContext.jsx';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
|
||||||
|
let preferences;
|
||||||
|
function Spy() {
|
||||||
|
preferences = usePreferences();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const themeColor = () => document.querySelector('meta[name="theme-color"]').content;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.head.innerHTML = '<meta name="theme-color" content="#09090b">';
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PreferencesProvider theme color', () => {
|
||||||
|
it('gives the installed app a light title bar in light mode and a dark one again in dark mode', () => {
|
||||||
|
localStorage.setItem('tbp_dark_mode', 'false');
|
||||||
|
render(<PreferencesProvider><Spy/></PreferencesProvider>);
|
||||||
|
|
||||||
|
expect(themeColor()).toBe('#fafafa');
|
||||||
|
|
||||||
|
act(() => preferences.setDarkMode(true));
|
||||||
|
expect(themeColor()).toBe('#09090b');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import {readdirSync, readFileSync} from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import {describe, expect, it} from 'vitest';
|
||||||
|
|
||||||
|
const root = path.resolve(import.meta.dirname, '..');
|
||||||
|
const nginx = readFileSync(path.join(root, 'nginx.conf'), 'utf8');
|
||||||
|
const policy = nginx.match(/Content-Security-Policy(?:-Report-Only)? "([^"]+)"/)[1];
|
||||||
|
const directive = (name) => policy.split(';').map((part) => part.trim()).find((part) => part.startsWith(`${name} `));
|
||||||
|
|
||||||
|
const LINK_ONLY_HOSTS = ['store.steampowered.com'];
|
||||||
|
|
||||||
|
const allows = (sources, host) => sources.split(' ').some((source) =>
|
||||||
|
source === `https://${host}` || (source.startsWith('https://*.') && host.endsWith(source.slice('https://*'.length))));
|
||||||
|
|
||||||
|
describe('Content Security Policy in nginx.conf', () => {
|
||||||
|
it('allows every image host the code builds URLs for', () => {
|
||||||
|
const code = readdirSync(path.join(root, 'src'), {recursive: true})
|
||||||
|
.filter((file) => /\.jsx?$/.test(file) && !/\.test\.jsx?$/.test(file) && !file.startsWith('test'))
|
||||||
|
.map((file) => readFileSync(path.join(root, 'src', file), 'utf8'))
|
||||||
|
.join('\n');
|
||||||
|
const hosts = new Set([...code.matchAll(/`https:\/\/([a-z0-9.-]+)\//g)].map((match) => match[1]));
|
||||||
|
// Only links: a navigation needs no img-src.
|
||||||
|
LINK_ONLY_HOSTS.forEach((host) => hosts.delete(host));
|
||||||
|
// Hosts the backends pass on: Steam avatars and achievement icons, IGDB pictures.
|
||||||
|
['avatars.steamstatic.com', 'steamcdn-a.akamaihd.net', 'media.steampowered.com', 'images.igdb.com']
|
||||||
|
.forEach((host) => hosts.add(host));
|
||||||
|
|
||||||
|
for (const host of hosts) {
|
||||||
|
expect(allows(directive('img-src'), host), host).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets the app reach Auth and Core, whose hosts come from the container env', () => {
|
||||||
|
expect(directive('connect-src')).toBe("connect-src 'self' https://${AUTH_HOST} https://${API_HOST}");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows no inline or foreign script', () => {
|
||||||
|
expect(directive('script-src')).toBe("script-src 'self'");
|
||||||
|
expect(directive('frame-ancestors')).toBe("frame-ancestors 'none'");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import {readFileSync} from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import {describe, expect, it} from 'vitest';
|
||||||
|
|
||||||
|
const root = path.resolve(import.meta.dirname, '..');
|
||||||
|
const read = (file) => readFileSync(path.join(root, file));
|
||||||
|
const manifest = JSON.parse(read('public/manifest.webmanifest'));
|
||||||
|
|
||||||
|
// Width and height sit at bytes 16-23 of every PNG.
|
||||||
|
const pngSize = (file) => {
|
||||||
|
const png = read(path.join('public', file));
|
||||||
|
return `${png.readUInt32BE(16)}x${png.readUInt32BE(20)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('PWA manifest', () => {
|
||||||
|
it('links every icon to a PNG of the size it claims', () => {
|
||||||
|
for (const icon of manifest.icons) {
|
||||||
|
expect(pngSize(icon.src)).toBe(icon.sizes);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has the icons Chrome needs to offer the install', () => {
|
||||||
|
const sizes = manifest.icons.filter((icon) => icon.purpose === 'any').map((icon) => icon.sizes);
|
||||||
|
expect(sizes).toEqual(expect.arrayContaining(['192x192', '512x512']));
|
||||||
|
expect(manifest.display).toBe('standalone');
|
||||||
|
expect(manifest.icons.some((icon) => icon.purpose === 'maskable')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is linked from index.html together with the iOS icon', () => {
|
||||||
|
const html = read('index.html').toString();
|
||||||
|
expect(html).toContain('<link rel="manifest" href="/manifest.webmanifest" />');
|
||||||
|
expect(html).toContain('<link rel="apple-touch-icon" href="/apple-touch-icon.png" />');
|
||||||
|
expect(pngSize('apple-touch-icon.png')).toBe('180x180');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user