test(e2e): server storage across browsers and the migration dialog

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-12 00:16:55 +02:00
1 parent 77df7922c1
commit ced58e3ac0
3 files changed
+213 -3

No files matched your search

@@ -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;
+26
View File
@@ -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: [
{
+168
View File
@@ -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<Page> {
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<void> {
await page.route('**/__indexeddb-seed__', (route) =>
route.fulfill({ contentType: 'text/html', body: '<!doctype html>' })
);
await page.goto('/__indexeddb-seed__');
await page.evaluate(
([playlistId, playlistTitle]) =>
new Promise<void>((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();
});
});