Files
iptvnator/docs/architecture/stalker-mock-server.md
T
6b855eb73b fix(e2e): stop mock servers from outliving Playwright runs (#1710)
* fix(e2e): stop mock servers from outliving Playwright runs

Playwright stops a webServer with a SIGKILL to the process group it
spawned, but `nx run-commands` starts its command in a detached process
group of its own. Launching the Xtream/Stalker mocks through
`pnpm nx run *-mock-server:serve` therefore left the tsx server running
(reparented to PID 1) and holding its port after every run, so the next
run failed with "…/health is already used" or silently reused a stale
server.

Every Playwright config now starts the mocks as a single
`node --import tsx apps/<mock>/src/main.ts` process with
TSX_TSCONFIG_PATH=tsconfig.base.json, which stays in Playwright's group.
A project-config spec guards all playwright*.config.ts files against
regressing to the Nx launch.

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

* fix(e2e): read sidebar categories atomically; tighten mock launch guard

- category-management: readVisibleSidebarCategoryNames read items one by
  one; when Save removed an item between isVisible() and textContent(),
  textContent() auto-waited for the gone label through the whole 15 s
  poll, so expect.poll never retried (ubuntu shard 1 failed 3/3 while the
  UI already showed "No categories available"). Take one snapshot with
  filter({ visible: true }).evaluateAll() instead.
- project-config.spec: pin which Playwright configs start which mock,
  reject any Nx form that mentions a mock server, and fail when a new
  config starts a mock without being listed (the old count check passed
  vacuously on zero matches).
- docs: state which configs start which mock instead of "every config
  starts both".

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

* revert(e2e): leave the sidebar category read race to #1728

#1728 fixes the same readVisibleSidebarCategoryNames race with a shared
helper; keeping a second copy here would only conflict. This PR stays
about mock-server lifecycle.

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

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:57:46 +02:00

19 KiB
Raw Blame History

Stalker Mock Server Architecture

This document describes the design decisions, data flow, and extension points of the stalker-mock-server development tool.

Purpose

The mock server enables:

  1. Local development without access to a real Stalker portal
  2. Playwright E2E testing with predictable, deterministic data
  3. Scenario-based testing via predefined MAC addresses that map to specific data shapes
  4. Screenshot-safe marketing capture with committed fictional posters shared with the Xtream mock

Key Design Decisions

Seeded Determinism (Not Per-Request Random)

Per-request random data would break navigation: if category IDs change between calls, content fetched under a category ID won't match the category list. Instead:

  • Data is generated once per MAC address on first request, then cached in memory.
  • @faker-js/faker is seeded with the scenario's seed value before generation: predefined scenario MACs use fixed seeds from scenarios.ts; unknown MACs derive the seed from the MAC via macToSeed().
  • Same MAC → identical data on every server restart.
  • Restart the server to reshuffle all data.

MAC Address as Identity

Stalker portals use MAC address as the primary credential. The mock server follows the same model:

  • Each unique MAC gets its own isolated dataset.
  • Predefined MACs map to specific ScenarioConfig shapes (see src/app/scenarios.ts).
  • Unknown MACs use the sum of their byte values as a seed, producing unique but deterministic data.

In-Memory Only

No files or databases are written. All state (generated content + favorites + portal sessions) lives in process memory and resets on server restart. This is intentional — tests should not share state across runs.

Endpoints With Different Strictness

The app classifies a portal by observed behavior, not by URL shape: endpoint discovery (see docs/architecture/stalker-portal.md, "Portal Mode and Endpoint Discovery") probes candidates at import and on lazy repair, treating a token-less content request that returns data as a token-free panel and the middleware's plain-text auth failure as a token-enforcing full portal. The mock serves the same action set at several paths so every classification branch is exercisable:

Path Router Behaviour
/portal.php createPortalRouter(false) Tolerant: ignores the token and the MAC format, like most reseller panels
/stalker_portal/server/load.php createPortalRouter(true) Strict: enforces both, like the real middleware
/server/load.php createPortalRouter(true) Strict: the bare canonical Ministra shape, enforced identically
/ministra/server/load.php createPortalRouter(true) Strict; the /ministra/* prefix has no portal.php (404s like genuine Ministra), so /ministra/c proves the probe's 404 fallthrough

The /stalker proxy route applies the same rule through isFullPortalUrlShape() — every URL the client would authenticate against is enforced, so tests cannot silently fall into the tolerant branch.

Keeping the tolerant path is what lets the pre-existing e2e suite (which imports portal.php) stay meaningful — it covers the simple-portal branch — while the strict path finally covers the authenticated branch that had no coverage at all.

The strict behaviours mirror the plaintext Stalker 4.9.35 middleware (server/lib/stb.class.php), the last openly readable ancestor of the encoded 5.x core:

  • Plain-text auth failures. Authorization failed. / Unauthorized request. are returned with HTTP 200 and a text/html body, because the real server exits before the JSON envelope is built. A client checking only status codes sees "success" and renders nothing. The /stalker proxy route still wraps the body in the { payload } envelope, matching what apps/web-backend does.

  • A handshake is not a session. The token only authorizes requests once get_profile has adopted it for that MAC. Adoption is deliberately stricter than the stock server: 4.9.35 issues handshake tokens statelessly and pins whatever Bearer get_profile presents, so a forged token would become a session on a real portal — the mock only adopts tokens it actually issued, so a client with a broken token pipeline fails loudly in tests.

  • Idempotent handshake. Presenting the MAC's current token returns that same token, which is what allows real clients to persist tokens across restarts.

  • Device-id pinning. device_id/device_id2 are stored on first non-empty value; any later change — including reverting to empty — is a permanent device conflict carrying the "Your STB is damaged." block message. This is the only identity check the stock server actually enforces.

  • signature, metrics, prehash are ignored, exactly as upstream ignores them; they exist for portals with a custom access_filter.php.

  • MAC format validation. Non-Infomir MACs (00:1A:79:XX:XX:XX) get a bare { status: 1 } from get_profile.

  • do_auth is a boolean login step. Non-empty credentials answer {js:true} and are recorded; the login-required scenario's get_profile keeps answering status: 2 until that record exists, because the app sends auth_second_step=1 on its very first profile request and a parameter check alone would be trivially bypassed.

Session state lives in src/app/auth-store.ts and is cleared by /reset. POST /invalidate-session?macAddress=<mac> drops a single MAC's tokens so tests can assert the client re-handshakes and retries instead of surfacing an error; pinned device identity survives invalidation, as it does on a real portal.

Beside the portal endpoints, main.ts mounts a handful of non-portal routes: GET /stalker (the PWA CORS-proxy mirror), GET /stream/gated/:file (the credential-gated media fixtures for the gated-stream scenario), GET /assets/marketing/poster/* (the committed screenshot-safe posters) and the /health + /reset + /invalidate-session utilities. The full list with request shapes is in apps/stalker-mock-server/README.md.

Data Generation Pipeline

faker.seed(config.seed)   // scenario seed; unknown MACs: macToSeed(mac)
│
├── generateCategories('itv', N)  → itvCategories[]
│     └── generateChannels()      → channels Map<categoryId, channel[]>
│           └── generateEpg()     → epg Map<channelId, program[]>
│
├── generateCategories('radio', N) → radioCategories[]
│     └── generateRadioStations()  → radio Map<categoryId, station[]>
│
├── generateCategories('vod', N)  → vodCategories[]
│     └── generateVodItems()      → vod Map<categoryId, item[]>
│           ├── normal VOD items
│           ├── is_series=1 items (fraction, Ministra flow)
│           └── embedded series[] items (fraction)
│
├── marketingFixture?             → shared curated VOD catalog
│     ├── @iptvnator/shared/marketing-fixtures
│     ├── vod Map<categoryId, item[]>
│     └── vodOrder[] preserves screenshot catalog order
│
└── generateCategories('series', N) → seriesCategories[]
      └── generateSeriesItems()    → series Map<categoryId, item[]>
            └── generateSeasons()  → seasons Map<seriesItemId, season[]>

Response Shapes

All responses follow the Stalker portal.php envelope:

{ "js": <action-specific payload> }

get_categories

{
  "js": [
    { "id": "2001", "title": "Action", "alias": "action" },
    ...
  ]
}

get_ordered_list (content)

{
  "js": {
    "data": [
      {
        "id": "20001",
        "name": "...",
        "cmd": "ffrt4://vod/20001/index.m3u8",
        "screenshot_uri": "https://picsum.photos/seed/vod-20001/300/200",
        "cover": "https://picsum.photos/seed/vod-cover-20001/300/450",
        "description": "...",
        "actors": "...",
        "director": "...",
        "year": "2019",
        "rating_imdb": "7.3",
        "category_id": "2001",
        "is_series": 0,
        "has_files": 1
      }
    ],
    "total_items": 40,
    "max_page_items": 14,
    "cur_page": 1,
    "total_pages": 3
  }
}

For type=radio, items use the same paginated response envelope as live TV channels, but each item is generated as a radio station with a radio: true marker and an ffrt4://radio/... command.

get_ordered_list (seasons — when movie_id is present)

{
  "js": [
    {
      "id": "30001-s1",
      "name": "Season 1",
      "cmd": "ffrt4://series/30001/season/1",
      "series": ["1", "2", "3", ...],
      "screenshot_uri": "https://picsum.photos/seed/30001-s1/300/200",
      "director": "...",
      "actors": "...",
      "year": "2021",
      "rating_imdb": "8.1"
    }
  ]
}
{
  "js": {
    "cmd": "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8",
    "streamer_id": "1",
    "load": "",
    "error": "",
    "cmd_received": "ffrt4://ch/live/1001/index.m3u8",
    "query_keys_received": ["JsHttpRequest", "action", "cmd", "type"]
  }
}

The stream URL is selected from a pool of 4 real public HLS test streams. The choice is deterministic based on the cmd field's character sum, so the same item always returns the same stream.

cmd_received and query_keys_received are mock-only diagnostics (a real portal does not send them): they echo the request's cmd after Express' single query decode — the same view a PHP portal gets from $_GET — plus the sorted set of query keys. E2E uses them to pin the client's cmd wire contract: no double-encoding, and no query-parameter injection through cmd.

get_short_epg

{
  "js": {
    "data": [
      {
        "id": "1",
        "name": "Channel Name: Program Title",
        "start": "2026-02-21T10:00:00.000Z",
        "stop": "2026-02-21T12:00:00.000Z",
        "start_timestamp": 1740128400,
        "stop_timestamp": 1740135600,
        "descr": "...",
        "category": "News"
      }
    ]
  }
}

get_short_epg returns the current program and upcoming items from the generated schedule, limited by the requested size.

get_epg_info

{
  "js": {
    "data": {
      "10000": [
        {
          "id": "1",
          "name": "Channel Name: Program Title",
          "start": "2026-02-21T10:00:00.000Z",
          "stop": "2026-02-21T12:00:00.000Z",
          "start_timestamp": 1740128400,
          "stop_timestamp": 1740135600,
          "descr": "...",
          "category": "News"
        }
      ]
    }
  }
}

get_epg_info returns bulk EPG keyed by channel id and filters the generated 7-day schedule from the current UTC day start through now + period.

EPG programs are generated as 2-hour slots across 7 days for each channel, starting at the current UTC day boundary.

Scenarios

Scenarios are defined in src/app/scenarios.ts. Each scenario is a ScenarioConfig:

interface ScenarioConfig {
  name: string;
  description: string;
  seed: number;
  categoryCount: { itv: number; radio: number; vod: number; series: number };
  itemsPerCategory: number;
  seasonsPerSeries: number;
  episodesPerSeason: number;
  isSeriesFraction: number;      // 0–1: fraction of VOD with is_series=1
  embeddedSeriesFraction: number; // 0–1: fraction of VOD with embedded series[]
  supportsGetAllChannels?: boolean; // default true; false mimics legacy portals
                                    // without the ITV get_all_channels action
  marketingFixture?: true;          // replace generated VOD with shared posters
  requiresLogin?: true;             // get_profile answers status 2 until do_auth
  gatedStream?: true;               // create_link returns a credential-gated URL
  staticChannelCmd?: true;          // ITV rows need no temporary link
}

The legacy-pagination scenario (00:1A:79:00:00:06) sets supportsGetAllChannels: false: get_all_channels then answers with an error payload so clients fall back to the paginated get_ordered_list crawl. For supporting scenarios, get_all_channels (get-all-channels.handler.ts, type=itv only) returns the complete ITV channel list in one { js: { data, total_items } } response, excluding channels from censored (adult) genres.

The login-required scenario also recognizes 00:1A:79:AE:<slot>:02. This test-only alias range preserves the same status: 2/do_auth contract while giving concurrent Playwright parallel slots independent per-MAC auth state.

The marketing-demo scenario (00:1A:79:00:00:07) replaces faker-generated VOD with the 35-movie provider-neutral showcase catalog from @iptvnator/shared/marketing-fixtures. Its newest 20 movies are returned first for the wildcard VOD listing. Fixtures keep deployment-neutral poster paths, then the JSON middleware resolves them against the request origin (including forwarded host/protocol) as <portal-origin>/assets/marketing/poster/<slug>.png. main.ts serves the committed PNG directory directly, so the Xtream server does not need to run.

Every generated ITV channel and radio station carries use_http_tmp_link and use_load_balancing, the flags a real portal uses to tell a client whether the row needs create_link. They are '1'/'0' for the default generators — honest, because those rows carry ffrt4://… pseudo-URLs. The static-channel-cmd scenario (00:1A:79:00:00:0A) sets staticChannelCmd, which gives ITV rows both flags at '0' and a real ffrt3 https://…m3u8 command, so apps/web-e2e/src/stalker.e2e.ts can assert that no create_link request reaches the portal. See docs/architecture/stalker-portal.md, "Playback Link Resolution".

Adding a New Scenario

  1. Add an entry to the SCENARIOS map in src/app/scenarios.ts.
  2. Use any unique MAC address as the key (lowercase, colon-separated).
  3. Document it in README.md and this file.

Favorites

Favorites are stored in a Map<mac, Set<itemId>> in src/app/data-store.ts. They persist for the lifetime of the server process and are shared across all requests for the same MAC.

Call POST /reset?macAddress=<mac> to clear a MAC's favorites (and its cached generated data) between test runs — see "Test Isolation" for why the scoped form is the one specs should use.

Playwright Integration

apps/web-e2e/playwright.config.ts registers the mock server as a second webServer entry:

webServer: [
  { command: webServerCommand /* web:serve */, url: baseURL },
  {
    command: 'node --import tsx apps/stalker-mock-server/src/main.ts',
    env: {
      NODE_ENV: 'development',
      TSX_TSCONFIG_PATH: 'tsconfig.base.json',
    },
    url: `http://localhost:${process.env['MOCK_PORT'] ?? '3210'}/health`,
    reuseExistingServer: !process.env['CI'],
  },
  // plus the xtream mock (3211) and web-backend (3333) entries
]

