Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

POC: zoneless harness support with context-based knob #29428

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 11 additions & 11 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,12 @@
},
"version": "19.0.0-next.1",
"dependencies": {
"@angular/animations": "^18.2.0-next.2",
"@angular/common": "^18.2.0-next.2",
"@angular/compiler": "^18.2.0-next.2",
"@angular/core": "^18.2.0-next.2",
"@angular/forms": "^18.2.0-next.2",
"@angular/platform-browser": "^18.2.0-next.2",
"@angular/animations": "^19.0.0-next.1",
"@angular/common": "^19.0.0-next.1",
"@angular/compiler": "^19.0.0-next.1",
"@angular/core": "^19.0.0-next.1",
"@angular/forms": "^19.0.0-next.1",
"@angular/platform-browser": "^19.0.0-next.1",
"@types/google.maps": "^3.54.10",
"@types/youtube": "^0.0.50",
"rxjs": "^6.6.7",
Expand All @@ -77,12 +77,12 @@
"@angular/build-tooling": "https://github.com/angular/dev-infra-private-build-tooling-builds.git#74e0e7b090c6e16056290836b2d936ca7820b86f",
"@angular/build": "^18.2.0-next.2",
"@angular/cli": "^18.2.0-next.2",
"@angular/compiler-cli": "^18.2.0-next.2",
"@angular/localize": "^18.2.0-next.2",
"@angular/compiler-cli": "^19.0.0-next.1",
"@angular/localize": "^19.0.0-next.1",
"@angular/ng-dev": "https://github.com/angular/dev-infra-private-ng-dev-builds.git#36946be4df61f6549ae3829c026022e47674eae2",
"@angular/platform-browser-dynamic": "^18.2.0-next.2",
"@angular/platform-server": "^18.2.0-next.2",
"@angular/router": "^18.2.0-next.2",
"@angular/platform-browser-dynamic": "^19.0.0-next.1",
"@angular/platform-server": "^19.0.0-next.1",
"@angular/router": "^19.0.0-next.1",
"@babel/core": "^7.16.12",
"@babel/helper-explode-assignable-expression": "^7.18.6",
"@babel/helper-string-parser": "^7.22.5",
Expand Down
13 changes: 13 additions & 0 deletions src/cdk/testing/change-detection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,3 +189,16 @@ export function parallel<T>(values: () => (T | PromiseLike<T>)[]): Promise<T[]>;
export async function parallel<T>(values: () => Iterable<T | PromiseLike<T>>): Promise<T[]> {
return batchChangeDetection(() => Promise.all(values()), true);
}

let zoneless = true;

export const zonelessHarnesses = () => zoneless;

export async function waitForZoneInHarnesses<T>(fn: () => Promise<T>) {
zoneless = false;
try {
return await fn();
} finally {
zoneless = true;
}
}
34 changes: 26 additions & 8 deletions src/cdk/testing/component-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,13 @@ export type LocatorFnResult<T extends (HarnessQuery<any> | string)[]> = {
[I in keyof T]: T[I] extends new (...args: any[]) => infer C // Map `ComponentHarnessConstructor<C>` to `C`.
? C
: // Map `HarnessPredicate<C>` to `C`.
T[I] extends {harnessType: new (...args: any[]) => infer C}
? C
: // Map `string` to `TestElement`.
T[I] extends string
? TestElement
: // Map everything else to `never` (should not happen due to the type constraint on `T`).
never;
T[I] extends {harnessType: new (...args: any[]) => infer C}
? C
: // Map `string` to `TestElement`.
T[I] extends string
? TestElement
: // Map everything else to `never` (should not happen due to the type constraint on `T`).
never;
}[number];

/**
Expand Down Expand Up @@ -263,6 +263,13 @@ export interface LocatorFactory {
* authors to wait for async tasks outside of the Angular zone.
*/
waitForTasksOutsideAngular(): Promise<void>;

/**
* Waits for the given condition
*/
until(log: string, condition: () => boolean | Promise<boolean>): Promise<void>;

sleep(ms: number): Promise<void>;
}

