From bf29f690fc528ea5df6fd92d1e890f9ba3ba86b3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 12:19:06 +0000 Subject: [PATCH 1/6] chore(release): v1.6.1 [skip ci] --- package.json | 2 +- packages/shared/src/constants.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 7be5994..3952507 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "stremlist", - "version": "1.6.0", + "version": "1.6.1", "private": true, "description": "Stremlist - Sync your IMDb watchlist with Stremio", "repository": { diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 8a4a63e..b7ad71f 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -1,7 +1,7 @@ import type { StremioManifest } from "./stremio.types"; export const APP_NAME = "Stremlist"; -export const ADDON_VERSION = "1.6.0"; +export const ADDON_VERSION = "1.6.1"; export const APP_DESCRIPTION = "Your IMDb Watchlist in Stremio"; export const APP_LOGO = "https://stremlist.com/icon.png"; export const APP_ID_PREFIX = "com.stremlist"; From 41d5748fdd2b13549ac404605dc5b729b24fe12f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 16:39:01 +0000 Subject: [PATCH 2/6] chore(release): v1.7.0 [skip ci] --- package.json | 2 +- packages/shared/src/constants.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 3952507..936674c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "stremlist", - "version": "1.6.1", + "version": "1.7.0", "private": true, "description": "Stremlist - Sync your IMDb watchlist with Stremio", "repository": { diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index a232186..218b118 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -1,7 +1,7 @@ import type { StremioManifest } from "./stremio.types"; export const APP_NAME = "Stremlist"; -export const ADDON_VERSION = "1.6.1"; +export const ADDON_VERSION = "1.7.0"; export const APP_DESCRIPTION = "Your IMDb Watchlist in Stremio"; export const APP_LOGO = "https://stremlist.com/icon.png"; export const APP_ID_PREFIX = "com.stremlist"; From 92fcc73bc3179257419025e0219ed9a2d543380a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Mathurin?= Date: Wed, 26 Aug 2026 16:05:03 +0200 Subject: [PATCH 3/6] feat(e2e): add Stremio end-to-end test harness - Add Playwright API, UI, and live smoke coverage - Configure disposable Supabase CI jobs and local test setup - Update shared Stremio types, tooling, and database schema --- .github/workflows/ci.yml | 57 +++ .gitignore | 4 + apps/e2e/.prettierignore | 2 + apps/e2e/README.md | 68 +++ apps/e2e/env.ts | 52 ++ apps/e2e/eslint.config.mjs | 19 + apps/e2e/helpers/api.ts | 117 +++++ apps/e2e/helpers/db.ts | 39 ++ apps/e2e/helpers/stremio.ts | 76 +++ apps/e2e/helpers/test-data.ts | 32 ++ apps/e2e/package.json | 33 ++ apps/e2e/playwright.config.ts | 90 ++++ apps/e2e/preflight.ts | 27 + apps/e2e/tests/addon-api.spec.ts | 469 ++++++++++++++++++ apps/e2e/tests/configure-page.spec.ts | 170 +++++++ apps/e2e/tests/home-onboarding.spec.ts | 71 +++ apps/e2e/tests/stremio-catalogs.spec.ts | 178 +++++++ apps/e2e/tests/stremio-install.spec.ts | 65 +++ apps/e2e/tests/stremio-meta.spec.ts | 37 ++ apps/e2e/tsconfig.json | 14 + package.json | 1 + packages/shared/src/stremio.types.ts | 2 +- pnpm-lock.yaml | 74 +++ supabase/.gitignore | 8 + supabase/config.toml | 408 +++++++++++++++ .../20260219000000_baseline_schema.sql | 21 + turbo.json | 4 + 27 files changed, 2137 insertions(+), 1 deletion(-) create mode 100644 apps/e2e/.prettierignore create mode 100644 apps/e2e/README.md create mode 100644 apps/e2e/env.ts create mode 100644 apps/e2e/eslint.config.mjs create mode 100644 apps/e2e/helpers/api.ts create mode 100644 apps/e2e/helpers/db.ts create mode 100644 apps/e2e/helpers/stremio.ts create mode 100644 apps/e2e/helpers/test-data.ts create mode 100644 apps/e2e/package.json create mode 100644 apps/e2e/playwright.config.ts create mode 100644 apps/e2e/preflight.ts create mode 100644 apps/e2e/tests/addon-api.spec.ts create mode 100644 apps/e2e/tests/configure-page.spec.ts create mode 100644 apps/e2e/tests/home-onboarding.spec.ts create mode 100644 apps/e2e/tests/stremio-catalogs.spec.ts create mode 100644 apps/e2e/tests/stremio-install.spec.ts create mode 100644 apps/e2e/tests/stremio-meta.spec.ts create mode 100644 apps/e2e/tsconfig.json create mode 100644 supabase/.gitignore create mode 100644 supabase/config.toml create mode 100644 supabase/migrations/20260219000000_baseline_schema.sql diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef90f27..f13c3bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,63 @@ jobs: - name: Lint, build, and test run: pnpm turbo run lint build test + e2e: + name: E2E (${{ matrix.name }}) + # Local coverage and the small live smoke suite run in separate jobs. Each + # gets its own disposable Supabase stack, and an external outage cannot hide + # failures in the deterministic suite. + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - suite: local + name: Local + - suite: live + name: Live smoke + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 10.30.1 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - uses: supabase/setup-cli@v1 + with: + version: 2.98.2 + + - name: Start local Supabase (db + REST only) + run: supabase start -x gotrue,realtime,storage-api,imgproxy,studio,edge-runtime,logflare,vector,supavisor,mailpit,postgres-meta + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + + - name: Install Playwright Chromium + run: pnpm --filter @stremlist/e2e exec playwright install --with-deps chromium + + - name: Run E2E tests + run: pnpm --filter @stremlist/e2e test:e2e:${{ matrix.suite }} + + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report-${{ matrix.suite }} + path: apps/e2e/playwright-report + retention-days: 7 + version-bump: name: Auto version bump needs: ci diff --git a/.gitignore b/.gitignore index cac4628..1c0fdfa 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,7 @@ lerna-debug.log* !robots.txt # Supabase CLI local state supabase/.temp/ + +# Playwright +test-results/ +playwright-report/ diff --git a/apps/e2e/.prettierignore b/apps/e2e/.prettierignore new file mode 100644 index 0000000..f2459b4 --- /dev/null +++ b/apps/e2e/.prettierignore @@ -0,0 +1,2 @@ +playwright-report +test-results diff --git a/apps/e2e/README.md b/apps/e2e/README.md new file mode 100644 index 0000000..cd2e539 --- /dev/null +++ b/apps/e2e/README.md @@ -0,0 +1,68 @@ +# @stremlist/e2e + +End-to-end tests that exercise Stremlist the way a real user does: the addon +is installed into the **hosted Stremio Web app** (web.stremio.com) from a +backend running locally, with **live IMDb data** and a **local Supabase +stack**. The configure/onboarding pages of the frontend are covered too. + +## How it works + +- Playwright starts the backend (`:7301`) and the frontend (`:7302`) as web + servers with ports distinct from the dev ones, so tests can run next to a + normal dev session. +- The backend points at a local Supabase stack (`supabase start`), reset + between tests. Functional seeding goes through the backend's own HTTP API, + so tests exercise real code paths. +- Stremio Web runs in anonymous mode: each fresh browser context has its own + local addon collection. No Stremio account or shared state is involved. +- Chromium is launched with `--disable-features=LocalNetworkAccessChecks,...` + because Chrome otherwise blocks the HTTPS Stremio Web page from fetching the + addon on `127.0.0.1` (Local Network Access permission, never grantable in + headless runs). +- IMDb is live. Assertions are structural (ordering invariants, id shapes, + counts) or compare the Stremio UI against the addon's own catalog JSON from + the same run, so they do not depend on what is in the watchlist today. +- The default run contains deterministic local coverage plus four live smoke + tests. Broader live checks stay in the `live-regression` project and run only + when requested. + +## Running locally + +```sh +# One-time / per boot: start the local Supabase stack (needs Docker running) +supabase start -x gotrue,realtime,storage-api,imgproxy,studio,edge-runtime,logflare,vector,supavisor,mailpit,postgres-meta + +# From the repo root: local coverage plus the small live smoke suite +pnpm test:e2e + +# Or from this package, with more options +pnpm --filter @stremlist/e2e test:e2e:local # deterministic local suite +pnpm --filter @stremlist/e2e test:e2e:live # four live smoke tests +pnpm --filter @stremlist/e2e test:e2e:live:full # all live external checks +pnpm --filter @stremlist/e2e test:e2e:all # every project +pnpm --filter @stremlist/e2e test:e2e:headed # smoke tests with a browser +pnpm --filter @stremlist/e2e report # open the last HTML report +``` + +The suite deletes test users between cases. Foreign-key cascades clear their +watchlists and caches in the same database statement. The harness rejects any +non-loopback Supabase URL unless the caller provides the explicit destructive +confirmation described below. + +## Environment knobs + +| Variable | Purpose | +| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `E2E_SUPABASE_URL` / `E2E_SUPABASE_SERVICE_ROLE_KEY` | Non-default local Supabase stack | +| `E2E_ALLOW_REMOTE_DATABASE=I_UNDERSTAND_THIS_WIPES_DATA` | Permit an isolated remote test project. Cleanup deletes every user and all dependent data | +| `E2E_IMDB_USER_ID` / `E2E_IMDB_USER_ID_2` | Override the public watchlists under test | +| `E2E_IMDB_LIST_ID` | Override the public `ls` list under test | +| `E2E_PRIVATE_IMDB_USER_ID` | Override the private watchlist under test (default: a maintainer-owned account kept private on purpose) | +| `E2E_PRIVATE_IMDB_LIST_ID` | Enable the private `ls` list test (skipped otherwise — no stable private list id is available) | + +## Known limitations + +- Drag-and-drop catalog reordering (pointer-based dnd-kit) is not covered. +- The newsletter endpoint is not covered (it would email real people). +- The live smoke and full regression suites depend on web.stremio.com and IMDb. + CI runs only the four smoke checks and retries them twice on failure. diff --git a/apps/e2e/env.ts b/apps/e2e/env.ts new file mode 100644 index 0000000..fd44750 --- /dev/null +++ b/apps/e2e/env.ts @@ -0,0 +1,52 @@ +// Shared constants for the E2E harness. Every port is distinct from the +// regular dev ports (7001/5173) so tests can run next to a dev session. + +export const BACKEND_PORT = 7301; +export const FRONTEND_PORT = 7302; + +export const BACKEND_URL = `http://127.0.0.1:${BACKEND_PORT}`; +export const FRONTEND_URL = `http://127.0.0.1:${FRONTEND_PORT}`; + +export const STREMIO_WEB_URL = "https://web.stremio.com"; + +// Local Supabase stack (supabase start). The service-role key below is the +// public, well-known key every local Supabase CLI stack ships with — it is not +// a secret. Both values can be overridden for non-default stacks. +export const SUPABASE_URL = + process.env.E2E_SUPABASE_URL ?? "http://127.0.0.1:54321"; +export const SUPABASE_SERVICE_ROLE_KEY = + process.env.E2E_SUPABASE_SERVICE_ROLE_KEY ?? + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU"; + +const REMOTE_DATABASE_CONFIRMATION = "I_UNDERSTAND_THIS_WIPES_DATA"; + +export function assertSafeSupabaseTarget(): void { + let hostname: string; + try { + hostname = new URL(SUPABASE_URL).hostname.toLowerCase(); + } catch { + throw new Error(`E2E_SUPABASE_URL is not a valid URL: ${SUPABASE_URL}`); + } + + const isLoopback = ["localhost", "127.0.0.1", "[::1]", "::1"].includes( + hostname, + ); + const remoteWipeConfirmed = + process.env.E2E_ALLOW_REMOTE_DATABASE === REMOTE_DATABASE_CONFIRMATION; + + if (!isLoopback && !remoteWipeConfirmed) { + throw new Error( + `Refusing to run destructive E2E cleanup against non-loopback Supabase host "${hostname}". ` + + `Use a disposable local stack, or set E2E_ALLOW_REMOTE_DATABASE=${REMOTE_DATABASE_CONFIRMATION} only for an isolated remote test project.`, + ); + } +} + +assertSafeSupabaseTarget(); + +// Short cooldown so refresh-throttle tests stay fast. +export const REFRESH_COOLDOWN_SECONDS = 2; + +export function addonManifestUrl(userId: string): string { + return `${BACKEND_URL}/${userId}/manifest.json`; +} diff --git a/apps/e2e/eslint.config.mjs b/apps/e2e/eslint.config.mjs new file mode 100644 index 0000000..b971edd --- /dev/null +++ b/apps/e2e/eslint.config.mjs @@ -0,0 +1,19 @@ +import baseConfig from "@stremlist/eslint-config/base"; +import prettier from "eslint-config-prettier/flat"; + +export default [ + { ignores: ["eslint.config.mjs", "playwright-report/**", "test-results/**"] }, + ...baseConfig, + { + languageOptions: { + parserOptions: { + project: "./tsconfig.json", + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + curly: ["error", "multi-line"], + }, + }, + prettier, +]; diff --git a/apps/e2e/helpers/api.ts b/apps/e2e/helpers/api.ts new file mode 100644 index 0000000..08c13cb --- /dev/null +++ b/apps/e2e/helpers/api.ts @@ -0,0 +1,117 @@ +import type { + StremioManifest, + StremioMeta, + UserConfigResponse, + UserConfigUpdateWatchlist, +} from "@stremlist/shared"; +import { hcWithType } from "@stremlist/backend/client"; +import { BACKEND_URL } from "../env.js"; + +// Thin typed wrappers over the backend HTTP API. Tests use these both to +// arrange state (through real code paths) and to assert the addon protocol +// contract that Stremio clients consume. + +export type CatalogMeta = StremioMeta; +type Manifest = StremioManifest; +type UserConfig = UserConfigResponse; +const api = hcWithType(BACKEND_URL); + +async function getJson(path: string): Promise<{ status: number; body: T }> { + const response = await fetch(`${BACKEND_URL}${path}`); + return { status: response.status, body: (await response.json()) as T }; +} + +export async function getBaseManifest(): Promise { + return (await getJson("/manifest.json")).body; +} + +export async function getUserManifest(userId: string): Promise { + return (await getJson(`/${userId}/manifest.json`)).body; +} + +export async function getConfig( + userId: string, +): Promise<{ status: number; body: UserConfig }> { + const response = await api[":userId"].config.$get({ param: { userId } }); + return { + status: response.status, + body: (await response.json()) as UserConfig, + }; +} + +type ConfigWatchlistInput = UserConfigUpdateWatchlist; + +export async function postConfig( + userId: string, + watchlists: ConfigWatchlistInput[], + rpdbApiKey?: string, +): Promise<{ status: number; body: unknown }> { + const response = await api[":userId"].config.$post({ + param: { userId }, + json: { watchlists, rpdbApiKey }, + }); + return { status: response.status, body: await response.json() }; +} + +export async function getCatalog( + userId: string, + type: string, + catalogId: string, +): Promise<{ status: number; metas: CatalogMeta[] }> { + const { status, body } = await getJson<{ metas: CatalogMeta[] }>( + `/${userId}/catalog/${type}/${catalogId}.json`, + ); + return { status, metas: body.metas }; +} + +export async function getMeta( + userId: string, + type: string, + id: string, +): Promise<{ status: number; meta: CatalogMeta | null }> { + const { status, body } = await getJson<{ meta: CatalogMeta | null }>( + `/${userId}/meta/${type}/${id}.json`, + ); + return { status, meta: body.meta }; +} + +export async function refresh( + userId: string, +): Promise<{ status: number; body: Record }> { + const response = await api[":userId"].refresh.$post({ + param: { userId }, + }); + return { + status: response.status, + body: (await response.json()) as Record, + }; +} + +export async function validateUser( + userId: string, +): Promise> { + const response = await api.validate[":userId"].$get({ param: { userId } }); + return (await response.json()) as Record; +} + +export async function validateList( + listId: string, +): Promise> { + const response = await api["validate-list"][":listId"].$get({ + param: { listId }, + }); + return (await response.json()) as Record; +} + +/** + * Bootstrap a user exactly the way a real install does: the first manifest + * fetch upserts the user and seeds the default watchlist. Returns the config. + */ +export async function bootstrapUser(userId: string): Promise { + await getUserManifest(userId); + const { status, body } = await getConfig(userId); + if (status !== 200) { + throw new Error(`bootstrapUser(${userId}) got ${status}`); + } + return body; +} diff --git a/apps/e2e/helpers/db.ts b/apps/e2e/helpers/db.ts new file mode 100644 index 0000000..947ec47 --- /dev/null +++ b/apps/e2e/helpers/db.ts @@ -0,0 +1,39 @@ +import { createClient } from "@supabase/supabase-js"; +import type { Database } from "@stremlist/shared"; +import { SUPABASE_SERVICE_ROLE_KEY, SUPABASE_URL } from "../env.js"; + +// Service-role client: bypasses RLS, used only to reset and inspect state +// between tests. All functional seeding goes through the backend's own HTTP +// API so the tests exercise real code paths. +export const db = createClient( + SUPABASE_URL, + SUPABASE_SERVICE_ROLE_KEY, +); + +/** Delete test users. Foreign-key cascades reset every dependent table. */ +export async function resetDb(): Promise { + const { error } = await db + .from("users") + .delete() + .neq("imdb_user_id", "__e2e_no_matching_user__"); + if (error) throw new Error(`resetDb failed: ${error.message}`); +} + +/** Rewind a user's last_fetched_at so the refresh cooldown does not apply. */ +export async function clearRefreshCooldown(userId: string): Promise { + const past = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + const { error } = await db + .from("users") + .update({ last_fetched_at: past }) + .eq("imdb_user_id", userId); + if (error) throw new Error(`clearRefreshCooldown failed: ${error.message}`); +} + +export async function countCacheItems(watchlistId: string): Promise { + const { count, error } = await db + .from("watchlist_cache_items") + .select("*", { count: "exact", head: true }) + .eq("watchlist_id", watchlistId); + if (error) throw new Error(`countCacheItems failed: ${error.message}`); + return count ?? 0; +} diff --git a/apps/e2e/helpers/stremio.ts b/apps/e2e/helpers/stremio.ts new file mode 100644 index 0000000..50d0231 --- /dev/null +++ b/apps/e2e/helpers/stremio.ts @@ -0,0 +1,76 @@ +import type { Page } from "@playwright/test"; +import { expect } from "@playwright/test"; +import { STREMIO_WEB_URL } from "../env.js"; + +// Page helpers for the hosted Stremio Web app (web.stremio.com). The app runs +// in anonymous/local mode: a fresh browser context has no account and stores +// the addon collection in localStorage, so tests are fully isolated. + +export function addonsDeepLink(manifestUrl: string): string { + return `${STREMIO_WEB_URL}/#/addons?addon=${encodeURIComponent(manifestUrl)}`; +} + +export function discoverUrl( + manifestUrl: string, + type: "movie" | "series", + catalogId: string, +): string { + return `${STREMIO_WEB_URL}/#/discover/${encodeURIComponent(manifestUrl)}/${type}/${encodeURIComponent(catalogId)}`; +} + +export function detailUrl(type: "movie" | "series", metaId: string): string { + return `${STREMIO_WEB_URL}/#/detail/${type}/${metaId}/${metaId}`; +} + +/** + * Dismiss the "install the desktop app" prompt if it is showing. Its "Install" + * link would otherwise collide with the addon modal's Install button. + */ +export async function dismissDesktopAppPrompt(page: Page): Promise { + const dismiss = page.getByText("Don't show again", { exact: true }); + try { + await dismiss.click({ timeout: 3_000 }); + } catch { + // Prompt not shown — nothing to do. + } +} + +/** + * Open the addon deep link and complete the install through the modal. + * Resolves once the modal is gone and the addon shows as installed. + */ +export async function installAddon( + page: Page, + manifestUrl: string, +): Promise { + await page.goto(addonsDeepLink(manifestUrl)); + await dismissDesktopAppPrompt(page); + const installButton = page.getByText("Install", { exact: true }).last(); + await expect(installButton).toBeVisible(); + await installButton.click(); + // The modal closes on success; "Uninstall"/"Configure" appear on the card. + await expect( + page.getByText("Stremlist", { exact: true }).first(), + ).toBeVisible(); +} + +/** Uninstall the addon from the Addons page, confirming in the modal. */ +export async function uninstallAddon( + page: Page, + manifestUrl: string, +): Promise { + await page.goto(addonsDeepLink(manifestUrl)); + await dismissDesktopAppPrompt(page); + const uninstallButton = page.getByText("Uninstall", { exact: true }).last(); + await expect(uninstallButton).toBeVisible(); + await uninstallButton.click(); +} + +/** Titles of the meta items currently rendered on a Discover page, in order. */ +export async function discoverItemTitles(page: Page): Promise { + const links = page.locator('a[href^="#/detail/"][title]'); + await expect(links.first()).toBeVisible(); + return links.evaluateAll((elements) => + elements.map((el) => el.getAttribute("title") ?? ""), + ); +} diff --git a/apps/e2e/helpers/test-data.ts b/apps/e2e/helpers/test-data.ts new file mode 100644 index 0000000..297cf57 --- /dev/null +++ b/apps/e2e/helpers/test-data.ts @@ -0,0 +1,32 @@ +// Real IMDb ids used by the E2E suite. Tests hit live IMDb through the +// backend, so assertions stay structural (ordering invariants, counts, id +// shapes) instead of pinning exact titles that could change over time. + +// Small public watchlist (~19 items) — one of the ids the backend stress test +// already exercises, kept small so live fetches stay fast. +export const PUBLIC_USER = process.env.E2E_IMDB_USER_ID ?? "ur102135398"; + +// Second public watchlist for multi-catalog scenarios. +export const PUBLIC_USER_2 = process.env.E2E_IMDB_USER_ID_2 ?? "ur102551738"; + +// Long-standing public IMDb list ("Top 100 Greatest Movies of All Time"). +export const PUBLIC_LIST = process.env.E2E_IMDB_LIST_ID ?? "ls055592025"; + +// Syntactically valid ids that do not exist. IMDb user ids are ~9 digits; +// a 13-digit id is far outside the allocated range. +export const UNKNOWN_USER = "ur9999999999999"; +export const UNKNOWN_LIST = "ls9999999999999"; + +// Account whose watchlist is deliberately kept private for these tests +// (maintainer-owned). The p-handle resolves to the same account, covering the +// handle-resolution path for private sources too. +export const PRIVATE_USER = + process.env.E2E_PRIVATE_IMDB_USER_ID ?? "ur198342247"; +export const PRIVATE_P_HANDLE = "p.e4ialbfdp3rntdahbslk5yzovm"; + +// No stable private ls list is available; provide one via env to enable the +// private-list test. +export const PRIVATE_LIST = process.env.E2E_PRIVATE_IMDB_LIST_ID; + +// p-handle that must resolve to a canonical ur id (IMDb founder's profile). +export const P_HANDLE = "p.colneedham"; diff --git a/apps/e2e/package.json b/apps/e2e/package.json new file mode 100644 index 0000000..f80452a --- /dev/null +++ b/apps/e2e/package.json @@ -0,0 +1,33 @@ +{ + "name": "@stremlist/e2e", + "private": true, + "type": "module", + "scripts": { + "preflight": "tsx preflight.ts", + "test:e2e": "pnpm preflight && playwright test --project=local --project=live-smoke", + "test:e2e:local": "pnpm preflight && playwright test --project=local", + "test:e2e:live": "pnpm preflight && playwright test --project=live-smoke", + "test:e2e:live:full": "pnpm preflight && playwright test --project=live-smoke --project=live-regression", + "test:e2e:all": "pnpm preflight && playwright test", + "test:e2e:headed": "pnpm preflight && playwright test --project=live-smoke --headed", + "report": "playwright show-report", + "typecheck": "tsc --noEmit", + "lint": "eslint .", + "format": "prettier . --write", + "format:check": "prettier . --check" + }, + "devDependencies": { + "@playwright/test": "^1.62.1", + "@stremlist/backend": "workspace:*", + "@stremlist/eslint-config": "workspace:*", + "@stremlist/frontend": "workspace:*", + "@stremlist/shared": "workspace:*", + "@supabase/supabase-js": "^2.95.3", + "@types/node": "^20.11.17", + "eslint": "^9.39.2", + "eslint-config-prettier": "^10.1.8", + "prettier": "^3.8.1", + "tsx": "^4.7.1", + "typescript": "^5.8.3" + } +} diff --git a/apps/e2e/playwright.config.ts b/apps/e2e/playwright.config.ts new file mode 100644 index 0000000..04fe807 --- /dev/null +++ b/apps/e2e/playwright.config.ts @@ -0,0 +1,90 @@ +import { defineConfig, devices } from "@playwright/test"; +import { + BACKEND_PORT, + BACKEND_URL, + FRONTEND_PORT, + FRONTEND_URL, + REFRESH_COOLDOWN_SECONDS, + SUPABASE_SERVICE_ROLE_KEY, + SUPABASE_URL, +} from "./env.js"; + +export default defineConfig({ + testDir: "./tests", + // Tests within a project share one backend and one database. CI runs local + // and live-smoke projects as separate jobs with isolated Supabase stacks. + fullyParallel: false, + workers: 1, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + timeout: 120_000, + expect: { timeout: 20_000 }, + reporter: process.env.CI + ? [["list"], ["html", { open: "never" }], ["github"]] + : [["list"], ["html", { open: "never" }]], + use: { + trace: "retain-on-failure", + screenshot: "only-on-failure", + // stremio-web registers a service worker; blocking it keeps network + // behavior deterministic across fresh contexts. + serviceWorkers: "block", + launchOptions: { + args: [ + // Chrome blocks fetches from public HTTPS pages (web.stremio.com) to + // loopback addresses behind the Local Network Access permission, which + // headless runs can never grant. Older Chromium versions gate the same + // thing behind Private Network Access preflights. Disable both so the + // hosted Stremio Web app can talk to the local addon under test. + "--disable-features=LocalNetworkAccessChecks,BlockInsecurePrivateNetworkRequests,PrivateNetworkAccessSendPreflights,PrivateNetworkAccessRespectPreflightResults", + ], + }, + }, + projects: [ + { + name: "local", + grep: /@local/, + use: { ...devices["Desktop Chrome"] }, + }, + { + name: "live-smoke", + grep: /@live-smoke/, + use: { ...devices["Desktop Chrome"] }, + }, + { + name: "live-regression", + grep: /@live-regression/, + use: { ...devices["Desktop Chrome"] }, + }, + ], + webServer: [ + { + command: "pnpm exec tsx src/dev.ts", + cwd: "../backend", + url: `${BACKEND_URL}/health`, + reuseExistingServer: !process.env.CI, + timeout: 60_000, + env: { + PORT: String(BACKEND_PORT), + SUPABASE_URL, + SUPABASE_SERVICE_ROLE_KEY, + FRONTEND_URL, + REFRESH_COOLDOWN_SECONDS: String(REFRESH_COOLDOWN_SECONDS), + CACHE_TTL_MINUTES: "30", + // The Resend SDK throws at import time without a key. Newsletter + // delivery is deliberately out of E2E scope (it would email real + // people), so a dummy key is enough to boot the app. + RESEND_API_KEY: "re_e2e_dummy_key", + }, + }, + { + command: `pnpm exec vite --port ${FRONTEND_PORT} --strictPort`, + cwd: "../frontend", + url: FRONTEND_URL, + reuseExistingServer: !process.env.CI, + timeout: 60_000, + env: { + VITE_BACKEND_URL: BACKEND_URL, + }, + }, + ], +}); diff --git a/apps/e2e/preflight.ts b/apps/e2e/preflight.ts new file mode 100644 index 0000000..ae9e447 --- /dev/null +++ b/apps/e2e/preflight.ts @@ -0,0 +1,27 @@ +import { SUPABASE_SERVICE_ROLE_KEY, SUPABASE_URL } from "./env.js"; + +// This script runs before Playwright starts its web servers, so database +// failures surface immediately instead of becoming a backend startup timeout. +try { + const response = await fetch( + `${SUPABASE_URL}/rest/v1/users?select=imdb_user_id&limit=1`, + { + headers: { + apikey: SUPABASE_SERVICE_ROLE_KEY, + Authorization: `Bearer ${SUPABASE_SERVICE_ROLE_KEY}`, + }, + }, + ); + if (!response.ok) { + throw new Error( + `Supabase responded ${response.status}: ${await response.text()}`, + ); + } +} catch (error) { + throw new Error( + `E2E Supabase stack is not reachable at ${SUPABASE_URL}.\n` + + `Start the local stack from the repository root with:\n` + + ` supabase start -x gotrue,realtime,storage-api,imgproxy,studio,edge-runtime,logflare,vector,supavisor,mailpit,postgres-meta\n` + + `Underlying error: ${error instanceof Error ? error.message : String(error)}`, + ); +} diff --git a/apps/e2e/tests/addon-api.spec.ts b/apps/e2e/tests/addon-api.spec.ts new file mode 100644 index 0000000..f0e0a9e --- /dev/null +++ b/apps/e2e/tests/addon-api.spec.ts @@ -0,0 +1,469 @@ +import { expect, test } from "@playwright/test"; +import { FRONTEND_URL, BACKEND_URL } from "../env.js"; +import { + bootstrapUser, + getBaseManifest, + getCatalog, + getConfig, + getMeta, + getUserManifest, + postConfig, + refresh, + validateList, + validateUser, + type CatalogMeta, +} from "../helpers/api.js"; +import { + clearRefreshCooldown, + countCacheItems, + resetDb, +} from "../helpers/db.js"; +import { + P_HANDLE, + PRIVATE_LIST, + PRIVATE_P_HANDLE, + PRIVATE_USER, + PUBLIC_LIST, + PUBLIC_USER, + UNKNOWN_LIST, + UNKNOWN_USER, +} from "../helpers/test-data.js"; + +// Addon protocol contract — the exact HTTP surface every Stremio client +// (web, desktop, mobile) consumes. Data comes from live IMDb, so assertions +// are structural: ordering invariants, id shapes, counts. + +const CATALOG_ID_PATTERN = + /^wl-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}-(movie|series)$/; + +test.beforeEach(async () => { + await resetDb(); +}); + +test.describe("manifest", () => { + test("base manifest requires configuration", { tag: "@local" }, async () => { + const manifest = await getBaseManifest(); + expect(manifest.behaviorHints?.configurationRequired).toBe(true); + expect(manifest.behaviorHints?.configurable).toBe(true); + expect(manifest.name).toBe("Stremlist"); + }); + + test( + "first user manifest bootstraps the install", + { tag: "@local" }, + async () => { + const manifest = await getUserManifest(PUBLIC_USER); + expect(manifest.id).toBe(`com.stremlist.${PUBLIC_USER}`); + expect(manifest.behaviorHints?.configurationRequired).toBe(false); + // Default watchlist in split mode → one movie + one series catalog. + expect(manifest.catalogs).toHaveLength(2); + expect(manifest.catalogs.map((c) => c.type).sort()).toEqual([ + "movie", + "series", + ]); + for (const catalogRef of manifest.catalogs) { + expect(catalogRef.id).toMatch(CATALOG_ID_PATTERN); + expect(catalogRef.name).toContain("Stremlist"); + } + // The meta resource must stay declared — Stremio clients rely on it. + const metaResource = manifest.resources.find( + (r) => + typeof r === "object" && + r !== null && + (r as { name?: string }).name === "meta", + ) as { idPrefixes?: string[] } | undefined; + expect(metaResource?.idPrefixes).toEqual(["tt"]); + }, + ); + + test( + "display mode controls emitted catalogs", + { tag: "@local" }, + async () => { + const config = await bootstrapUser(PUBLIC_USER); + const watchlist = config.watchlists[0]; + await postConfig(PUBLIC_USER, [ + { + id: watchlist.id, + imdbUserId: watchlist.imdbUserId, + sortOption: "added_at-asc", + displayMode: "movie", + }, + ]); + const manifest = await getUserManifest(PUBLIC_USER); + expect(manifest.catalogs).toHaveLength(1); + expect(manifest.catalogs[0].type).toBe("movie"); + }, + ); +}); + +test.describe("catalogs", () => { + test( + "movie catalog serves the live IMDb watchlist", + { tag: "@live-smoke" }, + async () => { + const config = await bootstrapUser(PUBLIC_USER); + const catalogId = `wl-${config.watchlists[0].id}-movie`; + const { status, metas } = await getCatalog( + PUBLIC_USER, + "movie", + catalogId, + ); + expect(status).toBe(200); + expect(metas.length).toBeGreaterThan(0); + for (const meta of metas) { + expect(meta.type).toBe("movie"); + expect(meta.id).toMatch(/^tt\d+$/); + expect(meta.name.length).toBeGreaterThan(0); + } + // The fetch must have populated the per-item cache. + expect(await countCacheItems(config.watchlists[0].id)).toBeGreaterThan(0); + }, + ); + + test( + "every sort option orders the catalog correctly", + { tag: "@live-regression" }, + async () => { + const config = await bootstrapUser(PUBLIC_USER); + const watchlist = config.watchlists[0]; + const catalogId = `wl-${watchlist.id}-movie`; + + const setSort = async (sortOption: string) => { + const { status } = await postConfig(PUBLIC_USER, [ + { id: watchlist.id, imdbUserId: watchlist.imdbUserId, sortOption }, + ]); + expect(status).toBe(200); + const { metas } = await getCatalog(PUBLIC_USER, "movie", catalogId); + return metas; + }; + + const baseline = await setSort("added_at-asc"); + expect(baseline.length).toBeGreaterThan(1); + const ids = (metas: CatalogMeta[]) => metas.map((m) => m.id); + const years = (metas: CatalogMeta[]) => + metas.map((m) => parseInt(m.releaseInfo ?? "0", 10) || 0); + const ratings = (metas: CatalogMeta[]) => + metas.map((m) => parseFloat(m.imdbRating ?? "0") || 0); + const expectMonotonic = (values: number[], direction: "asc" | "desc") => { + for (let i = 1; i < values.length; i++) { + if (direction === "asc") + expect(values[i]).toBeGreaterThanOrEqual(values[i - 1]); + else expect(values[i]).toBeLessThanOrEqual(values[i - 1]); + } + }; + + const addedDesc = await setSort("added_at-desc"); + expect(ids(addedDesc)).toEqual([...ids(baseline)].reverse()); + + const titleAsc = await setSort("title-asc"); + const namesAsc = titleAsc.map((m) => m.name); + expect(namesAsc).toEqual( + [...namesAsc].sort((a, b) => a.localeCompare(b)), + ); + + const titleDesc = await setSort("title-desc"); + const namesDesc = titleDesc.map((m) => m.name); + expect(namesDesc).toEqual( + [...namesDesc].sort((a, b) => b.localeCompare(a)), + ); + + expectMonotonic(years(await setSort("year-asc")), "asc"); + expectMonotonic(years(await setSort("year-desc")), "desc"); + expectMonotonic(ratings(await setSort("rating-asc")), "asc"); + expectMonotonic(ratings(await setSort("rating-desc")), "desc"); + + const random = await setSort("random"); + expect(ids(random).sort()).toEqual(ids(baseline).sort()); + }, + ); + + test( + "ls list source serves a public IMDb list", + { tag: "@live-regression" }, + async () => { + const config = await bootstrapUser(PUBLIC_USER); + await postConfig(PUBLIC_USER, [ + { imdbUserId: PUBLIC_LIST, sortOption: "added_at-asc" }, + ]); + const updated = await getConfig(PUBLIC_USER); + const watchlist = updated.body.watchlists[0]; + expect(watchlist.imdbUserId).toBe(PUBLIC_LIST); + const { metas } = await getCatalog( + PUBLIC_USER, + "movie", + `wl-${watchlist.id}-movie`, + ); + expect(metas.length).toBeGreaterThan(0); + expect(config.watchlists[0].id).not.toBe(watchlist.id); + }, + ); + + test( + "built-in chart catalog serves live chart data", + { tag: "@live-regression" }, + async () => { + await bootstrapUser(PUBLIC_USER); + await postConfig(PUBLIC_USER, [ + { + imdbUserId: "imdb:top-rated-movies", + sortOption: "added_at-asc", + displayMode: "movie", + }, + ]); + const { body } = await getConfig(PUBLIC_USER); + const chart = body.watchlists[0]; + const { metas } = await getCatalog( + PUBLIC_USER, + "movie", + `wl-${chart.id}-movie`, + ); + // IMDb Top 250 — allow slack for titles Stremio types can't represent. + expect(metas.length).toBeGreaterThan(200); + for (const meta of metas.slice(0, 10)) { + expect(meta.type).toBe("movie"); + expect(meta.id).toMatch(/^tt\d+$/); + } + }, + ); + + test("RPDB key rewrites posters", { tag: "@live-regression" }, async () => { + const config = await bootstrapUser(PUBLIC_USER); + const watchlist = config.watchlists[0]; + await postConfig( + PUBLIC_USER, + [ + { + id: watchlist.id, + imdbUserId: watchlist.imdbUserId, + sortOption: "added_at-asc", + }, + ], + "e2e-test-key", + ); + const { metas } = await getCatalog( + PUBLIC_USER, + "movie", + `wl-${watchlist.id}-movie`, + ); + expect(metas.length).toBeGreaterThan(0); + for (const meta of metas) { + expect(meta.poster).toContain( + "https://api.ratingposterdb.com/e2e-test-key/imdb/poster-default/", + ); + } + }); + + test( + "unknown watchlist degrades to an informational card, not a 500", + { tag: "@live-regression" }, + async () => { + const config = await bootstrapUser(UNKNOWN_USER); + const { status, metas } = await getCatalog( + UNKNOWN_USER, + "movie", + `wl-${config.watchlists[0].id}-movie`, + ); + expect(status).toBe(200); + expect(metas).toHaveLength(1); + expect(metas[0].id).toBe("stremlist:unavailable:not_found"); + expect(metas[0].name).toContain("not found"); + }, + ); + + test( + "private watchlist degrades to an informational card", + { tag: "@live-regression" }, + async () => { + const config = await bootstrapUser(PRIVATE_USER); + const { status, metas } = await getCatalog( + PRIVATE_USER, + "movie", + `wl-${config.watchlists[0].id}-movie`, + ); + expect(status).toBe(200); + expect(metas).toHaveLength(1); + expect(metas[0].id).toBe("stremlist:unavailable:private"); + expect(metas[0].name).toContain("private"); + }, + ); + + test( + "malformed catalog requests return empty catalogs", + { tag: "@local" }, + async () => { + await bootstrapUser(PUBLIC_USER); + const unknownCatalog = await getCatalog( + PUBLIC_USER, + "movie", + "wl-00000000-0000-4000-8000-000000000000-movie", + ); + expect(unknownCatalog.status).toBe(200); + expect(unknownCatalog.metas).toEqual([]); + + const badType = await getCatalog( + PUBLIC_USER, + "channel", + "stremlist-movies", + ); + expect(badType.status).toBe(200); + expect(badType.metas).toEqual([]); + }, + ); +}); + +test.describe("meta", () => { + test( + "serves cached meta and falls back to null on misses", + { tag: "@live-regression" }, + async () => { + const config = await bootstrapUser(PUBLIC_USER); + const catalogId = `wl-${config.watchlists[0].id}-movie`; + const { metas } = await getCatalog(PUBLIC_USER, "movie", catalogId); + const first = metas[0]; + + const hit = await getMeta(PUBLIC_USER, "movie", first.id); + expect(hit.status).toBe(200); + expect(hit.meta?.name).toBe(first.name); + expect(hit.meta?.id).toBe(first.id); + + // Cache-only: unknown ids must return null (Stremio then asks Cinemeta), + // never 500. + const miss = await getMeta(PUBLIC_USER, "movie", "tt9999999999"); + expect(miss.status).toBe(200); + expect(miss.meta).toBeNull(); + }, + ); +}); + +test.describe("validation endpoints", () => { + test( + "validates public, unknown, and p-handle sources", + { tag: "@live-regression" }, + async () => { + expect(await validateUser(PUBLIC_USER)).toEqual({ + valid: true, + userId: PUBLIC_USER, + }); + expect(await validateUser(UNKNOWN_USER)).toEqual({ + valid: false, + reason: "not_found", + }); + expect(await validateList(PUBLIC_LIST)).toEqual({ valid: true }); + expect(await validateList(UNKNOWN_LIST)).toEqual({ + valid: false, + reason: "not_found", + }); + + // p-handles resolve to a canonical ur id first. The target account's + // watchlist visibility is not under our control, so only assert shape. + const handleResult = await validateUser(P_HANDLE); + if (handleResult.valid) { + expect(String(handleResult.userId)).toMatch(/^ur\d+$/); + } else { + expect(["private", "not_found"]).toContain(handleResult.reason); + } + }, + ); + + test("reports private sources", { tag: "@live-regression" }, async () => { + expect(await validateUser(PRIVATE_USER)).toEqual({ + valid: false, + reason: "private", + }); + // Same account via its p-handle: exercises handle resolution on a + // private source. + expect(await validateUser(PRIVATE_P_HANDLE)).toEqual({ + valid: false, + reason: "private", + }); + if (PRIVATE_LIST) { + expect(await validateList(PRIVATE_LIST)).toEqual({ + valid: false, + reason: "private", + }); + } + }); +}); + +test.describe("config API", () => { + test("rejects invalid configurations", { tag: "@local" }, async () => { + await bootstrapUser(PUBLIC_USER); + const valid = { imdbUserId: PUBLIC_USER, sortOption: "added_at-asc" }; + + expect((await postConfig(PUBLIC_USER, [])).status).toBe(400); + expect( + ( + await postConfig( + PUBLIC_USER, + Array.from({ length: 11 }, () => valid), + ) + ).status, + ).toBe(400); + expect( + ( + await postConfig(PUBLIC_USER, [ + { ...valid, catalogTitle: "x".repeat(31) }, + ]) + ).status, + ).toBe(400); + expect( + (await postConfig(PUBLIC_USER, [{ ...valid, sortOption: "bogus" }])) + .status, + ).toBe(400); + expect((await postConfig(PUBLIC_USER, [valid, valid])).status).toBe(400); + expect( + (await postConfig(PUBLIC_USER, [{ ...valid, imdbUserId: "banana" }])) + .status, + ).toBe(400); + }); + + test("404s for users that never installed", { tag: "@local" }, async () => { + const { status } = await getConfig(PUBLIC_USER); + expect(status).toBe(404); + }); +}); + +test.describe("refresh", () => { + test( + "refreshes from live IMDb and then throttles", + { tag: "@live-regression" }, + async () => { + const config = await bootstrapUser(PUBLIC_USER); + await clearRefreshCooldown(PUBLIC_USER); + + const first = await refresh(PUBLIC_USER); + expect(first.status).toBe(200); + expect(first.body.ok).toBe(true); + expect(first.body.refreshed).toBe(1); + expect(first.body.failed).toBe(0); + expect(first.body.total).toBe(1); + expect(await countCacheItems(config.watchlists[0].id)).toBeGreaterThan(0); + + const second = await refresh(PUBLIC_USER); + expect(second.body.throttled).toBe(true); + }, + ); +}); + +test.describe("misc endpoints", () => { + test("health, stats, and configure redirect", { tag: "@local" }, async () => { + const health = await fetch(`${BACKEND_URL}/health`); + expect(health.status).toBe(200); + expect(((await health.json()) as { database: string }).database).toBe("up"); + + await bootstrapUser(PUBLIC_USER); + const stats = await fetch(`${BACKEND_URL}/stats`); + expect( + ((await stats.json()) as { activeUsers: number }).activeUsers, + ).toBeGreaterThanOrEqual(1); + + const configure = await fetch(`${BACKEND_URL}/${PUBLIC_USER}/configure`, { + redirect: "manual", + }); + expect(configure.status).toBe(302); + expect(configure.headers.get("location")).toBe( + `${FRONTEND_URL}/configure?userId=${PUBLIC_USER}`, + ); + }); +}); diff --git a/apps/e2e/tests/configure-page.spec.ts b/apps/e2e/tests/configure-page.spec.ts new file mode 100644 index 0000000..5f5e209 --- /dev/null +++ b/apps/e2e/tests/configure-page.spec.ts @@ -0,0 +1,170 @@ +import { expect, test } from "@playwright/test"; +import { FRONTEND_URL } from "../env.js"; +import { bootstrapUser, getConfig } from "../helpers/api.js"; +import { resetDb } from "../helpers/db.js"; +import { + PUBLIC_LIST, + PUBLIC_USER, + UNKNOWN_USER, +} from "../helpers/test-data.js"; + +// The /configure page: catalog management, options, refresh, install links. + +const configureUrl = (userId: string) => + `${FRONTEND_URL}/configure?userId=${userId}`; + +test.beforeEach(async () => { + await resetDb(); +}); + +test( + "loads the existing configuration", + { tag: "@local" }, + async ({ page }) => { + await bootstrapUser(PUBLIC_USER); + await page.goto(configureUrl(PUBLIC_USER)); + + await expect(page.getByText("Catalog 1")).toBeVisible(); + await expect( + page.locator(`input[value="${PUBLIC_USER}"]`).first(), + ).toBeVisible(); + await expect( + page.getByRole("link", { name: "Open in Stremio Web" }), + ).toBeVisible(); + }, +); + +test( + "adds an ls list catalog and saves", + { tag: "@local" }, + async ({ page }) => { + await bootstrapUser(PUBLIC_USER); + await page.goto(configureUrl(PUBLIC_USER)); + await expect(page.getByText("Catalog 1")).toBeVisible(); + + await page.getByRole("button", { name: "Add Catalog" }).click(); + await expect(page.getByText("Catalog 2")).toBeVisible(); + + const idInputs = page.locator('input[placeholder*="ur12345678"]'); + await idInputs.last().fill(PUBLIC_LIST); + + await page.getByRole("button", { name: "Save", exact: true }).click(); + await expect(page.getByText("Saved!", { exact: false })).toBeVisible(); + + const { body } = await getConfig(PUBLIC_USER); + expect(body.watchlists).toHaveLength(2); + expect(body.watchlists.map((w) => w.imdbUserId)).toContain(PUBLIC_LIST); + }, +); + +test("adds a built-in chart catalog", { tag: "@local" }, async ({ page }) => { + await bootstrapUser(PUBLIC_USER); + await page.goto(configureUrl(PUBLIC_USER)); + await expect(page.getByText("Catalog 1")).toBeVisible(); + + await page.getByRole("button", { name: "Add Built-in Catalog" }).click(); + await page.getByRole("menuitem", { name: "Top 250 Movies" }).click(); + await expect(page.getByText("Built-in", { exact: true })).toBeVisible(); + + await page.getByRole("button", { name: "Save", exact: true }).click(); + await expect(page.getByText("Saved!", { exact: false })).toBeVisible(); + + const { body } = await getConfig(PUBLIC_USER); + expect(body.watchlists.map((w) => w.imdbUserId)).toContain( + "imdb:top-rated-movies", + ); +}); + +test( + "changes sort order and content filter", + { tag: "@local" }, + async ({ page }) => { + await bootstrapUser(PUBLIC_USER); + await page.goto(configureUrl(PUBLIC_USER)); + await expect(page.getByText("Catalog 1")).toBeVisible(); + + // Radix selects: one combobox for "Sort Order", one for "Show", in DOM order. + await page.getByRole("combobox").nth(0).click(); + await page.getByRole("option", { name: "Highest Rated" }).click(); + await page.getByRole("combobox").nth(1).click(); + await page.getByRole("option", { name: "Movies only" }).click(); + await page.getByRole("button", { name: "Save", exact: true }).click(); + await expect(page.getByText("Saved!", { exact: false })).toBeVisible(); + + const { body } = await getConfig(PUBLIC_USER); + expect(body.watchlists[0].sortOption).toBe("rating-desc"); + expect(body.watchlists[0].displayMode).toBe("movie"); + }, +); + +test("saves and clears the RPDB key", { tag: "@local" }, async ({ page }) => { + await bootstrapUser(PUBLIC_USER); + await page.goto(configureUrl(PUBLIC_USER)); + await expect(page.getByText("Catalog 1")).toBeVisible(); + + await page.locator("#rpdb-api-key").fill("e2e-rpdb-key"); + await page.getByRole("button", { name: "Save", exact: true }).click(); + await expect(page.getByText("Saved!", { exact: false })).toBeVisible(); + expect((await getConfig(PUBLIC_USER)).body.rpdbApiKey).toBe("e2e-rpdb-key"); + + await page.locator("#rpdb-api-key").fill(""); + await page.getByRole("button", { name: "Save", exact: true }).click(); + await expect(page.getByText("Saved!", { exact: false })).toBeVisible(); + expect((await getConfig(PUBLIC_USER)).body.rpdbApiKey).toBeNull(); +}); + +test("removes a catalog", { tag: "@local" }, async ({ page }) => { + await bootstrapUser(PUBLIC_USER); + await page.goto(configureUrl(PUBLIC_USER)); + await page.getByRole("button", { name: "Add Built-in Catalog" }).click(); + await page.getByRole("menuitem", { name: "Box Office (Weekend)" }).click(); + await expect(page.getByText("Catalog 2")).toBeVisible(); + + await page.getByLabel("Remove catalog").last().click(); + await expect(page.getByText("Catalog 2")).not.toBeVisible(); + + await page.getByRole("button", { name: "Save", exact: true }).click(); + await expect(page.getByText("Saved!", { exact: false })).toBeVisible(); + expect((await getConfig(PUBLIC_USER)).body.watchlists).toHaveLength(1); +}); + +test( + "manual refresh hits the backend and starts the cooldown", + { tag: "@live-regression" }, + async ({ page }) => { + await bootstrapUser(PUBLIC_USER); + await page.goto(configureUrl(PUBLIC_USER)); + await expect(page.getByText("Catalog 1")).toBeVisible(); + + const refreshResponse = page.waitForResponse( + (response) => + response.url().includes("/refresh") && + response.request().method() === "POST", + ); + await page.getByRole("button", { name: /Refresh now|Refresh in/ }).click(); + expect((await refreshResponse).status()).toBe(200); + }, +); + +test( + "unknown user is told to install first", + { tag: "@local" }, + async ({ page }) => { + await page.goto(configureUrl(UNKNOWN_USER)); + await expect( + page.getByText("User not found.", { exact: false }), + ).toBeVisible(); + }, +); + +test( + "without userId, entering an id loads its configuration", + { tag: "@local" }, + async ({ page }) => { + await bootstrapUser(PUBLIC_USER); + await page.goto(`${FRONTEND_URL}/configure`); + await page.locator("#imdb-id").fill(PUBLIC_USER); + await expect(page).toHaveURL(new RegExp(`userId=${PUBLIC_USER}`)); + await expect(page.getByText("Catalog 1")).toBeVisible(); + }, +); diff --git a/apps/e2e/tests/home-onboarding.spec.ts b/apps/e2e/tests/home-onboarding.spec.ts new file mode 100644 index 0000000..8ccd944 --- /dev/null +++ b/apps/e2e/tests/home-onboarding.spec.ts @@ -0,0 +1,71 @@ +import { expect, test } from "@playwright/test"; +import { addonManifestUrl, FRONTEND_URL } from "../env.js"; +import { bootstrapUser } from "../helpers/api.js"; +import { resetDb } from "../helpers/db.js"; +import { + PUBLIC_USER, + PUBLIC_USER_2, + UNKNOWN_USER, +} from "../helpers/test-data.js"; + +// First-install flow on the Stremlist home page. + +test.beforeEach(async ({ page }) => { + await resetDb(); + await page.goto(FRONTEND_URL); +}); + +test( + "new user gets install actions after live validation", + { tag: "@live-smoke" }, + async ({ page }) => { + await page.locator("#imdb-id").fill(PUBLIC_USER_2); + + const webInstall = page.getByRole("link", { name: "Open in Stremio Web" }); + await expect(webInstall).toBeVisible(); + await expect(webInstall).toHaveAttribute( + "href", + `https://web.stremio.com/#/addons?addon=${encodeURIComponent(addonManifestUrl(PUBLIC_USER_2))}`, + ); + await expect( + page.getByRole("link", { name: "Open in Stremio Desktop" }), + ).toHaveAttribute( + "href", + `stremio://127.0.0.1:7301/${PUBLIC_USER_2}/manifest.json`, + ); + }, +); + +test( + "returning user is welcomed back", + { tag: "@live-regression" }, + async ({ page }) => { + await bootstrapUser(PUBLIC_USER); + await page.locator("#imdb-id").fill(PUBLIC_USER); + await expect(page.getByText(`Welcome back, ${PUBLIC_USER}!`)).toBeVisible(); + }, +); + +test( + "unknown IMDb id shows the not-found error", + { tag: "@live-regression" }, + async ({ page }) => { + await page.locator("#imdb-id").fill(UNKNOWN_USER); + await expect( + page.getByText( + "This IMDb ID does not exist. Please check and try again.", + ), + ).toBeVisible(); + }, +); + +test( + "garbage input shows the format error", + { tag: "@local" }, + async ({ page }) => { + await page.locator("#imdb-id").fill("banana"); + await expect( + page.getByText("Could not find a valid IMDb ID", { exact: false }), + ).toBeVisible(); + }, +); diff --git a/apps/e2e/tests/stremio-catalogs.spec.ts b/apps/e2e/tests/stremio-catalogs.spec.ts new file mode 100644 index 0000000..a37799c --- /dev/null +++ b/apps/e2e/tests/stremio-catalogs.spec.ts @@ -0,0 +1,178 @@ +import { expect, test } from "@playwright/test"; +import { addonManifestUrl } from "../env.js"; +import { + bootstrapUser, + getCatalog, + getConfig, + postConfig, +} from "../helpers/api.js"; +import { resetDb } from "../helpers/db.js"; +import { + discoverItemTitles, + discoverUrl, + installAddon, + uninstallAddon, +} from "../helpers/stremio.js"; +import { + PRIVATE_USER, + PUBLIC_USER, + UNKNOWN_USER, +} from "../helpers/test-data.js"; + +// Catalog rendering inside the real Stremio Web app. Expected content is read +// from the addon's own catalog endpoint in the same run, so assertions stay +// deterministic even though the underlying IMDb data is live. + +test.beforeEach(async () => { + await resetDb(); +}); + +test( + "watchlist catalog renders in Discover, in catalog order", + { tag: "@live-smoke" }, + async ({ page }) => { + const config = await bootstrapUser(PUBLIC_USER); + const watchlist = config.watchlists[0]; + const catalogId = `wl-${watchlist.id}-movie`; + const manifestUrl = addonManifestUrl(PUBLIC_USER); + const { metas } = await getCatalog(PUBLIC_USER, "movie", catalogId); + expect(metas.length).toBeGreaterThan(0); + + await installAddon(page, manifestUrl); + await page.goto(discoverUrl(manifestUrl, "movie", catalogId)); + + const rendered = await discoverItemTitles(page); + expect(rendered.length).toBeGreaterThan(0); + const expected = metas.map((meta) => meta.name); + expect(rendered.slice(0, Math.min(5, expected.length))).toEqual( + expected.slice(0, Math.min(5, rendered.length)), + ); + }, +); + +test( + "sort option changes reorder the catalog without reinstalling", + { tag: "@live-regression" }, + async ({ page }) => { + const config = await bootstrapUser(PUBLIC_USER); + const watchlist = config.watchlists[0]; + const catalogId = `wl-${watchlist.id}-movie`; + const manifestUrl = addonManifestUrl(PUBLIC_USER); + await getCatalog(PUBLIC_USER, "movie", catalogId); + await installAddon(page, manifestUrl); + + await postConfig(PUBLIC_USER, [ + { + id: watchlist.id, + imdbUserId: watchlist.imdbUserId, + sortOption: "title-asc", + }, + ]); + const { metas } = await getCatalog(PUBLIC_USER, "movie", catalogId); + const expected = metas.map((meta) => meta.name); + expect(expected).toEqual([...expected].sort((a, b) => a.localeCompare(b))); + + await page.goto(discoverUrl(manifestUrl, "movie", catalogId)); + const rendered = await discoverItemTitles(page); + expect(rendered.slice(0, Math.min(5, expected.length))).toEqual( + expected.slice(0, Math.min(5, rendered.length)), + ); + }, +); + +test( + "built-in chart catalog renders after a reinstall", + { tag: "@live-regression" }, + async ({ page }) => { + await bootstrapUser(PUBLIC_USER); + const manifestUrl = addonManifestUrl(PUBLIC_USER); + await installAddon(page, manifestUrl); + + // Adding a catalog changes the manifest, which Stremio only picks up on + // reinstall — exactly what the configure page tells the user to do. + await postConfig(PUBLIC_USER, [ + { imdbUserId: PUBLIC_USER, sortOption: "added_at-asc" }, + { + imdbUserId: "imdb:box-office", + sortOption: "added_at-asc", + displayMode: "movie", + }, + ]); + const { body } = await getConfig(PUBLIC_USER); + const chart = body.watchlists.find( + (w) => w.imdbUserId === "imdb:box-office", + ); + expect(chart).toBeDefined(); + const catalogId = `wl-${chart!.id}-movie`; + const { metas } = await getCatalog(PUBLIC_USER, "movie", catalogId); + expect(metas.length).toBeGreaterThan(0); + + await uninstallAddon(page, manifestUrl); + await installAddon(page, manifestUrl); + await page.goto(discoverUrl(manifestUrl, "movie", catalogId)); + + const rendered = await discoverItemTitles(page); + expect(rendered.length).toBeGreaterThan(0); + expect(rendered[0]).toBe(metas[0].name); + }, +); + +test( + "catalog rows appear on the Board", + { tag: "@live-regression" }, + async ({ page }) => { + const config = await bootstrapUser(PUBLIC_USER); + const watchlist = config.watchlists[0]; + // Distinctive title so the Board row is unambiguous. + await postConfig(PUBLIC_USER, [ + { + id: watchlist.id, + imdbUserId: watchlist.imdbUserId, + sortOption: "added_at-asc", + catalogTitle: "E2E QA", + }, + ]); + const manifestUrl = addonManifestUrl(PUBLIC_USER); + await getCatalog(PUBLIC_USER, "movie", `wl-${watchlist.id}-movie`); + + await installAddon(page, manifestUrl); + await page.goto("https://web.stremio.com/#/"); + await expect( + page.getByText("Stremlist E2E QA", { exact: false }).first(), + ).toBeAttached({ timeout: 30_000 }); + }, +); + +test( + "broken watchlist shows the informational card in Stremio", + { tag: "@live-regression" }, + async ({ page }) => { + const config = await bootstrapUser(UNKNOWN_USER); + const catalogId = `wl-${config.watchlists[0].id}-movie`; + const manifestUrl = addonManifestUrl(UNKNOWN_USER); + + await installAddon(page, manifestUrl); + await page.goto(discoverUrl(manifestUrl, "movie", catalogId)); + await expect( + page.getByText("IMDb watchlist not found", { exact: false }).first(), + ).toBeVisible(); + }, +); + +test( + "private watchlist shows the private card in Stremio", + { tag: "@live-regression" }, + async ({ page }) => { + const config = await bootstrapUser(PRIVATE_USER); + const catalogId = `wl-${config.watchlists[0].id}-movie`; + const manifestUrl = addonManifestUrl(PRIVATE_USER); + + await installAddon(page, manifestUrl); + await page.goto(discoverUrl(manifestUrl, "movie", catalogId)); + await expect( + page + .getByText("This IMDb watchlist is private", { exact: false }) + .first(), + ).toBeVisible(); + }, +); diff --git a/apps/e2e/tests/stremio-install.spec.ts b/apps/e2e/tests/stremio-install.spec.ts new file mode 100644 index 0000000..0151f5e --- /dev/null +++ b/apps/e2e/tests/stremio-install.spec.ts @@ -0,0 +1,65 @@ +import { expect, test } from "@playwright/test"; +import { addonManifestUrl, FRONTEND_URL } from "../env.js"; +import { bootstrapUser } from "../helpers/api.js"; +import { resetDb } from "../helpers/db.js"; +import { + addonsDeepLink, + dismissDesktopAppPrompt, + installAddon, +} from "../helpers/stremio.js"; +import { PUBLIC_USER } from "../helpers/test-data.js"; + +// Install lifecycle inside the real Stremio Web app (anonymous profile — +// a fresh browser context has its own local addon collection). + +test.beforeEach(async () => { + await resetDb(); +}); + +test( + "installs and uninstalls the addon through Stremio Web", + { tag: "@live-smoke" }, + async ({ page }) => { + await bootstrapUser(PUBLIC_USER); + const manifestUrl = addonManifestUrl(PUBLIC_USER); + + await installAddon(page, manifestUrl); + + // Re-opening the deep link on an installed addon offers Uninstall. + await page.goto(addonsDeepLink(manifestUrl)); + await page.reload(); + await dismissDesktopAppPrompt(page); + const uninstall = page.getByText("Uninstall", { exact: true }).last(); + await expect(uninstall).toBeVisible(); + await uninstall.click(); + + // And once uninstalled, the same deep link offers Install again. + await page.goto(addonsDeepLink(manifestUrl)); + await page.reload(); + await dismissDesktopAppPrompt(page); + await expect( + page.getByText("Install", { exact: true }).last(), + ).toBeVisible(); + }, +); + +test( + "configure page links straight into Stremio Web's install dialog", + { tag: "@live-regression" }, + async ({ page, context }) => { + await bootstrapUser(PUBLIC_USER); + await page.goto(`${FRONTEND_URL}/configure?userId=${PUBLIC_USER}`); + + const popupPromise = context.waitForEvent("page"); + await page.getByRole("link", { name: "Open in Stremio Web" }).click(); + const popup = await popupPromise; + await popup.waitForLoadState(); + expect(popup.url()).toBe( + `https://web.stremio.com/#/addons?addon=${encodeURIComponent(addonManifestUrl(PUBLIC_USER))}`, + ); + await dismissDesktopAppPrompt(popup); + await expect( + popup.getByText("Install", { exact: true }).last(), + ).toBeVisible(); + }, +); diff --git a/apps/e2e/tests/stremio-meta.spec.ts b/apps/e2e/tests/stremio-meta.spec.ts new file mode 100644 index 0000000..cff1960 --- /dev/null +++ b/apps/e2e/tests/stremio-meta.spec.ts @@ -0,0 +1,37 @@ +import { expect, test } from "@playwright/test"; +import { addonManifestUrl } from "../env.js"; +import { bootstrapUser, getCatalog } from "../helpers/api.js"; +import { resetDb } from "../helpers/db.js"; +import { discoverUrl, installAddon } from "../helpers/stremio.js"; +import { PUBLIC_USER } from "../helpers/test-data.js"; + +// Clicking through from a Stremlist catalog to a detail page in Stremio Web. + +test.beforeEach(async () => { + await resetDb(); +}); + +test( + "catalog items open their detail page", + { tag: "@live-regression" }, + async ({ page }) => { + const config = await bootstrapUser(PUBLIC_USER); + const watchlist = config.watchlists[0]; + const catalogId = `wl-${watchlist.id}-movie`; + const manifestUrl = addonManifestUrl(PUBLIC_USER); + const { metas } = await getCatalog(PUBLIC_USER, "movie", catalogId); + const first = metas[0]; + + await installAddon(page, manifestUrl); + await page.goto(discoverUrl(manifestUrl, "movie", catalogId)); + + const firstItem = page.locator('a[href^="#/detail/"]').first(); + await expect(firstItem).toBeVisible(); + await firstItem.click(); + + await expect(page).toHaveURL(new RegExp(`#/detail/movie/${first.id}`)); + // The detail page renders the title as a logo image, not text — assert on + // imagery for the exact tt id we clicked (logo/background src embed it). + await expect(page.locator(`img[src*="${first.id}"]`).first()).toBeVisible(); + }, +); diff --git a/apps/e2e/tsconfig.json b/apps/e2e/tsconfig.json new file mode 100644 index 0000000..a0d8688 --- /dev/null +++ b/apps/e2e/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "types": ["node"], + "noEmit": true + }, + "include": ["tests", "fixtures", "helpers", "*.ts"], + "exclude": ["node_modules"] +} diff --git a/package.json b/package.json index 936674c..970b1ab 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "format": "turbo run format", "format:check": "turbo run format:check", "test": "turbo run test", + "test:e2e": "turbo run test:e2e", "typecheck": "turbo run typecheck", "generate:types": "pnpm supabase gen types typescript --project-id igbmxubzxjfdbgmawrtr --schema public > packages/shared/src/database.types.ts", "prepare": "husky" diff --git a/packages/shared/src/stremio.types.ts b/packages/shared/src/stremio.types.ts index 2ddef38..bd68b20 100644 --- a/packages/shared/src/stremio.types.ts +++ b/packages/shared/src/stremio.types.ts @@ -26,7 +26,7 @@ export interface UserConfigUpdateWatchlist { catalogTitle?: string; sortOption: string; displayMode?: DisplayMode; - position: number; + position?: number; } export interface UserConfigUpdatePayload { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a99ea8a..6a29e7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -82,6 +82,45 @@ importers: specifier: ^4.0.18 version: 4.0.18(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + apps/e2e: + devDependencies: + '@playwright/test': + specifier: ^1.62.1 + version: 1.62.1 + '@stremlist/backend': + specifier: workspace:* + version: link:../backend + '@stremlist/eslint-config': + specifier: workspace:* + version: link:../../packages/eslint-config + '@stremlist/frontend': + specifier: workspace:* + version: link:../frontend + '@stremlist/shared': + specifier: workspace:* + version: link:../../packages/shared + '@supabase/supabase-js': + specifier: ^2.95.3 + version: 2.95.3 + '@types/node': + specifier: ^20.11.17 + version: 20.19.33 + eslint: + specifier: ^9.39.2 + version: 9.39.2(jiti@2.6.1) + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@9.39.2(jiti@2.6.1)) + prettier: + specifier: ^3.8.1 + version: 3.8.1 + tsx: + specifier: ^4.7.1 + version: 4.21.0 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + apps/frontend: dependencies: '@dnd-kit/helpers': @@ -782,6 +821,11 @@ packages: '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@playwright/test@1.62.1': + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + engines: {node: '>=20'} + hasBin: true + '@preact/signals-core@1.14.0': resolution: {integrity: sha512-AowtCcCU/33lFlh1zRFf/u+12rfrhtNakj7UpaGEsmMwUKpKWMVvcktOGcwBBNiB4lWrZWc01LhiyyzVklJyaQ==} @@ -2054,6 +2098,11 @@ packages: react-dom: optional: true + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2425,6 +2474,16 @@ packages: engines: {node: '>=0.10'} hasBin: true + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + postal-mime@2.7.3: resolution: {integrity: sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw==} @@ -3315,6 +3374,10 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@playwright/test@1.62.1': + dependencies: + playwright: 1.62.1 + '@preact/signals-core@1.14.0': {} '@radix-ui/number@1.1.1': {} @@ -4484,6 +4547,9 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -4778,6 +4844,14 @@ snapshots: pidtree@0.6.0: {} + playwright-core@1.62.1: {} + + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + postal-mime@2.7.3: {} postcss@8.5.6: diff --git a/supabase/.gitignore b/supabase/.gitignore new file mode 100644 index 0000000..ad9264f --- /dev/null +++ b/supabase/.gitignore @@ -0,0 +1,8 @@ +# Supabase +.branches +.temp + +# dotenvx +.env.keys +.env.local +.env.*.local diff --git a/supabase/config.toml b/supabase/config.toml new file mode 100644 index 0000000..2cdde7b --- /dev/null +++ b/supabase/config.toml @@ -0,0 +1,408 @@ +# For detailed configuration reference documentation, visit: +# https://supabase.com/docs/guides/local-development/cli/config +# A string used to distinguish different Supabase projects on the same host. Defaults to the +# working directory name when running `supabase init`. +project_id = "stremlist" + +[api] +enabled = true +# Port to use for the API URL. +port = 54321 +# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API +# endpoints. `public` and `graphql_public` schemas are included by default. +schemas = ["public", "graphql_public"] +# Extra schemas to add to the search_path of every request. +extra_search_path = ["public", "extensions"] +# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size +# for accidental or malicious requests. +max_rows = 1000 + +[api.tls] +# Enable HTTPS endpoints locally using a self-signed certificate. +enabled = false +# Paths to self-signed certificate pair. +# cert_path = "../certs/my-cert.pem" +# key_path = "../certs/my-key.pem" + +[db] +# Port to use for the local database URL. +port = 54322 +# Port used by db diff command to initialize the shadow database. +shadow_port = 54320 +# Maximum amount of time to wait for health check when starting the local database. +health_timeout = "2m" +# The database major version to use. This has to be the same as your remote database's. Run `SHOW +# server_version;` on the remote database to check. +major_version = 17 + +[db.pooler] +enabled = false +# Port to use for the local connection pooler. +port = 54329 +# Specifies when a server connection can be reused by other clients. +# Configure one of the supported pooler modes: `transaction`, `session`. +pool_mode = "transaction" +# How many server connections to allow per user/database pair. +default_pool_size = 20 +# Maximum number of client connections allowed. +max_client_conn = 100 + +# [db.vault] +# secret_key = "env(SECRET_VALUE)" + +[db.migrations] +# If disabled, migrations will be skipped during a db push or reset. +enabled = true +# Specifies an ordered list of schema files that describe your database. +# Supports glob patterns relative to supabase directory: "./schemas/*.sql" +schema_paths = [] + +[db.seed] +# If enabled, seeds the database after migrations during a db reset. +enabled = true +# Specifies an ordered list of seed files to load during db reset. +# Supports glob patterns relative to supabase directory: "./seeds/*.sql" +sql_paths = ["./seed.sql"] + +[db.network_restrictions] +# Enable management of network restrictions. +enabled = false +# List of IPv4 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv4 connections. Set empty array to block all IPs. +allowed_cidrs = ["0.0.0.0/0"] +# List of IPv6 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv6 connections. Set empty array to block all IPs. +allowed_cidrs_v6 = ["::/0"] + +# Uncomment to reject non-secure connections to the database. +# [db.ssl_enforcement] +# enabled = true + +[realtime] +enabled = true +# Bind realtime via either IPv4 or IPv6. (default: IPv4) +# ip_version = "IPv6" +# The maximum length in bytes of HTTP request headers. (default: 4096) +# max_header_length = 4096 + +[studio] +enabled = true +# Port to use for Supabase Studio. +port = 54323 +# External URL of the API server that frontend connects to. +api_url = "http://127.0.0.1" +# OpenAI API Key to use for Supabase AI in the Supabase Studio. +openai_api_key = "env(OPENAI_API_KEY)" + +# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they +# are monitored, and you can view the emails that would have been sent from the web interface. +[inbucket] +enabled = true +# Port to use for the email testing server web interface. +port = 54324 +# Uncomment to expose additional ports for testing user applications that send emails. +# smtp_port = 54325 +# pop3_port = 54326 +# admin_email = "admin@email.com" +# sender_name = "Admin" + +[storage] +enabled = true +# The maximum file size allowed (e.g. "5MB", "500KB"). +file_size_limit = "50MiB" + +# Uncomment to configure local storage buckets +# [storage.buckets.images] +# public = false +# file_size_limit = "50MiB" +# allowed_mime_types = ["image/png", "image/jpeg"] +# objects_path = "./images" + +# Allow connections via S3 compatible clients +[storage.s3_protocol] +enabled = true + +# Image transformation API is available to Supabase Pro plan. +# [storage.image_transformation] +# enabled = true + +# Store analytical data in S3 for running ETL jobs over Iceberg Catalog +# This feature is only available on the hosted platform. +[storage.analytics] +enabled = false +max_namespaces = 5 +max_tables = 10 +max_catalogs = 2 + +# Analytics Buckets is available to Supabase Pro plan. +# [storage.analytics.buckets.my-warehouse] + +# Store vector embeddings in S3 for large and durable datasets +# This feature is only available on the hosted platform. +[storage.vector] +enabled = false +max_buckets = 10 +max_indexes = 5 + +# Vector Buckets is available to Supabase Pro plan. +# [storage.vector.buckets.documents-openai] + +[auth] +enabled = true +# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used +# in emails. +site_url = "http://127.0.0.1:3000" +# The public URL that Auth serves on. Defaults to the API external URL with `/auth/v1` appended. +# external_url = "" +# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. +additional_redirect_urls = ["https://127.0.0.1:3000"] +# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). +jwt_expiry = 3600 +# JWT issuer URL. If not set, defaults to auth.external_url. +# jwt_issuer = "" +# Path to JWT signing key. DO NOT commit your signing keys file to git. +# signing_keys_path = "./signing_keys.json" +# If disabled, the refresh token will never expire. +enable_refresh_token_rotation = true +# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. +# Requires enable_refresh_token_rotation = true. +refresh_token_reuse_interval = 10 +# Allow/disallow new user signups to your project. +enable_signup = true +# Allow/disallow anonymous sign-ins to your project. +enable_anonymous_sign_ins = false +# Allow/disallow testing manual linking of accounts +enable_manual_linking = false +# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more. +minimum_password_length = 6 +# Passwords that do not meet the following requirements will be rejected as weak. Supported values +# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols` +password_requirements = "" + +# Configure passkey sign-ins. +# [auth.passkey] +# enabled = false + +# Configure WebAuthn relying party settings (required when passkey is enabled). +# [auth.webauthn] +# rp_display_name = "Supabase" +# rp_id = "localhost" +# rp_origins = ["http://127.0.0.1:3000"] + +[auth.rate_limit] +# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled. +email_sent = 2 +# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled. +sms_sent = 30 +# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true. +anonymous_users = 30 +# Number of sessions that can be refreshed in a 5 minute interval per IP address. +token_refresh = 150 +# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users). +sign_in_sign_ups = 30 +# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address. +token_verifications = 30 +# Number of Web3 logins that can be made in a 5 minute interval per IP address. +web3 = 30 + +# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`. +# [auth.captcha] +# enabled = true +# provider = "hcaptcha" +# secret = "" + +[auth.email] +# Allow/disallow new user signups via email to your project. +enable_signup = true +# If enabled, a user will be required to confirm any email change on both the old, and new email +# addresses. If disabled, only the new email is required to confirm. +double_confirm_changes = true +# If enabled, users need to confirm their email address before signing in. +enable_confirmations = false +# If enabled, users will need to reauthenticate or have logged in recently to change their password. +secure_password_change = false +# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. +max_frequency = "1s" +# Number of characters used in the email OTP. +otp_length = 6 +# Number of seconds before the email OTP expires (defaults to 1 hour). +otp_expiry = 3600 + +# Use a production-ready SMTP server +# [auth.email.smtp] +# enabled = true +# host = "smtp.sendgrid.net" +# port = 587 +# user = "apikey" +# pass = "env(SENDGRID_API_KEY)" +# admin_email = "admin@email.com" +# sender_name = "Admin" + +# Uncomment to customize email template +# [auth.email.template.invite] +# subject = "You have been invited" +# content_path = "./supabase/templates/invite.html" + +# Uncomment to customize notification email template +# [auth.email.notification.password_changed] +# enabled = true +# subject = "Your password has been changed" +# content_path = "./templates/password_changed_notification.html" + +[auth.sms] +# Allow/disallow new user signups via SMS to your project. +enable_signup = false +# If enabled, users need to confirm their phone number before signing in. +enable_confirmations = false +# Template for sending OTP to users +template = "Your code is {{ .Code }}" +# Controls the minimum amount of time that must pass before sending another sms otp. +max_frequency = "5s" + +# Use pre-defined map of phone number to OTP for testing. +# [auth.sms.test_otp] +# 4152127777 = "123456" + +# Configure logged in session timeouts. +# [auth.sessions] +# Force log out after the specified duration. +# timebox = "24h" +# Force log out if the user has been inactive longer than the specified duration. +# inactivity_timeout = "8h" + +# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object. +# [auth.hook.before_user_created] +# enabled = true +# uri = "pg-functions://postgres/auth/before-user-created-hook" + +# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. +# [auth.hook.custom_access_token] +# enabled = true +# uri = "pg-functions:////" + +# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. +[auth.sms.twilio] +enabled = false +account_sid = "" +message_service_sid = "" +# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: +auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" + +# Multi-factor-authentication is available to Supabase Pro plan. +[auth.mfa] +# Control how many MFA factors can be enrolled at once per user. +max_enrolled_factors = 10 + +# Control MFA via App Authenticator (TOTP) +[auth.mfa.totp] +enroll_enabled = false +verify_enabled = false + +# Configure MFA via Phone Messaging +[auth.mfa.phone] +enroll_enabled = false +verify_enabled = false +otp_length = 6 +template = "Your code is {{ .Code }}" +max_frequency = "5s" + +# Configure MFA via WebAuthn +# [auth.mfa.web_authn] +# enroll_enabled = true +# verify_enabled = true + +# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, +# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, +# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`. +[auth.external.apple] +enabled = false +client_id = "" +# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: +secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" +# Overrides the default auth callback URL derived from auth.external_url. +redirect_uri = "" +# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, +# or any other third-party OIDC providers. +url = "" +# If enabled, the nonce check will be skipped. Required for local sign in with Google auth. +skip_nonce_check = false +# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address. +email_optional = false + +# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard. +# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting. +[auth.web3.solana] +enabled = false + +# Use Firebase Auth as a third-party provider alongside Supabase Auth. +[auth.third_party.firebase] +enabled = false +# project_id = "my-firebase-project" + +# Use Auth0 as a third-party provider alongside Supabase Auth. +[auth.third_party.auth0] +enabled = false +# tenant = "my-auth0-tenant" +# tenant_region = "us" + +# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth. +[auth.third_party.aws_cognito] +enabled = false +# user_pool_id = "my-user-pool-id" +# user_pool_region = "us-east-1" + +# Use Clerk as a third-party provider alongside Supabase Auth. +[auth.third_party.clerk] +enabled = false +# Obtain from https://clerk.com/setup/supabase +# domain = "example.clerk.accounts.dev" + +# OAuth server configuration +[auth.oauth_server] +# Enable OAuth server functionality +enabled = false +# Path for OAuth consent flow UI +authorization_url_path = "/oauth/consent" +# Allow dynamic client registration +allow_dynamic_registration = false + +[edge_runtime] +enabled = true +# Supported request policies: `oneshot`, `per_worker`. +# `per_worker` (default) — enables hot reload during local development. +# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks). +policy = "per_worker" +# Port to attach the Chrome inspector for debugging edge functions. +inspector_port = 8083 +# The Deno major version to use. +deno_version = 2 + +# [edge_runtime.secrets] +# secret_key = "env(SECRET_VALUE)" + +[analytics] +enabled = true +port = 54327 +# Configure one of the supported backends: `postgres`, `bigquery`. +backend = "postgres" + +# Experimental features may be deprecated any time +[experimental] +# Configures Postgres storage engine to use OrioleDB (S3) +orioledb_version = "" +# Configures S3 bucket URL, eg. .s3-.amazonaws.com +s3_host = "env(S3_HOST)" +# Configures S3 bucket region, eg. us-east-1 +s3_region = "env(S3_REGION)" +# Configures AWS_ACCESS_KEY_ID for S3 bucket +s3_access_key = "env(S3_ACCESS_KEY)" +# Configures AWS_SECRET_ACCESS_KEY for S3 bucket +s3_secret_key = "env(S3_SECRET_KEY)" + +# [experimental.pgdelta] +# When enabled, pg-delta becomes the active engine for supported schema flows. +# enabled = false +# Directory under `supabase/` where declarative files are written. +# declarative_schema_path = "./database" +# JSON string passed through to pg-delta SQL formatting. +# format_options = "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}" diff --git a/supabase/migrations/20260219000000_baseline_schema.sql b/supabase/migrations/20260219000000_baseline_schema.sql new file mode 100644 index 0000000..4011b8f --- /dev/null +++ b/supabase/migrations/20260219000000_baseline_schema.sql @@ -0,0 +1,21 @@ +-- Baseline for environments created from scratch (local dev, CI). +-- The production database predates migration tracking: `users` and the legacy +-- `watchlist_cache` blob table were created by hand, so the oldest committed +-- migration (20260220000000_enable_rls) assumes they already exist. Everything +-- here is IF NOT EXISTS so pushing this migration to production is a no-op. + +CREATE TABLE IF NOT EXISTS public.users ( + imdb_user_id text PRIMARY KEY, + is_active boolean NOT NULL DEFAULT true, + last_fetched_at timestamptz NOT NULL DEFAULT now(), + created_at timestamptz NOT NULL DEFAULT now(), + last_cache_served_at timestamptz +); + +-- Legacy blob cache, unused by the application since watchlist_cache_items. +-- Kept only because later migrations reference it. +CREATE TABLE IF NOT EXISTS public.watchlist_cache ( + imdb_user_id text PRIMARY KEY REFERENCES public.users(imdb_user_id) ON DELETE CASCADE, + cached_data jsonb, + cached_at timestamptz NOT NULL DEFAULT now() +); diff --git a/turbo.json b/turbo.json index d0071d3..03d027b 100644 --- a/turbo.json +++ b/turbo.json @@ -22,6 +22,10 @@ "test": { "dependsOn": ["^build"] }, + "test:e2e": { + "dependsOn": ["^build"], + "cache": false + }, "typecheck": { "dependsOn": ["^build"] } From 648917663dd39307be8032bd328a5673a9b864d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Mathurin?= Date: Wed, 26 Aug 2026 16:23:49 +0200 Subject: [PATCH 4/6] fix(e2e): address Greptile review findings --- .github/workflows/ci.yml | 2 +- apps/e2e/helpers/db.ts | 5 +++-- apps/e2e/helpers/test-data.ts | 4 ++++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f13c3bf..6a483b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,7 +57,7 @@ jobs: node-version: 20 cache: pnpm - - uses: supabase/setup-cli@v1 + - uses: supabase/setup-cli@ab058987d8d6c725971f6cf9d0b5c98467e30bd1 # v1 with: version: 2.98.2 diff --git a/apps/e2e/helpers/db.ts b/apps/e2e/helpers/db.ts index 947ec47..651aea5 100644 --- a/apps/e2e/helpers/db.ts +++ b/apps/e2e/helpers/db.ts @@ -1,6 +1,7 @@ import { createClient } from "@supabase/supabase-js"; import type { Database } from "@stremlist/shared"; import { SUPABASE_SERVICE_ROLE_KEY, SUPABASE_URL } from "../env.js"; +import { E2E_USER_IDS } from "./test-data.js"; // Service-role client: bypasses RLS, used only to reset and inspect state // between tests. All functional seeding goes through the backend's own HTTP @@ -10,12 +11,12 @@ export const db = createClient( SUPABASE_SERVICE_ROLE_KEY, ); -/** Delete test users. Foreign-key cascades reset every dependent table. */ +/** Delete only this run's test users. Foreign-key cascades reset their data. */ export async function resetDb(): Promise { const { error } = await db .from("users") .delete() - .neq("imdb_user_id", "__e2e_no_matching_user__"); + .in("imdb_user_id", [...E2E_USER_IDS]); if (error) throw new Error(`resetDb failed: ${error.message}`); } diff --git a/apps/e2e/helpers/test-data.ts b/apps/e2e/helpers/test-data.ts index 297cf57..2a523f3 100644 --- a/apps/e2e/helpers/test-data.ts +++ b/apps/e2e/helpers/test-data.ts @@ -9,6 +9,10 @@ export const PUBLIC_USER = process.env.E2E_IMDB_USER_ID ?? "ur102135398"; // Second public watchlist for multi-catalog scenarios. export const PUBLIC_USER_2 = process.env.E2E_IMDB_USER_ID_2 ?? "ur102551738"; +// Cleanup must stay scoped to identities explicitly owned by this E2E run. +// The local Supabase stack may share a persisted volume with development. +export const E2E_USER_IDS = [PUBLIC_USER, PUBLIC_USER_2] as const; + // Long-standing public IMDb list ("Top 100 Greatest Movies of All Time"). export const PUBLIC_LIST = process.env.E2E_IMDB_LIST_ID ?? "ls055592025"; From 5064df8b93a98ac408ec2a2312cce2286bb7b892 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Mathurin?= Date: Wed, 26 Aug 2026 16:28:30 +0200 Subject: [PATCH 5/6] fix(e2e): include all persisted test users in cleanup --- apps/e2e/helpers/test-data.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/e2e/helpers/test-data.ts b/apps/e2e/helpers/test-data.ts index 2a523f3..db6e9df 100644 --- a/apps/e2e/helpers/test-data.ts +++ b/apps/e2e/helpers/test-data.ts @@ -9,10 +9,6 @@ export const PUBLIC_USER = process.env.E2E_IMDB_USER_ID ?? "ur102135398"; // Second public watchlist for multi-catalog scenarios. export const PUBLIC_USER_2 = process.env.E2E_IMDB_USER_ID_2 ?? "ur102551738"; -// Cleanup must stay scoped to identities explicitly owned by this E2E run. -// The local Supabase stack may share a persisted volume with development. -export const E2E_USER_IDS = [PUBLIC_USER, PUBLIC_USER_2] as const; - // Long-standing public IMDb list ("Top 100 Greatest Movies of All Time"). export const PUBLIC_LIST = process.env.E2E_IMDB_LIST_ID ?? "ls055592025"; @@ -28,6 +24,15 @@ export const PRIVATE_USER = process.env.E2E_PRIVATE_IMDB_USER_ID ?? "ur198342247"; export const PRIVATE_P_HANDLE = "p.e4ialbfdp3rntdahbslk5yzovm"; +// Cleanup must stay scoped to identities explicitly owned by this E2E run. +// The local Supabase stack may share a persisted volume with development. +export const E2E_USER_IDS = [ + PUBLIC_USER, + PUBLIC_USER_2, + UNKNOWN_USER, + PRIVATE_USER, +] as const; + // No stable private ls list is available; provide one via env to enable the // private-list test. export const PRIVATE_LIST = process.env.E2E_PRIVATE_IMDB_LIST_ID; From 0843152d361c126d6c6252a53bd14faa05ec3148 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Mathurin?= Date: Wed, 26 Aug 2026 16:56:49 +0200 Subject: [PATCH 6/6] test(e2e): run full suite by default --- .github/workflows/ci.yml | 17 +++------------- apps/e2e/README.md | 38 ++++++++++++++++------------------- apps/e2e/env.ts | 2 +- apps/e2e/helpers/db.ts | 5 +---- apps/e2e/helpers/stremio.ts | 4 ---- apps/e2e/package.json | 9 +-------- apps/e2e/playwright.config.ts | 4 +--- apps/e2e/tsconfig.json | 2 +- turbo.json | 1 - 9 files changed, 25 insertions(+), 57 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a483b0..866a45b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,20 +31,9 @@ jobs: run: pnpm turbo run lint build test e2e: - name: E2E (${{ matrix.name }}) - # Local coverage and the small live smoke suite run in separate jobs. Each - # gets its own disposable Supabase stack, and an external outage cannot hide - # failures in the deterministic suite. + name: E2E if: github.event_name == 'pull_request' runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - - suite: local - name: Local - - suite: live - name: Live smoke steps: - uses: actions/checkout@v4 @@ -77,13 +66,13 @@ jobs: run: pnpm --filter @stremlist/e2e exec playwright install --with-deps chromium - name: Run E2E tests - run: pnpm --filter @stremlist/e2e test:e2e:${{ matrix.suite }} + run: pnpm --filter @stremlist/e2e test:e2e - name: Upload Playwright report if: failure() uses: actions/upload-artifact@v4 with: - name: playwright-report-${{ matrix.suite }} + name: playwright-report path: apps/e2e/playwright-report retention-days: 7 diff --git a/apps/e2e/README.md b/apps/e2e/README.md index cd2e539..430e99e 100644 --- a/apps/e2e/README.md +++ b/apps/e2e/README.md @@ -22,9 +22,8 @@ stack**. The configure/onboarding pages of the frontend are covered too. - IMDb is live. Assertions are structural (ordering invariants, id shapes, counts) or compare the Stremio UI against the addon's own catalog JSON from the same run, so they do not depend on what is in the watchlist today. -- The default run contains deterministic local coverage plus four live smoke - tests. Broader live checks stay in the `live-regression` project and run only - when requested. +- The default run and pull request CI execute all three projects: deterministic + local coverage, four live smoke tests, and the broader live regression suite. ## Running locally @@ -32,16 +31,13 @@ stack**. The configure/onboarding pages of the frontend are covered too. # One-time / per boot: start the local Supabase stack (needs Docker running) supabase start -x gotrue,realtime,storage-api,imgproxy,studio,edge-runtime,logflare,vector,supavisor,mailpit,postgres-meta -# From the repo root: local coverage plus the small live smoke suite +# From the repo root: run every E2E project pnpm test:e2e -# Or from this package, with more options -pnpm --filter @stremlist/e2e test:e2e:local # deterministic local suite -pnpm --filter @stremlist/e2e test:e2e:live # four live smoke tests -pnpm --filter @stremlist/e2e test:e2e:live:full # all live external checks -pnpm --filter @stremlist/e2e test:e2e:all # every project -pnpm --filter @stremlist/e2e test:e2e:headed # smoke tests with a browser -pnpm --filter @stremlist/e2e report # open the last HTML report +# Select one project while debugging +pnpm --filter @stremlist/e2e test:e2e --project=local +pnpm --filter @stremlist/e2e test:e2e --project=live-smoke +pnpm --filter @stremlist/e2e test:e2e --project=live-regression ``` The suite deletes test users between cases. Foreign-key cascades clear their @@ -51,18 +47,18 @@ confirmation described below. ## Environment knobs -| Variable | Purpose | -| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -| `E2E_SUPABASE_URL` / `E2E_SUPABASE_SERVICE_ROLE_KEY` | Non-default local Supabase stack | -| `E2E_ALLOW_REMOTE_DATABASE=I_UNDERSTAND_THIS_WIPES_DATA` | Permit an isolated remote test project. Cleanup deletes every user and all dependent data | -| `E2E_IMDB_USER_ID` / `E2E_IMDB_USER_ID_2` | Override the public watchlists under test | -| `E2E_IMDB_LIST_ID` | Override the public `ls` list under test | -| `E2E_PRIVATE_IMDB_USER_ID` | Override the private watchlist under test (default: a maintainer-owned account kept private on purpose) | -| `E2E_PRIVATE_IMDB_LIST_ID` | Enable the private `ls` list test (skipped otherwise — no stable private list id is available) | +| Variable | Purpose | +| -------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| `E2E_SUPABASE_URL` / `E2E_SUPABASE_SERVICE_ROLE_KEY` | Non-default local Supabase stack | +| `E2E_ALLOW_REMOTE_DATABASE=I_UNDERSTAND_THIS_WIPES_DATA` | Permit an isolated remote test project. Cleanup deletes every user and all dependent data | +| `E2E_IMDB_USER_ID` / `E2E_IMDB_USER_ID_2` | Override the public watchlists under test | +| `E2E_IMDB_LIST_ID` | Override the public `ls` list under test | +| `E2E_PRIVATE_IMDB_USER_ID` | Override the private watchlist under test | +| `E2E_PRIVATE_IMDB_LIST_ID` | Enable the private `ls` list test | ## Known limitations - Drag-and-drop catalog reordering (pointer-based dnd-kit) is not covered. - The newsletter endpoint is not covered (it would email real people). -- The live smoke and full regression suites depend on web.stremio.com and IMDb. - CI runs only the four smoke checks and retries them twice on failure. +- The live smoke and regression suites depend on web.stremio.com and IMDb. CI + retries failures twice. diff --git a/apps/e2e/env.ts b/apps/e2e/env.ts index fd44750..fcbec1d 100644 --- a/apps/e2e/env.ts +++ b/apps/e2e/env.ts @@ -20,7 +20,7 @@ export const SUPABASE_SERVICE_ROLE_KEY = const REMOTE_DATABASE_CONFIRMATION = "I_UNDERSTAND_THIS_WIPES_DATA"; -export function assertSafeSupabaseTarget(): void { +function assertSafeSupabaseTarget(): void { let hostname: string; try { hostname = new URL(SUPABASE_URL).hostname.toLowerCase(); diff --git a/apps/e2e/helpers/db.ts b/apps/e2e/helpers/db.ts index 651aea5..54e6214 100644 --- a/apps/e2e/helpers/db.ts +++ b/apps/e2e/helpers/db.ts @@ -6,10 +6,7 @@ import { E2E_USER_IDS } from "./test-data.js"; // Service-role client: bypasses RLS, used only to reset and inspect state // between tests. All functional seeding goes through the backend's own HTTP // API so the tests exercise real code paths. -export const db = createClient( - SUPABASE_URL, - SUPABASE_SERVICE_ROLE_KEY, -); +const db = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY); /** Delete only this run's test users. Foreign-key cascades reset their data. */ export async function resetDb(): Promise { diff --git a/apps/e2e/helpers/stremio.ts b/apps/e2e/helpers/stremio.ts index 50d0231..dd100aa 100644 --- a/apps/e2e/helpers/stremio.ts +++ b/apps/e2e/helpers/stremio.ts @@ -18,10 +18,6 @@ export function discoverUrl( return `${STREMIO_WEB_URL}/#/discover/${encodeURIComponent(manifestUrl)}/${type}/${encodeURIComponent(catalogId)}`; } -export function detailUrl(type: "movie" | "series", metaId: string): string { - return `${STREMIO_WEB_URL}/#/detail/${type}/${metaId}/${metaId}`; -} - /** * Dismiss the "install the desktop app" prompt if it is showing. Its "Install" * link would otherwise collide with the addon modal's Install button. diff --git a/apps/e2e/package.json b/apps/e2e/package.json index f80452a..6ca7054 100644 --- a/apps/e2e/package.json +++ b/apps/e2e/package.json @@ -3,14 +3,7 @@ "private": true, "type": "module", "scripts": { - "preflight": "tsx preflight.ts", - "test:e2e": "pnpm preflight && playwright test --project=local --project=live-smoke", - "test:e2e:local": "pnpm preflight && playwright test --project=local", - "test:e2e:live": "pnpm preflight && playwright test --project=live-smoke", - "test:e2e:live:full": "pnpm preflight && playwright test --project=live-smoke --project=live-regression", - "test:e2e:all": "pnpm preflight && playwright test", - "test:e2e:headed": "pnpm preflight && playwright test --project=live-smoke --headed", - "report": "playwright show-report", + "test:e2e": "tsx preflight.ts && playwright test", "typecheck": "tsc --noEmit", "lint": "eslint .", "format": "prettier . --write", diff --git a/apps/e2e/playwright.config.ts b/apps/e2e/playwright.config.ts index 04fe807..36bccb6 100644 --- a/apps/e2e/playwright.config.ts +++ b/apps/e2e/playwright.config.ts @@ -11,8 +11,7 @@ import { export default defineConfig({ testDir: "./tests", - // Tests within a project share one backend and one database. CI runs local - // and live-smoke projects as separate jobs with isolated Supabase stacks. + // Every project shares one backend and database, so tests run serially. fullyParallel: false, workers: 1, forbidOnly: !!process.env.CI, @@ -69,7 +68,6 @@ export default defineConfig({ SUPABASE_SERVICE_ROLE_KEY, FRONTEND_URL, REFRESH_COOLDOWN_SECONDS: String(REFRESH_COOLDOWN_SECONDS), - CACHE_TTL_MINUTES: "30", // The Resend SDK throws at import time without a key. Newsletter // delivery is deliberately out of E2E scope (it would email real // people), so a dummy key is enough to boot the app. diff --git a/apps/e2e/tsconfig.json b/apps/e2e/tsconfig.json index a0d8688..32ca233 100644 --- a/apps/e2e/tsconfig.json +++ b/apps/e2e/tsconfig.json @@ -9,6 +9,6 @@ "types": ["node"], "noEmit": true }, - "include": ["tests", "fixtures", "helpers", "*.ts"], + "include": ["tests", "helpers", "*.ts"], "exclude": ["node_modules"] } diff --git a/turbo.json b/turbo.json index 03d027b..f1c48f2 100644 --- a/turbo.json +++ b/turbo.json @@ -23,7 +23,6 @@ "dependsOn": ["^build"] }, "test:e2e": { - "dependsOn": ["^build"], "cache": false }, "typecheck": {