Playwright waits for every server to be healthy before starting tests. If one is already running (e.g. in local dev), it reuses the existing instance.

The mock runs as a single node process instead of pnpm nx run stalker-mock-server:serve, because Nx starts its command in a detached process group that Playwright's process-group kill never reached, so the server outlived the run. The Xtream mock's Playwright section has the details.

MOCK_PORT relocates the whole run. Playwright's health-check URL and the MOCK_SERVER constants in the specs read it, and main.ts resolves the server's port as PORT, then MOCK_PORT, then 3210 — so MOCK_PORT=3310 pnpm exec playwright test … starts the mock on 3310 and points every spec at it, which is how two worktrees run E2E side by side when one already holds 3210. A hand-started Nx mock honours the same knobs because the serve and serve-with-watch targets no longer pin PORT in project.json: an env entry in nx:run-commands overrides the shell ({...process.env, ...env}), so a pinned value silently discarded every override. PORT=3310 pnpm nx run stalker-mock-server:serve relocates the server alone. The Xtream mock has the same shape with XTREAM_MOCK_PORT.

Test Isolation

Mock state is keyed by MAC and one mock-server process is shared by every spec file running in parallel workers, so isolation is per-MAC rather than global:

  • POST /reset accepts one or more ?macAddress= params and clears only those MACs. A bare POST /reset clears everything and is only safe when nothing else is talking to the server — a spec that used it would wipe a sibling spec's session mid-test.
  • apps/web-e2e/src/stalker.e2e.ts declares its shared scenario MACs in OWNED_MACS and clears them in one batched request. The sibling specs that reach this server (self-hosted.e2e.ts, the sources-pwa helpers) own a disjoint 00:1A:79:5F:* range, so neither file can clear the other's state.
  • Within each browser project, tests deliberately share content-scenario MACs (their fixture shapes are what the assertions are written against), so the file uses test.describe.configure({ mode: 'serial' }).
  • Authentication tests whose assertions span multiple requests derive a disjoint 00:1A:79:AE:<slot>:* range from Playwright's bounded parallelIndex. Their beforeEach adds only the current parallel slot's range to the batched reset, so browser projects cannot clear one another's token, login completion, invalidated session or pinned device identity; a restarted worker retains the same slot instead of consuming a wider MAC value.

