mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
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:
1 parent
77df7922c1
commit
ced58e3ac0
3 files changed
+213
-3
No files matched your search
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user