Files
650da4a1d3 ci(test): type-check Jest spec programs and gate it in CI (#1705)
* build(test): make spec tsconfigs resolve what Jest resolves

Lib spec tsconfigs used module: commonjs with node10 resolution, which cannot
see Angular's exports-only secondary entry points, and dropped global.d.ts, so
tsc reported thousands of resolution errors and no window.electron typing.
Switch them to module: preserve with bundler resolution (ts-jest still forces
CommonJS emit outside ESM mode), add global.d.ts to every spec program, type
jest.unstable_mockModule for the ESM workspace, include the ui-epg and
ui-playback specs that jest.web-esm.workspace.ts runs under the web spec
config, and drop the snack-bar stub that shadowed the real Material types.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* ci(test): gate spec type-checking with typecheck:spec

Add tools/typecheck/spec-typecheck.mjs, which runs tsc --noEmit over every
tsconfig.spec.json with a small pool and fails on any diagnostic, wire it into
the unit-and-typecheck job after typecheck:ci, and document the gate and the
spec tsconfig conventions in the validation map. Also bring the non-Tier-A
spec configs (remote-control-web, ui-remote-control, stalker-mock-server) to
the same conventions so the gate covers the whole workspace.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test: fix the spec type errors surfaced by typecheck:spec

With the spec programs resolving modules and ambient typings correctly,
tsc reported 432 genuine errors across the Tier A projects: read-only
capability flags assigned on Partial<> doubles, signal-store values used as
types, fixtures missing required fields, index-signature property access,
partial bridge doubles cast through incompatible shapes, and deferred
resolvers narrowed to never. Type the doubles instead of casting to any:
writable mapped types for capability flags, InstanceType<typeof StalkerStore>,
typed jest.fn signatures, protectedState: false on test signal stores, and
completed fixtures. Production changes are limited to bracket access for
index-signature properties under the libs' noPropertyAccessFromIndexSignature
setting and two narrowing guards in the global favorites loader.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(playback): use the ESM setup's jest global in the controls fixtures

The fixture imported jest from @jest/globals, which is not a direct
dependency. Jest provides that module at runtime, so tests passed, but on a
clean pnpm install tsc cannot resolve it and typecheck:spec failed in CI.
The ESM test setup already installs import.meta.jest as the global, typed
by @types/jest, as the other ESM specs use it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: type the parental lock doubles merged since the gate was written

The parental lock feature (#1601) and the Stalker actor route landed on master
with spec doubles declared as zero-argument jest.fn()s that the tests then
drive with the real arguments, plus a copy of the ResizableDirective override
imported from a library that does not export it. Give the doubles the lock
service's real signatures, drop the dead override as in the sibling layout
specs, use bracket access for the actor route's personId param, and keep the
Stalker layout spec within the 1200-line limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-27 20:54:27 +02:00
..

Stalker Mock Server

A local mock implementation of the Stalker/Ministra portal API for development and end-to-end testing of IPTVnator.

Overview

The mock server speaks the same portal.php HTTP protocol as a real Stalker portal, generating deterministic fake data using @faker-js/faker seeded from the connecting MAC address. This means:

  • The same MAC address always returns the same data (consistent across page refreshes and test runs).
  • Different MAC addresses produce different datasets — use predefined scenario MACs for specific test conditions.
  • Data is generated once per MAC on first request and cached in memory for the server's lifetime. Restart to regenerate.

Quick Start

# Start the mock server (port 3210)
nx serve stalker-mock-server

# Or with file watching (auto-restarts on source changes)
nx run stalker-mock-server:serve-with-watch

# Or run both the mock server + Angular dev server in parallel
nx run-many --targets=serve --projects=stalker-mock-server,web

Then in IPTVnator, add a new Stalker portal:

  • Portal URL: http://localhost:3210/portal.php (tolerant panel-style endpoint) or http://localhost:3210/stalker_portal/server/load.php (canonical Ministra endpoint — see Endpoints below)
  • MAC Address: one of the predefined scenarios below (or any MAC for auto-generated data)

Endpoints: tolerant, strict, and the /ministra host

The same actions are served at several paths with deliberately different strictness. Since endpoint discovery landed, the app no longer guesses the portal mode from the URL shape: at import it probes portal.php → server/load.php → stalker_portal/server/load.php and classifies each endpoint by observed behavior (token-less get_genres answering data ⇒ token-free panel; the plain-text auth failure ⇒ full portal, confirmed by a real handshake + get_profile). The mock's split makes every branch of that classification exercisable:

Path Behaviour
/portal.php Tolerant. Ignores the Bearer token and the MAC format, like most reseller panels in the wild — discovery classifies it as a token-free simple portal.
/stalker_portal/server/load.php Strict. Enforces the token and the MAC format exactly like the real middleware — discovery classifies it as a full portal.
/server/load.php Strict, enforced identically. Importing this bare canonical URL now authenticates (the historical import/runtime predicate divergence that skipped the handshake here is fixed).
/ministra/server/load.php Strict. The /ministra/* prefix simulates a genuine Ministra host: /ministra/portal.php 404s like a real installation (portal.php is a reseller alias official Stalker never ships), so http://localhost:3210/ministra/c exercises the probe's 404 fallthrough end to end.

The strict endpoint reproduces the parts of Stalker 4.9.35 that a client can actually get wrong:

  • Every action except handshake, get_profile, get_localization and do_auth requires Authorization: Bearer <token>.
  • A token only counts once get_profile has adopted it — a handshake alone is not a session. Adoption is deliberately stricter than the stock server: only tokens the mock actually issued (or the already-bound one) are accepted, so a client with a broken token pipeline fails loudly.
  • Auth failures come back as HTTP 200 with a plain-text body (Authorization failed., Unauthorized request.), never a 401/403. Clients that only check status codes will silently render nothing. The stock server has a third body, Access denied. (blocked account), which the mock does not produce — the app classifies all three (libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts).
  • The handshake is idempotent: presenting the MAC's current token returns that same token instead of rotating it.
  • device_id/device_id2 are pinned to the MAC on first non-empty value; any later change — including sending them empty again — is a permanent device conflict with the "Your STB is damaged." block message.
  • signature, metrics and prehash are accepted and ignored, exactly as the stock server does.
  • The MAC must match the Infomir OUI format (00:1A:79:XX:XX:XX) or get_profile answers with a bare { status: 1 }.

Predefined Scenario MAC Addresses

MAC Address Scenario Description
00:1A:79:00:00:01 default 8 categories per type, 40 items each — the balanced go-to for daily dev
00:1A:79:FF:FF:FF large 20 categories, 200 items each — stress-test pagination and virtual scroll
00:1A:79:00:00:02 series-heavy 15 series categories with 6 seasons × 10 episodes — test deep series navigation
00:1A:79:00:00:03 minimal 2 categories, 5 items — edge case testing (empty states, single items)
00:1A:79:00:00:04 is-series 60% of VOD items have is_series=1 — tests the Ministra lazy-season flow
00:1A:79:00:00:05 embedded-series 50% of VOD items have embedded series[] arrays — tests the embedded series flow
00:1A:79:00:00:06 legacy-pagination No get_all_channels support — tests the paginated get_ordered_list crawl fallback for the full ITV channel list
00:1A:79:00:00:07 marketing-demo 35 original poster movies with the newest 20 first, plus the fictional live channels shared with the Xtream mock (logos served from /assets/marketing/logo/<slug>.svg, no censored category) — safe for screenshots and marketing
00:1A:79:00:00:08 login-required get_profile answers status: 2 until the client completes do_auth with non-empty credentials. The app drives this end to end: enter a username and password in the Stalker import dialog and it runs do_auth, then retries get_profile with auth_second_step=1. Covered by stalker.e2e.ts (@stalker full portal authentication)
00:1A:79:AE:<slot>:02 login-required Parallel-slot-scoped alias range used by concurrent auth E2E runs; it has the same login contract as 00:1A:79:00:00:08 but independent per-MAC state
00:1A:79:00:00:09 gated-stream create_link returns a local /stream/gated/… URL that answers 403 without the mac cookie and the MAC's current Bearer token — proves a player's media requests really carry the portal credentials
00:1A:79:00:00:0A static-channel-cmd ITV rows carry a directly playable cmd with use_http_tmp_link/use_load_balancing both '0' — a client honouring the flags must play them without calling create_link
<any other MAC> auto MAC bytes used as seed → deterministic unique dataset

Every generated ITV channel and radio station carries the two temporary-link flags a real portal sends. Outside the static-channel-cmd scenario they are use_http_tmp_link: '1', which is the honest annotation of their ffrt4://… commands: those are portal-internal pseudo-URLs that only create_link can resolve.

Configuration

Environment Variable Default Description
PORT 3210 HTTP port the server listens on. The Nx serve targets do not pin it, so PORT=3310 pnpm nx run stalker-mock-server:serve relocates the server
MOCK_PORT — Fallback for PORT. It is the client-side knob apps/web-e2e/playwright.config.ts and the specs already read, so MOCK_PORT=3310 pnpm exec playwright test … moves the mock AND the tests together when another worktree holds 3210
HOST 127.0.0.1 Bind address. Loopback by default — the fixture serves fabricated, unauthenticated content, so set HOST=0.0.0.0 only to deliberately point a phone or STB at it
NODE_ENV development Node environment

Non-Portal Endpoints

Besides the portal paths above, src/main.ts mounts:

Endpoint Method Description
/stalker?url=<portal_url>&macAddress=<mac>&action=<action>&… GET CORS-proxy mirror of the IPTVnator web-backend /stalker endpoint, used by PWA/Playwright runs. macAddress, token and serialNumber are control params turned into headers and stripped from the portal query (except handshake's candidate token); the response is wrapped as { payload }. Strictness follows the proxied url's shape
/stream/gated/:file GET video.mp4 / audio.mp4 fixtures for the gated-stream scenario — 403 without the mac cookie and the MAC's current Bearer token
/assets/marketing/poster/<slug>.png GET The committed screenshot-safe poster catalog shared with the Xtream mock, served from this process so marketing-demo needs no second server
/assets/marketing/logo/<slug>.svg[?size=WxH] GET Generated channel logo (initials on a gradient) for the marketing-demo live channels and radio stations, rendered by @iptvnator/shared/marketing-fixtures
/health GET Health check — returns { status: "ok" }
/reset[?macAddress=<mac>&macAddress=…] POST Clear generated data, favorites, session/auth state (including pinned device IDs) and watchdog counters. Pass the MACs you own: state is per-MAC and parallel spec files share this process, so a bare /reset wipes their state mid-test. Repeated params clear several MACs in one request
/invalidate-session?macAddress=<mac> POST Drop that MAC's tokens so the next portal call fails with Authorization failed. — lets tests assert the client re-handshakes and retries. Pinned device identity survives, as on a real portal

API Coverage

Every action is served by the same dispatcher (src/app/routes/dispatch.ts) at every portal path — GET /portal.php?action=<action>&... and the strict server/load.php shapes alike — matching the real Stalker protocol:

Action Description
handshake Issues the access token (idempotent) plus the 5.x random nonce and not_valid flag
get_profile Turns the handshake token into a session; enforces device-id pinning, and on the strict endpoint the MAC format
get_events Watchdog ping; records the call and returns an empty event set (never affects authorization, as on a real portal)
do_auth Boolean login step: {js:true} for non-empty credentials (recorded for the login-required scenario), {js:false} otherwise
get_main_info account_info/get_main_info — simple-mode subscription facts for the Stalker account-info dialog, in the nested js.account_info envelope
get_categories Category list filtered by type (itv/vod/series); get_genres_itv / get_genres_vod are handled by the same handler
get_genres Genre list (mirrors categories)
get_ordered_list Paginated content list; if movie_id is present → returns seasons
get_all_channels Complete ITV channel list in one response (type=itv only); excludes censored (adult) genres; disabled in the legacy-pagination scenario
create_link Returns a playable stream URL — a real public HLS stream, or the credential-gated local URL in the gated-stream scenario. Also echoes the mock-only cmd_received / query_keys_received diagnostics that pin the client's cmd wire format
favorites Add / remove / get favorites (in-memory, resets on restart)
get_short_epg Current-and-upcoming EPG window for a channel (ch_id, size)
get_epg_info Bulk EPG keyed by channel id for a requested period window

Cover Images

Generated scenarios use Picsum Photos for cover images and logos, so they need an internet connection to display artwork. The marketing-demo scenario instead uses the committed, screenshot-safe poster catalog shared with the Xtream mock. Stalker serves those PNGs itself from /assets/marketing/poster/<slug>.png, so screenshots remain deterministic and offline once the repository is checked out.

Stream URLs

create_link returns real public HLS test streams so video actually plays:

  • https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8
  • https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_4x3/bipbop_4x3_variant.m3u8
  • https://playertest.longtailvideo.com/adaptive/oceans/oceans.m3u8
  • https://playertest.longtailvideo.com/adaptive/bbbfull/bbbfull.m3u8

The stream chosen for a given item is deterministic based on the item's cmd string.

Using with Playwright E2E Tests

The Playwright config in apps/web-e2e/playwright.config.ts starts the mock server automatically alongside the Angular dev server when running e2e tests. See apps/web-e2e/src/stalker.e2e.ts for example stalker tests.

# Run all e2e tests (starts mock server automatically)
nx e2e web-e2e

# Or run only stalker-specific e2e tests
nx e2e web-e2e --grep "@stalker"

The test suite uses 00:1A:79:00:00:01 (default scenario) for most tests. State is per-MAC and several spec files share this one server process, so beforeEach resets only the MACs the file owns (OWNED_MACS in stalker.e2e.ts, sent as repeated macAddress params in a single request) rather than clearing everything. The sibling specs that talk to this server (self-hosted.e2e.ts, the sources-pwa helpers) own a disjoint 00:1A:79:5F:* range for the same reason, and stalker.e2e.ts runs mode: 'serial' because its tests deliberately share scenario MACs. Its stateful authentication tests derive a separate 00:1A:79:AE:<slot>:* range from Playwright's bounded parallelIndex and add only that range to the scoped reset, so concurrent browser projects do not clear one another's sessions or pinned device identity; restarted workers retain their slot. Full contract: docs/architecture/stalker-mock-server.md.

EPG Behavior

The mock server generates a 7-day EPG schedule for every ITV channel using 2-hour slots starting at the current UTC day boundary.

  • get_short_epg returns the current program and upcoming items from that schedule, limited by size
  • get_epg_info returns bulk data in the shape { js: { data: Record<channelId, program[]> } }
  • get_epg_info filters the bulk response from the current UTC day start through now + period

Architecture

See docs/architecture/stalker-mock-server.md for full implementation details.

Project Structure

apps/stalker-mock-server/
├── src/
│   ├── main.ts                            # Express bootstrap + non-portal routes
│   └── app/
│       ├── scenarios.ts                   # MAC → scenario config mapping
│       ├── data-generator.ts              # Seeded faker data generation
│       ├── data-store.ts                  # Lazy per-MAC in-memory cache
│       ├── auth-store.ts                  # Tokens, device pinning, do_auth state
│       ├── request-mac.ts                 # MAC read from the `mac=` cookie
│       ├── marketing-poster-url.ts        # Origin-resolved poster paths
│       ├── routes/
│       │   ├── portal.route.ts            # Portal router (tolerant + strict)
│       │   └── dispatch.ts                # Shared Stalker action dispatcher
│       └── handlers/
│           ├── handshake.handler.ts
│           ├── get-profile.handler.ts
│           ├── get-events.handler.ts
│           ├── do-auth.handler.ts
│           ├── get-main-info.handler.ts
│           ├── get-categories.handler.ts
│           ├── get-genres.handler.ts
│           ├── get-ordered-list.handler.ts
│           ├── get-all-channels.handler.ts
│           ├── get-seasons.handler.ts
│           ├── create-link.handler.ts
│           ├── favorites.handler.ts
│           ├── get-epg-info.handler.ts
│           └── get-short-epg.handler.ts
├── project.json
├── tsconfig.json
└── README.md