diff --git a/apps/website/README.md b/apps/website/README.md index 179730d51..ee25690f4 100644 --- a/apps/website/README.md +++ b/apps/website/README.md @@ -64,6 +64,17 @@ resolved version. canonicals, direct asset links, JSON-LD, cross-links and sitemap entries without depending on a specific version. +`tools/testing/website-screenshot-showcase.test.mjs` drives the built site in +a real browser: the home page channel switcher (autoplay, hover/focus pausing, +keyboard navigation, deferred frame sources). It relies on +`tools/testing/website-browser-support.mjs`, which serves `dist/apps/website` +on a loopback port and launches Chromium from the Playwright download or, +failing that, the system Chrome/Chromium channel. Without any Chromium the +browser half is **skipped locally** (the structural checks still run) and +**fails in CI**, so a green local run only proves the interactions when a +browser was found — run `pnpm exec playwright install chromium` once if the +skip shows up in your output. + ## Guides Evergreen how-to posts live in the blog collection next to release notes diff --git a/apps/website/project.json b/apps/website/project.json index ba1b4c4fd..560b68091 100644 --- a/apps/website/project.json +++ b/apps/website/project.json @@ -18,7 +18,7 @@ "executor": "nx:run-commands", "dependsOn": ["build"], "options": { - "command": "node --test tools/testing/website-giscus-comments.test.mjs tools/testing/website-download-pages.test.mjs tools/testing/website-guides.test.mjs tools/testing/website-feature-pages.test.mjs tools/testing/website-compare-pages.test.mjs tools/testing/website-blog-tags.test.mjs" + "command": "node --test tools/testing/website-giscus-comments.test.mjs tools/testing/website-download-pages.test.mjs tools/testing/website-guides.test.mjs tools/testing/website-feature-pages.test.mjs tools/testing/website-compare-pages.test.mjs tools/testing/website-blog-tags.test.mjs tools/testing/website-screenshot-showcase.test.mjs" } }, "serve": { diff --git a/apps/website/src/components/ScreenshotShowcase.astro b/apps/website/src/components/ScreenshotShowcase.astro index 5d387c3d5..87e43a1ce 100644 --- a/apps/website/src/components/ScreenshotShowcase.astro +++ b/apps/website/src/components/ScreenshotShowcase.astro @@ -1,159 +1,414 @@ --- -const tabs = [ +/** + * "Flip through the app": the screenshot showcase as a channel switcher. + * + * The list on the left is the channel list of a TV player — number, name, + * one line about what the screen is for — and the screen on the right shows + * the selected shot. Channels advance on their own (a progress hairline runs + * under the active row) until the visitor hovers, focuses or scrolls the block + * out of view; `prefers-reduced-motion` turns autoplay off entirely. Arrow keys + * move between channels like a remote. + * + * Only the first frame ships with a `src`; the inactive panels sit stacked + * under it with opacity 0, so native lazy loading would fetch all six at + * once. The script assigns `src` from `data-src` when a channel is shown and + * preloads the one after it, so at most two frames are in flight. + * + * The list precedes the screen in the DOM at every breakpoint (tabs before + * their panels, focus order equals reading order); on phones the list simply + * hides the per-channel descriptions and the keyboard hint so the screen + * stays close. + * + * Hover and focus only pause while they last, which is no use to a touch or + * screen-reader visitor, so the list footer carries a "Pause auto-advance" + * toggle that stays paused until pressed again (WCAG 2.2.2). It is removed + * under reduced motion, where there is nothing to pause. The transient pauses + * watch the channel list and the screen only, so pressing Resume restarts + * autoplay at once even though the button keeps the pointer and the focus. + */ +const channels = [ { id: 'dashboard', label: 'Dashboard', + description: 'Continue watching, recent channels and favorites across every source.', image: '/iptvnator/screenshots/dashboard-with-content.webp', - alt: 'Dashboard with recently watched content and global favorites', + alt: 'Dashboard with continue watching, recently watched and global favorites', width: 2072, height: 1602, + link: { href: '/iptvnator/features/', label: 'Every feature' }, }, { id: 'live-tv', label: 'Live TV', + description: 'Channel list with the current program, the player and the EPG timeline side by side.', image: '/iptvnator/screenshots/screenshot-player.webp', - alt: 'Live channels with the inline player and EPG', + alt: 'Live channels with the inline player and the program timeline', width: 2442, height: 1806, + link: { href: '/iptvnator/features/m3u-player/', label: 'M3U playlist player' }, }, { id: 'epg', - label: 'EPG Guide', + label: 'Program guide', + description: 'A multi-channel grid with a now-line you can scrub through the day.', image: '/iptvnator/screenshots/multi-epg-view.webp', alt: 'Multi-channel electronic program guide', width: 2180, height: 1448, + link: { href: '/iptvnator/features/epg/', label: 'TV guide (EPG)' }, + }, + { + id: 'movies', + label: 'Movies & series', + description: 'Detail pages with cast, seasons and the position you stopped at.', + image: '/iptvnator/screenshots/vod-details.webp', + alt: 'Movie detail page with poster, cast and play button', + width: 2260, + height: 1490, + link: { href: '/iptvnator/features/xtream-codes-player/', label: 'Xtream Codes player' }, + }, + { + id: 'downloads', + label: 'Downloads', + description: 'Offline library, download queue and live-TV recordings in one place.', + image: '/iptvnator/screenshots/download-manager.webp', + alt: 'Download manager with an offline library and a queue', + width: 2500, + height: 1602, + link: { href: '/iptvnator/blog/v0-23-release-notes/', label: 'Download manager notes' }, }, { id: 'settings', label: 'Settings', + description: 'Player engine, EPG, TMDB metadata, remote control and backup.', image: '/iptvnator/screenshots/settings.webp', alt: 'Application settings in the dark theme', width: 2500, height: 1602, + link: { href: '/iptvnator/features/remote-control/', label: 'Phone remote control' }, }, - { - id: 'add-playlist', - label: 'Add Playlist', - image: '/iptvnator/screenshots/add-playlist.webp', - alt: 'Add playlist dialog for M3U, Xtream, and Stalker sources', - width: 2260, - height: 1490, - }, -]; +] as const; + +const pad = (n: number) => String(n).padStart(2, '0'); ---

