diff --git a/apps/web-backend/src/app/project-config.spec.ts b/apps/web-backend/src/app/project-config.spec.ts index 473153b61..81b26b070 100644 --- a/apps/web-backend/src/app/project-config.spec.ts +++ b/apps/web-backend/src/app/project-config.spec.ts @@ -41,9 +41,25 @@ describe('Playwright web-backend launch', () => { expect(source).not.toMatch(/\bnx\b[^'"`\n]*web-backend/); expect(start).toBeGreaterThan(-1); - expect( - source.indexOf(launchCommand, start + launchCommand.length) - ).toBe(-1); + // One stateless backend, plus at most one storage-enabled one. + const second = source.indexOf( + `'${launchCommand}'`, + start + launchCommand.length + ); + if (second !== -1) { + const storageEntry = source.slice( + second, + source.indexOf('url:', second) + ); + expect(storageEntry).toContain(`IPTVNATOR_STORAGE: 'server'`); + expect(storageEntry).toContain('PORT: storageBackendPort,'); + expect( + source.indexOf( + `'${launchCommand}'`, + second + launchCommand.length + ) + ).toBe(-1); + } // WEB_BACKEND_PORT relocates the run; its default is the target's. const { PORT: port, ...fixedEnv } = serveEnv; diff --git a/apps/web-e2e/playwright.config.ts b/apps/web-e2e/playwright.config.ts index 8f64d1d4d..8578f875d 100644 --- a/apps/web-e2e/playwright.config.ts +++ b/apps/web-e2e/playwright.config.ts @@ -1,3 +1,5 @@ +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { defineConfig, devices } from '@playwright/test'; import { nxE2EPreset } from '@nx/playwright/preset'; import { workspaceRoot } from '@nx/devkit'; @@ -16,6 +18,12 @@ const webServerCommand = : 'pnpm nx run web:serve'; const reuseExistingWebServer = isStaticPwaE2E ? false : !process.env['CI']; const webBackendPort = process.env['WEB_BACKEND_PORT'] ?? '3333'; +const storageBackendPort = process.env['STORAGE_BACKEND_PORT'] ?? '3334'; +// Path only; the backend creates it. A new pid gives every run an empty store. +const storageBackendDataDir = join( + tmpdir(), + `iptvnator-web-e2e-storage-${process.pid}` +); /** * Read environment variables from file. @@ -106,6 +114,24 @@ export default defineConfig({ reuseExistingServer: !process.env['CI'], cwd: workspaceRoot, }, + /* A second backend with server storage, used only by + * server-storage.e2e.ts; the specs above keep the stateless one. + * STORAGE_BACKEND_PORT relocates it like WEB_BACKEND_PORT. */ + { + command: 'node --import tsx apps/web-backend/src/main.ts', + env: { + PORT: storageBackendPort, + CLIENT_URL: `http://localhost:4200,http://localhost:${staticPwaPort}`, + BACKEND_URL: '/api', + IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS: '1', + TSX_TSCONFIG_PATH: 'tsconfig.base.json', + IPTVNATOR_STORAGE: 'server', + IPTVNATOR_DATA_DIR: storageBackendDataDir, + }, + url: `http://localhost:${storageBackendPort}/health`, + reuseExistingServer: !process.env['CI'], + cwd: workspaceRoot, + }, ], projects: [ { diff --git a/apps/web-e2e/src/server-storage.e2e.ts b/apps/web-e2e/src/server-storage.e2e.ts new file mode 100644 index 000000000..d502d7977 --- /dev/null +++ b/apps/web-e2e/src/server-storage.e2e.ts @@ -0,0 +1,168 @@ +import type { Browser, Page } from '@playwright/test'; +import { expect, test } from './fixtures'; +import { + addM3uUrlPlaylist, + openSources, + sourceRowByTitle, +} from './sources-pwa.helpers'; + +/** The storage-enabled backend from playwright.config.ts. */ +const STORAGE_BACKEND_URL = `http://localhost:${ + process.env['STORAGE_BACKEND_PORT'] ?? '3334' +}`; + +/** A fresh browser profile (a "device") whose runtime config points at it. */ +async function newServerPage(browser: Browser): Promise { + const context = await browser.newContext(); + const page = await context.newPage(); + await page.route('**/assets/app-config.js', (route) => + route.fulfill({ + contentType: 'application/javascript', + body: `window.__IPTVNATOR_CONFIG__ = { BACKEND_URL: ${JSON.stringify(STORAGE_BACKEND_URL)} };\n`, + }) + ); + return page; +} + +/** A legacy browser playlist, written before the app boots. */ +async function seedBrowserPlaylist( + page: Page, + id: string, + title: string +): Promise { + await page.route('**/__indexeddb-seed__', (route) => + route.fulfill({ contentType: 'text/html', body: '' }) + ); + await page.goto('/__indexeddb-seed__'); + await page.evaluate( + ([playlistId, playlistTitle]) => + new Promise((resolve, reject) => { + const request = indexedDB.open('iptvnator', 1); + request.onerror = () => reject(request.error); + request.onupgradeneeded = () => { + const store = request.result.createObjectStore( + 'playlists', + { keyPath: '_id', autoIncrement: false } + ); + for (const name of [ + '_id', + 'filename', + 'title', + 'count', + 'playlist', + 'importDate', + 'lastUsage', + 'favorites', + 'recentlyViewed', + 'autoRefresh', + 'url', + 'filePath', + ]) { + store.createIndex(name, name, { unique: false }); + } + }; + request.onsuccess = () => { + const db = request.result; + const transaction = db.transaction( + 'playlists', + 'readwrite' + ); + transaction.oncomplete = () => { + db.close(); + resolve(); + }; + transaction.onabort = () => { + db.close(); + reject(transaction.error); + }; + transaction.objectStore('playlists').add({ + _id: playlistId, + title: playlistTitle, + importDate: '2026-01-01T00:00:00.000Z', + lastUsage: '2026-01-01T00:00:00.000Z', + count: 1, + autoRefresh: false, + favorites: [], + recentlyViewed: [], + playlist: { + header: { attrs: {}, raw: '#EXTM3U' }, + items: [ + { + id: 'c1', + name: 'Channel', + url: 'https://example.invalid/c1.m3u8', + }, + ], + }, + }); + }; + }), + [id, title] as const + ); +} + +test.describe('server storage @self-hosted @persistence', () => { + // Both tests share one server database. + test.describe.configure({ mode: 'serial' }); + + test.beforeEach(async ({ request }) => { + const response = await request.delete( + `${STORAGE_BACKEND_URL}/v1/playlists` + ); + expect(response.ok()).toBe(true); + }); + + test('a playlist added on one device appears on another', async ({ + browser, + }) => { + const first = await newServerPage(browser); + await openSources(first); + await addM3uUrlPlaylist(first, 'Shared M3U'); + + const second = await newServerPage(browser); + await openSources(second); + await expect(sourceRowByTitle(second, 'Shared M3U')).toBeVisible(); + + await first.context().close(); + await second.context().close(); + }); + + test('moves browser playlists to an empty server', async ({ + browser, + request, + }) => { + const page = await newServerPage(browser); + await seedBrowserPlaylist(page, 'browser-only', 'Browser only'); + + await page.goto('/workspace/sources'); + const dialog = page.locator('mat-dialog-container'); + await expect(dialog).toContainText('Browser only'); + await dialog.locator('[data-test-id="server-migration-move"]').click(); + await expect( + dialog.locator('[data-test-id="server-migration-reload"]') + ).toBeVisible(); + + const response = await request.get( + `${STORAGE_BACKEND_URL}/v1/playlists?view=meta` + ); + const metas = (await response.json()) as { + _id: string; + title: string; + }[]; + expect(metas).toEqual([ + expect.objectContaining({ + _id: 'browser-only', + title: 'Browser only', + }), + ]); + + // After the reload the library comes from the server. + await dialog + .locator('[data-test-id="server-migration-reload"]') + .click(); + await expect(sourceRowByTitle(page, 'Browser only')).toBeVisible(); + await expect(page.locator('mat-dialog-container')).toHaveCount(0); + + await page.context().close(); + }); +});