Skip to content

Commit

Permalink
feat(ct): add mount support for template strings
Browse files Browse the repository at this point in the history
  • Loading branch information
chronospatian committed May 15, 2024
1 parent 7f4626a commit 9eb93a1
Show file tree
Hide file tree
Showing 11 changed files with 215 additions and 21 deletions.
8 changes: 6 additions & 2 deletions packages/playwright-ct-angular/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,18 @@
import type { Locator } from 'playwright/test';
import type { JsonObject } from '@playwright/experimental-ct-core/types/component';
import type { TestType } from '@playwright/experimental-ct-core';
import type { Type } from '@angular/core';
import type { EnvironmentProviders, Provider, Type } from '@angular/core';

export type ComponentEvents = Record<string, Function>;

export interface MountOptions<HooksConfig extends JsonObject, Component> {
props?: Partial<Component> | Record<string, unknown>, // TODO: filter props and handle signals
on?: ComponentEvents;
hooksConfig?: HooksConfig;
imports?: (Type<any> | ReadonlyArray<any>)[]
environmentProviders?: (EnvironmentProviders | Provider)[]
providers?: Provider[]
viewProviders?: Provider[]
}

export interface MountResult<Component> extends Locator {
Expand All @@ -37,7 +41,7 @@ export interface MountResult<Component> extends Locator {

export const test: TestType<{
mount<HooksConfig extends JsonObject, Component = unknown>(
component: Type<Component>,
component: Type<Component> | string,
options?: MountOptions<HooksConfig, Component>
): Promise<MountResult<Component>>;
}>;
Expand Down
91 changes: 79 additions & 12 deletions packages/playwright-ct-angular/registerSource.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,15 @@
/** @typedef {import('../playwright-ct-core/types/component').ObjectComponent} ObjectComponent */

import 'zone.js';
import { reflectComponentType } from '@angular/core';
import { getTestBed, TestBed } from '@angular/core/testing';
import {
BrowserDynamicTestingModule,
platformBrowserDynamicTesting,
} from '@angular/platform-browser-dynamic/testing';
Component,
createComponent,
EnvironmentInjector,
EventEmitter, INJECTOR,
reflectComponentType
} from '@angular/core';
import { getTestBed, TestBed } from '@angular/core/testing';
import { BrowserDynamicTestingModule, platformBrowserDynamicTesting, } from '@angular/platform-browser-dynamic/testing';

/** @type {WeakMap<import('@angular/core/testing').ComponentFixture, Record<string, import('rxjs').Subscription>>} */
const __pwOutputSubscriptionRegistry = new WeakMap();
Expand All @@ -39,26 +42,82 @@ getTestBed().initTestEnvironment(
);

/**
* @param {ObjectComponent} component
* @param {ObjectComponent & MountOptions} component
*/
async function __pwRenderComponent(component) {
function __pwConfigureComponent(component) {
const componentMetadata = reflectComponentType(component.type);
if (!componentMetadata?.isStandalone)
throw new Error('Only standalone components are supported');

TestBed.configureTestingModule({
imports: [component.type],
});
})

if (component.environmentProviders) {
TestBed.configureTestingModule({
providers: component.environmentProviders
})
}

if (component.imports) {
TestBed.overrideComponent(component.type, {
add: {
imports: component.imports,
}
})
}

if (component.providers) {
TestBed.overrideComponent(component.type, {
add: {
providers: component.providers,
}
})
}

if (component.viewProviders) {
TestBed.overrideComponent(component.type, {
add: {
viewProviders: component.viewProviders,
}
})
}}

/**
* @param {ObjectComponent & MountOptions} component
*/
function __pwConfigureTemplate(component) {
const inputs = Object.keys(component.props ?? {})
const outputs = Object.keys(component.on ?? {})
component.type = Component({
standalone: true,
template: component.type,
inputs: inputs
})(class WrapperComponent {
constructor() {
for (const output of outputs) {
this[output] = new EventEmitter()
}
}
})
}

