fix(import): consistent add-source forms with masked passwords and URL errors (#1796)

* 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>
This commit is contained in:
4grayandClaude Opus 5.5 authored and GitHub committed 2026-10-03 13:13:54 +02:00
1 parent 8e1cb05913
commit a8dd1eaa97
56 files changed
+1278 -252

No files matched your search

+117
View File
@@ -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();
});
+8 -5
View File
@@ -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();
+3 -1
View File
@@ -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();
}
+3 -1
View File
@@ -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();
+7 -2
View File
@@ -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();
+2 -2
View File
@@ -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 });
+25 -15
View File
@@ -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();
+1 -1
View File
@@ -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/);
}