/**
Expand Down Expand Up @@ -399,6 +406,14 @@ export abstract class ComponentHarness {
protected async waitForTasksOutsideAngular() {
return this.locatorFactory.waitForTasksOutsideAngular();
}

protected async sleep(ms: number) {
return this.locatorFactory.sleep(ms);
}

protected async until(log: string, condition: () => boolean | Promise<boolean>) {
return this.locatorFactory.until(log, condition);
}
}

/**
Expand Down Expand Up @@ -471,7 +486,10 @@ export class HarnessPredicate<T extends ComponentHarness> {
private _descriptions: string[] = [];
private _ancestor: string;

constructor(public harnessType: ComponentHarnessConstructor<T>, options: BaseHarnessFilters) {
constructor(
public harnessType: ComponentHarnessConstructor<T>,
options: BaseHarnessFilters,
) {
this._addBaseOptions(options);
}

Expand Down
11 changes: 11 additions & 0 deletions src/cdk/testing/harness-environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,17 @@ export abstract class HarnessEnvironment<E> implements HarnessLoader, LocatorFac
// Part of LocatorFactory interface, subclasses will implement.
abstract waitForTasksOutsideAngular(): Promise<void>;

// Part of LocatorFactory interface, subclasses will implement.
abstract sleep(ms: number): Promise<void>;

async until(log: string, condition: () => boolean | Promise<boolean>) {
console.log(`Waiting for condition: ${log} (PENDING...)`);
while (!(await condition())) {
await this.sleep(1);
}
console.log(`Waiting for condition: ${log} (COMPLETE!)`);
}

/** Gets the root element for the document. */
protected abstract getDocumentRoot(): E;

