mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
* test(stalker): enforce portal auth in the mock and cover the full-portal flow
The mock server implemented neither get_profile nor get_events and
validated no auth at all, and the e2e suite imported the portal through
/portal.php — which the app classifies as a *simple* portal. The entire
authenticated branch (handshake, token, watchdog, re-auth) therefore had
zero coverage, right before a series of PRs that reworks exactly that.
Mock server:
- serve the canonical /stalker_portal/server/load.php endpoint, which
enforces the Bearer token and the Infomir MAC format like the real
middleware; /portal.php stays tolerant so the existing suite keeps
covering the simple-portal branch
- auth-store.ts models the parts of Stalker 4.9.35 a client can get
wrong: plain-text auth failures with HTTP 200, a handshake that is not
yet a session, idempotent token re-presentation, and permanent
device_id pinning (including the blank-after-pinned lockout)
- add get_profile (status 0/1/2, device conflict, block_msg) and the
get_events watchdog; profile advertises watchdog_timeout/timeslot
- new login-required scenario MAC and POST /invalidate-session so tests
can force a mid-session token loss
- the /stalker proxy route now forwards the token as a Bearer header and
wraps auth failures in the { payload } envelope, matching web-backend
Also moves extractMac into request-mac.ts: importing it from the
categories handler dragged the whole data generator into any consumer,
which broke unit tests on the workspace alias.
E2E: new stalker-auth.e2e.ts asserts handshake precedes get_profile
precedes content, that content requests carry the token while the
handshake does not, that the plain-text failure body is never rendered,
and that the client re-authenticates after the portal drops the session.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(mock): address CodeQL findings in the new portal auth code
Two genuine defects in the code this PR added:
- readBearerToken's /Bearer\s+(.*)$/ backtracks polynomially on
"bearer" followed by a long run of spaces; require the token to start
with a non-space character instead
- the /stalker proxy route read query params as strings without
narrowing, so a repeated key (?url=a&url=b) arrives as an array and
String.prototype.includes silently changes meaning
The remaining three alerts (missing rate limiting x2, sensitive data in
a GET query) are web-service hygiene rules aimed at internet-facing
services. The mock servers bind to localhost, serve fabricated data,
ship in no artifact, and deliberately mirror the real backend proxy's
token-in-query contract; a rate limiter would break the E2E suite that
hammers them. Exclude only those two apps from analysis via a documented
CodeQL config; every shipped path keeps full coverage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(mock): tighten portal-auth fidelity per review
Review follow-up on #1324 (Greptile 2xP1, Codex 3xP2), all valid:
- adoptToken only accepts tokens the mock actually issued (or the
already-bound one). The stock server pins any presented Bearer —
handshake is stateless there — but a fixture that does the same
cannot catch a client with a broken token pipeline; documented as a
deliberate strictness divergence.
- /invalidate-session clears tokens but keeps pinned device identity:
losing a token never unpins device_id on a real portal, so changed
identity after re-auth must still hit the device-conflict branch.
- The login-required scenario gates on actual do_auth completion
instead of auth_second_step: the app sends auth_second_step=1 on its
very first get_profile, so the parameter check was trivially
bypassed and the status-2 flow never exercised. do_auth is now the
faithful boolean step (non-empty credentials -> {js:true}, recorded;
empty -> {js:false}).
- /server/load.php — the second URL shape isFullStalkerPortal
recognizes — is now served and enforced, directly and through the
/stalker proxy predicate, so full-portal tests cannot silently fall
into the tolerant branch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(stalker): prove content actually reloads after re-authentication
Review follow-up on #1324 (Codex P2, valid — the "passes for the wrong
reason" class): the re-auth test only polled for a fresh handshake and
a negative body-text assertion, both of which pass even if the original
content request is never replayed or stays unauthorized. Capture the
content token from the initial import, then assert a post-invalidation
CONTENT request goes out under a DIFFERENT token and that the ITV
categories actually render — the mock only answers content for an
adopted token, so this proves the new token round-tripped through
get_profile. Verified against a live mock that the token genuinely
rotates (old token -> "Authorization failed.", new token -> content).
Also documents the second Codex P2: the mock is deliberately strict on
/server/load.php (a real portal enforces auth there); the import dialog
vs session predicate divergence is a separate app bug the strict
endpoint will let a later PR cover.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(stalker): serialize the portal specs and bind mocks to loopback
Review follow-up on #1324 (Codex, 4xP2):
- Parallel-reset race: under the workspace `fullyParallel` preset the new
auth file ran concurrently with stalker.e2e.ts against one shared mock
process, and each `beforeEach` wiped global state (sessions, favorites)
mid-assertion in the other. Reproduced locally: both suites green in
isolation, two failures when run together. Merged the auth tests into
stalker.e2e.ts and pinned the file to `mode: 'serial'`, which also
removes the pre-existing race between that file's own tests. 19/19
green across three consecutive runs.
- Watchdog was recorded but never asserted, so the suite would stay green
if the full-portal workflow stopped pinging or dropped its token —
`sendWatchdogPing` swallows failures. Now polls for an authenticated
`get_events`.
- Both mock servers listened on every interface (stalker: `listen(PORT)`
with no host; xtream: an explicit `0.0.0.0` default), which made the
CodeQL exclusion's "binds to localhost" rationale untrue. Both now
default to `127.0.0.1` with a `HOST` opt-in, and the config comment
states plainly what the directory-wide ignore trades away.
- Documented that the login-required scenario is HTTP-level only for now:
the client's `do_auth` path is dormant and sends empty credentials, so
the fixture is waiting on that client-side work rather than claiming
end-to-end coverage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(stalker): force a real auth failure before asserting it stays hidden
Review follow-up on #1324 (Codex 2xP2 + 1xP3), all valid:
- The "never surfaces the plain-text auth failure" test only performed a
successful import, so its negative body assertions were vacuous. It now
imports with a MAC outside the Infomir OUI: the strict endpoint answers
get_profile with a bare {status:1}, no token is ever adopted, and every
content request keeps returning "Authorization failed." Unlike an
invalidated session this cannot be repaired by the client retry, so the
failure is genuinely observed (asserted directly against the proxy) and
only then checked for not leaking into the UI.
- docs/architecture/xtream-mock-server.md still documented the wildcard
bind that 4b31f7167 replaced with a loopback default; it now states the
new default and the HOST=0.0.0.0 opt-in needed for phone/STB/container.
- Removed a dangling "Known app-side gap: the" fragment left in the
stalker mock README.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(mock): scope /reset by MAC so parallel specs stop wiping each other
The re-authentication test passed locally but failed all three CI
attempts: no request carried a token, because self-hosted.e2e.ts issues
a GLOBAL `POST /reset` against the same mock from a parallel Playwright
worker, destroying the session mid-import. Running only stalker.e2e.ts
locally never triggered it.
Serializing within one file (4b31f7167) could not fix this — the
interference is between files. Mock state is per-MAC, so `/reset` now
accepts `?macAddress=` and clears only that MAC's data, favorites,
session and watchdog counters; the unscoped form is kept for callers
that own the whole server. Both spec files now reset only the MACs they
own, so no worker can disturb another.
Verified: a scoped reset of one MAC leaves another MAC's session intact
(and its own dies), and stalker.e2e.ts + self-hosted.e2e.ts run together
23/23 green — the combination that reproduced the CI failure.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): scope the last global Stalker reset in sources-pwa helpers
Completes 3a93fef0f: that commit scoped self-hosted.e2e.ts but missed
resetPwaMockServers, which still wiped the whole Stalker fixture from a
third spec file. Scope it to the two MACs this suite owns.
The auth tests use dedicated MACs no sibling touches, so portal sessions
— the fragile state — can no longer be cleared by a parallel worker.
Content MACs still overlap between files, which is harmless: that data is
regenerated deterministically from the same seed.
Verified with the full interfering set running together:
stalker.e2e.ts + self-hosted.e2e.ts + sources-pwa.e2e.ts, 26/26 green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(stalker): await the first authenticated content request
The re-auth test kept failing on CI (3/3 attempts) with an undefined
token while passing locally. My earlier diagnosis — a sibling spec's
global /reset — was wrong: the failure survived the scoped-reset fix.
Real cause is a race in the test itself. `addFullStalkerPortal` only
awaits the route change, so on a slower runner the first authenticated
content request has not been recorded yet when the token is read; the
sibling test that passes happens to await `.category-item` first. Poll
for a content request carrying a token before capturing it.
The scoped-reset work stands on its own merits (cross-file resets were
a real hazard), it just was not what broke this test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(stalker): drop serial mode, batch resets, cover the auth handlers
Review round on a44f8135f plus a stability regression I introduced.
Codex, both valid:
- The proxy route stripped `token` from the forwarded query, so
`handshake` never saw a presented token and the idempotent-handshake
behaviour I documented was unreachable through the PWA path. The real
backend forwards every param except `targetId` *and* sets the header;
match it. Verified through the proxy: re-handshake now returns the
same token with not_valid 0.
- The login-required scenario had no committed test, so the README claim
was unbacked. Added auth-handlers.spec.ts (status 2 -> do_auth ->
profile, MAC-format rejection, device conflict, idempotent handshake,
watchdog). Handlers are called directly because the dispatcher pulls in
the faker-based generator, which this project's Jest cannot transform.
- Sibling suites now own disjoint MACs (00:1A:79:5F:*) instead of
sharing the Stalker suite's, so no reset can reach another suite's
state at all.
Stability: a baseline run of master passed 23/23 first try while this
branch failed a different test each run, so the flakiness was mine.
`mode: 'serial'` was a stand-in for isolation that per-MAC scoping now
provides properly, and it amplified every flake by aborting the rest of
the file; removed. `beforeEach` also fired seven sequential resets — the
endpoint now accepts repeated `macAddress` params so a suite clears all
of its MACs in one request. Added a retrying POST helper after an
ECONNRESET on a control call.
Verified: three consecutive runs of stalker + self-hosted + sources-pwa,
26/26 each; 28 mock unit tests; lint clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(stalker): restore serial mode for the shared-scenario file
Review follow-up (Codex P2), valid: the previous commit removed
`mode: 'serial'` while every `beforeEach` still resets all OWNED_MACS,
so under fullyParallel one test in this file could clear another's data
or session mid-run.
Of the two suggested fixes, serialize rather than give each test its own
MAC: the tests here are written against scenario fixtures (default,
minimal, embedded-series) whose shapes the assertions encode, so a MAC
per test would mean inventing a scenario per test and rewriting
pre-existing assertions. Cross-file isolation stays with the disjoint
sibling MAC range, which is what serial was wrongly standing in for
before.
The header now states both levels explicitly so the next reader does not
undo one of them.
Verified: three consecutive runs of stalker + self-hosted + sources-pwa,
26/26 each.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
583 lines
23 KiB
Markdown
583 lines
23 KiB
Markdown
# Xtream Mock Server — Architecture
|
|
|
|
## Purpose
|
|
|
|
`apps/xtream-mock-server` is a self-contained Express server that emulates the
|
|
Xtream Codes API protocol. It is used for:
|
|
|
|
- **Local development** — run a full portal without a real Xtream subscription
|
|
- **E2E testing** — Playwright uses it for both web and Electron workflows
|
|
- **Performance investigation** — a locked-down loopback control plane
|
|
prepares and identifies a fixed synthetic 100k catalog before capture
|
|
|
|
---
|
|
|
|
## Data Pipeline
|
|
|
|
```
|
|
credentials (username + password)
|
|
│
|
|
▼
|
|
credentialsToSeed(u, p) ←── deterministic polynomial hash
|
|
│
|
|
▼
|
|
faker.seed(seed) ←── all faker calls use same seed per credentials
|
|
│
|
|
├── performance:performance?
|
|
│ └── index-derived generator (no Faker/runtime clock)
|
|
│
|
|
▼
|
|
generateCategories() ←── live / vod / series categories
|
|
│
|
|
generateLiveStreams() ←── live TV stream list
|
|
scenario EPG fixture? ←── optional deterministic per-stream EPG override
|
|
generateVodStreams() ←── VOD movie list
|
|
generateSeriesItems() ←── series list
|
|
generateSeriesInfo() ←── nested seasons + episodes (pre-populated)
|
|
│
|
|
▼
|
|
PortalData (cached) ←── Map<"username:password", PortalData>
|
|
```
|
|
|
|
Re-requesting with the same credentials returns the exact same data until
|
|
`POST /reset` clears all caches.
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
```
|
|
apps/xtream-mock-server/
|
|
├── project.json ← Nx serve, watch, lint, and test targets
|
|
├── public/
|
|
│ └── marketing/ ← committed fictional release artwork PNGs
|
|
├── tsconfig.json
|
|
└── src/
|
|
├── main.ts ← env validation, HTTP lifecycle, safe logging
|
|
└── app/
|
|
├── server.ts ← side-effect-free Express app factory/routes
|
|
├── server-lifecycle.ts ← bounded, idempotent HTTP shutdown
|
|
├── scenarios.ts ← Credential → ScenarioConfig mapping
|
|
├── data-store.ts ← Lazy cache, per-credentials generation
|
|
├── performance-control.ts ← bounded request lifecycle controller
|
|
├── performance-interception-lifecycle.ts ← reset/shutdown generation
|
|
├── performance-control-routes.ts ← token-gated control HTTP API
|
|
├── performance-control-validation.ts ← strict request validation
|
|
├── performance-control.types.ts ← safe manifest/state contracts
|
|
├── performance-manifest.ts ← fixed-order catalog hash/counts
|
|
├── generators/
|
|
│ ├── categories.generator.ts
|
|
│ ├── live.generator.ts ← Live streams + EPG listings
|
|
│ ├── marketing.generator.ts ← Fictional release screenshot fixture
|
|
│ ├── performance.generator.ts ← local-only deterministic 100k fixture
|
|
│ ├── vod.generator.ts ← VOD streams + VodDetails
|
|
│ └── series.generator.ts ← Series items + SeriesInfo
|
|
├── handlers/
|
|
│ ├── get-account-info.handler.ts
|
|
│ ├── get-categories.handler.ts ← live/vod/series categories
|
|
│ ├── get-full-epg.handler.ts ← full EPG + legacy typo alias
|
|
│ ├── get-streams.handler.ts ← live/vod/series stream lists
|
|
│ ├── get-vod-info.handler.ts
|
|
│ ├── get-series-info.handler.ts
|
|
│ └── get-short-epg.handler.ts
|
|
└── routes/
|
|
└── dispatch.ts ← Action → handler routing
|
|
```
|
|
|
|
---
|
|
|
|
## API Protocol
|
|
|
|
### Direct Xtream endpoint
|
|
|
|
```
|
|
GET /player_api.php?action=<action>&username=<u>&password=<p>[&...]
|
|
```
|
|
|
|
Response: raw JSON (no envelope). Matches the real Xtream Codes API format.
|
|
|
|
### PWA proxy endpoint
|
|
|
|
IPTVnator's PWA routes Xtream calls through:
|
|
|
|
```
|
|
GET /xtream?url=<serverUrl>&action=<action>&username=<u>&password=<p>
|
|
```
|
|
|
|
Response: `{ payload: <data>, action: <action> }`
|
|
|
|
This mirrors the backend proxy in `apps/electron-backend` so the same
|
|
Angular service code works in both environments.
|
|
|
|
### Performance control endpoint
|
|
|
|
The control plane is absent by default. It is mounted only when
|
|
`IPTVNATOR_XTREAM_MOCK_CONTROL=1`; startup then requires a non-empty
|
|
`IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN` and a literal loopback `HOST`
|
|
(`127.0.0.1` or `::1`). Every `/__control/*` request must carry that exact value
|
|
in `x-iptvnator-performance-token`, including `OPTIONS` preflight requests.
|
|
Configuration is validated before the HTTP listener opens. Both modes now
|
|
default to `127.0.0.1` when `HOST` is unset — the fixture serves fabricated but
|
|
unauthenticated content, so it should not be reachable from other hosts by
|
|
accident. Set `HOST=0.0.0.0` explicitly to expose it, which is what you need
|
|
when driving the mock from a phone, an STB, a container, or another machine.
|
|
Control mode additionally *rejects* an explicitly configured non-loopback host. The Nx serve targets do not pin `PORT`, so an explicit shell
|
|
value reaches the parser; its no-value default remains `3211`.
|
|
|
|
Use a dedicated port rather than the normal `3211` E2E server:
|
|
|
|
```bash
|
|
HOST=127.0.0.1 \
|
|
PORT=3221 \
|
|
IPTVNATOR_XTREAM_MOCK_CONTROL=1 \
|
|
IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN=local-benchmark-token \
|
|
pnpm nx run xtream-mock-server:serve
|
|
```
|
|
|
|
The strict control API is:
|
|
|
|
```text
|
|
POST /__control/prepare
|
|
POST /__control/reset
|
|
POST /__control/barriers
|
|
POST /__control/barriers/:id/release
|
|
POST /__control/delays
|
|
GET /__control/state
|
|
```
|
|
|
|
`prepare` accepts exactly `{"scenario":"performance-100k"}` and materializes
|
|
the fixed `performance:performance` fixture before application capture. It
|
|
returns only this manifest:
|
|
|
|
```json
|
|
{
|
|
"epoch": 1,
|
|
"scenario": "performance-100k",
|
|
"seed": 91001,
|
|
"counts": {
|
|
"categories": { "live": 60, "vod": 20, "series": 20, "total": 100 },
|
|
"items": {
|
|
"live": 60000,
|
|
"vod": 20000,
|
|
"series": 20000,
|
|
"total": 100000
|
|
}
|
|
},
|
|
"bytes": 123,
|
|
"catalogSha256": "64-lowercase-hex-characters"
|
|
}
|
|
```
|
|
|
|
`bytes` is the UTF-8 byte length and `catalogSha256` is the SHA-256 of one JSON
|
|
object whose property order is fixed as:
|
|
|
|
1. live categories
|
|
2. VOD categories
|
|
3. series categories
|
|
4. live catalog
|
|
5. VOD catalog
|
|
6. series catalog
|
|
|
|
Credentials, server origins, response envelopes, and EPG/detail caches are not
|
|
part of the hash input.
|
|
|
|
`reset` accepts exactly `{"mode":"observations"}` or `{"mode":"all"}`.
|
|
Observation reset clears active/held rules, per-identity occurrences, and the
|
|
ledger while retaining the prepared manifest and epoch. All-state reset also
|
|
calls the data-store reset, clears the manifest, and increments the epoch.
|
|
Either mode first advances an internal observation generation and detaches all
|
|
pre-reset response listeners, so a late `finish` or `close` cannot repopulate
|
|
the freshly cleared ledger. Held barrier/delay clients retain the safe `409`
|
|
reset response where possible; unmatched active responses are aborted.
|
|
|
|
Barrier and delay rules have an ID plus the exact match tuple:
|
|
|
|
```text
|
|
(epoch, scenario, transport, canonicalAction, categoryId, occurrence)
|
|
```
|
|
|
|
`transport` is `direct` or `proxy`. Empty actions canonicalize to
|
|
`get_account_info`; the dispatcher's legacy `get_simple_date_table` alias is
|
|
allowlisted. Occurrences are counted per tuple excluding occurrence, so
|
|
parallel requests for different categories cannot consume each other's rule.
|
|
Numeric category IDs must use canonical decimal spelling. Signs, whitespace,
|
|
leading zeroes, non-decimal notation, unsafe integers, and values outside the
|
|
closed scenario ranges are rejected; invalid incoming aliases collapse to the
|
|
single `all` observation identity and cannot expand the bounded state map.
|
|
Rules are one-shot and match before dispatch or JSON serialization. Barrier
|
|
lifecycle is `arrived → blocked → responded|aborted`; delay lifecycle is
|
|
`arrived → delayed → responded|aborted`. Real request abort events release held
|
|
state; a normally consumed request's `close` event is not treated as an abort.
|
|
|
|
The state response contains only epoch, the safe manifest, active rules, held
|
|
IDs/count, bounded occurrence counts, and bounded lifecycle entries with
|
|
monotonic timestamps. It never stores or returns the token, username, password,
|
|
raw URL/query, request/response payload, titles, or catalog arrays. Unknown
|
|
incoming actions are represented only as `unknown`.
|
|
|
|
Control JSON is limited to 16 KiB. Bodies reject unknown/missing/wrong-type
|
|
fields. Each observation epoch accepts at most 32 rules, and the serialized
|
|
ledger retains at most 128 entries. Occurrence state has 512 slots: enough for
|
|
the complete closed scenario/action/category/transport identity domain without
|
|
eviction or counter restart; an unexpected overflow fails closed. IDs,
|
|
scenarios, transports, actions, categories, occurrences, and delays
|
|
(`0..5000` ms) use closed validation. Duplicate IDs/matches and already-past
|
|
occurrences fail closed.
|
|
|
|
The legacy unauthenticated `POST /reset` remains available in normal mode. It
|
|
returns `410` while the control plane is enabled, preventing cache invalidation
|
|
that would leave the controller's prepared manifest stale. Performance runs
|
|
must use the token-authenticated `/__control/reset`.
|
|
|
|
Barriers and delays exist for deterministic coordination and smoke tests only.
|
|
Formal performance captures must prove that both rule sets are empty and must
|
|
never add an artificial delay to the timed application path.
|
|
|
|
`SIGINT` and `SIGTERM` use one bounded, idempotent shutdown path. The
|
|
application hook invalidates active observations, settles barriers and delays,
|
|
and clears their timers/listeners before the HTTP listener closes. The shared
|
|
HTTP helper then closes idle and active connections, so an unreleased control
|
|
rule cannot keep the mock process alive.
|
|
|
|
### M3U fixture endpoint
|
|
|
|
```
|
|
GET /playlist.m3u
|
|
```
|
|
|
|
Returns a small deterministic four-channel playlist. The self-hosted PWA E2E
|
|
suite uses this endpoint to verify M3U URL imports through `apps/web-backend`
|
|
and the provider target registry.
|
|
|
|
### Stream stub endpoints
|
|
|
|
```
|
|
GET /live/<username>/<password>/<streamId>.m3u8
|
|
GET /movie/<username>/<password>/<streamId>.<ext>
|
|
GET /series/<username>/<password>/<streamId>.<ext>
|
|
```
|
|
|
|
In normal mode all redirect to a publicly available HLS test stream
|
|
(`https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8`). With the control plane
|
|
enabled, every stream/timeshift route for the performance credentials returns
|
|
`410` without a redirect or outbound request. `/playlist.m3u` is also `410` in
|
|
that mode. This keeps formal performance fixtures local-only; normal
|
|
control-disabled E2E behavior is unchanged.
|
|
|
|
---
|
|
|
|
## Key Response Shapes
|
|
|
|
### `get_account_info`
|
|
|
|
```json
|
|
{
|
|
"user_info": {
|
|
"username": "user1",
|
|
"password": "pass1",
|
|
"status": "active",
|
|
"exp_date": "4102444799",
|
|
"is_trial": "0",
|
|
"active_cons": "1",
|
|
"max_connections": "2",
|
|
"allowed_output_formats": ["m3u8", "ts", "rtmp"]
|
|
},
|
|
"server_info": {
|
|
"url": "http://localhost:3211",
|
|
"port": "3211",
|
|
"timezone": "UTC",
|
|
"timestamp_now": 1234567890
|
|
}
|
|
}
|
|
```
|
|
|
|
### `get_live_categories` / `get_vod_categories` / `get_series_categories`
|
|
|
|
```json
|
|
[
|
|
{ "category_id": "101", "category_name": "News", "parent_id": 0 },
|
|
...
|
|
]
|
|
```
|
|
|
|
### `get_live_streams` (sample item)
|
|
|
|
```json
|
|
{
|
|
"num": 1,
|
|
"name": "Acme Corp TV",
|
|
"stream_type": "live",
|
|
"stream_id": 10000,
|
|
"stream_icon": "https://picsum.photos/seed/live-10000/100/100",
|
|
"epg_channel_id": "channel-10000.mock",
|
|
"category_id": "101",
|
|
"tv_archive": 0,
|
|
"tv_archive_duration": 0
|
|
}
|
|
```
|
|
|
|
### `get_short_epg` (sample item)
|
|
|
|
```json
|
|
{
|
|
"epg_listings": [
|
|
{
|
|
"id": "1000000",
|
|
"epg_id": "channel-10000.mock",
|
|
"title": "base64encodedTitle",
|
|
"description": "base64encodedDescription",
|
|
"start": "2024-01-01 12:00:00",
|
|
"end": "2024-01-01 12:30:00",
|
|
"start_timestamp": "1704110400",
|
|
"stop_timestamp": "1704112200"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
Note: `title` and `description` are **base64-encoded**, matching the real Xtream API.
|
|
|
|
### `get_simple_data_table` / `get_simple_date_table`
|
|
|
|
Both actions return the same full per-channel schedule shape:
|
|
|
|
```json
|
|
{
|
|
"epg_listings": [
|
|
{
|
|
"id": "10000-current",
|
|
"epg_id": "channel-10000.mock",
|
|
"title": "base64encodedTitle",
|
|
"description": "base64encodedDescription",
|
|
"start": "2026-04-05 04:30:00",
|
|
"end": "2026-04-05 05:00:00",
|
|
"start_timestamp": "1775363400",
|
|
"stop_timestamp": "1775365200",
|
|
"channel_id": "channel-10000.mock"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
The legacy `get_simple_date_table` typo alias exists because real Xtream panels
|
|
sometimes only respond to that misspelled action.
|
|
|
|
### `get_series_info` (structure)
|
|
|
|
```json
|
|
{
|
|
"seasons": [
|
|
{
|
|
"id": 3000100, "name": "Season 1", "season_number": 1,
|
|
"episode_count": 8, "air_date": "2022-05-14",
|
|
"cover": "https://picsum.photos/seed/season-30001-1/300/450"
|
|
}
|
|
],
|
|
"info": { "name": "...", "cover": "...", "plot": "...", "cast": "...", ... },
|
|
"episodes": {
|
|
"1": [
|
|
{
|
|
"id": "80001", "episode_num": 1, "title": "Series Name S1E1",
|
|
"season": 1, "container_extension": "mkv",
|
|
"info": { "duration_secs": 2400, "rating": 8.3, ... }
|
|
}
|
|
]
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Scenarios
|
|
|
|
| Key (`username:password`) | Seed | Categories | Items/cat | Account status |
|
|
| ------------------------- | ----- | -------------------------- | --------- | -------------- |
|
|
| `user1:pass1` | 1001 | 8 each | 40 | active |
|
|
| `large:large` | 9999 | 20 each | 200 | active |
|
|
| `stress:stress` | 7777 | 16 each | 120 | active |
|
|
| `performance:performance` | 91001 | live:60, vod:20, series:20 | 1,000 | active |
|
|
| `series:series` | 2002 | live:3, vod:4, series:15 | 30 | active |
|
|
| `minimal:minimal` | 3003 | 2 each | 5 | active |
|
|
| `epg:epg` | 6006 | live:2, vod:1, series:1 | 3 | active |
|
|
| `emptyvod:emptyvod` | 7007 | 2 each | 5 | active |
|
|
| `marketing:marketing` | 8020 | live:4, vod:4, series:4 | curated | active |
|
|
| `expired:expired` | 4004 | 4 each | 10 | Expired |
|
|
| `inactive:inactive` | 5005 | 4 each | 10 | Disabled |
|
|
| `<any other>` | hash | 6 each | 30 | active |
|
|
|
|
### `epg:epg` fixture details
|
|
|
|
This scenario is reserved for Xtream EPG tests:
|
|
|
|
- live category `EPG Focus` contains deterministic channels such as `Timezone News`
|
|
- `Timezone News` serves a fixed `get_short_epg` window and a full `get_simple_data_table` schedule
|
|
- the full schedule includes a program that spans a UTC midnight boundary and another program after midnight
|
|
- raw `start` / `end` strings are intentionally offset from `start_timestamp` / `stop_timestamp`
|
|
|
|
That deliberate mismatch lets Electron tests verify the renderer uses timestamp
|
|
fields for sorting, current-program selection, progress bars, and local clock
|
|
labels instead of trusting provider-local strings.
|
|
|
|
### `marketing:marketing` fixture details
|
|
|
|
This scenario is reserved for release screenshots and marketing materials:
|
|
|
|
- VOD and series data use 65 curated fictional titles instead of faker-generated
|
|
titles or real media metadata
|
|
- the core 30-title generated artwork pack keeps matched posters and backdrops;
|
|
35 additional movie-poster showcase titles use approved local poster PNGs and
|
|
the deterministic SVG fallback for their missing backdrops; the newest 20
|
|
appear first in the VOD catalog
|
|
- showcase metadata comes from the provider-neutral
|
|
`@iptvnator/shared/marketing-fixtures` catalog shared with the Stalker mock
|
|
- posters and backdrops are served from committed PNG files in
|
|
`apps/xtream-mock-server/public/marketing/{poster,backdrop}/`
|
|
- `tools/release/generate-marketing-artwork.ts` generates the PNGs with
|
|
`gpt-image-2` only when `OPENAI_API_KEY` is present; it also writes the
|
|
prompt/asset manifest at `apps/xtream-mock-server/public/marketing/manifest.json`
|
|
- the manifest assigns distinct genre and visual-medium profiles per title,
|
|
including documentary, noir, animated family, retro rescue drama, cyberpunk,
|
|
anime-inspired space school, and workplace dramedy styles
|
|
- screenshot capture remains deterministic and offline because
|
|
`tools/release/capture-release-screenshots.ts` consumes the local mock server
|
|
assets and never calls OpenAI; its guards additionally verify that the
|
|
service answering on the mock port really is this fixture server
|
|
- the SVG renderer in `marketing.generator.ts` remains the fallback for missing
|
|
assets, live logos, season covers, and episode thumbnails
|
|
|
|
### `performance:performance` fixture details
|
|
|
|
This scenario is reserved for local performance captures:
|
|
|
|
- exactly 60 live categories/60,000 channels, 20 VOD categories/20,000 movies,
|
|
and 20 series categories/20,000 series
|
|
- exactly 1,000 catalog items in every category
|
|
- fixed IDs, timestamps, ratings, names, and one lazy season/episode generated
|
|
from indexes rather than Faker, `Date.now()`, or `Math.random()`
|
|
- empty/local-only artwork and direct-source fields
|
|
- VOD and series detail records are materialized only on request
|
|
- preparation and manifest hashing happen before the timed application request
|
|
|
|
### Electron Xtream performance benchmark
|
|
|
|
Run a formal benchmark from a clean worktree with the standard CDP endpoint
|
|
available at `127.0.0.1:9222`:
|
|
|
|
```bash
|
|
perf_output="$PWD/dist/performance/$(date -u +%Y%m%dT%H%M%SZ)-xtream"
|
|
IPTVNATOR_PERF_OUTPUT_DIR="$perf_output" \
|
|
IPTVNATOR_PERF_VARIANT=baseline \
|
|
NX_TASKS_RUNNER_DYNAMIC_OUTPUT=false \
|
|
pnpm nx run electron-backend-e2e:benchmark-xtream --skip-nx-cache
|
|
```
|
|
|
|
The target starts an isolated control-enabled mock server on
|
|
`127.0.0.1:3221` and uses only the synthetic `performance:performance`
|
|
credentials and fixture. It covers initial import, refresh, deletion,
|
|
cancellation during import, and navigation/search/UI interaction during a
|
|
background operation. Every scenario has one warm-up, five measured
|
|
iterations, and one diagnostic iteration. Only the five measured iterations
|
|
of a clean formal run can have `validForComparison: true`.
|
|
|
|
Capture setup and operation measurement are separate boundaries. The harness
|
|
first installs and arms the main, renderer, and worker capture infrastructure;
|
|
it then starts the main measurement and renderer operation window immediately
|
|
before triggering the user operation. Profiler attachment, fixture preparation,
|
|
and other setup outside that operation window are not application work. Because
|
|
`database.worker` is persistent but created lazily, the harness awaits the
|
|
read-only `window.electron.dbGetAppPlaylists()` preload call immediately after
|
|
installing main capture. Its result is discarded: this pre-arm only guarantees
|
|
that the exact worker can be profiled and happens before seed or measured
|
|
capture.
|
|
|
|
The renderer heartbeat is sampled on a fixed 50 ms deadline grid. One CDP
|
|
delivery records every elapsed grid point, and successful capture shutdown
|
|
waits for an in-flight delivery before performing one terminal-clipped drain.
|
|
The iteration fails closed if the resulting sample count does not cover the
|
|
complete operation window; this avoids coordinated omission when renderer work
|
|
delays multiple heartbeat deadlines.
|
|
|
|
Diagnostic profiles are evidence envelopes, not comparison totals. Starting
|
|
Chromium tracing and the CPU profilers can add a short pre-trigger setup segment
|
|
that contains no application workload; the raw boundary timestamps preserve
|
|
that segment explicitly. Formal summaries compare only the five measured runs,
|
|
never warm-up or diagnostic totals.
|
|
|
|
CPU scopes in the summary are intentionally not interchangeable. Electron main
|
|
CPU uses `process.threadCpuUsage()` and is labeled
|
|
`electron-main-thread`. Database-worker CPU is the sum of thread CPU from valid,
|
|
non-overlapping request work and is labeled
|
|
`sum-valid-request-thread-cpu`; it is neither process-wide CPU nor a whole-worker
|
|
sample.
|
|
|
|
Scenarios that need an existing portal seed it outside the measured iteration.
|
|
Seed completion is authoritative only after a successful
|
|
`store.xtream-import-terminal` renderer phase marker, the matching terminal DOM
|
|
gate, and two consecutive painted `requestAnimationFrame` frames. The harness
|
|
then verifies main-process settlement before rolling over to the measured
|
|
capture; merely seeing the catalog or loading overlay disappear is not a seed
|
|
terminal.
|
|
|
|
For an end-to-end wiring check, run smoke mode with one comparison-ineligible
|
|
measured iteration per scenario. An unused loopback CDP port may be selected
|
|
when the formal port is occupied:
|
|
|
|
```bash
|
|
perf_output="$PWD/dist/performance/$(date -u +%Y%m%dT%H%M%SZ)-xtream-smoke"
|
|
IPTVNATOR_PERF_OUTPUT_DIR="$perf_output" \
|
|
IPTVNATOR_PERF_VARIANT=smoke \
|
|
IPTVNATOR_PERF_SMOKE=1 \
|
|
IPTVNATOR_PERF_CDP_PORT=9322 \
|
|
NX_TASKS_RUNNER_DYNAMIC_OUTPUT=false \
|
|
pnpm nx run electron-backend-e2e:benchmark-xtream --skip-nx-cache
|
|
```
|
|
|
|
The manifest, summary, per-iteration JSON, traces, CPU profiles, and heap
|
|
snapshots stay under the git-ignored `dist/performance/` tree. Never commit
|
|
those artifacts, and never substitute real credentials, portal URLs, or
|
|
provider data.
|
|
|
|
---
|
|
|
|
## Playwright Integration
|
|
|
|
### Configuration (`apps/web-e2e/playwright.config.ts`)
|
|
|
|
The mock server is listed as a third `webServer` entry:
|
|
|
|
```typescript
|
|
{
|
|
command: 'pnpm nx run xtream-mock-server:serve',
|
|
url: 'http://localhost:3211/health',
|
|
reuseExistingServer: !process.env['CI'],
|
|
cwd: workspaceRoot,
|
|
}
|
|
```
|
|
|
|
### Request Interception
|
|
|
|
The Angular PWA calls `localhost:3000/xtream?...`. Playwright intercepts these:
|
|
|
|
```typescript
|
|
await page.route('**/localhost:3000/xtream**', async (route) => {
|
|
const originalUrl = new URL(route.request().url());
|
|
const mockUrl = new URL('http://localhost:3211/xtream');
|
|
originalUrl.searchParams.forEach((v, k) => mockUrl.searchParams.set(k, v));
|
|
await route.continue({ url: mockUrl.toString() });
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Extension Points
|
|
|
|
- **Add new actions**: Implement a handler function and add a `case` in `routes/dispatch.ts`
|
|
- **Add new scenarios**: Add an entry to `SCENARIOS` in `scenarios.ts`
|
|
- **Add deterministic EPG fixtures**: Extend `ScenarioConfig.epgFixture` and populate `epgListingsByStreamId` in `data-store.ts`
|
|
- **Refresh release artwork**: Run `pnpm release:artwork:dry-run`, then
|
|
`OPENAI_API_KEY=... pnpm release:artwork:generate`, inspect the generated PNGs,
|
|
and finish with `pnpm release:artwork:validate`
|
|
- **Adjust data volume**: Change `itemsPerCategory`, `seasonsPerSeries`, or `episodesPerSeason` per scenario
|
|
- **Custom stream URLs**: Edit the normal-mode HLS stub redirect in
|
|
`app/server.ts`; never enable it for the performance fixture
|