Search code examples
angularkarma-jasminegoogle-chrome-headlessheadless-browser

Nested @Self() ngControl not provided in headless tests


I have a common input component with the constructor

constructor(private fg: FormGroupDirective, @Self() public ngControl: NgControl) {
    if (this.ngControl) {
      this.ngControl.valueAccessor = this;
    }
}

This is used within my angular component:

<!-- Email -->
    <app-input
      ngDefaultControl
      formControlName="email"
      [additionalClasses]="['mb-32']"
      [idPrefix]="'email'"
      [label]="'Email address'"
      [maxLength]="100"
      [size]="fullWidthInput">
    </app-input>

The custom input component inherits from a BaseInput Component

@Component({ template: `` })
export abstract class BaseInputComponent implements ControlValueAccessor, OnDestroy {
  @Input() additionalClasses: string[] = [];
  @Input() additionalInputClasses: string[] = [];
  @Input() additionalLabelClasses: string[] = [];
  @Input() idPrefix: string;
  @Input() label: string;
  @Input() required = true;
  @Input() size: InputSize = InputSize.Small;

  protected ngUnsubscribe = new Subject<void>();

  constructor(private fg: FormGroupDirective, @Self() protected ngControl: NgControl) {
    if (this.ngControl) {
      this.ngControl.valueAccessor = this;
    }
  }

  get errorId(): string {
    return `${this.idPrefix}-error`;
  }

  get errorMessage(): string {
    return InputErrors.getErrorMessageForControl(this.ngControl, this.label);
  }

  get formControl(): AbstractControl | null {
    return this.ngControl?.control;
  }

  get inputId(): string {
    return `${this.idPrefix}-input`;
  }

  get labelClasses(): string {
    return this.additionalLabelClasses.join(' ');
  }

  get isInvalid(): boolean {
    return CustomValidation.isFormControlInvalid(this.ngControl, this.fg);
  }

  get labelId(): string {
    return `${this.idPrefix}-label`;
  }

  get parentForm(): FormGroup {
    return this.fg.form;
  }

  get value(): any {
    return this.ngControl.value;
  }

  set value(v: any) {
    this.propagateChange(v);
    this.propagateTouched();
  }

  get wrapperClasses(): string[] {
    const classes = ['input', this.size.toString(), ...this.additionalClasses];

    if (this.isInvalid) {
      classes.push('ft-invalid');
    }

    return classes;
  }

  // propagate changes to form control
  propagateChange = (_: string) => {};

  // propagate touched changes to form control
  propagateTouched = () => {};

  // From ControlValueAccessor interface
  registerOnChange(fn: any): void {
    this.propagateChange = fn;
  }

  // From ControlValueAccessor interface
  registerOnTouched(fn: any): void {
    this.propagateTouched = fn;
  }

  // From ControlValueAccessor interface
  writeValue(value: string): void {
    if (value && value !== this.value) {
      this.value = value;
    }
  }

  ngOnDestroy(): void {
    this.ngUnsubscribe.next();
    this.ngUnsubscribe.complete();
  }
}

And the custom input component:

<mat-form-field [ngClass]="inputClasses" [floatLabel]="'never'" *ngIf="!readonly; else ReadonlyInput">
    <mat-placeholder *ngIf="placeholder">{{ placeholder }}</mat-placeholder>
    <input
      matInput
      [id]="inputId"
      [formControl]="formControl"
      [maxLength]="maxLength"
      [type]="type"
      [value]="value"
      (input)="propagateChange($event.target.value)"
      (keyup.enter)="onEnter()" />
    <div *ngIf="isInvalid" [id]="errorId">
      <mat-error>{{ errorMessage }}</mat-error>
    </div>
  </mat-form-field>

get value() is what is causing the errors (I think), as the injected ngControl is undefined.

I have a set of Karma/Jasmine tests. When running with the command ng test locally, the browser opens, all tests pass and there are no errors or warnings in the console.

When running the command ran in DevOps, ng test --codeCoverage=true --watch=false --browsers ChromeHeadless, (both in DevOps and locally), the below error is displayed:

TypeError: Cannot read property 'value' of undefined
            at <Jasmine>
            at InputComponent.get value [as value] (http://localhost:9877/_karma_webpack_/src/app/shared/components/form/base-input.component.ts:72:4)
            at InputComponent_mat_form_field_2_Template (ng:///InputComponent.js:83:62)
            at executeTemplate (http://localhost:9877/_karma_webpack_/node_modules/@angular/core/__ivy_ngcc__/fesm2015/core.js:7447:1)
            at refreshView (http://localhost:9877/_karma_webpack_/node_modules/@angular/core/__ivy_ngcc__/fesm2015/core.js:7316:1)
            at refreshEmbeddedViews (http://localhost:9877/_karma_webpack_/node_modules/@angular/core/__ivy_ngcc__/fesm2015/core.js:8408:1)
            at refreshView (http://localhost:9877/_karma_webpack_/node_modules/@angular/core/__ivy_ngcc__/fesm2015/core.js:7340:1)
            at refreshComponent (http://localhost:9877/_karma_webpack_/node_modules/@angular/core/__ivy_ngcc__/fesm2015/core.js:8454:1)
            at refreshChildComponents (http://localhost:9877/_karma_webpack_/node_modules/@angular/core/__ivy_ngcc__/fesm2015/core.js:7109:1)
            at refreshView (http://localhost:9877/_karma_webpack_/node_modules/@angular/core/__ivy_ngcc__/fesm2015/core.js:7366:1)
            at refreshComponent (http://localhost:9877/_karma_webpack_/node_modules/@angular/core/__ivy_ngcc__/fesm2015/core.js:8454:1)
        TypeError: Cannot read property 'getCheckedValue' of undefined
            at <Jasmine>
            at http://localhost:9877/_karma_webpack_/src/app/users/components/new-user-dialog/new-user-dialog.component.spec.ts:101:55
            at <Jasmine>
            at http://localhost:9877/_karma_webpack_/main.js:364670:71
            at new ZoneAwarePromise (http://localhost:9877/_karma_webpack_/node_modules/zone.js/dist/zone-evergreen.js:960:1)
            at ./src/app/users/components/new-user-dialog/new-user-dialog.component.spec.ts.__awaiter (http://localhost:9877/_karma_webpack_/main.js:364666:12)
            at UserContext.<anonymous> (http://localhost:9877/_karma_webpack_/src/app/users/components/new-user-dialog/new-user-dialog.component.spec.ts:100:47)
            at ZoneDelegate.invoke (http://localhost:9877/_karma_webpack_/node_modules/zone.js/dist/zone-evergreen.js:364:1)
            at ProxyZoneSpec.push../node_modules/zone.js/dist/zone-testing.js.ProxyZoneSpec.onInvoke (http://localhost:9877/_karma_webpack_/node_modules/zone.js/dist/zone-testing.js:292:1)
            at ZoneDelegate.invoke (http://localhost:9877/_karma_webpack_/node_modules/zone.js/dist/zone-evergreen.js:363:1)

Can anyone help me with a solution? The fact this works with the browser is what's throwing me - all dependency injection etc seems to be configured correctly.


Solution

  • For anyone seeing this, seeing the same issue, the problem was with a constructor in a base component.

    In the base component:

    constructor(private fg: FormGroupDirective, @Self() protected ngControl: NgControl) {
      if (this.ngControl) {
        this.ngControl.valueAccessor = this;
      }
    }
    

    And in the input component, no constructor at all. For the headless tests, this seems to be required.

    constructor(fg: FormGroupDirective, @Self() ngControl: NgControl) {
      super(fg, ngControl);
    }