Expand Down
18 changes: 17 additions & 1 deletion src/cdk/testing/testbed/testbed-harness-environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ import {
HarnessLoader,
stopHandlingAutoChangeDetectionStatus,
TestElement,
zonelessHarnesses,
} from '@angular/cdk/testing';
import {ComponentFixture, flush} from '@angular/core/testing';
import {ComponentFixture, flush, tick} from '@angular/core/testing';
import {Observable} from 'rxjs';
import {takeWhile} from 'rxjs/operators';
import {TaskState, TaskStateZoneInterceptor} from './task-state-zone-interceptor';
Expand Down Expand Up @@ -78,6 +79,9 @@ function isInFakeAsyncZone() {
*/
async function detectChanges(fixture: ComponentFixture<unknown>) {
fixture.detectChanges();
if (zonelessHarnesses()) {
return;
}
if (isInFakeAsyncZone()) {
flush();
} else {
Expand Down Expand Up @@ -180,6 +184,10 @@ export class TestbedHarnessEnvironment extends HarnessEnvironment<Element> {
* authors to wait for async tasks outside of the Angular zone.
*/
async waitForTasksOutsideAngular(): Promise<void> {
if (zonelessHarnesses()) {
return;
}

// If we run in the fake async zone, we run "flush" to run any scheduled tasks. This
// ensures that the harnesses behave inside of the FakeAsyncTestZone similar to the
// "AsyncTestZone" and the root zone (i.e. neither fakeAsync or async). Note that we
Expand All @@ -197,6 +205,14 @@ export class TestbedHarnessEnvironment extends HarnessEnvironment<Element> {
await this._taskState?.pipe(takeWhile(state => !state.stable)).toPromise();
}

async sleep(ms: number) {
if (isInFakeAsyncZone()) {
tick(ms); // TODO: Doesn't work for zoneless + fakeAsync.
} else {
await new Promise(resolve => setTimeout(resolve, ms));
}
}

/** Gets the root element for the document. */
protected getDocumentRoot(): Element {
return document.body;
Expand Down
3 changes: 2 additions & 1 deletion src/cdk/testing/tests/cross-environment.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
HarnessPredicate,
parallel,
TestElement,
waitForZoneInHarnesses,
} from '@angular/cdk/testing';
import {MainComponentHarness} from './harnesses/main-component-harness';
import {SubComponentHarness, SubComponentSpecialHarness} from './harnesses/sub-component-harness';
Expand Down Expand Up @@ -120,7 +121,7 @@ export function crossEnvironmentSpecs(
let harness: MainComponentHarness;

beforeEach(async () => {
harness = await getMainComponentHarnessFromEnvironment();
harness = await waitForZoneInHarnesses(getMainComponentHarnessFromEnvironment);
});

it('should locate a required element based on CSS selector', async () => {
Expand Down
40 changes: 39 additions & 1 deletion src/cdk/testing/tests/harnesses/main-component-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/

import {waitForZoneInHarnesses} from '../../change-detection';
import {ComponentHarness} from '../../component-harness';
import {TestElement, TestKey} from '../../test-element';
import {CompoundSelectorHarness} from './compound-selector-harness';
Expand Down Expand Up @@ -130,7 +131,7 @@ export class MainComponentHarness extends ComponentHarness {
async increaseCounter(times: number) {
const button = await this.button();
for (let i = 0; i < times; i++) {
await button.click();
await waitForZoneInHarnesses(() => button.click());
}
}

Expand Down Expand Up @@ -160,3 +161,40 @@ export class MainComponentHarness extends ComponentHarness {
return (await this.taskStateTestResult()).text();
}
}

export class MainComponentZonelessHarness extends MainComponentHarness {
async untilInitialized() {
await this.until(
'counter initialized',
async () => (await (await this.asyncCounter()).text()) !== '0',
);
}

override async increaseCounter(times: number) {
const button = await this.button();
for (let i = 0; i < times; i++) {
const oldCount = await (await this.asyncCounter()).text();
await button.click();
await this.until(
`counter incremented to ${Number(oldCount) + 1}`,
async () => oldCount !== (await (await this.asyncCounter()).text()),
);
}
}
}

export class BrokenMainComponentZonelessHarness extends MainComponentHarness {
override async increaseCounter(times: number) {
const button = await this.button();
for (let i = 0; i < times; i++) {
await button.click();
}
}

async untilCounterIs100() {
return this.until(
'counter is 100',
async () => (await (await this.asyncCounter()).text()) === '100',
);
}
}
99 changes: 81 additions & 18 deletions src/cdk/testing/tests/testbed.spec.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,29 @@
import {_supportsShadowDom} from '@angular/cdk/platform';
import {HarnessLoader, manualChangeDetection, parallel} from '@angular/cdk/testing';
import {
HarnessLoader,
manualChangeDetection,
parallel,
waitForZoneInHarnesses,
} from '@angular/cdk/testing';
import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed';
import {waitForAsync, ComponentFixture, fakeAsync, TestBed} from '@angular/core/testing';
import {provideZoneChangeDetection} from '@angular/core';
import {provideExperimentalZonelessChangeDetection} from '@angular/core';
import {ComponentFixture, fakeAsync, TestBed, waitForAsync} from '@angular/core/testing';
import {querySelectorAll as piercingQuerySelectorAll} from 'kagekiri';
import {crossEnvironmentSpecs} from './cross-environment.spec';
import {FakeOverlayHarness} from './harnesses/fake-overlay-harness';
import {MainComponentHarness} from './harnesses/main-component-harness';
import {
BrokenMainComponentZonelessHarness,
MainComponentHarness,
MainComponentZonelessHarness,
} from './harnesses/main-component-harness';
import {TestMainComponent} from './test-main-component';

describe('TestbedHarnessEnvironment', () => {
let fixture: ComponentFixture<{}>;

beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideZoneChangeDetection()],
providers: [provideExperimentalZonelessChangeDetection()],
});
fixture = TestBed.createComponent(TestMainComponent);
});
Expand Down Expand Up @@ -54,9 +63,14 @@ describe('TestbedHarnessEnvironment', () => {
describe('ComponentHarness', () => {
let harness: MainComponentHarness;

beforeEach(async () => {
harness = await TestbedHarnessEnvironment.harnessForFixture(fixture, MainComponentHarness);
});
beforeEach(() =>
waitForZoneInHarnesses(async () => {
harness = await TestbedHarnessEnvironment.harnessForFixture(
fixture,
MainComponentHarness,
);
}),
);

it('can get elements outside of host', async () => {
const subcomponents = await harness.allLists();
Expand All @@ -66,24 +80,73 @@ describe('TestbedHarnessEnvironment', () => {
expect(await globalEl.text()).toBe('Hello Yi from Angular 2!');
});

it('should be able to wait for tasks outside of Angular within native async/await', async () => {
expect(await harness.getTaskStateResult()).toBe('result');
});
it('should be able to wait for tasks outside of Angular within native async/await', () =>
waitForZoneInHarnesses(async () => {
expect(await harness.getTaskStateResult()).toBe('result');
}));

it('should be able to wait for tasks outside of Angular within async test zone', waitForAsync(() => {
harness.getTaskStateResult().then(res => expect(res).toBe('result'));
}));
it('should be able to wait for tasks outside of Angular within async test zone', waitForAsync(() =>
waitForZoneInHarnesses(async () => {
await harness.getTaskStateResult().then(res => expect(res).toBe('result'));
})));

it('should be able to wait for tasks outside of Angular within fakeAsync test zone', fakeAsync(async () => {
expect(await harness.getTaskStateResult()).toBe('result');
}));
it('should be able to wait for tasks outside of Angular within fakeAsync test zone', fakeAsync(() =>
waitForZoneInHarnesses(async () => {
expect(await harness.getTaskStateResult()).toBe('result');
})));

it('should be able to retrieve the native DOM element from a UnitTestElement', async () => {
const element = TestbedHarnessEnvironment.getNativeElement(await harness.host());
expect(element.id).toContain('root');
});

it('should wait for async operation to complete in fakeAsync test', fakeAsync(async () => {
it('should wait for async operation to complete in fake async test', fakeAsync(async () => {
const asyncCounter = await harness.asyncCounter();
expect(await asyncCounter.text()).toBe('5');
await harness.increaseCounter(3);
expect(await asyncCounter.text()).toBe('8');
}));

it('should wait for async operation to complete in real async test', async () => {
const asyncCounter = await harness.asyncCounter();
expect(await asyncCounter.text()).toBe('5');
await harness.increaseCounter(3);
expect(await asyncCounter.text()).toBe('8');
});
});

describe('zoneless ComponentHarness', () => {
let harness: MainComponentZonelessHarness;
let brokenHarness: BrokenMainComponentZonelessHarness;

beforeEach(async () => {
harness = await TestbedHarnessEnvironment.harnessForFixture(
fixture,
MainComponentZonelessHarness,
);
brokenHarness = await TestbedHarnessEnvironment.harnessForFixture(
fixture,
BrokenMainComponentZonelessHarness,
);
});

it('should wait for async operation to complete in real async test', async () => {
await harness.untilInitialized();
const asyncCounter = await harness.asyncCounter();
expect(await asyncCounter.text()).toBe('5');
await harness.increaseCounter(3);
expect(await asyncCounter.text()).toBe('8');
});

it(`should not wait for async operation to complete in real async test if harness doesn't explicitly await`, async () => {
const asyncCounter = await brokenHarness.asyncCounter();
expect(await asyncCounter.text()).toBe('0');
await brokenHarness.increaseCounter(3);
expect(await asyncCounter.text()).toBe('0');
});

fit('should wait for async operation to complete in fake async test', fakeAsync(async () => {
await harness.untilInitialized();
const asyncCounter = await harness.asyncCounter();
expect(await asyncCounter.text()).toBe('5');
await harness.increaseCounter(3);
Expand Down
Loading
Loading