mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(website): add the Xtream Codes setup guide with FAQ and guide screenshots
Publish "How to Add an Xtream Codes Account to IPTVnator" as the first evergreen guide: what the server URL, username and password are, the Add playlist flow with the connection test and its four verdicts, the Auto-detect method for pasted provider messages, what the import syncs, Account info, refresh, troubleshooting and a seven-question FAQ. The guide is cross-linked from the three download pages and llms.txt. Blog posts gain an optional `faq` frontmatter list: BlogPost.astro renders it as an accordion after the body and emits FAQPage JSON-LD next to the BlogPosting entry. LinkCards and PostButton keep internal links in the same tab. Guide screenshots come from the release capture script: manifest shots may carry a `group`, `--group guides` captures only those into apps/website/public/blog/guides/screenshots/, and a release run skips them. New setup actions open the Add playlist dialog with the mock's fictional Xtream credentials (connection test shown), the Auto-detect method with a labeled hand-out, and the Xtream Live TV view. Dialog helpers and fixture identities move into shared modules so the driver and the navigation actions cannot import each other cyclically. tools/testing/website-guides.test.mjs checks the FAQPage schema, the download-hub link and the shipped screenshots of every guide; screenshot-guards.test.mjs covers group validation and output routing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
81ce8e8c90
commit
90d26d499f
27 files changed
+661
-53
No files matched your search
@@ -41,7 +41,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
- Pushes to `master` and `v*` can publish Docker images. A `v*` tag build creates a draft GitHub release.
|
||||
- `pnpm run release:verify:draft` waits for that tag build (polling until the run is indexed, then `gh run watch`) and verifies the draft's status, authored body, and complete required asset set. It is read-only and deliberately fails on an already-published release, because it is the gate that runs before publication.
|
||||
- Publishing the GitHub release verifies its Snap assets and automatically uploads them to `edge`; installed-Snap smoke and candidate/stable promotion remain manual.
|
||||
- Release-post screenshots come only from the release capture script running against the mock servers. Never add a screenshot taken from a real playlist or account to `apps/website/public/blog/**` — real streams, logos, and metadata are copyrighted, and credentials must never reach a published image.
|
||||
- Release-post screenshots come only from the release capture script running against the mock servers. Never add a screenshot taken from a real playlist or account to `apps/website/public/blog/**` — real streams, logos, and metadata are copyrighted, and credentials must never reach a published image. Website guide screenshots use the same script: manifest shots with `"group": "guides"` are captured only by `pnpm release:screenshots --group guides` and land in `apps/website/public/blog/guides/screenshots/`.
|
||||
- Final task summaries should state whether a release note was added or why it was skipped.
|
||||
|
||||
## Regression Prevention And Test Updates
|
||||
|
||||
@@ -62,3 +62,22 @@ resolved version.
|
||||
`tools/testing/website-download-pages.test.mjs`, which checks titles,
|
||||
canonicals, direct asset links, JSON-LD, cross-links and sitemap entries
|
||||
without depending on a specific version.
|
||||
|
||||
## Guides
|
||||
|
||||
Evergreen how-to posts live in the blog collection next to release notes
|
||||
(`apps/website/src/content/blog/xtream-codes-setup-guide.mdx` is the first).
|
||||
Two conventions set them apart:
|
||||
|
||||
- **`faq` frontmatter.** An optional list of `{ q, a }` entries. `BlogPost.astro`
|
||||
renders it as an accordion after the body and emits a `FAQPage` JSON-LD block
|
||||
next to the `BlogPosting` one, so the answers can surface as rich results.
|
||||
- **Screenshots from the capture script.** Guide frames are captured by
|
||||
`pnpm release:screenshots --group guides` into
|
||||
`apps/website/public/blog/guides/screenshots/<slug>-<theme>.png`; the shots are
|
||||
declared in `tools/release/screenshots.manifest.json` with `"group": "guides"`
|
||||
and never appear in a release run.
|
||||
|
||||
`tools/testing/website-guides.test.mjs` (part of `pnpm nx test website`) checks
|
||||
each guide for the FAQPage schema, a link to the download hub and the presence
|
||||
of every referenced screenshot in the build output.
|
||||
@@ -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"
|
||||
"command": "node --test tools/testing/website-giscus-comments.test.mjs tools/testing/website-download-pages.test.mjs tools/testing/website-guides.test.mjs"
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 418 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 440 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 433 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 454 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 162 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 160 KiB |
@@ -11,6 +11,7 @@
|
||||
- Download for Windows: https://4gray.github.io/iptvnator/download/windows/
|
||||
- Download for macOS: https://4gray.github.io/iptvnator/download/macos/
|
||||
- Download for Linux: https://4gray.github.io/iptvnator/download/linux/
|
||||
- Guide, Xtream Codes setup: https://4gray.github.io/iptvnator/blog/xtream-codes-setup-guide/
|
||||
- Release Notes (v0.18): https://4gray.github.io/iptvnator/blog/v0-18-release-notes/
|
||||
- Troubleshooting (macOS app damaged fix): https://4gray.github.io/iptvnator/blog/macos-arm-app-damaged-fix/
|
||||
- Source Code: https://github.com/4gray/iptvnator
|
||||
|
||||
@@ -29,8 +29,8 @@ const iconPaths = {
|
||||
{links.map((item) => (
|
||||
<a
|
||||
href={item.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
target={item.href.startsWith('/') ? undefined : '_blank'}
|
||||
rel={item.href.startsWith('/') ? undefined : 'noopener noreferrer'}
|
||||
class="group rounded-xl border border-dashed border-surface-700/70 bg-surface-900/50 p-4 transition-all duration-300 hover:border-accent-500/40 hover:bg-accent-500/[0.06]"
|
||||
>
|
||||
<div class="flex items-center gap-2.5">
|
||||
|
||||
@@ -13,6 +13,8 @@ const blog = defineCollection({
|
||||
heroImage: z.string().optional(),
|
||||
tags: z.array(z.string()).default([]),
|
||||
draft: z.boolean().default(false),
|
||||
/** Rendered as an accordion after the post and emitted as FAQPage JSON-LD. */
|
||||
faq: z.array(z.object({ q: z.string(), a: z.string() })).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
---
|
||||
title: How to Add an Xtream Codes Account to IPTVnator
|
||||
description: Connect an Xtream Codes API provider to IPTVnator step by step. What the server URL, username and password are, how to test the login, how to paste the message from your provider, and what to do when the portal refuses to connect.
|
||||
pubDate: 2026-09-03
|
||||
author: 4gray
|
||||
heroImage: /iptvnator/blog/guides/screenshots/guide-xtream-live-dark.png
|
||||
tags:
|
||||
- guide
|
||||
- xtream-codes
|
||||
- setup
|
||||
- tutorial
|
||||
draft: false
|
||||
faq:
|
||||
- q: Is an Xtream Codes login the same thing as an M3U link?
|
||||
a: Usually both come from the same account. An M3U link that contains get.php with username and password in its query string is generated by an Xtream Codes panel, so you can add it as Xtream credentials instead. You get the same channels plus movies, series, provider metadata, EPG and catch-up, which the flat M3U file cannot carry.
|
||||
- q: Where do I find my Xtream server URL?
|
||||
a: It is the host part of any link your provider gave you, including the scheme and the port, for example http followed by the hostname and a port number. If you only have a get.php or player_api.php link, paste it into the Server URL field. IPTVnator strips the endpoint and reads the username and password from the query string.
|
||||
- q: Can I use the same Xtream account on several devices?
|
||||
a: Your provider decides that through the connection limit of the account. The Account info dialog in IPTVnator shows the active and maximum connections. Playing on more devices than allowed produces authentication errors or streams that stop after a few seconds.
|
||||
- q: Does IPTVnator show a program guide and catch-up for Xtream channels?
|
||||
a: Yes. IPTVnator asks the portal for the schedule of each channel and shows the current program in the channel list and a timeline under the player. Channels that the portal marks as archived get a catch-up badge, and past programs can be played from the timeline. On the desktop app, XMLTV sources configured in the settings are used as a fallback.
|
||||
- q: Can I add more than one Xtream account?
|
||||
a: Yes. Every account becomes its own source with separate favorites, history and cached catalog. The dashboard and the playlist switcher in the header list all of them, and global favorites, recent items and search work across sources.
|
||||
- q: Where are my credentials stored, and are they sent anywhere else?
|
||||
a: Credentials stay on your device, in the local database of the desktop app or in browser storage of the web version, and are only sent to the server URL you entered. IPTVnator has no account system and no analytics. The self-hosted web version routes requests through your own backend container.
|
||||
- q: My provider only gave me a MAC address and a portal URL. Is that Xtream Codes?
|
||||
a: No, that is a Stalker or Ministra portal. Choose the Stalker portal method in the same Add playlist dialog. The Auto-detect method recognizes a MAC address in the pasted message and pre-fills the Stalker form for you.
|
||||
---
|
||||
|
||||
import Alert from '../../components/blog/Alert.astro';
|
||||
import PostButton from '../../components/blog/PostButton.astro';
|
||||
import StepRail from '../../components/blog/StepRail.astro';
|
||||
import LinkCards from '../../components/blog/LinkCards.astro';
|
||||
|
||||
Most IPTV providers hand out three pieces of data: a **server URL**, a **username** and a
|
||||
**password**. That trio is an Xtream Codes account, and it is the richest way to connect a
|
||||
provider to IPTVnator. Instead of a flat channel list you get live TV, movies and series with
|
||||
posters and descriptions, a program guide, catch-up where the provider offers it, and an
|
||||
account page that tells you when the subscription ends.
|
||||
|
||||
This guide walks through adding such an account in IPTVnator, checking that it works, and
|
||||
fixing the usual problems. It applies to the desktop app on Windows, macOS and Linux and to the
|
||||
self-hosted browser version.
|
||||
|
||||
<Alert type="info" title="IPTVnator is a player, not a service">
|
||||
It does not include channels, playlists or accounts. The credentials in this guide come from
|
||||
the provider you already have a contract with.
|
||||
</Alert>
|
||||
|
||||
## What you need from your provider
|
||||
|
||||
| Field | What it looks like | Notes |
|
||||
| --- | --- | --- |
|
||||
| Server URL | `http://tv.example-provider.net:8080` | Scheme, host and port. Some panels live under a subpath such as `/panel`; keep it. |
|
||||
| Username | `janedoe` | Case-sensitive. |
|
||||
| Password | `s3cret` | Case-sensitive. |
|
||||
|
||||
Providers rarely label the data this cleanly. Often you receive one long link that ends in
|
||||
`get.php` or `player_api.php` with the username and password inside the query string. That link
|
||||
is enough: the part before `/get.php` is the server URL, and the two query parameters are your
|
||||
credentials. IPTVnator can read them out of the link for you, as shown below.
|
||||
|
||||
## Add the account
|
||||
|
||||
<StepRail
|
||||
title="In IPTVnator"
|
||||
steps={[
|
||||
'Open the Add playlist dialog. You will find the button on the dashboard, in the header playlist switcher, and in the command palette under "Add Xtream Codes playlist".',
|
||||
'Choose the "Xtream credentials" method.',
|
||||
'Fill in a playlist title of your choice, the server URL, the username and the password.',
|
||||
'Click "Test Connection". IPTVnator asks the portal for the account status and shows the result under the form.',
|
||||
'Click "Add". IPTVnator downloads the categories for live TV, movies and series and opens the new source.',
|
||||
]}
|
||||
/>
|
||||
|
||||

|
||||
|
||||
The connection test reports one of four states. **Connection successful! Portal is active** is
|
||||
the one you want. **Portal subscription has expired** and **Portal is inactive** mean the
|
||||
portal answered but refuses the account; check the subscription with your provider. **Could
|
||||
not connect to the portal** means no answer at all: a typo in the server URL, a wrong port, a
|
||||
missing `http://` or `https://`, or a host that is down.
|
||||
|
||||
<Alert type="success" title="Paste the whole link">
|
||||
If you paste a complete `get.php` or `player_api.php` link into the Server URL field while the
|
||||
username and password fields are still empty, IPTVnator cuts the link down to the server
|
||||
URL and fills in the credentials from the query string. Trailing slashes and surrounding
|
||||
whitespace are removed as well.
|
||||
</Alert>
|
||||
|
||||
## Or paste the message from your provider
|
||||
|
||||
Provider messages are messy: a link, a username and a password somewhere in a chat message
|
||||
or an email, sometimes dressed up in fancy Unicode letters to get past spam filters. The
|
||||
**Auto-detect** method in the same dialog takes the whole message and finds the source for
|
||||
you.
|
||||
|
||||

|
||||
|
||||
Paste everything, then click **Fill the form** on the candidate that looks right. IPTVnator
|
||||
switches to the matching method with the fields pre-filled, and you continue with the
|
||||
connection test as above. Detection runs inside the app; nothing you paste is sent anywhere.
|
||||
|
||||
The detector ranks what it finds: a MAC address means a Stalker portal, a `get.php` or
|
||||
`player_api.php` link means Xtream Codes, a labeled username and password without a MAC
|
||||
address leans Xtream, and a link ending in `.m3u` or `.m3u8` is a plain playlist URL. It never
|
||||
invents or "fixes" a value, so a password that is almost right stays wrong, and the connection
|
||||
test remains the authority on whether the account works.
|
||||
|
||||
## After the import
|
||||
|
||||
Right after you click Add, IPTVnator syncs the catalog: the categories for live TV, movies and
|
||||
series, then their content. A progress panel shows what is being fetched. On the desktop app
|
||||
the result is stored in a local library, so the next time you open the source it appears
|
||||
instantly and only changed data is downloaded.
|
||||
|
||||

|
||||
|
||||
The source gets its own sidebar with these sections:
|
||||
|
||||
- **Live TV**: categories on the left, channels with the current program and a progress bar,
|
||||
the player and a timeline of the schedule. Channels that the portal marks as archived carry
|
||||
a **Catchup available** badge with the number of days, and past programs play straight from
|
||||
the timeline.
|
||||
- **Movies** and **Series**: poster grids per category, a detail page with description, cast,
|
||||
trailer and similar titles, resume positions, watched markers, and on the desktop app a
|
||||
download button for offline viewing.
|
||||
- **Search**, **Favorites**, **Recent** and **Recently added** for the source. Global
|
||||
favorites, recent items and search on the dashboard combine all your sources.
|
||||
- **Discover**, when TMDB metadata is enabled in the settings: browse the portal by year,
|
||||
genre and country.
|
||||
|
||||
<Alert type="info" title="Program guide">
|
||||
Xtream portals ship their own schedule, so the guide works without any extra setup. If a
|
||||
channel shows no program information, the portal did not provide any for it. The desktop
|
||||
app additionally uses XMLTV sources configured under Settings › EPG as a fallback, and a
|
||||
right-click on a channel offers "Map EPG channel" to link it to an XMLTV channel by hand.
|
||||
</Alert>
|
||||
|
||||
## Check the subscription
|
||||
|
||||
Open **Account info** from the playlist switcher in the header (the menu next to the source)
|
||||
or from the source card on the dashboard. The dialog shows the account status, the expiry
|
||||
date, active and maximum connections, whether the account is a trial, the stream formats the
|
||||
portal allows, and the server details. The source card on the dashboard also carries a small
|
||||
expiry chip that turns amber during the last week of the subscription.
|
||||
|
||||
## Keep the catalog fresh
|
||||
|
||||
Providers add and remove content all the time. **Refresh Xtream playlist from remote** in the
|
||||
source menu downloads the categories and content again while keeping your favorites,
|
||||
watch history, hidden categories and playback positions. To change the server URL or
|
||||
credentials after a provider migration, open the playlist details of the source and edit
|
||||
them there; the next request uses the new connection.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Unauthorized, Account inactive or Account expired** after the import worked before:
|
||||
the portal now rejects the credentials. Open Account info, then check the subscription with
|
||||
your provider or correct the credentials in the playlist details.
|
||||
- **Portal not found**: the server URL is wrong. It must start with `http://` or `https://`,
|
||||
carry the right port, and keep any subpath the provider uses. Try the exact host from the
|
||||
link you were given.
|
||||
- **Portal unavailable**: the host did not answer. Check your network or VPN, then use
|
||||
**Retry**. After two failed connection attempts IPTVnator pauses requests to that host for
|
||||
30 seconds to keep the app responsive; a manual retry lifts the pause immediately.
|
||||
- **The catalog loads but streams do not play**: the built-in web players cannot decode every
|
||||
codec. Open the stream in MPV or VLC via Settings › Playback, or enable the experimental
|
||||
embedded MPV engine. Settings › Playback also has a **Stream Format** option for Xtream
|
||||
live streams; `auto` picks HLS when the portal allows it and falls back to MPEG-TS.
|
||||
- **Streams stop after a few seconds or refuse to start on a second device**: the account's
|
||||
connection limit is reached. Account info shows the active and maximum connections.
|
||||
- **The provider blocks the app**: some panels only answer clients with a specific
|
||||
User-Agent. On the desktop app you can set one in the playlist details; otherwise IPTVnator
|
||||
identifies itself with a common IPTV player signature.
|
||||
|
||||
## Related
|
||||
|
||||
<LinkCards
|
||||
links={[
|
||||
{ label: 'Download IPTVnator', href: '/iptvnator/download/', hint: 'Windows, macOS and Linux builds, package managers and the browser version.', icon: 'download' },
|
||||
{ label: 'Why some streams need an external player', href: '/iptvnator/blog/why-external-players-help/', hint: 'Codecs, containers and when MPV or VLC is the right tool.', icon: 'docs' },
|
||||
{ label: 'Understanding the program guide', href: '/iptvnator/blog/epg-guide/', hint: 'How XMLTV data reaches your channels.', icon: 'docs' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<PostButton href="/iptvnator/download/" label="Get IPTVnator for your platform" external={false} />
|
||||
@@ -1,6 +1,12 @@
|
||||
---
|
||||
import BaseLayout from './BaseLayout.astro';
|
||||
import GiscusComments from '../components/GiscusComments.astro';
|
||||
import FaqAccordion from '../components/blog/FaqAccordion.astro';
|
||||
|
||||
interface FaqEntry {
|
||||
q: string;
|
||||
a: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
@@ -10,9 +16,11 @@ interface Props {
|
||||
author: string;
|
||||
tags?: string[];
|
||||
heroImage?: string;
|
||||
/** Optional FAQ from the post frontmatter: rendered after the body and emitted as FAQPage schema. */
|
||||
faq?: FaqEntry[];
|
||||
}
|
||||
|
||||
const { title, description, pubDate, updatedDate, author, tags = [], heroImage } = Astro.props;
|
||||
const { title, description, pubDate, updatedDate, author, tags = [], heroImage, faq = [] } = Astro.props;
|
||||
|
||||
const formattedDate = pubDate.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
@@ -52,6 +60,19 @@ const blogPostSchema = {
|
||||
mainEntityOfPage: canonicalURL,
|
||||
image: heroImage ? new URL(heroImage, Astro.site).toString() : undefined,
|
||||
};
|
||||
const faqSchema =
|
||||
faq.length > 0
|
||||
? {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: faq.map((entry) => ({
|
||||
'@type': 'Question',
|
||||
name: entry.q,
|
||||
acceptedAnswer: { '@type': 'Answer', text: entry.a },
|
||||
})),
|
||||
}
|
||||
: null;
|
||||
const jsonLd = faqSchema ? [blogPostSchema, faqSchema] : blogPostSchema;
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
@@ -62,7 +83,7 @@ const blogPostSchema = {
|
||||
publishedTime={pubDate.toISOString()}
|
||||
modifiedTime={(updatedDate ?? pubDate).toISOString()}
|
||||
keywords={tags}
|
||||
jsonLd={blogPostSchema}
|
||||
jsonLd={jsonLd}
|
||||
>
|
||||
<article class="pt-36 pb-24">
|
||||
<div class="relative mx-auto max-w-3xl px-6">
|
||||
@@ -152,6 +173,17 @@ const blogPostSchema = {
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
{faq.length > 0 && (
|
||||
<section class="mt-16" aria-labelledby="post-faq-heading">
|
||||
<hr class="section-divider" />
|
||||
<span class="mt-12 block font-mono text-xs uppercase tracking-[0.2em] text-accent-500">FAQ</span>
|
||||
<h2 id="post-faq-heading" class="mt-3 font-display text-2xl font-bold tracking-tight text-surface-50 sm:text-3xl">
|
||||
Frequently asked questions
|
||||
</h2>
|
||||
<FaqAccordion items={faq} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
<GiscusComments />
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -25,6 +25,7 @@ const { Content } = await render(post);
|
||||
author={post.data.author}
|
||||
tags={post.data.tags}
|
||||
heroImage={post.data.heroImage}
|
||||
faq={post.data.faq}
|
||||
>
|
||||
<Content />
|
||||
</BlogPost>
|
||||
@@ -298,7 +298,7 @@ sudo emerge iptvnator-bin</TerminalBlock>
|
||||
</DownloadSection>
|
||||
|
||||
<DownloadSection id="guides" eyebrow="Keep reading" title="Guides and" accent="background">
|
||||
<RelatedPosts slugs={['why-external-players-help', 'epg-guide', 'beware-unofficial-iptvnator-websites']} />
|
||||
<RelatedPosts slugs={['xtream-codes-setup-guide', 'why-external-players-help', 'beware-unofficial-iptvnator-websites']} />
|
||||
<div class="mt-10">
|
||||
<OfficialSourcesNote />
|
||||
</div>
|
||||
|
||||
@@ -207,7 +207,7 @@ const jsonLd = buildDownloadPageSchema({
|
||||
</DownloadSection>
|
||||
|
||||
<DownloadSection id="guides" eyebrow="Keep reading" title="Guides and" accent="background">
|
||||
<RelatedPosts slugs={['macos-arm-app-damaged-fix', 'embedded-mpv-macos-experiment', 'why-external-players-help']} />
|
||||
<RelatedPosts slugs={['xtream-codes-setup-guide', 'macos-arm-app-damaged-fix', 'why-external-players-help']} />
|
||||
<div class="mt-10">
|
||||
<OfficialSourcesNote />
|
||||
</div>
|
||||
|
||||
@@ -228,7 +228,7 @@ const jsonLd = buildDownloadPageSchema({
|
||||
</DownloadSection>
|
||||
|
||||
<DownloadSection id="guides" eyebrow="Keep reading" title="Guides and" accent="background">
|
||||
<RelatedPosts slugs={['why-external-players-help', 'epg-guide', 'beware-unofficial-iptvnator-websites']} />
|
||||
<RelatedPosts slugs={['xtream-codes-setup-guide', 'why-external-players-help', 'beware-unofficial-iptvnator-websites']} />
|
||||
<div class="mt-10">
|
||||
<OfficialSourcesNote />
|
||||
</div>
|
||||
|
||||
@@ -155,6 +155,16 @@ Screenshots come only from the capture script running against the mock servers.
|
||||
Never publish one taken from a real playlist or account — streams, logos and
|
||||
metadata are copyrighted, and credentials must never reach a published image.
|
||||
|
||||
The same script also produces the evergreen screenshots of the website guides.
|
||||
Manifest shots that carry `"group": "guides"` are skipped by a release run and
|
||||
captured only with `pnpm release:screenshots --group guides`, which publishes
|
||||
into `apps/website/public/blog/guides/screenshots/` instead of a release folder
|
||||
(`outputDirectoryFor` in `screenshot-guards.mjs`). Guide shots go through every
|
||||
guard a release shot does; the add-playlist dialog shots fill the form with the
|
||||
mock's fictional `marketing` credentials and use a labeled hand-out for the
|
||||
Auto-detect method rather than a `get.php?username=…` link, because G4 rejects
|
||||
any URL carrying query credentials.
|
||||
|
||||
Output lands in `dist/release-highlight-cards/v<version>/`, outside version
|
||||
control — keyed by the exact version, because 0.24.0 and 0.24.1 share a blog
|
||||
post but not a card set. A run first removes the cards a previous run left in
|
||||
|
||||
@@ -14,11 +14,24 @@ import {
|
||||
type Page,
|
||||
} from '@playwright/test';
|
||||
|
||||
import { registerPlaylistId, requirePlaylistId } from './capture-navigation';
|
||||
import {
|
||||
M3U_FIXTURE_TITLE,
|
||||
XTREAM_FIXTURE_CREDENTIALS,
|
||||
XTREAM_FIXTURE_TITLE,
|
||||
XTREAM_MOCK_ORIGIN,
|
||||
} from './capture-fixtures';
|
||||
import {
|
||||
clickDialogOption,
|
||||
openAddPlaylistDialog,
|
||||
registerPlaylistId,
|
||||
requirePlaylistId,
|
||||
} from './capture-navigation';
|
||||
|
||||
export const XTREAM_MOCK_ORIGIN = 'http://localhost:3211';
|
||||
export const XTREAM_FIXTURE_TITLE = 'Fictional Xtream Demo';
|
||||
export const M3U_FIXTURE_TITLE = 'release-demo';
|
||||
export {
|
||||
M3U_FIXTURE_TITLE,
|
||||
XTREAM_FIXTURE_TITLE,
|
||||
XTREAM_MOCK_ORIGIN,
|
||||
} from './capture-fixtures';
|
||||
|
||||
/** Synthetic categories that only the marketing fixture generator produces. */
|
||||
const MOCK_FIXTURE_CATEGORIES = ['Action & Mystery', 'Urban Drama'];
|
||||
@@ -288,8 +301,8 @@ async function addXtreamPortal(page: Page): Promise<void> {
|
||||
await clickDialogOption(dialog, /xtream credentials/i);
|
||||
await dialog.locator('#title').fill(XTREAM_FIXTURE_TITLE);
|
||||
await dialog.locator('#serverUrl').fill(XTREAM_MOCK_ORIGIN);
|
||||
await dialog.locator('#username').fill('marketing');
|
||||
await dialog.locator('#password').fill('marketing');
|
||||
await dialog.locator('#username').fill(XTREAM_FIXTURE_CREDENTIALS.username);
|
||||
await dialog.locator('#password').fill(XTREAM_FIXTURE_CREDENTIALS.password);
|
||||
await dialog
|
||||
.getByRole('button', { name: /^(add|add playlist)$/i })
|
||||
.last()
|
||||
@@ -329,33 +342,6 @@ async function addM3uPlaylist(page: Page, m3uPath: string): Promise<void> {
|
||||
.waitFor({ state: 'visible', timeout: 60_000 });
|
||||
}
|
||||
|
||||
async function openAddPlaylistDialog(page: Page): Promise<void> {
|
||||
await page.getByRole('button', { name: /add playlist/i }).first().click();
|
||||
await page
|
||||
.locator('mat-dialog-container')
|
||||
.last()
|
||||
.waitFor({ state: 'visible', timeout: 15_000 });
|
||||
}
|
||||
|
||||
async function clickDialogOption(
|
||||
dialog: ReturnType<Page['locator']>,
|
||||
label: RegExp
|
||||
): Promise<void> {
|
||||
// The add-playlist dialog has changed shape across releases: source
|
||||
// methods were tabs, then plain buttons, now a radio group.
|
||||
for (const role of ['radio', 'tab', 'button'] as const) {
|
||||
const option = dialog.getByRole(role, { name: label }).first();
|
||||
|
||||
if ((await option.count()) > 0) {
|
||||
await option.click();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Dialog option matching ${label} not found`);
|
||||
}
|
||||
|
||||
|
||||
function idFromUrl(url: string, provider: 'playlists' | 'xtreams'): string {
|
||||
// `provider` is a closed union, but build the pattern from a literal
|
||||
// anyway so no future caller can inject regex syntax through it.
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Fixture identities shared by the capture driver (seeding) and the named
|
||||
* setup actions (guide shots that re-enter the add-playlist dialog). Kept in
|
||||
* a leaf module so capture-navigation.ts can import them without pulling in
|
||||
* the driver, which itself imports the navigation module.
|
||||
*
|
||||
* Everything here is fictional and resolves only against the local Xtream
|
||||
* mock server; the G4 frame guard still rejects any URL carrying query
|
||||
* credentials, so the auto-detect hand-out deliberately uses labeled lines
|
||||
* instead of a `get.php?username=…` link.
|
||||
*/
|
||||
|
||||
export const XTREAM_MOCK_ORIGIN = 'http://localhost:3211';
|
||||
export const XTREAM_FIXTURE_TITLE = 'Fictional Xtream Demo';
|
||||
export const M3U_FIXTURE_TITLE = 'release-demo';
|
||||
|
||||
/** Credential pair of the mock server's curated `marketing` scenario. */
|
||||
export const XTREAM_FIXTURE_CREDENTIALS = {
|
||||
username: 'marketing',
|
||||
password: 'marketing',
|
||||
} as const;
|
||||
|
||||
/** A fictional "your subscription is ready" message for the Auto-detect shot. */
|
||||
export const AUTO_DETECT_FIXTURE_MESSAGE = [
|
||||
'Welcome to Fictional TV! Your account is ready.',
|
||||
'',
|
||||
`Host: ${XTREAM_MOCK_ORIGIN}`,
|
||||
`Username: ${XTREAM_FIXTURE_CREDENTIALS.username}`,
|
||||
`Password: ${XTREAM_FIXTURE_CREDENTIALS.password}`,
|
||||
'',
|
||||
'Use these details in any Xtream Codes compatible player.',
|
||||
].join('\n');
|
||||
@@ -6,6 +6,13 @@
|
||||
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import {
|
||||
AUTO_DETECT_FIXTURE_MESSAGE,
|
||||
XTREAM_FIXTURE_CREDENTIALS,
|
||||
XTREAM_FIXTURE_TITLE,
|
||||
XTREAM_MOCK_ORIGIN,
|
||||
} from './capture-fixtures';
|
||||
|
||||
let m3uPlaylistId: string | undefined;
|
||||
let xtreamPlaylistId: string | undefined;
|
||||
|
||||
@@ -76,6 +83,11 @@ export async function runAction(
|
||||
action: string,
|
||||
param: string | null
|
||||
): Promise<void> {
|
||||
// Manifest steps are order-independent, and two of them end with a modal
|
||||
// dialog open. Close whatever the previous step left behind before this
|
||||
// one starts navigating, or the dialog backdrop swallows every click.
|
||||
await dismissDialogs(page);
|
||||
|
||||
switch (action) {
|
||||
case 'open-settings': {
|
||||
await page.locator('a[href$="/workspace/settings"]').first().click();
|
||||
@@ -167,11 +179,133 @@ export async function runAction(
|
||||
await page.waitForTimeout(500);
|
||||
return;
|
||||
}
|
||||
case 'open-add-playlist-xtream': {
|
||||
await goHome(page);
|
||||
await openAddPlaylistDialog(page);
|
||||
const dialog = page.locator('mat-dialog-container').last();
|
||||
|
||||
await clickDialogOption(dialog, /xtream credentials/i);
|
||||
await dialog.locator('#title').fill(XTREAM_FIXTURE_TITLE);
|
||||
await dialog.locator('#serverUrl').fill(XTREAM_MOCK_ORIGIN);
|
||||
await dialog
|
||||
.locator('#username')
|
||||
.fill(XTREAM_FIXTURE_CREDENTIALS.username);
|
||||
await dialog
|
||||
.locator('#password')
|
||||
.fill(XTREAM_FIXTURE_CREDENTIALS.password);
|
||||
// The status probe only talks to the local mock, so the frame can
|
||||
// show the successful "portal is active" verdict the guide explains.
|
||||
await dialog
|
||||
.getByRole('button', { name: /test connection/i })
|
||||
.first()
|
||||
.click();
|
||||
const status = dialog.locator('.connection-status');
|
||||
await status.waitFor({ state: 'visible', timeout: 30_000 });
|
||||
// The dialog body scrolls; bring the verdict the guide explains
|
||||
// into frame together with the credential fields above it.
|
||||
await status.scrollIntoViewIfNeeded();
|
||||
await page.waitForTimeout(500);
|
||||
return;
|
||||
}
|
||||
case 'open-add-playlist-auto': {
|
||||
await goHome(page);
|
||||
await openAddPlaylistDialog(page);
|
||||
const dialog = page.locator('mat-dialog-container').last();
|
||||
|
||||
await clickDialogOption(dialog, /auto-detect/i);
|
||||
await dialog
|
||||
.locator('[data-test-id="auto-detect-textarea"]')
|
||||
.fill(AUTO_DETECT_FIXTURE_MESSAGE);
|
||||
const candidate = dialog
|
||||
.locator('[data-test-id="auto-detect-candidate"]')
|
||||
.first();
|
||||
await candidate.waitFor({ state: 'visible', timeout: 15_000 });
|
||||
await candidate.scrollIntoViewIfNeeded();
|
||||
await page.waitForTimeout(500);
|
||||
return;
|
||||
}
|
||||
case 'open-xtream-live': {
|
||||
await goHome(page);
|
||||
await clickHrefSuffix(
|
||||
page,
|
||||
`/workspace/xtreams/${requireId('xtreams')}/vod`
|
||||
);
|
||||
await clickHrefSuffix(
|
||||
page,
|
||||
`/workspace/xtreams/${requireId('xtreams')}/live`
|
||||
);
|
||||
|
||||
const categories = page.locator(
|
||||
'app-workspace-context-panel .category-item'
|
||||
);
|
||||
const category = param
|
||||
? categories.filter({ hasText: param }).first()
|
||||
: categories.first();
|
||||
|
||||
await category.waitFor({ state: 'visible', timeout: 30_000 });
|
||||
await category.click();
|
||||
// Deliberately no channel click: playback would pull the mock's
|
||||
// redirect to a public demo stream, and third-party video frames
|
||||
// must never enter a published shot.
|
||||
await page
|
||||
.locator('app-channel-list-item')
|
||||
.first()
|
||||
.waitFor({ state: 'visible', timeout: 30_000 });
|
||||
await page.waitForTimeout(700);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown setup action: ${action}`);
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Dialog helpers (shared with the seeding driver) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export async function openAddPlaylistDialog(page: Page): Promise<void> {
|
||||
await page.getByRole('button', { name: /add playlist/i }).first().click();
|
||||
await page
|
||||
.locator('mat-dialog-container')
|
||||
.last()
|
||||
.waitFor({ state: 'visible', timeout: 15_000 });
|
||||
}
|
||||
|
||||
export async function clickDialogOption(
|
||||
dialog: ReturnType<Page['locator']>,
|
||||
label: RegExp
|
||||
): Promise<void> {
|
||||
// The add-playlist dialog has changed shape across releases: source
|
||||
// methods were tabs, then plain buttons, now a radio group.
|
||||
for (const role of ['radio', 'tab', 'button'] as const) {
|
||||
const option = dialog.getByRole(role, { name: label }).first();
|
||||
|
||||
if ((await option.count()) > 0) {
|
||||
await option.click();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Dialog option matching ${label} not found`);
|
||||
}
|
||||
|
||||
async function dismissDialogs(page: Page): Promise<void> {
|
||||
const dialogs = page.locator('mat-dialog-container');
|
||||
|
||||
if ((await dialogs.count()) === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await dialogs
|
||||
.first()
|
||||
.waitFor({ state: 'detached', timeout: 10_000 })
|
||||
.catch(async () => {
|
||||
await page.getByRole('button', { name: /^cancel$/i }).last().click();
|
||||
await dialogs.first().waitFor({ state: 'detached', timeout: 10_000 });
|
||||
});
|
||||
}
|
||||
|
||||
/** Returns to the dashboard via the always-visible brand link. */
|
||||
async function goHome(page: Page): Promise<void> {
|
||||
if (/\/workspace\/dashboard/.test(page.url())) {
|
||||
|
||||
@@ -4,10 +4,14 @@
|
||||
* pnpm release:screenshots # release slug from package.json
|
||||
* pnpm release:screenshots --release v0-24 # explicit
|
||||
* pnpm release:screenshots --only dashboard --theme dark
|
||||
* pnpm release:screenshots --group guides # evergreen guide shots
|
||||
*
|
||||
* Reads tools/release/screenshots.manifest.json and writes
|
||||
* apps/website/public/blog/<release>/screenshots/<slug>-<theme>.png against
|
||||
* dist builds + the xtream mock server. Guards (screenshot-guards.mjs):
|
||||
* dist builds + the xtream mock server. Shots carrying a `group` (for
|
||||
* example `guides`) are skipped by a release run and land in
|
||||
* apps/website/public/blog/<group>/screenshots/ when that group is selected.
|
||||
* Guards (screenshot-guards.mjs):
|
||||
*
|
||||
* G1 the real ~/.iptvnator/databases directory — including the SQLite WAL
|
||||
* sidecars, compared after Electron exits and checkpoints — is proven
|
||||
@@ -30,6 +34,7 @@ import process from 'node:process';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import {
|
||||
DEFAULT_SHOT_GROUP,
|
||||
buildCaptureEnv,
|
||||
compareDatabaseStates,
|
||||
evaluateFrameReport,
|
||||
@@ -37,8 +42,10 @@ import {
|
||||
HOST_RESOLVER_RULES,
|
||||
isAllowedRequestUrl,
|
||||
networkPolicy,
|
||||
outputDirectoryFor,
|
||||
parseSetupStep,
|
||||
publishDirectory,
|
||||
shotGroup,
|
||||
snapshotDatabaseState,
|
||||
stubbedResponseFor,
|
||||
validateManifest,
|
||||
@@ -90,14 +97,26 @@ async function main(): Promise<void> {
|
||||
throw new Error(`--release rejected: ${releaseError}`);
|
||||
}
|
||||
|
||||
const group = flag('group') ?? DEFAULT_SHOT_GROUP;
|
||||
const groupError = validateReleaseSlug(group);
|
||||
|
||||
if (groupError) {
|
||||
throw new Error(`--group rejected: ${groupError}`);
|
||||
}
|
||||
|
||||
const only = flag('only');
|
||||
const themeFilter = flag('theme');
|
||||
const shots = manifest.shots.filter(
|
||||
(shot: { slug: string }) => !only || shot.slug === only
|
||||
(shot: { slug: string; group?: string }) =>
|
||||
shotGroup(shot) === group && (!only || shot.slug === only)
|
||||
);
|
||||
|
||||
if (shots.length === 0) {
|
||||
throw new Error(`--only ${only} matches no manifest slug`);
|
||||
throw new Error(
|
||||
only
|
||||
? `--only ${only} matches no manifest slug in group ${group}`
|
||||
: `--group ${group} matches no manifest shots`
|
||||
);
|
||||
}
|
||||
|
||||
// Without this an erased cast would accept `--theme --only`, and every
|
||||
@@ -112,7 +131,7 @@ async function main(): Promise<void> {
|
||||
? [themeFilter as Theme]
|
||||
: manifest.themes;
|
||||
const blogRoot = path.join(workspaceRoot, 'apps/website/public/blog');
|
||||
const outputRoot = path.join(blogRoot, release, 'screenshots');
|
||||
const outputRoot = outputDirectoryFor({ blogRoot, group, release });
|
||||
|
||||
// Belt and braces: the slug is validated above, but assert the resolved
|
||||
// path really lands inside the blog tree before anything deletes there.
|
||||
|
||||
@@ -45,8 +45,35 @@ export const KNOWN_ACTIONS = [
|
||||
'open-xtream-vod',
|
||||
'open-xtream-series',
|
||||
'open-m3u-groups',
|
||||
'open-add-playlist-xtream',
|
||||
'open-add-playlist-auto',
|
||||
'open-xtream-live',
|
||||
];
|
||||
|
||||
/**
|
||||
* Shots without a `group` belong to the release post and land in
|
||||
* `blog/<release>/screenshots/`. Any other group (for example `guides`) is
|
||||
* captured only when asked for with `--group` and lands in its own
|
||||
* `blog/<group>/screenshots/` directory, so a release run can never publish
|
||||
* guide frames into a release folder or the other way round.
|
||||
*/
|
||||
export const DEFAULT_SHOT_GROUP = 'release';
|
||||
|
||||
/** @param {{ group?: unknown }} shot */
|
||||
export function shotGroup(shot) {
|
||||
return typeof shot?.group === 'string' ? shot.group : DEFAULT_SHOT_GROUP;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ blogRoot: string, group: string, release: string }} input
|
||||
* @returns {string} the directory a run of `group` publishes into
|
||||
*/
|
||||
export function outputDirectoryFor({ blogRoot, group, release }) {
|
||||
const folder = group === DEFAULT_SHOT_GROUP ? release : group;
|
||||
|
||||
return path.join(blogRoot, folder, 'screenshots');
|
||||
}
|
||||
|
||||
/** @param {string} step e.g. `open-xtream-vod=Hero Premieres` */
|
||||
export function parseSetupStep(step) {
|
||||
const separator = step.indexOf('=');
|
||||
@@ -104,6 +131,10 @@ export function validateManifest(manifest) {
|
||||
|
||||
seen.add(shot?.slug);
|
||||
|
||||
if (shot?.group !== undefined && !SLUG_PATTERN.test(String(shot.group))) {
|
||||
errors.push(`shot "${label}": group must be a lowercase slug`);
|
||||
}
|
||||
|
||||
if (!Array.isArray(shot?.setup) || shot.setup.length === 0) {
|
||||
errors.push(`shot "${label}": setup must be a non-empty array`);
|
||||
continue;
|
||||
|
||||
@@ -25,6 +25,9 @@ import {
|
||||
parseSetupStep,
|
||||
snapshotDatabaseState,
|
||||
stubbedResponseFor,
|
||||
DEFAULT_SHOT_GROUP,
|
||||
outputDirectoryFor,
|
||||
shotGroup,
|
||||
validateManifest,
|
||||
validateReleaseSlug,
|
||||
} from './screenshot-guards.mjs';
|
||||
@@ -103,6 +106,61 @@ describe('manifest validation', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts guide shots that name a group and the actions they use', () => {
|
||||
const manifest = validManifest();
|
||||
manifest.shots.push(
|
||||
{
|
||||
slug: 'guide-xtream-add-playlist',
|
||||
title: 'Add playlist',
|
||||
group: 'guides',
|
||||
setup: ['open-add-playlist-xtream'],
|
||||
},
|
||||
{
|
||||
slug: 'guide-xtream-auto-detect',
|
||||
title: 'Auto-detect',
|
||||
group: 'guides',
|
||||
setup: ['open-add-playlist-auto'],
|
||||
},
|
||||
{
|
||||
slug: 'guide-xtream-live',
|
||||
title: 'Live TV',
|
||||
group: 'guides',
|
||||
setup: ['open-xtream-live=News'],
|
||||
}
|
||||
);
|
||||
|
||||
assert.deepEqual(validateManifest(manifest), []);
|
||||
});
|
||||
|
||||
it('rejects a group that is not a lowercase slug', () => {
|
||||
const manifest = validManifest();
|
||||
manifest.shots.push({
|
||||
slug: 'guide',
|
||||
title: 'x',
|
||||
group: '../v0-24',
|
||||
setup: ['open-dashboard'],
|
||||
});
|
||||
|
||||
assert.ok(
|
||||
validateManifest(manifest).some((error) =>
|
||||
/group must be a lowercase slug/.test(error)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('routes release shots by release and grouped shots by group', () => {
|
||||
assert.equal(shotGroup({ slug: 'dashboard' }), DEFAULT_SHOT_GROUP);
|
||||
assert.equal(shotGroup({ slug: 'guide', group: 'guides' }), 'guides');
|
||||
assert.equal(
|
||||
outputDirectoryFor({ blogRoot: '/blog', group: DEFAULT_SHOT_GROUP, release: 'v0-24' }),
|
||||
path.join('/blog', 'v0-24', 'screenshots')
|
||||
);
|
||||
assert.equal(
|
||||
outputDirectoryFor({ blogRoot: '/blog', group: 'guides', release: 'v0-24' }),
|
||||
path.join('/blog', 'guides', 'screenshots')
|
||||
);
|
||||
});
|
||||
|
||||
it('parses setup steps with and without a parameter', () => {
|
||||
assert.deepEqual(parseSetupStep('open-dashboard'), {
|
||||
action: 'open-dashboard',
|
||||
|
||||
@@ -1,32 +1,72 @@
|
||||
{
|
||||
"version": 1,
|
||||
"viewport": { "width": 1280, "height": 720 },
|
||||
"themes": ["dark", "light"],
|
||||
"viewport": {
|
||||
"width": 1280,
|
||||
"height": 720
|
||||
},
|
||||
"themes": [
|
||||
"dark",
|
||||
"light"
|
||||
],
|
||||
"shots": [
|
||||
{
|
||||
"slug": "dashboard",
|
||||
"title": "Dashboard",
|
||||
"setup": ["open-dashboard"]
|
||||
"setup": [
|
||||
"open-dashboard"
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "settings",
|
||||
"title": "Settings",
|
||||
"setup": ["open-settings"]
|
||||
"setup": [
|
||||
"open-settings"
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "xtream-vod-details",
|
||||
"title": "Movie details",
|
||||
"setup": ["open-xtream-vod=Action & Mystery"]
|
||||
"setup": [
|
||||
"open-xtream-vod=Action & Mystery"
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "xtream-series-season-open",
|
||||
"title": "Series season view",
|
||||
"setup": ["open-xtream-series=Urban Drama"]
|
||||
"setup": [
|
||||
"open-xtream-series=Urban Drama"
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "m3u-live-groups-two-column",
|
||||
"title": "Live TV groups",
|
||||
"setup": ["open-m3u-groups"]
|
||||
"setup": [
|
||||
"open-m3u-groups"
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "guide-xtream-add-playlist",
|
||||
"title": "Add playlist: Xtream credentials",
|
||||
"group": "guides",
|
||||
"setup": [
|
||||
"open-add-playlist-xtream"
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "guide-xtream-auto-detect",
|
||||
"title": "Add playlist: Auto-detect",
|
||||
"group": "guides",
|
||||
"setup": [
|
||||
"open-add-playlist-auto"
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "guide-xtream-live",
|
||||
"title": "Xtream Live TV",
|
||||
"group": "guides",
|
||||
"setup": [
|
||||
"open-xtream-live"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { access, readFile } from 'node:fs/promises';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
/**
|
||||
* Structural checks for guide posts: they must carry FAQPage structured data
|
||||
* next to the BlogPosting entry, link to the download hub, and reference only
|
||||
* screenshots that the build actually shipped.
|
||||
*/
|
||||
|
||||
const distRoot = new URL('../../dist/apps/website/', import.meta.url);
|
||||
const SITE = 'https://4gray.github.io/iptvnator';
|
||||
|
||||
const GUIDES = [
|
||||
{
|
||||
slug: 'xtream-codes-setup-guide',
|
||||
screenshots: [
|
||||
'blog/guides/screenshots/guide-xtream-add-playlist-dark.png',
|
||||
'blog/guides/screenshots/guide-xtream-auto-detect-dark.png',
|
||||
'blog/guides/screenshots/guide-xtream-live-dark.png',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const readDist = (relativePath) => readFile(new URL(relativePath, distRoot), 'utf8');
|
||||
|
||||
function extractJsonLd(html) {
|
||||
const blocks = [...html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)];
|
||||
assert.ok(blocks.length > 0, 'Expected at least one JSON-LD script block.');
|
||||
return blocks.flatMap((match) => JSON.parse(match[1]));
|
||||
}
|
||||
|
||||
for (const guide of GUIDES) {
|
||||
test(`${guide.slug}: BlogPosting and FAQPage structured data`, async () => {
|
||||
const html = await readDist(`blog/${guide.slug}/index.html`);
|
||||
const schema = extractJsonLd(html);
|
||||
|
||||
assert.ok(schema.some((entry) => entry['@type'] === 'BlogPosting'), 'Expected a BlogPosting entry.');
|
||||
|
||||
const faq = schema.find((entry) => entry['@type'] === 'FAQPage');
|
||||
assert.ok(faq, 'Expected a FAQPage entry.');
|
||||
assert.ok(faq.mainEntity.length >= 5, 'Expected at least five FAQ questions.');
|
||||
assert.match(html, /Frequently asked questions/);
|
||||
assert.match(html, new RegExp(`<link rel="canonical" href="${SITE}/blog/${guide.slug}/"`));
|
||||
});
|
||||
|
||||
test(`${guide.slug}: links to the download hub and ships its screenshots`, async () => {
|
||||
const html = await readDist(`blog/${guide.slug}/index.html`);
|
||||
assert.match(html, /href="\/iptvnator\/download\/"/);
|
||||
|
||||
for (const screenshot of guide.screenshots) {
|
||||
assert.match(html, new RegExp(`src="/iptvnator/${screenshot.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`));
|
||||
await access(new URL(screenshot, distRoot));
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user