A reset drops the generated content cache, favorites (data-store.ts), the auth/session record (auth-store.ts, including any pinned device identity) and the watchdog ping counters. Because generation is seed-deterministic, the next request regenerates identical content, so the observable data does not change across resets.

import { test, expect } from '@playwright/test';

const MOCK_URL = 'http://localhost:3210/portal.php';
const MOCK_MAC = '00:1A:79:00:00:01'; // default scenario
const OWNED_MACS = [MOCK_MAC];

test.describe.configure({ mode: 'serial' });

test.beforeEach(async ({ request }) => {
  // Scoped reset: only the MACs this file owns.
  const query = OWNED_MACS.map(
    (mac) => `macAddress=${encodeURIComponent(mac)}`
  ).join('&');
  await request.post(`http://localhost:3210/reset?${query}`);
});

test('browse VOD categories', async ({ page }) => {
  // Add portal via UI or programmatically via IndexedDB
  // Navigate to portal
  // Assert category list matches expected count (8 for default scenario)
});

Extension Points

  • New content types: Add a new generator function in data-generator.ts and a new handler in handlers/.
  • New scenarios: Add to SCENARIOS in scenarios.ts.
  • Session behaviour: auth-store.ts owns tokens and device pinning. Add TTLs or a "token replaced by another device" mode there rather than in the handlers.
  • Error simulation: Add a special MAC or query param to trigger error responses for testing error handling in the Stalker store. Note that portal-level auth errors are not HTTP errors — see Endpoints With Different Strictness.
  • Slow responses: Add a MOCK_DELAY_MS env var and apply it in middleware for testing loading states.