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:
4grayandClaude Opus 5.5 committed 2026-10-03 09:55:16 +02:00
1 parent 23a1860119
commit 0ea9ecdb27
56 files changed
+1208 -252

No files matched your search

@@ -12,7 +12,9 @@
<div
class="apd-method-grid"
role="radiogroup"
aria-label="Source method"
[attr.aria-label]="
'HOME.ADD_PLAYLIST.METHOD_GROUP_LABEL' | translate
"
>
@for (opt of methodOptions; track opt.value) {
<button
@@ -128,7 +130,7 @@
)
"
>
{{ 'HOME.TEXT_IMPORT.BUTTON_LABEL' | translate }}
{{ 'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate }}
</button>
}
@case ('xtream') {
@@ -159,7 +161,7 @@
"
(click)="xtreamImport()?.addPlaylist()"
>
{{ 'HOME.XTREAM_PLAYLIST.ADD' | translate }}
{{ 'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate }}
</button>
}
@case ('stalker') {
@@ -170,9 +172,7 @@
type="button"
disabled
>
{{
'HOME.STALKER_PORTAL.VALIDATING' | translate
}}...
{{ 'HOME.STALKER_PORTAL.VALIDATING' | translate }}
</button>
} @else {
<button
@@ -182,7 +182,7 @@
[disabled]="!stalkerImport()?.form?.valid"
(click)="stalkerImport()?.addPlaylist()"
>
{{ 'HOME.STALKER_PORTAL.ADD' | translate }}
{{ 'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate }}
</button>
}
}
@@ -2,9 +2,10 @@ import { TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { PlaylistActions } from '@iptvnator/m3u-state';
import { DataService } from '@iptvnator/services';
import { StalkerPortalDiscoveryService } from '@iptvnator/portal/stalker/data-access';
import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services';
import {
PLAYLIST_PARSE_BY_URL,
ProviderImportCandidate,
@@ -418,3 +419,68 @@ describe('AddPlaylistDialogComponent', () => {
component.method.set(type);
}
});
describe('AddPlaylistDialogComponent actions', () => {
function render(type: PlaylistType) {
TestBed.configureTestingModule({
imports: [AddPlaylistDialogComponent, TranslateModule.forRoot()],
providers: [
{ provide: DataService, useValue: { sendIpcEvent: jest.fn() } },
{ provide: MatDialogRef, useValue: { close: jest.fn() } },
{ provide: Store, useValue: { dispatch: jest.fn() } },
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
{ provide: MAT_DIALOG_DATA, useValue: { type } },
{
provide: RuntimeCapabilitiesService,
useValue: { isElectron: true },
},
{
provide: StalkerPortalDiscoveryService,
useValue: { discover: jest.fn() },
},
],
});
const fixture = TestBed.createComponent(AddPlaylistDialogComponent);
fixture.detectChanges();
return fixture;
}
function primaryLabel(root: HTMLElement): string | undefined {
const buttons = root.querySelectorAll<HTMLButtonElement>(
'.apd-actions__decision button'
);
return buttons[buttons.length - 1]?.textContent?.trim();
}
it.each<PlaylistType>(['url', 'text', 'xtream', 'stalker'])(
'submits the %s form with the shared "Add playlist" label',
(type) => {
const fixture = render(type);
expect(primaryLabel(fixture.nativeElement)).toBe(
'HOME.URL_UPLOAD.ADD_PLAYLIST'
);
}
);
it('shows a single ellipsis while a Stalker portal validates', () => {
const fixture = render('stalker');
fixture.componentInstance.stalkerImport()?.isLoading.set(true);
fixture.detectChanges();
expect(primaryLabel(fixture.nativeElement)).toBe(
'HOME.STALKER_PORTAL.VALIDATING'
);
});
it('names the method picker with a translated label', () => {
const fixture = render('url');
const group = (fixture.nativeElement as HTMLElement).querySelector(
'[role="radiogroup"]'
);
expect(group?.getAttribute('aria-label')).toBe(
'HOME.ADD_PLAYLIST.METHOD_GROUP_LABEL'
);
});
});
@@ -1,4 +1,7 @@
import type { StalkerPortalErrorKind } from '@iptvnator/portal/stalker/data-access';
import {
asStalkerPortalError,
type StalkerPortalErrorKind,
} from '@iptvnator/portal/stalker/data-access';
import type { StalkerPortalIdentity } from '@iptvnator/shared/interfaces';
/** The `stalker*` playlist columns the import form writes. */
@@ -49,3 +52,32 @@ export const STALKER_IMPORT_ERROR_KEY_BY_KIND: Readonly<
blocked: 'HOME.STALKER_PORTAL.PORTAL_REFUSED',
'auth-failed': 'HOME.STALKER_PORTAL.AUTH_FAILED',
};
/**
* What the import form shows inline when a portal refuses it: a translation
* key, plus the portal's own explanation when it gave one. The template
* translates both, so the message follows a language switch.
*/
export interface StalkerImportFeedback {
key: string;
portalText?: string;
}
/**
* Turns an authentication failure into feedback the user can act on. The
* portal explains refusals itself (`msg`/`block_msg`, or one of the
* documented plain-text bodies); its own words are relayed verbatim.
*/
export function toStalkerImportFeedback(error: unknown): StalkerImportFeedback {
const portalError = asStalkerPortalError(error);
if (!portalError) {
return { key: 'HOME.STALKER_PORTAL.AUTH_FAILED' };
}
return {
key: STALKER_IMPORT_ERROR_KEY_BY_KIND[portalError.kind],
...(portalError.portalText
? { portalText: portalError.portalText }
: {}),
};
}
@@ -1,7 +1,7 @@
<form [formGroup]="form">
<mat-form-field class="w-full">
<mat-label for="title">{{
'HOME.STALKER_PORTAL.TITLE' | translate
'HOME.XTREAM_PLAYLIST.TITLE' | translate
}}</mat-label>
<input matInput type="text" id="title" formControlName="title" />
</mat-form-field>
@@ -20,6 +20,17 @@
'HOME.STALKER_PORTAL.URL_VALIDATION_ERROR' | translate
}}</mat-error>
</mat-form-field>
@if (feedback(); as message) {
<p #feedbackMessage role="status" aria-live="polite">
{{ message.key | translate }}
@if (message.portalText) {
{{
'HOME.STALKER_PORTAL.PORTAL_MESSAGE'
| translate: { message: message.portalText }
}}
}
</p>
}
<mat-form-field class="w-full" subscriptSizing="dynamic">
<mat-label for="macAddress">{{
'HOME.STALKER_PORTAL.MAC_ADDRESS' | translate
@@ -142,10 +153,19 @@
}}</mat-label>
<input
matInput
type="password"
[type]="passwordToggle.inputType()"
id="password"
formControlName="password"
/>
<button
mat-icon-button
matSuffix
appPasswordVisibilityToggle
#passwordToggle="appPasswordVisibilityToggle"
[attr.aria-label]="'HOME.SHOW_PASSWORD' | translate"
>
<mat-icon>{{ passwordToggle.icon() }}</mat-icon>
</button>
<mat-hint>{{
'HOME.STALKER_PORTAL.CREDENTIALS_HINT' | translate
}}</mat-hint>
@@ -2,7 +2,7 @@ import { webcrypto } from 'node:crypto';
import { TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import {
stalkerSessionFingerprint,
StalkerPortalDiscoveryService,
@@ -305,11 +305,12 @@ describe('StalkerPortalImportComponent identity handling', () => {
await component.addPlaylist();
expect(snackBar.open).toHaveBeenCalledWith(
'HOME.STALKER_PORTAL.LOGIN_REQUIRED',
undefined,
expect.any(Object)
);
// Inline, like the Xtream connection test: the dialog stays open, so
// a snackbar would cover the footer the user acts from next.
expect(component.feedback()).toEqual({
key: 'HOME.STALKER_PORTAL.LOGIN_REQUIRED',
});
expect(snackBar.open).not.toHaveBeenCalled();
expect(store.dispatch).not.toHaveBeenCalled();
});
@@ -374,13 +375,103 @@ describe('StalkerPortalImportComponent identity handling', () => {
await component.addPlaylist();
// The translate mock returns keys, so both the kind headline and the
// portal-message wrapper must be present.
expect(component.feedback()).toEqual({
key: 'HOME.STALKER_PORTAL.PORTAL_REFUSED',
portalText: 'device conflict - device_id mismatch',
});
expect(snackBar.open).not.toHaveBeenCalled();
});
it('shows the generic refusal inline when a canonical portal is unreachable', async () => {
portalDiscovery.discover.mockResolvedValue({ status: 'unreachable' });
component.form.patchValue({
title: 'Offline Portal',
macAddress: '00:1A:79:AA:BB:CC',
portalUrl:
'https://portal.example.com/stalker_portal/server/load.php',
});
await component.addPlaylist();
expect(component.feedback()).toEqual({
key: 'HOME.STALKER_PORTAL.AUTH_FAILED',
});
expect(snackBar.open).not.toHaveBeenCalled();
expect(store.dispatch).not.toHaveBeenCalled();
});
it('announces an unvalidated import with a translated snackbar', async () => {
portalDiscovery.discover.mockResolvedValue({ status: 'unreachable' });
component.form.patchValue({
title: 'Offline Panel',
macAddress: '00:1A:79:AA:BB:CC',
portalUrl: 'https://panel.example.com/c',
});
await component.addPlaylist();
// The dialog closes on this path, so an inline message would vanish.
expect(snackBar.open).toHaveBeenCalledWith(
'HOME.STALKER_PORTAL.PORTAL_REFUSED HOME.STALKER_PORTAL.PORTAL_MESSAGE',
'HOME.STALKER_PORTAL.ADDED_WITHOUT_VALIDATION',
undefined,
expect.any(Object)
);
expect(component.feedback()).toBeNull();
expect(store.dispatch).toHaveBeenCalledTimes(1);
});
it('translates the expiry announced after a validated import', async () => {
const translate = TestBed.inject(TranslateService);
portalDiscovery.discover.mockResolvedValue({
status: 'resolved',
portalUrl:
'https://portal.example.com/stalker_portal/server/load.php',
isFullStalkerPortal: true,
token: 'token-1',
accountInfo: { login: 'demo', expire_date: 1893456000 },
});
component.form.patchValue({
title: 'Expiring Portal',
macAddress: '00:1A:79:AA:BB:CC',
portalUrl: 'https://portal.example.com/stalker_portal/c',
});
await component.addPlaylist();
expect(translate.instant).toHaveBeenCalledWith(
'HOME.STALKER_PORTAL.VALIDATED_EXPIRES',
{ date: new Date(1893456000 * 1000).toLocaleDateString() }
);
expect(snackBar.open).toHaveBeenCalledWith(
'HOME.STALKER_PORTAL.VALIDATED_EXPIRES',
undefined,
expect.any(Object)
);
});
it('drops a refusal once the user edits the form or retries', async () => {
portalDiscovery.discover.mockResolvedValue({
status: 'auth-rejected',
portalUrl:
'https://portal.example.com/stalker_portal/server/load.php',
error: new StalkerPortalError('login-required'),
});
component.form.patchValue({
title: 'Login Portal',
macAddress: '00:1A:79:00:00:08',
portalUrl: 'https://portal.example.com/stalker_portal/c',
});
await component.addPlaylist();
expect(component.feedback()).not.toBeNull();
component.form.controls.username.setValue('demo');
expect(component.feedback()).toBeNull();
await component.addPlaylist();
expect(component.feedback()).not.toBeNull();
component.clearForm();
expect(component.feedback()).toBeNull();
});
it('classifies the offline fallback on the normalized URL, not the raw query', async () => {
@@ -934,11 +1025,10 @@ describe('StalkerPortalImportComponent identity handling', () => {
await component.addPlaylist();
expect(snackBar.open).toHaveBeenCalledWith(
'HOME.STALKER_PORTAL.DEVICE_CONFLICT HOME.STALKER_PORTAL.PORTAL_MESSAGE',
undefined,
expect.any(Object)
);
expect(component.feedback()).toEqual({
key: 'HOME.STALKER_PORTAL.DEVICE_CONFLICT',
portalText: 'device conflict - device_id mismatch',
});
});
it('normalizes a query-carrying /c URL in the unreachable-host fallback', async () => {
@@ -986,3 +1076,101 @@ describe('StalkerPortalImportComponent identity handling', () => {
);
});
});
describe('StalkerPortalImportComponent form', () => {
let portalDiscovery: { discover: jest.Mock };
afterEach(() => {
// jsdom has no scrollIntoView; one test installs a spy for it.
delete (HTMLElement.prototype as { scrollIntoView?: unknown })
.scrollIntoView;
});
function render() {
portalDiscovery = { discover: jest.fn() };
TestBed.configureTestingModule({
imports: [StalkerPortalImportComponent, TranslateModule.forRoot()],
providers: [
{
provide: StalkerPortalDiscoveryService,
useValue: portalDiscovery,
},
{ provide: Store, useValue: { dispatch: jest.fn() } },
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
],
});
const translate = TestBed.inject(TranslateService);
translate.setTranslation('en', {
HOME: {
STALKER_PORTAL: {
PORTAL_REFUSED: 'Refused.',
PORTAL_MESSAGE: 'The portal reported: {{message}}',
},
},
});
translate.use('en');
const fixture = TestBed.createComponent(StalkerPortalImportComponent);
fixture.detectChanges();
const root = fixture.nativeElement as HTMLElement;
return { fixture, root, component: fixture.componentInstance };
}
it('labels the name field like the other add-source forms', () => {
const { root } = render();
expect(
root.querySelector('#title')?.closest('mat-form-field')?.textContent
).toContain('HOME.XTREAM_PLAYLIST.TITLE');
});
it('masks the password until the visibility toggle is pressed', () => {
const { fixture, root } = render();
const password = root.querySelector('#password') as HTMLInputElement;
const toggle = password
.closest('mat-form-field')
?.querySelector('button') as HTMLButtonElement;
expect(password.type).toBe('password');
expect(toggle.getAttribute('aria-label')).toBe('HOME.SHOW_PASSWORD');
expect(toggle.getAttribute('aria-pressed')).toBe('false');
toggle.click();
fixture.detectChanges();
expect(password.type).toBe('text');
expect(toggle.getAttribute('aria-pressed')).toBe('true');
});
it('shows a refusal inline under the portal URL and scrolls it into view', async () => {
const scrollIntoView = jest.fn();
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: scrollIntoView,
});
const { fixture, root, component } = render();
portalDiscovery.discover.mockResolvedValue({
status: 'auth-rejected',
portalUrl:
'https://portal.example.com/stalker_portal/server/load.php',
error: new StalkerPortalError('blocked', 'account disabled'),
});
component.form.patchValue({
title: 'Blocked Portal',
macAddress: '00:1A:79:AA:BB:CC',
portalUrl: 'https://portal.example.com/stalker_portal/c',
});
await component.addPlaylist();
fixture.detectChanges();
await fixture.whenStable();
const status = root.querySelector('[role="status"]') as HTMLElement;
expect(status.textContent?.replace(/\s+/g, ' ').trim()).toBe(
'Refused. The portal reported: account disabled'
);
expect(status.getAttribute('aria-live')).toBe('polite');
expect(
status.previousElementSibling?.querySelector('#portalUrl')
).not.toBeNull();
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' });
});
});
@@ -1,10 +1,14 @@
import {
afterRenderEffect,
Component,
ElementRef,
inject,
output,
signal,
viewChild,
ChangeDetectionStrategy,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
FormControl,
FormGroup,
@@ -12,15 +16,16 @@ import {
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { MatIconButton } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIcon } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { PlaylistActions } from '@iptvnator/m3u-state';
import {
asStalkerPortalError,
legacyTransformStalkerPortalUrl,
normalizeStalkerPortalInputUrl,
STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS,
@@ -38,8 +43,10 @@ import {
type StalkerDerivedDeviceIds,
validateStalkerMacAddressControl,
} from '@iptvnator/shared/interfaces';
import { PasswordVisibilityToggleDirective } from '@iptvnator/ui/components/password-visibility-toggle';
import {
STALKER_IMPORT_ERROR_KEY_BY_KIND,
type StalkerImportFeedback,
toStalkerImportFeedback,
toStalkerPlaylistIdentityFields,
} from './stalker-import-identity';
@@ -59,7 +66,10 @@ interface StalkerSettledIdentity {
FormsModule,
MatCheckboxModule,
MatFormFieldModule,
MatIcon,
MatIconButton,
MatInputModule,
PasswordVisibilityToggleDirective,
ReactiveFormsModule,
TranslatePipe,
],
@@ -130,12 +140,40 @@ export class StalkerPortalImportComponent {
readonly isLoading = signal(false);
/**
* Why the last import was refused, shown under the portal URL the same
* way the Xtream form shows its connection test. Outcomes that close the
* dialog (added, added without validation) use a snackbar instead,
* because an inline message would vanish with the form.
*/
readonly feedback = signal<StalkerImportFeedback | null>(null);
private readonly feedbackMessage =
viewChild<ElementRef<HTMLElement>>('feedbackMessage');
/** Whether the device IDs are being generated from the MAC. */
readonly derivesDeviceIds = signal(false);
/** Stamps each derivation so a late one cannot overwrite a newer one. */
private deriveGeneration = 0;
constructor() {
// Like the Xtream connection test: a message describes the values it
// was produced for, so any edit makes it stale.
this.form.valueChanges
.pipe(takeUntilDestroyed())
.subscribe(() => this.feedback.set(null));
// The form is long and Add sits in the dialog footer, so the user is
// usually scrolled to the credentials when a refusal comes back.
afterRenderEffect(() => {
if (this.feedback()) {
this.feedbackMessage()?.nativeElement.scrollIntoView?.({
block: 'nearest',
});
}
});
}
/**
* A MAC outside Infomir's range is imported anyway — plenty of resellers
* disable the check — but the stock portal answers a bare `{status: 1}`
@@ -308,6 +346,7 @@ export class StalkerPortalImportComponent {
// resolve into the freshly cleared form.
this.invalidatePendingDerivation();
this.derivesDeviceIds.set(false);
this.feedback.set(null);
this.form.controls.deviceId1.enable();
this.form.controls.deviceId2.enable();
this.form.reset({
@@ -333,6 +372,7 @@ export class StalkerPortalImportComponent {
}
this.isLoading.set(true);
this.feedback.set(null);
// The identity is frozen for the duration: an edit made now cannot
// reach the portal (discovery has the snapshot) and cannot be undone
// on it either (`get_profile` pins what it was sent), so the fields
@@ -422,7 +462,10 @@ export class StalkerPortalImportComponent {
stalkerAccountInfo.expireDate * 1000
);
this.snackBar.open(
`Portal validated. Expires: ${expireDate.toLocaleDateString()}`,
this.translate.instant(
'HOME.STALKER_PORTAL.VALIDATED_EXPIRES',
{ date: expireDate.toLocaleDateString() }
),
undefined,
{ duration: 3000 }
);
@@ -435,11 +478,7 @@ export class StalkerPortalImportComponent {
// The portal explains its own refusals — a demanded login, a
// rejected one, a device conflict — so relay those words
// instead of the generic "check URL and MAC".
this.snackBar.open(
this.buildAuthErrorMessage(discovery.error),
undefined,
{ duration: 8000 }
);
this.feedback.set(toStalkerImportFeedback(discovery.error));
if (discovery.abandonedInFlight) {
// The bounded error may arrive while get_profile is still
// on the wire. Keep Add and every identity field locked
@@ -457,11 +496,7 @@ export class StalkerPortalImportComponent {
// Unreachable host on a canonical-portal URL shape: the old
// flow aborted here too (its mandatory handshake could not
// succeed either).
this.snackBar.open(
'Failed to authenticate with portal. Please check URL and MAC address.',
undefined,
{ duration: 5000 }
);
this.feedback.set({ key: 'HOME.STALKER_PORTAL.AUTH_FAILED' });
return;
} else {
// Unreachable host on a panel-style URL: import with the
@@ -475,7 +510,9 @@ export class StalkerPortalImportComponent {
);
isFullStalkerPortal = false;
this.snackBar.open(
'Portal did not respond; added without validation.',
this.translate.instant(
'HOME.STALKER_PORTAL.ADDED_WITHOUT_VALIDATION'
),
undefined,
{ duration: 5000 }
);
@@ -537,28 +574,4 @@ export class StalkerPortalImportComponent {
this.isLoading.set(false);
}
}
/**
* Turns an authentication failure into a message the user can act on.
* The portal explains refusals itself (`msg`/`block_msg`, or one of the
* documented plain-text bodies); its own words are appended verbatim.
*/
private buildAuthErrorMessage(error: unknown): string {
const portalError = asStalkerPortalError(error);
const base = this.translate.instant(
portalError
? STALKER_IMPORT_ERROR_KEY_BY_KIND[portalError.kind]
: 'HOME.STALKER_PORTAL.AUTH_FAILED'
);
if (portalError?.portalText) {
const detail = this.translate.instant(
'HOME.STALKER_PORTAL.PORTAL_MESSAGE',
{ message: portalError.portalText }
);
return `${base} ${detail}`;
}
return base;
}
}
@@ -2,6 +2,9 @@
<mat-form-field class="w-full">
<mat-label>{{ 'HOME.URL_UPLOAD.PLAYLIST_URL' | translate }}</mat-label>
<input type="url" matInput formControlName="playlistUrl" />
<mat-error>{{
'HOME.URL_UPLOAD.URL_VALIDATION_ERROR' | translate
}}</mat-error>
</mat-form-field>
<mat-form-field class="w-full">
<mat-label>{{ 'HOME.XTREAM_PLAYLIST.TITLE' | translate }}</mat-label>
@@ -109,3 +109,62 @@ describe('UrlUploadComponent', () => {
expect(component.form.pristine).toBeTruthy();
});
});
describe('UrlUploadComponent form', () => {
let component: UrlUploadComponent;
let fixture: ComponentFixture<UrlUploadComponent>;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [
UrlUploadComponent,
TranslateModule.forRoot(),
NoopAnimationsModule,
],
providers: [
{
provide: RuntimeCapabilitiesService,
useValue: { isElectron: true },
},
],
});
fixture = TestBed.createComponent(UrlUploadComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
function fieldText(controlName: string): string {
return (
(fixture.nativeElement as HTMLElement)
.querySelector(`[formcontrolname="${controlName}"]`)
?.closest('mat-form-field')
?.textContent?.replace(/\s+/g, ' ')
.trim() ?? ''
);
}
it('labels the name field like the other add-source forms', () => {
expect(fieldText('playlistName')).toContain(
'HOME.XTREAM_PLAYLIST.TITLE'
);
});
it('explains an invalid playlist URL once the field was touched', () => {
const root = fixture.nativeElement as HTMLElement;
const url = component.form.controls.playlistUrl;
url.setValue('playlist.m3u');
fixture.detectChanges();
expect(root.querySelector('mat-error')).toBeNull();
url.markAsTouched();
fixture.detectChanges();
expect(root.querySelector('mat-error')?.textContent?.trim()).toBe(
'HOME.URL_UPLOAD.URL_VALIDATION_ERROR'
);
url.setValue('https://example.org/playlist.m3u');
fixture.detectChanges();
expect(root.querySelector('mat-error')).toBeNull();
});
});
@@ -16,10 +16,10 @@
formControlName="serverUrl"
(input)="extractParams(form.value.serverUrl ?? '')"
/>
<mat-hint>{{
<mat-hint>{{ 'HOME.XTREAM_PLAYLIST.URL_HINT' | translate }}</mat-hint>
<mat-error>{{
'HOME.XTREAM_PLAYLIST.URL_VALIDATION_ERROR' | translate
}}</mat-hint>
<mat-error>{{ 'SETTINGS.EPG_URL_ERROR' | translate }}</mat-error>
}}</mat-error>
</mat-form-field>
@if (connectionTest.messageKey()) {
<p role="status" aria-live="polite">
@@ -39,6 +39,20 @@
<mat-label for="password">{{
'HOME.XTREAM_PLAYLIST.PASSWORD' | translate
}}</mat-label>
<input matInput type="text" id="password" formControlName="password" />
<input
matInput
[type]="passwordToggle.inputType()"
id="password"
formControlName="password"
/>
<button
mat-icon-button
matSuffix
appPasswordVisibilityToggle
#passwordToggle="appPasswordVisibilityToggle"
[attr.aria-label]="'HOME.SHOW_PASSWORD' | translate"
>
<mat-icon>{{ passwordToggle.icon() }}</mat-icon>
</button>
</mat-form-field>
</form>
@@ -1,5 +1,6 @@
import { TestBed } from '@angular/core/testing';
import { Store } from '@ngrx/store';
import { TranslateModule } from '@ngx-translate/core';
import { PlaylistActions } from '@iptvnator/m3u-state';
import { XtreamConnectionTestService } from '@iptvnator/services';
import { XtreamCodeImportComponent } from './xtream-code-import.component';
@@ -198,3 +199,99 @@ describe('XtreamCodeImportComponent', () => {
expect(component.connectionTest.result()).toBeNull();
});
});
describe('XtreamCodeImportComponent form', () => {
function render() {
TestBed.configureTestingModule({
imports: [XtreamCodeImportComponent, TranslateModule.forRoot()],
providers: [
{ provide: Store, useValue: { dispatch: jest.fn() } },
{
provide: XtreamConnectionTestService,
useValue: { test: jest.fn() },
},
],
});
const fixture = TestBed.createComponent(XtreamCodeImportComponent);
fixture.detectChanges();
const root = fixture.nativeElement as HTMLElement;
return { fixture, root, component: fixture.componentInstance };
}
function fieldText(root: HTMLElement, inputId: string): string {
return (
root
.querySelector(`#${inputId}`)
?.closest('mat-form-field')
?.textContent?.replace(/\s+/g, ' ')
.trim() ?? ''
);
}
it('labels the name field like the other add-source forms', () => {
const { root } = render();
expect(fieldText(root, 'title')).toContain(
'HOME.XTREAM_PLAYLIST.TITLE'
);
});
it('masks the password until the visibility toggle is pressed', () => {
const { fixture, root } = render();
const password = root.querySelector('#password') as HTMLInputElement;
const toggle = password
.closest('mat-form-field')
?.querySelector('button') as HTMLButtonElement;
expect(password.type).toBe('password');
expect(toggle.type).toBe('button');
expect(toggle.getAttribute('aria-label')).toBe('HOME.SHOW_PASSWORD');
expect(toggle.getAttribute('aria-pressed')).toBe('false');
toggle.click();
fixture.detectChanges();
expect(password.type).toBe('text');
expect(toggle.getAttribute('aria-pressed')).toBe('true');
expect(toggle.getAttribute('aria-label')).toBe('HOME.SHOW_PASSWORD');
toggle.click();
fixture.detectChanges();
expect(password.type).toBe('password');
expect(toggle.getAttribute('aria-pressed')).toBe('false');
});
it('shows a neutral server hint until the URL is invalid', () => {
const { fixture, root, component } = render();
expect(fieldText(root, 'serverUrl')).toContain(
'HOME.XTREAM_PLAYLIST.URL_HINT'
);
expect(root.querySelector('mat-error')).toBeNull();
component.form.controls.serverUrl.setValue('ftp://panel.example');
component.form.controls.serverUrl.markAsTouched();
fixture.detectChanges();
const error = root.querySelector('mat-error');
expect(error?.textContent?.trim()).toBe(
'HOME.XTREAM_PLAYLIST.URL_VALIDATION_ERROR'
);
expect(fieldText(root, 'serverUrl')).not.toContain(
'SETTINGS.EPG_URL_ERROR'
);
});
it('explains a URL that carries inline credentials as invalid', () => {
const { fixture, root, component } = render();
component.form.controls.serverUrl.setValue(
'https://user:pass@panel.example'
);
component.form.controls.serverUrl.markAsTouched();
fixture.detectChanges();
expect(root.querySelector('mat-error')?.textContent?.trim()).toBe(
'HOME.XTREAM_PLAYLIST.URL_VALIDATION_ERROR'
);
});
});
@@ -14,12 +14,15 @@ import {
ValidationErrors,
Validators,
} from '@angular/forms';
import { MatIconButton } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIcon } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
import { PlaylistActions } from '@iptvnator/m3u-state';
import { createXtreamConnectionTestState } from '@iptvnator/services';
import { PasswordVisibilityToggleDirective } from '@iptvnator/ui/components/password-visibility-toggle';
import {
createRandomId,
extractXtreamCredentialsFromUrl,
@@ -47,7 +50,10 @@ function xtreamServerUrlValidator(
imports: [
FormsModule,
MatFormFieldModule,
MatIcon,
MatIconButton,
MatInputModule,
PasswordVisibilityToggleDirective,
ReactiveFormsModule,
TranslatePipe,
],
@@ -0,0 +1,110 @@
import { TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslateModule } from '@ngx-translate/core';
import { of, Subject } from 'rxjs';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import {
DatabaseService,
PlaylistsService,
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import { Playlist } from '@iptvnator/shared/interfaces';
import { PlaylistInfoComponent } from './playlist-info.component';
import { STALKER_PLAYLIST_CONNECTION_EDITOR } from './stalker-playlist-connection-editor.token';
/** The edit dialog masks a stored password exactly like the add forms. */
describe('PlaylistInfoComponent password field', () => {
const base = {
id: 'playlist-1',
_id: 'playlist-1',
title: 'Source',
count: 0,
importDate: '2026-04-01T00:00:00.000Z',
autoRefresh: false,
username: 'user1',
password: 'pass1',
};
const xtream = {
...base,
serverUrl: 'http://xtream.example:8080',
} as Playlist & { id: string };
const stalker = {
...base,
portalUrl: 'http://portal.example/portal.php',
macAddress: '00:1A:79:00:00:01',
} as Playlist & { id: string };
function render(playlist: Playlist & { id: string }) {
TestBed.configureTestingModule({
imports: [PlaylistInfoComponent, TranslateModule.forRoot()],
providers: [
{ provide: MAT_DIALOG_DATA, useValue: playlist },
{
provide: PlaylistsService,
useValue: { getPlaylistById: jest.fn(() => of(playlist)) },
},
{ provide: DatabaseService, useValue: {} },
{
provide: EpgRuntimeBridgeService,
useValue: { supportsDataManagement: false },
},
{ provide: Store, useValue: { dispatch: jest.fn() } },
{
provide: SettingsStore,
useValue: { getSettings: jest.fn(() => ({ epgUrl: [] })) },
},
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
{
provide: MatDialogRef,
useValue: { beforeClosed: () => new Subject<void>() },
},
{
provide: RuntimeCapabilitiesService,
useValue: { supportsDesktopFileSave: false },
},
{ provide: STALKER_PLAYLIST_CONNECTION_EDITOR, useValue: {} },
],
});
const fixture = TestBed.createComponent(PlaylistInfoComponent);
fixture.detectChanges();
return fixture;
}
it.each([
['Xtream', xtream],
['Stalker', stalker],
])(
'masks a stored %s password until it is revealed',
async (_, playlist) => {
const fixture = render(playlist);
await fixture.whenStable();
fixture.detectChanges();
const root = fixture.nativeElement as HTMLElement;
const password = root.querySelector(
'input[formcontrolname="password"]'
) as HTMLInputElement;
const toggle = password
.closest('mat-form-field')
?.querySelector('button') as HTMLButtonElement;
expect(password.value).toBe('pass1');
expect(password.type).toBe('password');
expect(toggle.type).toBe('button');
expect(toggle.getAttribute('aria-label')).toBe(
'HOME.SHOW_PASSWORD'
);
expect(toggle.getAttribute('aria-pressed')).toBe('false');
toggle.click();
fixture.detectChanges();
expect(password.type).toBe('text');
expect(toggle.getAttribute('aria-pressed')).toBe('true');
// A toggle never submits the edit form it sits in.
expect(fixture.componentInstance.isSaving()).toBe(false);
}
);
});
@@ -107,7 +107,20 @@
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.PASSWORD' | translate
}}</mat-label>
<input matInput formControlName="password" />
<input
matInput
[type]="passwordToggle.inputType()"
formControlName="password"
/>
<button
mat-icon-button
matSuffix
appPasswordVisibilityToggle
#passwordToggle="appPasswordVisibilityToggle"
[attr.aria-label]="'HOME.SHOW_PASSWORD' | translate"
>
<mat-icon>{{ passwordToggle.icon() }}</mat-icon>
</button>
</mat-form-field>
}
@if (playlist.serverUrl && !playlist.portalUrl) {
@@ -52,6 +52,7 @@ import {
normalizeEpgUrls,
resolvePlaylistEpgSourceState,
} from '@iptvnator/shared/m3u-utils';
import { PasswordVisibilityToggleDirective } from '@iptvnator/ui/components/password-visibility-toggle';
import {
hasStalkerConnectionChanged,
omitStalkerConnection,
@@ -175,6 +176,7 @@ type DesktopFileSaveBridge = Pick<
MatIconButton,
MatInputModule,
MatTooltip,
PasswordVisibilityToggleDirective,
ReactiveFormsModule,
TranslatePipe,
],
+1
View File
@@ -16,6 +16,7 @@ export * from './lib/portal-detail-shell/portal-detail-shell.component';
export * from './lib/view-in-portal-action/view-in-portal-action.component';
export * from './lib/view-in-portal-action/view-in-portal-handoff.token';
export * from './lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component';
export * from './lib/password-visibility-toggle/password-visibility-toggle.directive';
export * from './lib/progress-capsule/progress-capsule.component';
export * from './lib/resizable/resizable.directive';
export * from './lib/season-container/season-container.component';
@@ -0,0 +1,72 @@
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { PasswordVisibilityToggleDirective } from './password-visibility-toggle.directive';
@Component({
imports: [PasswordVisibilityToggleDirective],
template: `
<input [type]="toggle.inputType()" />
<button
appPasswordVisibilityToggle
#toggle="appPasswordVisibilityToggle"
aria-label="Show password"
>
{{ toggle.icon() }}
</button>
`,
})
class HostComponent {}
describe('PasswordVisibilityToggleDirective', () => {
function render() {
const fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
const root = fixture.nativeElement as HTMLElement;
return {
fixture,
input: root.querySelector('input') as HTMLInputElement,
button: root.querySelector('button') as HTMLButtonElement,
};
}
it('starts masked and is a non-submitting toggle button', () => {
const { input, button } = render();
expect(input.type).toBe('password');
expect(button.type).toBe('button');
expect(button.getAttribute('aria-pressed')).toBe('false');
expect(button.textContent?.trim()).toBe('visibility');
});
it('reveals and masks again on each click, keeping its label', () => {
const { fixture, input, button } = render();
button.click();
fixture.detectChanges();
expect(input.type).toBe('text');
expect(button.getAttribute('aria-pressed')).toBe('true');
expect(button.textContent?.trim()).toBe('visibility_off');
expect(button.getAttribute('aria-label')).toBe('Show password');
button.click();
fixture.detectChanges();
expect(input.type).toBe('password');
expect(button.getAttribute('aria-pressed')).toBe('false');
});
it('does not submit the surrounding form', () => {
const form = document.createElement('form');
const submit = jest.fn((event: Event) => event.preventDefault());
form.addEventListener('submit', submit);
const { button } = render();
form.appendChild(button);
document.body.appendChild(form);
button.click();
expect(submit).not.toHaveBeenCalled();
form.remove();
});
});
@@ -0,0 +1,32 @@
import { computed, Directive, signal } from '@angular/core';
/**
* Turns a `mat-icon-button` suffix into the show/hide control of a password
* field. The field starts masked; the input binds `[type]="toggle.inputType()"`
* and the icon renders `toggle.icon()`.
*
* It is a toggle button in the ARIA sense: the template gives it one constant
* label ("Show password") and `aria-pressed` carries the state, so a screen
* reader announces "Show password, toggle button, pressed" instead of a label
* that flips under the user's focus.
*/
@Directive({
selector: 'button[appPasswordVisibilityToggle]',
exportAs: 'appPasswordVisibilityToggle',
host: {
type: 'button',
'[attr.aria-pressed]': 'visible()',
'(click)': 'toggle()',
},
})
export class PasswordVisibilityToggleDirective {
readonly visible = signal(false);
readonly inputType = computed(() => (this.visible() ? 'text' : 'password'));
readonly icon = computed(() =>
this.visible() ? 'visibility_off' : 'visibility'
);
toggle(): void {
this.visible.update((visible) => !visible);
}
}