mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
* fix(import): consistent add-source forms with masked passwords and URL errors - Mask the Xtream password in add and edit (and the Stalker one in edit) behind a shared PasswordVisibilityToggleDirective: one translated "Show password" label, state in aria-pressed, type="button". - Give the Xtream server URL its own mat-error and a neutral hint instead of the EPG file error; give the M3U URL a mat-error. - Use "Playlist title" in every add form, "MAC address" casing, a single ellipsis in "Validating portal…" and one "Add playlist" submit label; translate the method radiogroup's aria-label. - Show Stalker refusals inline under the portal URL (role="status", like the Xtream connection test), translated in the template and cleared by edits; translate the snackbars for outcomes that close the dialog. - Translate new strings into all locales; reuse the identical Stalker URL error translations; fix MAC casing and ellipses; drop unused keys. - Unit specs per form, edit-dialog spec, new add-source-forms web E2E; update E2E locators; UI guidelines Forms section; Stalker contract. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(import): mask the password again when an add form is cleared Clear erased the password but left the visibility toggle on, so the next password typed in the Xtream or Stalker form showed in plain text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
348 lines
11 KiB
TypeScript
348 lines
11 KiB
TypeScript
import type { APIRequestContext, Locator, Page } from '@playwright/test';
|
|
import { expect } from './fixtures';
|
|
import { postWithRetry, setInputValue } from './e2e-helpers';
|
|
import {
|
|
getRegisteredProviderUrl,
|
|
interceptProviderTargetRegistration,
|
|
} from './provider-target-route';
|
|
|
|
const BACKEND_ORIGIN = 'http://localhost:3000';
|
|
const XTREAM_MOCK_PORT = process.env['XTREAM_MOCK_PORT'] ?? '3211';
|
|
const STALKER_MOCK_PORT = process.env['MOCK_PORT'] ?? '3210';
|
|
|
|
export const XTREAM_MOCK_SERVER = `http://localhost:${XTREAM_MOCK_PORT}`;
|
|
export const STALKER_MOCK_SERVER = `http://localhost:${STALKER_MOCK_PORT}`;
|
|
export const STALKER_PORTAL_URL = `${STALKER_MOCK_SERVER}/portal.php`;
|
|
export const EDITED_MAC = '00:1A:79:5F:00:03';
|
|
|
|
// Dedicated MACs (see EDITED_MAC): never share a MAC with stalker.e2e.ts,
|
|
// whose parallel worker would otherwise have its state reset mid-test.
|
|
const DEFAULT_MAC = '00:1A:79:5F:00:02';
|
|
const M3U_PLAYLIST_URL = `${XTREAM_MOCK_SERVER}/playlist.m3u`;
|
|
|
|
type RuntimeErrors = {
|
|
consoleErrors: string[];
|
|
pageErrors: string[];
|
|
};
|
|
|
|
type SourceDialogField =
|
|
| 'macAddress'
|
|
| 'password'
|
|
| 'portalUrl'
|
|
| 'serverUrl'
|
|
| 'title'
|
|
| 'username';
|
|
|
|
/**
|
|
* Reset the fixtures this suite uses. The Stalker reset is scoped to the MACs
|
|
* touched here: that mock is shared with stalker.e2e.ts, which runs in a
|
|
* parallel Playwright worker, and a global reset would destroy its portal
|
|
* sessions mid-test.
|
|
*/
|
|
export async function resetPwaMockServers(
|
|
request: APIRequestContext
|
|
): Promise<void> {
|
|
await postWithRetry(request, `${XTREAM_MOCK_SERVER}/reset`);
|
|
for (const mac of [DEFAULT_MAC, EDITED_MAC]) {
|
|
await postWithRetry(
|
|
request,
|
|
`${STALKER_MOCK_SERVER}/reset?macAddress=${encodeURIComponent(mac)}`
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function interceptPwaProviderRequests(
|
|
page: Page
|
|
): Promise<void> {
|
|
const providerTargets = await interceptProviderTargetRegistration(page);
|
|
|
|
await page.route(`${BACKEND_ORIGIN}/parse**`, async (route) => {
|
|
const originalUrl = new URL(route.request().url());
|
|
const providerUrl = getRegisteredProviderUrl(
|
|
originalUrl,
|
|
providerTargets
|
|
);
|
|
|
|
await route.fulfill({
|
|
body: JSON.stringify(createParsedM3uPlaylist(providerUrl)),
|
|
contentType: 'application/json',
|
|
status: 200,
|
|
});
|
|
});
|
|
|
|
await page.route(`${BACKEND_ORIGIN}/xtream**`, async (route) => {
|
|
const originalUrl = new URL(route.request().url());
|
|
const mockUrl = new URL(`${XTREAM_MOCK_SERVER}/xtream`);
|
|
const providerUrl = getRegisteredProviderUrl(
|
|
originalUrl,
|
|
providerTargets
|
|
);
|
|
|
|
if (providerUrl) {
|
|
mockUrl.searchParams.set('url', providerUrl);
|
|
}
|
|
|
|
originalUrl.searchParams.forEach((value, key) => {
|
|
if (key !== 'targetId') {
|
|
mockUrl.searchParams.set(key, value);
|
|
}
|
|
});
|
|
|
|
await route.continue({ url: mockUrl.toString() });
|
|
});
|
|
|
|
await page.route(`${BACKEND_ORIGIN}/stalker**`, async (route) => {
|
|
const originalUrl = new URL(route.request().url());
|
|
const mockUrl = new URL(`${STALKER_MOCK_SERVER}/stalker`);
|
|
const providerUrl = getRegisteredProviderUrl(
|
|
originalUrl,
|
|
providerTargets
|
|
);
|
|
|
|
if (providerUrl) {
|
|
mockUrl.searchParams.set('url', providerUrl);
|
|
}
|
|
|
|
originalUrl.searchParams.forEach((value, key) => {
|
|
if (key !== 'targetId') {
|
|
mockUrl.searchParams.set(key, value);
|
|
}
|
|
});
|
|
|
|
await route.continue({ url: mockUrl.toString() });
|
|
});
|
|
}
|
|
|
|
export async function addXtreamPortal(
|
|
page: Page,
|
|
title: string
|
|
): Promise<void> {
|
|
await openAddPlaylistDialog(page);
|
|
const dialog = page.locator('mat-dialog-container');
|
|
await dialog.getByRole('radio', { name: /Xtream credentials/i }).click();
|
|
await setInputValue(dialog.locator('#title'), title);
|
|
await setInputValue(dialog.locator('#serverUrl'), XTREAM_MOCK_SERVER);
|
|
await setInputValue(dialog.locator('#username'), 'user1');
|
|
await setInputValue(dialog.locator('#password'), 'pass1');
|
|
|
|
const addButton = dialog.getByRole('button', {
|
|
name: 'Add playlist',
|
|
exact: true,
|
|
});
|
|
await expect(addButton).toBeEnabled({ timeout: 10_000 });
|
|
await addButton.click();
|
|
await expect(dialog).toBeHidden();
|
|
await page.waitForURL(/xtreams.*vod/);
|
|
}
|
|
|
|
export async function addM3uUrlPlaylist(
|
|
page: Page,
|
|
title: string
|
|
): Promise<void> {
|
|
await openAddPlaylistDialog(page);
|
|
const dialog = page.locator('mat-dialog-container');
|
|
await setInputValue(
|
|
dialog.getByRole('textbox', { name: /Playlist URL/ }),
|
|
M3U_PLAYLIST_URL
|
|
);
|
|
await setInputValue(
|
|
dialog.getByRole('textbox', { name: 'Playlist title' }),
|
|
title
|
|
);
|
|
|
|
const addButton = dialog.getByRole('button', {
|
|
name: 'Add playlist',
|
|
exact: true,
|
|
});
|
|
await expect(addButton).toBeEnabled({ timeout: 10_000 });
|
|
await addButton.click();
|
|
await expect(dialog).toBeHidden();
|
|
await page.waitForURL(/playlists.*all/);
|
|
}
|
|
|
|
export async function addStalkerPortal(
|
|
page: Page,
|
|
title: string
|
|
): Promise<void> {
|
|
await openAddPlaylistDialog(page);
|
|
const dialog = page.locator('mat-dialog-container');
|
|
await dialog.getByRole('radio', { name: /Stalker portal/i }).click();
|
|
await setInputValue(dialog.locator('input#title'), title);
|
|
await setInputValue(dialog.locator('input#portalUrl'), STALKER_PORTAL_URL);
|
|
await setInputValue(dialog.locator('input#macAddress'), DEFAULT_MAC);
|
|
|
|
const addButton = dialog.getByRole('button', {
|
|
name: 'Add playlist',
|
|
exact: true,
|
|
});
|
|
await expect(addButton).toBeEnabled({ timeout: 10_000 });
|
|
await addButton.click();
|
|
await expect(dialog).toBeHidden();
|
|
await page.waitForURL(/stalker.*vod/);
|
|
}
|
|
|
|
export async function openSources(page: Page): Promise<void> {
|
|
await page.goto('/workspace/sources');
|
|
await page.waitForURL(/\/workspace\/sources(?:\?.*)?$/);
|
|
await expect(page.locator('app-workspace-sources')).toBeVisible({
|
|
timeout: 15_000,
|
|
});
|
|
}
|
|
|
|
export function sourceRowByTitle(page: Page, title: string): Locator {
|
|
return page.locator('app-playlist-item').filter({ hasText: title }).first();
|
|
}
|
|
|
|
export async function openSourceEditor(
|
|
page: Page,
|
|
title: string
|
|
): Promise<Locator> {
|
|
const row = sourceRowByTitle(page, title);
|
|
await expect(row).toBeVisible({ timeout: 15_000 });
|
|
await row.hover();
|
|
await row.locator('.edit-btn').click();
|
|
const dialog = page.locator('mat-dialog-container').last();
|
|
await expect(dialog).toBeVisible();
|
|
return dialog;
|
|
}
|
|
|
|
export async function updateSourceDialog(
|
|
dialog: Locator,
|
|
updates: Partial<Record<SourceDialogField, string>>
|
|
): Promise<void> {
|
|
for (const [field, value] of Object.entries(updates)) {
|
|
if (value == null) {
|
|
continue;
|
|
}
|
|
|
|
await setInputValue(
|
|
dialog.locator(`input[formcontrolname="${field}"]`),
|
|
value
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function saveSourceDialog(dialog: Locator): Promise<void> {
|
|
const saveButton = dialog
|
|
.locator('mat-dialog-actions')
|
|
.getByRole('button', { name: 'Save', exact: true });
|
|
|
|
await expect(saveButton).toBeEnabled();
|
|
await saveButton.click();
|
|
await expect(dialog).toBeHidden();
|
|
}
|
|
|
|
export async function closeSourceDialog(dialog: Locator): Promise<void> {
|
|
await dialog.getByRole('button', { name: 'Close', exact: true }).click();
|
|
await expect(dialog).toBeHidden();
|
|
}
|
|
|
|
export async function expectSourceDialogValues(
|
|
dialog: Locator,
|
|
expected: Partial<Record<SourceDialogField, string>>
|
|
): Promise<void> {
|
|
for (const [field, value] of Object.entries(expected)) {
|
|
if (value == null) {
|
|
continue;
|
|
}
|
|
|
|
await expect(
|
|
dialog.locator(`input[formcontrolname="${field}"]`)
|
|
).toHaveValue(value);
|
|
}
|
|
}
|
|
|
|
export async function expectElectronBridgeUnavailable(
|
|
page: Page
|
|
): Promise<void> {
|
|
await expect.poll(() => page.evaluate(() => window.electron)).toBeUndefined();
|
|
}
|
|
|
|
export function collectRuntimeErrors(page: Page): RuntimeErrors {
|
|
const errors: RuntimeErrors = {
|
|
consoleErrors: [],
|
|
pageErrors: [],
|
|
};
|
|
|
|
page.on('console', (message) => {
|
|
if (message.type() === 'error') {
|
|
errors.consoleErrors.push(message.text());
|
|
}
|
|
});
|
|
page.on('pageerror', (error) => {
|
|
errors.pageErrors.push(error.message);
|
|
});
|
|
|
|
return errors;
|
|
}
|
|
|
|
export function expectNoElectronDbRuntimeErrors(
|
|
errors: RuntimeErrors
|
|
): void {
|
|
const electronDbErrorPattern =
|
|
/window\.electron|Cannot read properties of undefined.*db|db(UpdatePlaylist|SetAppState|GetContentByXtreamId|GetAllCategories|UpdateCategoryVisibility|GlobalSearch)|DB_(UPDATE_PLAYLIST|SET_APP_STATE|GET_CONTENT_BY_XTREAM_ID|GET_ALL_CATEGORIES|UPDATE_CATEGORY_VISIBILITY|GLOBAL_SEARCH)|updateXtreamPlaylistDetails|globalSearchContent/i;
|
|
const matchingErrors = [...errors.consoleErrors, ...errors.pageErrors].filter(
|
|
(message) => electronDbErrorPattern.test(message)
|
|
);
|
|
|
|
expect(matchingErrors).toEqual([]);
|
|
}
|
|
|
|
async function openAddPlaylistDialog(page: Page): Promise<void> {
|
|
await page.getByRole('button', { name: 'Add playlist' }).click();
|
|
await expect(page.locator('mat-dialog-container')).toBeVisible();
|
|
}
|
|
|
|
function createParsedM3uPlaylist(providerUrl: string | null) {
|
|
return {
|
|
_id: 'pwa-url-source-id',
|
|
autoRefresh: false,
|
|
count: 2,
|
|
filename: 'pwa-url-source.m3u',
|
|
importDate: new Date('2026-01-01T00:00:00.000Z').toISOString(),
|
|
items: [
|
|
{
|
|
group: { title: 'News' },
|
|
http: {
|
|
origin: '',
|
|
referrer: '',
|
|
'user-agent': '',
|
|
},
|
|
id: 'pwa-url-channel-1',
|
|
name: 'PWA URL Channel One',
|
|
radio: 'false',
|
|
tvg: {
|
|
id: 'pwa-url-1',
|
|
logo: '',
|
|
name: 'PWA URL Channel One',
|
|
rec: '',
|
|
url: '',
|
|
},
|
|
url: 'https://streams.example.test/pwa-url-one.m3u8',
|
|
},
|
|
{
|
|
group: { title: 'News' },
|
|
http: {
|
|
origin: '',
|
|
referrer: '',
|
|
'user-agent': '',
|
|
},
|
|
id: 'pwa-url-channel-2',
|
|
name: 'PWA URL Channel Two',
|
|
radio: 'false',
|
|
tvg: {
|
|
id: 'pwa-url-2',
|
|
logo: '',
|
|
name: 'PWA URL Channel Two',
|
|
rec: '',
|
|
url: '',
|
|
},
|
|
url: 'https://streams.example.test/pwa-url-two.m3u8',
|
|
},
|
|
],
|
|
lastUsage: '',
|
|
title: 'pwa-url-source.m3u',
|
|
url: providerUrl ?? M3U_PLAYLIST_URL,
|
|
};
|
|
}
|