feat(ct-angular): render component with signal inputs

This commit is contained in:
Younes Jaaidi 2024-02-23 17:44:58 +01:00
parent fdf8595dfc
commit 0e5dbecf7e
No known key found for this signature in database
GPG key ID: 3126C5717BDF3241
5 changed files with 64 additions and 127 deletions

View file

@ -42,7 +42,7 @@ type ComponentSlots = Record<string, ComponentSlot> & { default?: ComponentSlot
type ComponentEvents = Record<string, Function>;
export interface MountOptions<HooksConfig extends JsonObject, Component> {
props?: Partial<Component>, // TODO: filter props
props?: Partial<Component> | Record<string, unknown>, // TODO: filter props and handle signals
slots?: ComponentSlots;
on?: ComponentEvents;
hooksConfig?: HooksConfig;

View file

@ -1,18 +1,18 @@
// /**
// * Copyright (c) Microsoft Corporation.
// *
// * Licensed under the Apache License, Version 2.0 (the "License");
// * you may not use this file except in compliance with the License.
// * You may obtain a copy of the License at
// *
// * http://www.apache.org/licenses/LICENSE-2.0
// *
// * Unless required by applicable law or agreed to in writing, software
// * distributed under the License is distributed on an "AS IS" BASIS,
// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// * See the License for the specific language governing permissions and
// * limitations under the License.
// */
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// @ts-check
// This file is injected into the registry as text, no dependencies are allowed.
@ -75,7 +75,7 @@ window.playwrightUpdate = async (rootElement, component) => {
if (!fixture)
throw new Error('Component was not mounted');
__pwUpdateProps(fixture, component.props);
__pwUpdateProps(fixture, component);
__pwUpdateEvents(fixture, component.on);
fixture.detectChanges();
@ -91,40 +91,35 @@ const __pwFixtureRegistry = new Map();
* @param {ComponentInfo} component
*/
async function __pwRenderComponent(component) {
let componentClass = component.type;
/** @type {import('@angular/core').Type<unknown>} */
let componentClass;
if (typeof componentClass === 'string') {
if (__pwIsTemplate(component)) {
const templateInfo = /** @type {TemplateInfo} */(component);
componentClass = defineComponent({
imports: templateInfo.imports,
selector: 'pw-template-component',
standalone: true,
template: componentClass
template: templateInfo.type,
})(class {});
} else {
componentClass = /** @type {import('@angular/core').Type<unknown>} */(component.type);
}
const componentMetadata = reflectComponentType(componentClass);
if (!componentMetadata?.isStandalone)
throw new Error('Only standalone components are supported');
const WrapperComponent = defineComponent({
selector: 'pw-wrapper-component',
template: ``,
})(class {});
TestBed.configureTestingModule({
imports: [componentClass],
declarations: [WrapperComponent]
});
await TestBed.compileComponents();
__pwUpdateSlots(WrapperComponent, component.slots, componentMetadata.selector);
const fixture = TestBed.createComponent(WrapperComponent);
const fixture = TestBed.createComponent(componentClass);
fixture.nativeElement.id = 'root';
__pwUpdateProps(fixture, component.props);
__pwUpdateProps(fixture, component);
__pwUpdateEvents(fixture, component.on);
fixture.autoDetectChanges();
@ -134,10 +129,18 @@ async function __pwRenderComponent(component) {
/**
* @param {import('@angular/core/testing').ComponentFixture} fixture
* @param {ComponentInfo} componentInfo
*/
function __pwUpdateProps(fixture, props = {}) {
for (const [name, value] of Object.entries(props))
fixture.debugElement.children[0].context[name] = value;
function __pwUpdateProps(fixture, componentInfo) {
if (!componentInfo.props) return;
if (__pwIsTemplate(componentInfo)) {
Object.assign(fixture.componentInstance, componentInfo.props);
} else {
for (const [name, value] of Object.entries(componentInfo.props))
fixture.componentRef.setInput(name, value);
}
}
/**
@ -150,7 +153,7 @@ function __pwUpdateEvents(fixture, events = {}) {
/* Unsubscribe previous listener. */
outputSubscriptionRecord[name]?.unsubscribe();
const subscription = fixture.debugElement.children[0].componentInstance[
const subscription = fixture.componentInstance[
name
].subscribe((/** @type {unknown} */ event) => listener(event));
@ -162,51 +165,10 @@ function __pwUpdateEvents(fixture, events = {}) {
__pwOutputSubscriptionRegistry.set(fixture, outputSubscriptionRecord);
}
/**
* @param {any} value
* @return {?HTMLElement}
* @param {ComponentInfo} component
*/
function __pwCreateSlot(value) {
return /** @type {?HTMLElement} */ (
document
.createRange()
.createContextualFragment(value)
.firstChild
);
}
function __pwUpdateSlots(Component, slots = {}, tagName) {
const wrapper = document.createElement(tagName);
for (const [key, value] of Object.entries(slots)) {
let slotElements;
if (typeof value !== 'object')
slotElements = [__pwCreateSlot(value)];
if (Array.isArray(value))
slotElements = value.map(__pwCreateSlot);
if (!slotElements)
throw new Error(`Invalid slot with name: \`${key}\` supplied to \`mount()\``);
for (const slotElement of slotElements) {
if (!slotElement)
throw new Error(`Invalid slot with name: \`${key}\` supplied to \`mount()\``);
if (key === 'default') {
wrapper.appendChild(slotElement);
continue;
}
if (slotElement.nodeName === '#text') {
throw new Error(
`Invalid slot with name: \`${key}\` supplied to \`mount()\`, expected \`HTMLElement\` but received \`TextNode\`.`
);
}
slotElement.setAttribute(key, '');
wrapper.appendChild(slotElement);
}
}
TestBed.overrideTemplate(Component, wrapper.outerHTML);
function __pwIsTemplate(component) {
return typeof component.type === 'string';
}

View file

@ -0,0 +1,12 @@
import { Component, input } from '@angular/core';
@Component({
standalone: true,
selector: 'app-button-signals',
template: `
<button>{{title()}}</button>
`
})
export class ButtonSignalsComponent {
title = input.required<string>();
}

View file

@ -3,8 +3,9 @@ import { ButtonComponent } from '@/components/button.component';
import { EmptyComponent } from '@/components/empty.component';
import { ComponentComponent } from '@/components/component.component';
import { NotInlinedComponent } from '@/components/not-inlined.component';
import { ButtonSignalsComponent } from '@/components/button-signals.component';
test('render props', async ({ mount }) => {
test('render inputs', async ({ mount }) => {
const component = await mount(ButtonComponent, {
props: {
title: 'Submit',
@ -13,6 +14,15 @@ test('render props', async ({ mount }) => {
await expect(component).toContainText('Submit');
});
test('render signal-based inputs', async ({ mount }) => {
const component = await mount(ButtonSignalsComponent, {
props: {
title: 'Submit',
},
});
await expect(component).toContainText('Submit');
});
test('get textContent of the empty component', async ({ mount }) => {
const component = await mount(EmptyComponent);
expect(await component.allTextContents()).toEqual(['']);

View file

@ -1,47 +0,0 @@
import { test, expect } from '@playwright/experimental-ct-angular';
import { DefaultSlotComponent } from '@/components/default-slot.component';
import { NamedSlotsComponent } from '@/components/named-slots.component';
test('render a default slot', async ({ mount }) => {
const component = await mount(DefaultSlotComponent, {
slots: {
default: '<strong>Main Content</strong>',
},
});
await expect(component.getByRole('strong')).toContainText('Main Content');
});
test('render a component as slot', async ({ mount }) => {
const component = await mount(DefaultSlotComponent, {
slots: {
default: '<app-button title="Submit" />', // component is registered globally in /playwright/index.ts
},
});
await expect(component).toContainText('Submit');
});
test('render a component with multiple slots', async ({ mount }) => {
const component = await mount(DefaultSlotComponent, {
slots: {
default: [
'<div data-testid="one">One</div>',
'<div data-testid="two">Two</div>',
],
},
});
await expect(component.getByTestId('one')).toContainText('One');
await expect(component.getByTestId('two')).toContainText('Two');
});
test('render a component with a named slots', async ({ mount }) => {
const component = await mount(NamedSlotsComponent, {
slots: {
header: '<div header>Header</div>', // <div header is optional
main: '<div>Main Content</div>',
footer: '<div>Footer</div>',
},
});
await expect(component).toContainText('Header');
await expect(component).toContainText('Main Content');
await expect(component).toContainText('Footer');
});