- -
- - Interface - -

- See it in action -

-

- A clean, intuitive interface designed for the best viewing experience. +

+
+ + Interface + +

+ Flip through the app +

+
+

+ Six screens, one player. Pick a channel or let it run.

-
- -
+
+ +
+
+ Channels + {pad(channels.length)} +
+
{ - tabs.map((tab, i) => ( + channels.map((channel, i) => ( )) } +
+
+ + +
- -
+ +
{ - tabs.map((tab, i) => ( + channels.map((channel, i) => (
-
- -
- - -
-
-
- - - -
- {tab.label} -
- {tab.alt} -
-
+ {channel.alt}
)) } + + + + + +
+
+ {channels[0].label} + +
+ + {channels[0].link.label} → + +
- + + diff --git a/tools/testing/website-browser-support.mjs b/tools/testing/website-browser-support.mjs new file mode 100644 index 000000000..f37f33a4f --- /dev/null +++ b/tools/testing/website-browser-support.mjs @@ -0,0 +1,101 @@ +import { createReadStream, existsSync, statSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { extname, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * Shared plumbing for the website tests that drive the built site in a real + * browser: a loopback static server for `dist/apps/website` mounted under the + * GitHub Pages base path, and a Chromium launcher. + * + * The server only ever hands out files below the build directory: the + * requested path is resolved against the root and anything that escapes it is + * answered with 404. Only local tests talk to it, but the guard keeps the + * helper honest and static analysis quiet. + * + * Chromium comes from the Playwright download when one exists, otherwise from + * the system Chrome/Chromium channel (GitHub's Ubuntu runners ship Google + * Chrome). In CI a missing browser is a failure, never a silent skip, so the + * interaction assertions cannot rot unnoticed; locally the caller may skip. + */ + +export const distRoot = fileURLToPath(new URL('../../dist/apps/website/', import.meta.url)); +export const BASE_PATH = '/iptvnator'; + +const MIME = { + '.html': 'text/html', + '.css': 'text/css', + '.js': 'text/javascript', + '.webp': 'image/webp', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', + '.xml': 'text/xml', +}; + +/** Maps a request URL to a file below `distRoot`, or `null` when it escapes the root. */ +export function resolveDistFile(url) { + let pathname; + try { + pathname = decodeURIComponent(new URL(url, 'http://localhost').pathname); + } catch { + return null; + } + if (pathname.startsWith(BASE_PATH)) { + pathname = pathname.slice(BASE_PATH.length) || '/'; + } + const root = resolve(distRoot); + const candidate = resolve(root, `.${pathname}`); + if (candidate !== root && !candidate.startsWith(root + sep)) { + return null; + } + const file = existsSync(candidate) && statSync(candidate).isDirectory() ? resolve(candidate, 'index.html') : candidate; + return existsSync(file) ? file : null; +} + +/** Serves the build on a random loopback port; resolves to `{ server, origin }`. */ +export function serveDist() { + const server = createServer((req, res) => { + const file = resolveDistFile(req.url ?? '/'); + if (!file) { + res.statusCode = 404; + res.end('not found'); + return; + } + res.setHeader('content-type', MIME[extname(file)] ?? 'application/octet-stream'); + createReadStream(file).pipe(res); + }); + return new Promise((done) => + server.listen(0, '127.0.0.1', () => done({ server, origin: `http://127.0.0.1:${server.address().port}` })), + ); +} + +/** + * Launches Chromium, trying the Playwright download first and the system + * channels next. Returns `null` when none is available — except in CI, where + * that is thrown as an error so the browser half of a test cannot be skipped. + */ +export async function launchBrowser() { + let playwright; + try { + playwright = await import('@playwright/test'); + } catch (error) { + return unavailable(`@playwright/test is not installed: ${error instanceof Error ? error.message : String(error)}`); + } + const attempts = []; + for (const options of [{}, { channel: 'chrome' }, { channel: 'chromium' }]) { + try { + return await playwright.chromium.launch(options); + } catch (error) { + attempts.push(`${JSON.stringify(options)}: ${error instanceof Error ? error.message.split('\n')[0] : String(error)}`); + } + } + return unavailable(`no Chromium could be launched (${attempts.join('; ')})`); +} + +function unavailable(reason) { + if (process.env.CI) { + throw new Error(`Website browser tests must run in CI, but ${reason}`); + } + return null; +} diff --git a/tools/testing/website-screenshot-showcase.test.mjs b/tools/testing/website-screenshot-showcase.test.mjs new file mode 100644 index 000000000..1b09e6763 --- /dev/null +++ b/tools/testing/website-screenshot-showcase.test.mjs @@ -0,0 +1,265 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { BASE_PATH as BASE, distRoot, launchBrowser, serveDist } from './website-browser-support.mjs'; + +/** + * The home page "Flip through the app" channel switcher. + * + * The structural half reads the built HTML: a vertical tablist with one + * selected channel, roving tabindex, and a panel per channel. The + * interaction half serves the build over HTTP and drives it in Chromium: + * autoplay advances, hover and focus pause independently, arrow keys move + * the selection together with the panel, caption and on-screen badge, and + * `prefers-reduced-motion` turns autoplay off. Without a Chromium the browser + * half is skipped locally and fails in CI (see website-browser-support.mjs). + */ + +const CHANNELS = ['dashboard', 'live-tv', 'epg', 'movies', 'downloads', 'settings']; + +test('showcase markup: a vertical tablist with one selected channel and a panel each', async () => { + const html = await readFile(join(distRoot, 'index.html'), 'utf8'); + assert.match(html, /
]*aria-orientation="vertical"/); + + const tabs = [...html.matchAll(/]*role="tab"[^>]*>/g)].map((m) => m[0]); + assert.equal(tabs.length, CHANNELS.length, 'one tab per channel'); + for (const [i, tab] of tabs.entries()) { + assert.match(tab, new RegExp(`data-channel="${CHANNELS[i]}"`)); + assert.match(tab, new RegExp(`aria-controls="screen-panel-${CHANNELS[i]}"`)); + assert.match(tab, new RegExp(`aria-selected="${i === 0 ? 'true' : 'false'}"`)); + assert.match(tab, new RegExp(`tabindex="${i === 0 ? '0' : '-1'}"`)); + } + + const panels = [...html.matchAll(/]*role="tabpanel"[^>]*>/g)].map((m) => m[0]); + assert.equal(panels.length, CHANNELS.length, 'one panel per channel'); + for (const [i, panel] of panels.entries()) { + assert.match(panel, new RegExp(`id="screen-panel-${CHANNELS[i]}"`)); + assert.match(panel, new RegExp(`aria-labelledby="screen-tab-${CHANNELS[i]}"`)); + assert.match(panel, new RegExp(`aria-hidden="${i === 0 ? 'false' : 'true'}"`)); + } + assert.match(html, /href="\/iptvnator\/features\/epg\/"/, 'captions link into the feature pages'); + const toggleTag = html.match(/]*data-autoplay-toggle[^>]*>/)?.[0]; + assert.ok(toggleTag && /aria-label="Pause auto-advance"/.test(toggleTag), 'a persistent pause control ships in the markup'); + assert.doesNotMatch(toggleTag, /aria-pressed/, 'the control is an action button, not a toggle with a changing name'); + assert.match(html, /channel-osd[^"]*motion-reduce:transition-none/, 'the OSD slide is off under reduced motion'); + assert.match(html, /channel-panel[^"]*motion-reduce:transition-none/, 'the panel fade is off under reduced motion'); + const tablist = html.match(/
/)?.[0]; + assert.ok(tablist, 'tablist element'); + assert.equal((tablist.match(/