/**
* @param {ObjectComponent & MountOptions} component
* @param {HTMLElement} rootElement
*/
async function __pwRenderComponent(component, rootElement) {
await TestBed.compileComponents();

const fixture = TestBed.createComponent(component.type);
fixture.nativeElement.id = 'root';
const fixture = TestBed.createComponent(component.type)

rootElement.replaceChildren(fixture.nativeElement)
document.body.replaceChildren(rootElement)

__pwUpdateProps(fixture, component.props);
__pwUpdateEvents(fixture, component.on);

fixture.autoDetectChanges();
fixture.autoDetectChanges(true);

return fixture;
}
Expand Down Expand Up @@ -100,7 +159,15 @@ window.playwrightMount = async (component, rootElement, hooksConfig) => {
for (const hook of window.__pw_hooks_before_mount || [])
await hook({ hooksConfig, TestBed });

const fixture = await __pwRenderComponent(component);
const isTemplate = typeof component.type === "string"

if (isTemplate) {
__pwConfigureTemplate(component)
}

__pwConfigureComponent(component)

const fixture = await __pwRenderComponent(component, rootElement);

for (const hook of window.__pw_hooks_after_mount || [])
await hook({ hooksConfig });
Expand Down
5 changes: 1 addition & 4 deletions tests/components/ct-angular/playwright.config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export default defineConfig({
ctViteConfig: {
plugins: [angular({
tsconfig: resolve('./tsconfig.spec.json'),
jit: true // must use jit otherwise TestBed.overrideComponent fails
}) as any], // TODO: remove any and resolve various installed conflicting Vite versions
resolve: {
alias: {
Expand All @@ -45,9 +46,5 @@ export default defineConfig({
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ import { Component, EventEmitter, Input, Output } from '@angular/core';
<ng-content></ng-content>
</div>
`,
styles: [`
:host {
display: block;
padding: 20px;
}
`]
})
export class CounterComponent {
remountCount = Number(localStorage.getItem('remountCount'));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Component } from '@angular/core';

@Component({
standalone: true,
selector: 'app-named-slots',
template: `
<div>
<header>
Expand Down
26 changes: 26 additions & 0 deletions tests/components/ct-angular/src/components/providers.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Component, inject, Injectable, NgModule } from '@angular/core';

@Injectable()
export class Store {
state = {
text: `Store`
}
}

@NgModule({
providers: [Store]
})
export class StoreModule {}

@Component({
standalone: true,
selector: 'app-imports',
template: `
{{ provider?.state.text }}: from provider
{{ environmentProvider?.state.text }}: from environmentProvider
`
})
export class ProvidersComponent {
provider = inject(Store, { self: true, optional: true })
environmentProvider = inject(Store, { skipSelf: true, optional: true })
}
4 changes: 2 additions & 2 deletions tests/components/ct-angular/tests/events.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ test('emit an submit event when the button is clicked', async ({ mount }) => {
submit: (data: string) => messages.push(data),
},
});
await component.click();
await component.locator('css=button').click();
expect(messages).toEqual(['hello']);
});

Expand All @@ -38,7 +38,7 @@ test('replace existing listener when new listener is set', async ({
},
});

await component.click();
await component.locator('css=button').click();
expect(called).toBe(true);
});

Expand Down
38 changes: 38 additions & 0 deletions tests/components/ct-angular/tests/providers.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { expect, test } from '@playwright/experimental-ct-angular';
import type { HooksConfig } from '../playwright';
import { ProvidersComponent, Store, StoreModule } from '@/components/providers.component';

test('should add imports', async ({ mount }) => {
const component = await mount<HooksConfig>(ProvidersComponent, {
imports: [StoreModule],
});
await expect(component).toContainText('Store: from environmentProvider')
await expect(component).not.toContainText('Store: from provider')
})

test('should add environment providers', async ({ mount }) => {
const component = await mount<HooksConfig>(ProvidersComponent, {
environmentProviders: [Store],
});

await expect(component).toContainText('Store: from environmentProvider')
await expect(component).not.toContainText('Store: from provider')
})

test('should add component providers', async ({ mount }) => {
const component = await mount<HooksConfig>(ProvidersComponent, {
providers: [Store],
});

await expect(component).not.toContainText('Store: from environmentProvider')
await expect(component).toContainText('Store: from provider')
})

test('should add component view providers', async ({ mount }) => {
const component = await mount<HooksConfig>(ProvidersComponent, {
viewProviders: [Store],
});

await expect(component).not.toContainText('Store: from environmentProvider')
await expect(component).toContainText('Store: from provider')
})
23 changes: 23 additions & 0 deletions tests/components/ct-angular/tests/slots.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { expect, test } from '@playwright/experimental-ct-angular';
import { NamedSlotsComponent } from '@/components/named-slots.component';

test('should render a string', async ({ mount }) => {
const component = await mount(`
<app-named-slots>
<div ngProjectAs="[header]">{{header}}</div>
<div ngProjectAs="[main]">{{main}}</div>
<div ngProjectAs="[footer]">{{footer}}</div>
</app-named-slots>
`, {
imports: [NamedSlotsComponent],
props: {
header: "Header",
main: "Main",
footer: "Footer"
}
});

await expect(component.getByRole('banner')).toHaveText('Header')
await expect(component.getByRole('main')).toHaveText('Main')
await expect(component.getByRole('contentinfo')).toHaveText('Footer')
})
30 changes: 30 additions & 0 deletions tests/components/ct-angular/tests/template.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { expect, test } from '@playwright/experimental-ct-angular';
import type { HooksConfig } from '../playwright';
import { ButtonComponent } from '@/components/button.component';

test('should render a string', async ({ mount }) => {
const messages = [] as any[]
const component = await mount<HooksConfig>(`<app-button data-testid="test" [title]="title" (click)="message.emit('Clicked!')" />`, {
imports: [ButtonComponent],
props: {
title: "Hello!"
},
on: {
message: (value: any) => messages.push(value)
}
});

await expect(component).toHaveText('Hello!')

await component.update({
props: {
title: 'Goodbye!'
}
})

await expect(component).toHaveText('Goodbye!')

await component.getByTestId('test').click()

expect(messages).toEqual(['Clicked!'])
})
4 changes: 3 additions & 1 deletion tests/components/ct-angular/tests/update.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ test('update event listeners without remounting', async ({ mount }) => {
submit: (data: string) => messages.push(data),
},
});
await component.click();
await component.locator('css=div').first().click({
position: { x: 0, y: 0 }
});
expect(messages).toEqual(['hello']);

await expect(component.getByTestId('remount-count')).toContainText('1');
Expand Down

0 comments on commit 9eb93a1

Please sign in to comment.