mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46:16 -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>
This commit is contained in:
1 parent
23a1860119
commit
0ea9ecdb27
56 files changed
+1208
-252
No files matched your search
@@ -0,0 +1,117 @@
|
||||
import type { Locator, Page } from '@playwright/test';
|
||||
import { setInputValue } from './e2e-helpers';
|
||||
import { expect, test } from './fixtures';
|
||||
import {
|
||||
XTREAM_MOCK_SERVER,
|
||||
interceptPwaProviderRequests,
|
||||
resetPwaMockServers,
|
||||
} from './sources-pwa.helpers';
|
||||
|
||||
/**
|
||||
* The three add-source forms share one vocabulary: the same name label, a
|
||||
* masked password with a show/hide toggle, a URL error of their own and the
|
||||
* single "Add playlist" submit. Credentials are the Xtream mock's fixtures.
|
||||
*/
|
||||
test.beforeEach(async ({ page, request }) => {
|
||||
await resetPwaMockServers(request);
|
||||
await interceptPwaProviderRequests(page);
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
async function openMethod(page: Page, method: RegExp): Promise<Locator> {
|
||||
await page.getByRole('button', { name: 'Add playlist' }).click();
|
||||
const dialog = page.locator('mat-dialog-container');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('radio', { name: method }).click();
|
||||
return dialog;
|
||||
}
|
||||
|
||||
async function expectPasswordToggle(dialog: Locator): Promise<void> {
|
||||
const password = dialog.locator('#password');
|
||||
const toggle = dialog.getByRole('button', { name: 'Show password' });
|
||||
|
||||
await expect(password).toHaveAttribute('type', 'password');
|
||||
await expect(toggle).toHaveAttribute('aria-pressed', 'false');
|
||||
await toggle.click();
|
||||
await expect(password).toHaveAttribute('type', 'text');
|
||||
await expect(toggle).toHaveAttribute('aria-pressed', 'true');
|
||||
await toggle.click();
|
||||
await expect(password).toHaveAttribute('type', 'password');
|
||||
await expect(toggle).toHaveAttribute('aria-pressed', 'false');
|
||||
}
|
||||
|
||||
test('@sources @pwa adds an Xtream source through the dialog', async ({
|
||||
page,
|
||||
}) => {
|
||||
const dialog = await openMethod(page, /Xtream credentials/i);
|
||||
const serverUrl = dialog.locator('#serverUrl');
|
||||
|
||||
await expect(
|
||||
dialog.getByText('Server address, e.g. http://host:port')
|
||||
).toBeVisible();
|
||||
await setInputValue(serverUrl, 'panel.example');
|
||||
await serverUrl.blur();
|
||||
await expect(dialog.locator('mat-error')).toHaveText(
|
||||
'Enter a valid http:// or https:// URL.'
|
||||
);
|
||||
// The field used to borrow the EPG source error.
|
||||
await expect(dialog.getByText(/absolute file path/i)).toHaveCount(0);
|
||||
|
||||
await setInputValue(
|
||||
dialog.getByRole('textbox', { name: 'Playlist title' }),
|
||||
'Dialog Xtream Source'
|
||||
);
|
||||
await setInputValue(serverUrl, XTREAM_MOCK_SERVER);
|
||||
await expect(dialog.locator('mat-error')).toHaveCount(0);
|
||||
await setInputValue(dialog.locator('#username'), 'user1');
|
||||
await setInputValue(dialog.locator('#password'), 'pass1');
|
||||
await expectPasswordToggle(dialog);
|
||||
await expect(dialog.locator('#password')).toHaveValue('pass1');
|
||||
|
||||
const add = dialog.getByRole('button', {
|
||||
name: 'Add playlist',
|
||||
exact: true,
|
||||
});
|
||||
await expect(add).toBeEnabled({ timeout: 10_000 });
|
||||
await add.click();
|
||||
await expect(dialog).toBeHidden();
|
||||
await page.waitForURL(/xtreams.*vod/);
|
||||
});
|
||||
|
||||
test('@sources @pwa explains an invalid M3U playlist URL', async ({ page }) => {
|
||||
const dialog = await openMethod(page, /M3U URL/i);
|
||||
const url = dialog.getByRole('textbox', {
|
||||
name: 'Playlist URL (m3u, m3u8)',
|
||||
});
|
||||
|
||||
await expect(
|
||||
dialog.getByRole('textbox', { name: 'Playlist title' })
|
||||
).toBeVisible();
|
||||
await setInputValue(url, 'playlist.m3u');
|
||||
await url.blur();
|
||||
|
||||
await expect(dialog.locator('mat-error')).toHaveText(
|
||||
'Enter a valid http:// or https:// URL.'
|
||||
);
|
||||
await expect(
|
||||
dialog.getByRole('button', { name: 'Add playlist', exact: true })
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test('@sources @pwa gives the Stalker form the shared labels and a masked password', async ({
|
||||
page,
|
||||
}) => {
|
||||
const dialog = await openMethod(page, /Stalker portal/i);
|
||||
|
||||
await expect(
|
||||
dialog.getByRole('textbox', { name: 'Playlist title' })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
dialog.getByRole('textbox', { name: 'MAC address', exact: true })
|
||||
).toBeVisible();
|
||||
await setInputValue(dialog.locator('#password'), 'secret');
|
||||
await expectPasswordToggle(dialog);
|
||||
await expect(
|
||||
dialog.getByRole('button', { name: 'Add playlist', exact: true })
|
||||
).toBeVisible();
|
||||
});
|
||||
@@ -82,14 +82,17 @@ test('@web @auto-detect pasted provider message prefills the Xtream form', async
|
||||
'http://tv.example.com:8080'
|
||||
);
|
||||
await expect(dialog.getByLabel('Username')).toHaveValue('e2euser');
|
||||
await expect(dialog.getByLabel('Password')).toHaveValue('e2epass');
|
||||
// Exact: the show/hide toggle beside it is labelled "Show password".
|
||||
await expect(dialog.getByLabel('Password', { exact: true })).toHaveValue(
|
||||
'e2epass'
|
||||
);
|
||||
await expect(dialog.getByLabel('Playlist title')).toHaveValue(
|
||||
'tv.example.com'
|
||||
);
|
||||
|
||||
// Detection only proposes: the regular Add action stays in charge.
|
||||
await expect(
|
||||
dialog.getByRole('button', { name: 'Add', exact: true })
|
||||
dialog.getByRole('button', { name: 'Add playlist', exact: true })
|
||||
).toBeEnabled();
|
||||
});
|
||||
|
||||
@@ -114,9 +117,9 @@ test('@web @auto-detect keeps the pasted message when switching methods', async
|
||||
).toBeChecked();
|
||||
// Exact: the derive-device-IDs checkbox is labelled "Generate device IDs
|
||||
// from the MAC address", which a substring match would also resolve to.
|
||||
await expect(
|
||||
dialog.getByLabel('Mac Address', { exact: true })
|
||||
).toHaveValue('00:1A:79:12:34:56');
|
||||
await expect(dialog.getByLabel('MAC address', { exact: true })).toHaveValue(
|
||||
'00:1A:79:12:34:56'
|
||||
);
|
||||
|
||||
// Returning to auto-detect must not cost the user their paste.
|
||||
await dialog.getByRole('radio', { name: /Auto-detect/i }).click();
|
||||
|
||||
@@ -131,7 +131,9 @@ async function importDashPlaylist(page: Page): Promise<void> {
|
||||
await dialog.locator('textarea').fill(DASH_PLAYLIST);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/workspace\/playlists\/.+\/all$/),
|
||||
dialog.getByRole('button', { name: 'Import', exact: true }).click(),
|
||||
dialog
|
||||
.getByRole('button', { name: 'Add playlist', exact: true })
|
||||
.click(),
|
||||
]);
|
||||
await expect(page.getByText('3 channels')).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -123,7 +123,9 @@ async function importPlaylist(
|
||||
await dialog.getByLabel('Insert m3u(8) playlist as text').fill(content);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/workspace\/playlists\/.+\/all$/),
|
||||
dialog.getByRole('button', { name: 'Import', exact: true }).click(),
|
||||
dialog
|
||||
.getByRole('button', { name: 'Add playlist', exact: true })
|
||||
.click(),
|
||||
]);
|
||||
await expect(page.getByText(`${count} channels`)).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -99,7 +99,9 @@ async function importFatalHlsPlaylist(page: Page): Promise<void> {
|
||||
.fill(FATAL_HLS_PLAYLIST);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/workspace\/playlists\/.+\/all$/),
|
||||
dialog.getByRole('button', { name: 'Import', exact: true }).click(),
|
||||
dialog
|
||||
.getByRole('button', { name: 'Add playlist', exact: true })
|
||||
.click(),
|
||||
]);
|
||||
await expect(page.getByText('1 channel')).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -76,7 +76,9 @@ export async function importPlaylist(page: Page): Promise<string> {
|
||||
await dialog.getByLabel('Insert m3u(8) playlist as text').fill(PLAYLIST);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/workspace\/playlists\/.+\/all$/),
|
||||
dialog.getByRole('button', { name: 'Import', exact: true }).click(),
|
||||
dialog
|
||||
.getByRole('button', { name: 'Add playlist', exact: true })
|
||||
.click(),
|
||||
]);
|
||||
await expect(page.getByText('1 channels')).toBeVisible();
|
||||
return page.url();
|
||||
|
||||
@@ -38,7 +38,9 @@ async function addXtreamPortal(page: Page): Promise<void> {
|
||||
await dialog.locator('#username').fill('user1');
|
||||
await dialog.locator('#password').fill('pass1');
|
||||
|
||||
await dialog.getByRole('button', { name: 'Add', exact: true }).click();
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Add playlist', exact: true })
|
||||
.click();
|
||||
await expect(dialog).toBeHidden();
|
||||
await page.waitForURL(/xtreams.*vod/);
|
||||
}
|
||||
@@ -75,7 +77,10 @@ async function addStalkerPortal(page: Page): Promise<void> {
|
||||
await setInputValue(dialog.locator('input#portalUrl'), STALKER_PORTAL_URL);
|
||||
await setInputValue(dialog.locator('input#macAddress'), DEFAULT_MAC);
|
||||
|
||||
const addButton = dialog.getByRole('button', { name: 'Add', exact: true });
|
||||
const addButton = dialog.getByRole('button', {
|
||||
name: 'Add playlist',
|
||||
exact: true,
|
||||
});
|
||||
await expect(addButton).toBeEnabled({ timeout: 10_000 });
|
||||
await addButton.click();
|
||||
await expect(dialog).toBeHidden();
|
||||
|
||||
@@ -126,7 +126,7 @@ export async function addXtreamPortal(
|
||||
await setInputValue(dialog.locator('#password'), 'pass1');
|
||||
|
||||
const addButton = dialog.getByRole('button', {
|
||||
name: 'Add',
|
||||
name: 'Add playlist',
|
||||
exact: true,
|
||||
});
|
||||
await expect(addButton).toBeEnabled({ timeout: 10_000 });
|
||||
@@ -172,7 +172,7 @@ export async function addStalkerPortal(
|
||||
await setInputValue(dialog.locator('input#macAddress'), DEFAULT_MAC);
|
||||
|
||||
const addButton = dialog.getByRole('button', {
|
||||
name: 'Add',
|
||||
name: 'Add playlist',
|
||||
exact: true,
|
||||
});
|
||||
await expect(addButton).toBeEnabled({ timeout: 10_000 });
|
||||
|
||||
@@ -240,7 +240,10 @@ async function addStalkerPortal(
|
||||
await setInputValue(dialog.locator('input#portalUrl'), PORTAL_URL);
|
||||
await setInputValue(dialog.locator('input#macAddress'), mac);
|
||||
|
||||
const addButton = dialog.getByRole('button', { name: 'Add', exact: true });
|
||||
const addButton = dialog.getByRole('button', {
|
||||
name: 'Add playlist',
|
||||
exact: true,
|
||||
});
|
||||
await expect(addButton).toBeEnabled({ timeout: 10_000 });
|
||||
await addButton.click();
|
||||
await expect(dialog).toBeHidden();
|
||||
@@ -284,7 +287,10 @@ async function addFullStalkerPortal(
|
||||
await setInputValue(dialog.locator('input#password'), password);
|
||||
}
|
||||
|
||||
const addButton = dialog.getByRole('button', { name: 'Add', exact: true });
|
||||
const addButton = dialog.getByRole('button', {
|
||||
name: 'Add playlist',
|
||||
exact: true,
|
||||
});
|
||||
await expect(addButton).toBeEnabled({ timeout: 10_000 });
|
||||
await addButton.click();
|
||||
await expect(dialog).toBeHidden();
|
||||
@@ -1387,15 +1393,17 @@ test.describe('@stalker full portal authentication', () => {
|
||||
await setInputValue(dialog.locator('input#macAddress'), mac);
|
||||
|
||||
const addButton = dialog.getByRole('button', {
|
||||
name: 'Add',
|
||||
name: 'Add playlist',
|
||||
exact: true,
|
||||
});
|
||||
await expect(addButton).toBeEnabled({ timeout: 10_000 });
|
||||
await addButton.click();
|
||||
|
||||
await expect(
|
||||
page.getByText(/requires a login and password/i)
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
// Inline under the portal URL, like the Xtream connection test.
|
||||
await expect(dialog.getByRole('status')).toContainText(
|
||||
/requires a login and password/i,
|
||||
{ timeout: 15_000 }
|
||||
);
|
||||
// The dialog stays open so the user can add the credentials.
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
@@ -1508,7 +1516,7 @@ test.describe('@stalker full portal authentication', () => {
|
||||
);
|
||||
|
||||
const addButton = dialog.getByRole('button', {
|
||||
name: 'Add',
|
||||
name: 'Add playlist',
|
||||
exact: true,
|
||||
});
|
||||
await expect(addButton).toBeEnabled({ timeout: 10_000 });
|
||||
@@ -1518,9 +1526,10 @@ test.describe('@stalker full portal authentication', () => {
|
||||
// stop there instead of persisting a portal that can never load. (It
|
||||
// used to be treated as success whenever the portal sent no msg text,
|
||||
// which imported a dead source.)
|
||||
await expect(page.getByText(/refused access/i)).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(dialog.getByRole('status')).toContainText(
|
||||
/refused access/i,
|
||||
{ timeout: 15_000 }
|
||||
);
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(page).not.toHaveURL(/stalker/);
|
||||
|
||||
@@ -1570,7 +1579,7 @@ test.describe('@stalker full portal authentication', () => {
|
||||
);
|
||||
|
||||
const addButton = dialog.getByRole('button', {
|
||||
name: 'Add',
|
||||
name: 'Add playlist',
|
||||
exact: true,
|
||||
});
|
||||
await expect(addButton).toBeEnabled({ timeout: 10_000 });
|
||||
@@ -1579,12 +1588,13 @@ test.describe('@stalker full portal authentication', () => {
|
||||
// The conflict gets its own headline. Asserting the generic one is
|
||||
// ABSENT is what makes this test fail if the classification is
|
||||
// removed — `blocked` would still surface the portal's text.
|
||||
await expect(
|
||||
page.getByText(/different device ID registered/i)
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
const status = dialog.getByRole('status');
|
||||
await expect(status).toContainText(/different device ID registered/i, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page.getByText(/refused access/i)).toHaveCount(0);
|
||||
// The portal's own words still travel with it, markup stripped.
|
||||
await expect(page.getByText(/device_id mismatch/i)).toBeVisible();
|
||||
await expect(status).toContainText(/device_id mismatch/i);
|
||||
await expect(page.locator('body')).not.toContainText('<br/>');
|
||||
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
@@ -75,7 +75,7 @@ for (const canTryHttp of [true, false]) {
|
||||
: ['https://panel.example/base']
|
||||
);
|
||||
await expect(
|
||||
dialog.getByRole('button', { name: 'Add', exact: true })
|
||||
dialog.getByRole('button', { name: 'Add playlist', exact: true })
|
||||
).toBeEnabled();
|
||||
});
|
||||
}
|
||||
@@ -40,7 +40,9 @@ export async function addLiveFormatPortal(page: Page) {
|
||||
await dialog.locator('#serverUrl').fill(liveFormatMock);
|
||||
await dialog.locator('#username').fill('live-fallback');
|
||||
await dialog.locator('#password').fill('live-fallback');
|
||||
await dialog.getByRole('button', { name: 'Add', exact: true }).click();
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Add playlist', exact: true })
|
||||
.click();
|
||||
await page.waitForURL(/xtreams.*vod/);
|
||||
await page.getByRole('link', { name: 'Live TV', exact: true }).click();
|
||||
await page.locator('.context-panel .category-item').first().click();
|
||||
|
||||
@@ -75,7 +75,9 @@ export async function addXtreamPortal(
|
||||
await setInputValue(dialog.locator('#username'), username);
|
||||
await setInputValue(dialog.locator('#password'), password);
|
||||
|
||||
await dialog.getByRole('button', { name: 'Add', exact: true }).click();
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Add playlist', exact: true })
|
||||
.click();
|
||||
await page.waitForSelector('mat-dialog-container', { state: 'detached' });
|
||||
await page.waitForURL(/xtreams.*vod/);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user