Add support for Angular 17 (#5)
* feat(ct): angular component testing * test(ct): test non-event-emitter outputs * test(ct): test output listener replacement * feat(ct): support non-event-emitter outputs * docs(ct): angular * fix(ct): angular source maps * chore(ct): new angular logo * refactor(ct-angular): bump to Angular 17 and move out analogjs plugin Co-authored-by: Edouard Bozon <bozonedouard@gmail.com> * refactor(ct-angular): fix component resolution by temporary removing analogjs plugin Co-authored-by: Edouard Bozon <bozonedouard@gmail.com> * refactor(ct-angular): fix mount Co-authored-by: Edouard Bozon <bozonedouard@gmail.com> * refactor(ct-angular): disable analog plugin as it breaks component registration * refactor(ct-angular): fix input forwarding * refactor(ct-angular): fix angular outputs * refactor(ct-angular): fix angular slots * test(ct-angular): fix all tests * test(ct-angular): fix all angular tests * test(ct-angular): use analog's vite plugin to handle template files * refactor(ct-angular): remove router-specific code * refactor(ct-angular): clean up dependencies * refactor(ct-angular): remove compiler import * refactor(ct-angular): fix vite version mismatch in tests * refactor(ct-angular): bump @playwright/experimental-ct-angular to 1.42.0-next * test(ct-angular): add tests for template rendering * feat(ct-angular): render simple template * feat(ct-angular): render template with child components * feat(ct-angular): render component with signal inputs * test(ct-angular): make input required * test(ct-angular): remove now useless import * feat(ct-angular): allow setting providers * refactor(ct-angular): clean up slots remains * feat(ct): angular component testing * test(ct): test non-event-emitter outputs * test(ct): test output listener replacement * feat(ct): support non-event-emitter outputs * fix(ct): angular source maps * docs(ct): angular * chore(ct): new angular logo * feat(ct-angular): add pw-angular bin * test(ct-angular): fix type check use strict dependencies versions to reduce unpredictable behavior as package-lock.json is gitignored * refactor(ct-angular): remove useless NODE_ENV=test as we are setting the tsconfig manually Cf. https://github.com/sand4rt/playwright/pull/5#discussion_r1541172952 * refactor(ct-angular): use playwright.config.mts as analog vite plugin is esm only * chore(ct-angular): lint * fix(ct-angular): resolve Angular component usages * fix(ct-angular): resolve Angular imports/providers usages * test(ct-angular): test url change * test(ct-angular): remove duplicate test * chore(ct-angular): remove useless pw-angular cli * chore(ct-angular): remove duplicate PlaywrightTestConfig type * feat(ct-angular): export the right types * chore(ct-angular): tidy up * feat(ct-angular): throw an explicit error when mounting JSX * chore(ct-angular): remove vite from devDependencies as not used anymore * chore(ct-angular): remove useless skipLibCheck flag * chore(ct-angular): remove useless @angular/compiler Angular's esbuild plugin will automatically add it in jit mode anyway. Users who really want to use another plugin that requires it can still add it manually to their playwright/index.ts. * test(ct-angular): improve output listener update test * chore(ct): revert adapters imports and template --------- Co-authored-by: sand4rt <info@mesander.com> Co-authored-by: Edouard Bozon <bozonedouard@gmail.com>
This commit is contained in:
parent
5f76257454
commit
2aa4597597
7368
package-lock.json
generated
7368
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -14,4 +14,7 @@
|
||||||
* See the License for the specific language governing permissions and
|
* See the License for the specific language governing permissions and
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
module.exports = require('@playwright/experimental-ct-core/cli');
|
|
||||||
|
const { program } = require('@playwright/experimental-ct-core/lib/program');
|
||||||
|
|
||||||
|
program.parse(process.argv);
|
||||||
|
|
|
||||||
43
packages/playwright-ct-angular/index.d.ts
vendored
43
packages/playwright-ct-angular/index.d.ts
vendored
|
|
@ -14,41 +14,20 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type {
|
import type { Locator } from 'playwright/test';
|
||||||
TestType,
|
|
||||||
PlaywrightTestArgs,
|
|
||||||
PlaywrightTestConfig as BasePlaywrightTestConfig,
|
|
||||||
PlaywrightTestOptions,
|
|
||||||
PlaywrightWorkerArgs,
|
|
||||||
PlaywrightWorkerOptions,
|
|
||||||
Locator,
|
|
||||||
} from '@playwright/test';
|
|
||||||
import type { JsonObject } from '@playwright/experimental-ct-core/types/component';
|
import type { JsonObject } from '@playwright/experimental-ct-core/types/component';
|
||||||
import type { InlineConfig } from 'vite';
|
import type { TestType } from '@playwright/experimental-ct-core';
|
||||||
import type { Type } from '@angular/core';
|
import type { Type } from '@angular/core';
|
||||||
|
|
||||||
export type PlaywrightTestConfig<T = {}, W = {}> = Omit<BasePlaywrightTestConfig<T, W>, 'use'> & {
|
export type ComponentEvents = Record<string, Function>;
|
||||||
use?: BasePlaywrightTestConfig<T, W>['use'] & {
|
|
||||||
ctPort?: number;
|
|
||||||
ctTemplateDir?: string;
|
|
||||||
ctCacheDir?: string;
|
|
||||||
ctViteConfig?: InlineConfig | (() => Promise<InlineConfig>);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type ComponentSlot = string | string[];
|
|
||||||
type ComponentSlots = Record<string, ComponentSlot> & { default?: ComponentSlot };
|
|
||||||
|
|
||||||
type ComponentEvents = Record<string, Function>;
|
|
||||||
|
|
||||||
export interface MountOptions<HooksConfig extends JsonObject, Component> {
|
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;
|
on?: ComponentEvents;
|
||||||
hooksConfig?: HooksConfig;
|
hooksConfig?: HooksConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MountResult<Component> extends Locator {
|
export interface MountResult<Component> extends Locator {
|
||||||
unmount(): Promise<void>;
|
unmount(): Promise<void>;
|
||||||
update(options: {
|
update(options: {
|
||||||
props?: Partial<Component>,
|
props?: Partial<Component>,
|
||||||
|
|
@ -63,13 +42,7 @@ export interface ComponentFixtures {
|
||||||
): Promise<MountResult<Component>>;
|
): Promise<MountResult<Component>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const test: TestType<
|
export const test: TestType<ComponentFixtures>;
|
||||||
PlaywrightTestArgs & PlaywrightTestOptions & ComponentFixtures,
|
|
||||||
PlaywrightWorkerArgs & PlaywrightWorkerOptions
|
|
||||||
>;
|
|
||||||
|
|
||||||
export function defineConfig(config: PlaywrightTestConfig): PlaywrightTestConfig;
|
export { defineConfig, PlaywrightTestConfig } from '@playwright/experimental-ct-core';
|
||||||
export function defineConfig<T>(config: PlaywrightTestConfig<T>): PlaywrightTestConfig<T>;
|
export { expect, devices } from 'playwright/test';
|
||||||
export function defineConfig<T, W>(config: PlaywrightTestConfig<T, W>): PlaywrightTestConfig<T, W>;
|
|
||||||
|
|
||||||
export { expect, devices } from '@playwright/test';
|
|
||||||
|
|
|
||||||
|
|
@ -14,25 +14,19 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { test, expect, devices, defineConfig: originalDefineConfig } = require('@playwright/experimental-ct-core');
|
const { defineConfig: originalDefineConfig, devices, expect, test } = require('@playwright/experimental-ct-core');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
process.env['NODE_ENV'] = 'test';
|
const defineConfig = (config, ...configs) => {
|
||||||
|
return originalDefineConfig({
|
||||||
|
...config,
|
||||||
|
'@playwright/test': {
|
||||||
|
packageJSON: require.resolve('./package.json'),
|
||||||
|
},
|
||||||
|
'@playwright/experimental-ct-core': {
|
||||||
|
registerSourceFile: path.join(__dirname, 'registerSource.mjs')
|
||||||
|
},
|
||||||
|
}, ...configs);
|
||||||
|
};
|
||||||
|
|
||||||
function plugin() {
|
module.exports = { defineConfig, devices, expect, test };
|
||||||
// Only fetch upon request to avoid resolution in workers.
|
|
||||||
const { createPlugin } = require('@playwright/experimental-ct-core/lib/vitePlugin');
|
|
||||||
return createPlugin(
|
|
||||||
path.join(__dirname, 'registerSource.mjs'),
|
|
||||||
() => import('@analogjs/vite-plugin-angular').then(plugin => {
|
|
||||||
// TODO: remove the typeof plugin.default check
|
|
||||||
if (typeof plugin.default === 'function')
|
|
||||||
return plugin.default({ jit: false });
|
|
||||||
return plugin.default.default({ jit: false });
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const defineConfig = config => originalDefineConfig({ ...config, _plugins: [plugin] });
|
|
||||||
|
|
||||||
module.exports = { test, expect, devices, defineConfig };
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
{
|
{
|
||||||
"name": "@playwright/experimental-ct-angular",
|
"name": "@playwright/experimental-ct-angular",
|
||||||
"version": "1.42.0-next",
|
"version": "1.43.0-next",
|
||||||
"description": "Playwright Component Testing for Angular",
|
"description": "Playwright Component Testing for Angular",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/sand4rt/playwright-ct-angular.git"
|
"url": "git+https://github.com/microsoft/playwright.git"
|
||||||
},
|
},
|
||||||
"homepage": "https://playwright.dev",
|
"homepage": "https://playwright.dev",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
@ -29,35 +29,23 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@analogjs/vite-plugin-angular": "0.2.28",
|
"@playwright/experimental-ct-core": "1.43.0-next"
|
||||||
"@angular-devkit/build-angular": "^16.1.0",
|
|
||||||
"@playwright/experimental-ct-core": "1.42.0-next",
|
|
||||||
"vite": "^4.4.9"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular/animations": "^16.1.7",
|
"@angular/compiler": "^17.0.0",
|
||||||
"@angular/common": "^16.1.7",
|
"@angular/core": "^17.0.0",
|
||||||
"@angular/compiler": "^16.1.7",
|
"@angular/platform-browser-dynamic": "^17.0.0",
|
||||||
"@angular/compiler-cli": "^16.1.7",
|
|
||||||
"@angular/core": "^16.1.7",
|
|
||||||
"@angular/platform-browser": "^16.1.7",
|
|
||||||
"@angular/platform-browser-dynamic": "^16.1.7",
|
|
||||||
"@angular/router": "^16.1.7",
|
|
||||||
"@playwright/test": "1.38.1",
|
|
||||||
"rxjs": "~7.8.1",
|
"rxjs": "~7.8.1",
|
||||||
"tslib": "^2.5.0",
|
"typescript": "~5.2.0",
|
||||||
"typescript": "^5.0.4",
|
"zone.js": "~0.14.0"
|
||||||
"zone.js": "~0.13.1"
|
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@playwright/test": ">=1.38.1",
|
"@angular/compiler": "^17.0.0",
|
||||||
"typescript": ">=4.9.3",
|
"@angular/core": "^17.0.0",
|
||||||
"@angular/common": ">=15.1.0 || >=16.0.0",
|
"@angular/platform-browser-dynamic": "^17.0.0",
|
||||||
"@angular/platform-browser": ">=15.1.0 || >=16.0.0",
|
"typescript": ">=5.2.0"
|
||||||
"@angular/router": ">=15.1.0 || >=16.0.0",
|
|
||||||
"@angular/core": ">=15.1.0 || >=16.0.0"
|
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "./cli.js"
|
"playwright": "cli.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
// This file is injected into the registry as text, no dependencies are allowed.
|
// This file is injected into the registry as text, no dependencies are allowed.
|
||||||
|
|
||||||
|
/** @typedef {import('../playwright-ct-core/types/component').ObjectComponent} ObjectComponent */
|
||||||
|
|
||||||
import 'zone.js';
|
import 'zone.js';
|
||||||
import {
|
import {
|
||||||
Component as defineComponent,
|
Component as defineComponent,
|
||||||
|
|
@ -27,69 +29,41 @@ import {
|
||||||
BrowserDynamicTestingModule,
|
BrowserDynamicTestingModule,
|
||||||
platformBrowserDynamicTesting,
|
platformBrowserDynamicTesting,
|
||||||
} from '@angular/platform-browser-dynamic/testing';
|
} from '@angular/platform-browser-dynamic/testing';
|
||||||
import { Router } from '@angular/router';
|
|
||||||
|
|
||||||
/** @typedef {import('@playwright/experimental-ct-core/types/component').Component} Component */
|
|
||||||
/** @typedef {import('@playwright/experimental-ct-core/types/component').JsxComponent} JsxComponent */
|
|
||||||
/** @typedef {import('@playwright/experimental-ct-core/types/component').ObjectComponent} ObjectComponent */
|
|
||||||
/** @typedef {import('@angular/core').Type} FrameworkComponent */
|
|
||||||
|
|
||||||
/** @type {Map<string, () => Promise<FrameworkComponent>>} */
|
|
||||||
const __pwLoaderRegistry = new Map();
|
|
||||||
/** @type {Map<string, FrameworkComponent>} */
|
|
||||||
const __pwRegistry = new Map();
|
|
||||||
/** @type {Map<string, import('@angular/core/testing').ComponentFixture>} */
|
|
||||||
const __pwFixtureRegistry = new Map();
|
|
||||||
/** @type {WeakMap<import('@angular/core/testing').ComponentFixture, Record<string, import('rxjs').Subscription>>} */
|
/** @type {WeakMap<import('@angular/core/testing').ComponentFixture, Record<string, import('rxjs').Subscription>>} */
|
||||||
const __pwOutputSubscriptionRegistry = new WeakMap();
|
const __pwOutputSubscriptionRegistry = new WeakMap();
|
||||||
|
|
||||||
|
/** @type {Map<string, import('@angular/core/testing').ComponentFixture>} */
|
||||||
|
const __pwFixtureRegistry = new Map();
|
||||||
|
|
||||||
getTestBed().initTestEnvironment(
|
getTestBed().initTestEnvironment(
|
||||||
BrowserDynamicTestingModule,
|
BrowserDynamicTestingModule,
|
||||||
platformBrowserDynamicTesting(),
|
platformBrowserDynamicTesting(),
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {{[key: string]: () => Promise<FrameworkComponent>}} components
|
* @param {ObjectComponent} component
|
||||||
*/
|
*/
|
||||||
export function pwRegister(components) {
|
async function __pwRenderComponent(component) {
|
||||||
for (const [name, value] of Object.entries(components))
|
const componentMetadata = reflectComponentType(component.type);
|
||||||
__pwLoaderRegistry.set(name, value);
|
if (!componentMetadata?.isStandalone)
|
||||||
}
|
throw new Error('Only standalone components are supported');
|
||||||
|
|
||||||
/**
|
TestBed.configureTestingModule({
|
||||||
* @param {Component} component
|
imports: [component.type],
|
||||||
* @returns {component is JsxComponent | ObjectComponent}
|
});
|
||||||
*/
|
|
||||||
function isComponent(component) {
|
|
||||||
return !(typeof component !== 'object' || Array.isArray(component));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
await TestBed.compileComponents();
|
||||||
* @param {Component} component
|
|
||||||
*/
|
|
||||||
async function __pwResolveComponent(component) {
|
|
||||||
if (!isComponent(component))
|
|
||||||
return;
|
|
||||||
|
|
||||||
let componentFactory = __pwLoaderRegistry.get(component.type);
|
const fixture = TestBed.createComponent(component.type);
|
||||||
if (!componentFactory) {
|
fixture.nativeElement.id = 'root';
|
||||||
// Lookup by shorthand.
|
|
||||||
for (const [name, value] of __pwLoaderRegistry) {
|
|
||||||
if (component.type.endsWith(`_${name}`)) {
|
|
||||||
componentFactory = value;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!componentFactory && component.type[0].toUpperCase() === component.type[0])
|
__pwUpdateProps(fixture, component.props);
|
||||||
throw new Error(`Unregistered component: ${component.type}. Following components are registered: ${[...__pwRegistry.keys()]}`);
|
__pwUpdateEvents(fixture, component.on);
|
||||||
|
|
||||||
if(componentFactory)
|
fixture.autoDetectChanges();
|
||||||
__pwRegistry.set(component.type, await componentFactory())
|
|
||||||
|
|
||||||
if ('children' in component)
|
return fixture;
|
||||||
await Promise.all(component.children.map(child => __pwResolveComponent(child)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -97,7 +71,7 @@ async function __pwResolveComponent(component) {
|
||||||
*/
|
*/
|
||||||
function __pwUpdateProps(fixture, props = {}) {
|
function __pwUpdateProps(fixture, props = {}) {
|
||||||
for (const [name, value] of Object.entries(props))
|
for (const [name, value] of Object.entries(props))
|
||||||
fixture.debugElement.children[0].context[name] = value;
|
fixture.componentRef.setInput(name, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -110,9 +84,9 @@ function __pwUpdateEvents(fixture, events = {}) {
|
||||||
/* Unsubscribe previous listener. */
|
/* Unsubscribe previous listener. */
|
||||||
outputSubscriptionRecord[name]?.unsubscribe();
|
outputSubscriptionRecord[name]?.unsubscribe();
|
||||||
|
|
||||||
const subscription = fixture.debugElement.children[0].componentInstance[
|
const subscription = fixture.componentInstance[
|
||||||
name
|
name
|
||||||
].subscribe((event) => listener(event));
|
].subscribe((/** @type {unknown} */ event) => listener(event));
|
||||||
|
|
||||||
/* Store new subscription. */
|
/* Store new subscription. */
|
||||||
outputSubscriptionRecord[name] = subscription;
|
outputSubscriptionRecord[name] = subscription;
|
||||||
|
|
@ -122,100 +96,10 @@ function __pwUpdateEvents(fixture, events = {}) {
|
||||||
__pwOutputSubscriptionRegistry.set(fixture, outputSubscriptionRecord);
|
__pwOutputSubscriptionRegistry.set(fixture, outputSubscriptionRecord);
|
||||||
}
|
}
|
||||||
|
|
||||||
function __pwUpdateSlots(Component, slots = {}, tagName) {
|
window.playwrightMount = async (component, rootElement, hooksConfig) => {
|
||||||
const wrapper = document.createElement(tagName);
|
if (component.__pw_type === 'jsx')
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {any} value
|
|
||||||
* @return {?HTMLElement}
|
|
||||||
*/
|
|
||||||
function __pwCreateSlot(value) {
|
|
||||||
return /** @type {?HTMLElement} */ (
|
|
||||||
document
|
|
||||||
.createRange()
|
|
||||||
.createContextualFragment(value)
|
|
||||||
.firstChild
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {Component} component
|
|
||||||
*/
|
|
||||||
async function __pwRenderComponent(component) {
|
|
||||||
const Component = __pwRegistry.get(component.type);
|
|
||||||
if (!Component)
|
|
||||||
throw new Error(`Unregistered component: ${component.type}. Following components are registered: ${[...__pwRegistry.keys()]}`);
|
|
||||||
|
|
||||||
if (component.kind !== 'object')
|
|
||||||
throw new Error('JSX mount notation is not supported');
|
throw new Error('JSX mount notation is not supported');
|
||||||
|
|
||||||
const componentMetadata = reflectComponentType(Component);
|
|
||||||
if (!componentMetadata?.isStandalone)
|
|
||||||
throw new Error('Only standalone components are supported');
|
|
||||||
|
|
||||||
const WrapperComponent = defineComponent({
|
|
||||||
selector: 'pw-wrapper-component',
|
|
||||||
template: ``,
|
|
||||||
})(class {});
|
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
imports: [Component],
|
|
||||||
declarations: [WrapperComponent]
|
|
||||||
});
|
|
||||||
|
|
||||||
await TestBed.compileComponents();
|
|
||||||
|
|
||||||
__pwUpdateSlots(WrapperComponent, component.options?.slots, componentMetadata.selector);
|
|
||||||
|
|
||||||
// TODO: only inject when router is provided
|
|
||||||
TestBed.inject(Router).initialNavigation();
|
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(WrapperComponent);
|
|
||||||
fixture.nativeElement.id = 'root';
|
|
||||||
|
|
||||||
__pwUpdateProps(fixture, component.options?.props);
|
|
||||||
__pwUpdateEvents(fixture, component.options?.on);
|
|
||||||
|
|
||||||
fixture.autoDetectChanges();
|
|
||||||
|
|
||||||
return fixture;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.playwrightMount = async (component, rootElement, hooksConfig) => {
|
|
||||||
await __pwResolveComponent(component);
|
|
||||||
for (const hook of window.__pw_hooks_before_mount || [])
|
for (const hook of window.__pw_hooks_before_mount || [])
|
||||||
await hook({ hooksConfig, TestBed });
|
await hook({ hooksConfig, TestBed });
|
||||||
|
|
||||||
|
|
@ -229,7 +113,8 @@ window.playwrightMount = async (component, rootElement, hooksConfig) => {
|
||||||
|
|
||||||
window.playwrightUnmount = async rootElement => {
|
window.playwrightUnmount = async rootElement => {
|
||||||
const fixture = __pwFixtureRegistry.get(rootElement.id);
|
const fixture = __pwFixtureRegistry.get(rootElement.id);
|
||||||
if (!fixture) throw new Error('Component was not mounted');
|
if (!fixture)
|
||||||
|
throw new Error('Component was not mounted');
|
||||||
|
|
||||||
/* Unsubscribe from all outputs. */
|
/* Unsubscribe from all outputs. */
|
||||||
for (const subscription of Object.values(__pwOutputSubscriptionRegistry.get(fixture) ?? {}))
|
for (const subscription of Object.values(__pwOutputSubscriptionRegistry.get(fixture) ?? {}))
|
||||||
|
|
@ -241,19 +126,15 @@ window.playwrightUnmount = async rootElement => {
|
||||||
};
|
};
|
||||||
|
|
||||||
window.playwrightUpdate = async (rootElement, component) => {
|
window.playwrightUpdate = async (rootElement, component) => {
|
||||||
await __pwResolveComponent(component);
|
if (component.__pw_type === 'jsx')
|
||||||
if (component.kind === 'jsx')
|
|
||||||
throw new Error('JSX mount notation is not supported');
|
throw new Error('JSX mount notation is not supported');
|
||||||
|
|
||||||
if (component.options?.slots)
|
|
||||||
throw new Error('Update slots is not supported yet');
|
|
||||||
|
|
||||||
const fixture = __pwFixtureRegistry.get(rootElement.id);
|
const fixture = __pwFixtureRegistry.get(rootElement.id);
|
||||||
if (!fixture)
|
if (!fixture)
|
||||||
throw new Error('Component was not mounted');
|
throw new Error('Component was not mounted');
|
||||||
|
|
||||||
__pwUpdateProps(fixture, component.options?.props);
|
__pwUpdateProps(fixture, component.props);
|
||||||
__pwUpdateEvents(fixture, component.options?.on);
|
__pwUpdateEvents(fixture, component.on);
|
||||||
|
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import { setTransformData } from 'playwright/lib/transform/transform';
|
||||||
const t: typeof T = types;
|
const t: typeof T = types;
|
||||||
|
|
||||||
let jsxComponentNames: Set<string>;
|
let jsxComponentNames: Set<string>;
|
||||||
|
let classComponentNames: Set<string>;
|
||||||
let importInfos: Map<string, ImportInfo>;
|
let importInfos: Map<string, ImportInfo>;
|
||||||
|
|
||||||
export default declare((api: BabelAPI) => {
|
export default declare((api: BabelAPI) => {
|
||||||
|
|
@ -32,6 +33,7 @@ export default declare((api: BabelAPI) => {
|
||||||
Program: {
|
Program: {
|
||||||
enter(path) {
|
enter(path) {
|
||||||
jsxComponentNames = collectJsxComponentUsages(path.node);
|
jsxComponentNames = collectJsxComponentUsages(path.node);
|
||||||
|
classComponentNames = collectClassMountUsages(path.node);
|
||||||
importInfos = new Map();
|
importInfos = new Map();
|
||||||
},
|
},
|
||||||
exit(path) {
|
exit(path) {
|
||||||
|
|
@ -93,7 +95,7 @@ export default declare((api: BabelAPI) => {
|
||||||
if (t.isImportNamespaceSpecifier(specifier))
|
if (t.isImportNamespaceSpecifier(specifier))
|
||||||
continue;
|
continue;
|
||||||
const { localName, info } = importInfo(importNode, specifier, this.filename!);
|
const { localName, info } = importInfo(importNode, specifier, this.filename!);
|
||||||
if (jsxComponentNames.has(localName)) {
|
if (jsxComponentNames.has(localName) || classComponentNames.has(localName)) {
|
||||||
importInfos.set(localName, info);
|
importInfos.set(localName, info);
|
||||||
++importCount;
|
++importCount;
|
||||||
}
|
}
|
||||||
|
|
@ -141,6 +143,24 @@ function collectJsxComponentUsages(node: T.Node): Set<string> {
|
||||||
return names;
|
return names;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function collectClassMountUsages(node: T.Node): Set<string> {
|
||||||
|
const names = new Set<string>();
|
||||||
|
traverse(node, {
|
||||||
|
enter: p => {
|
||||||
|
// Treat calls to mount and all identifiers in arguments as component usages.
|
||||||
|
// e.g. mount(MyComponent, { imports: [OtherComponent], providers: [Token]})
|
||||||
|
if (t.isCallExpression(p.node) && t.isIdentifier(p.node.callee) && p.node.callee.name === 'mount') {
|
||||||
|
p.traverse({
|
||||||
|
Identifier: p => {
|
||||||
|
names.add(p.node.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
export type ImportInfo = {
|
export type ImportInfo = {
|
||||||
id: string;
|
id: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
|
|
|
||||||
|
|
@ -11,22 +11,23 @@
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^15.1.0",
|
"@analogjs/vite-plugin-angular": "1.0.2",
|
||||||
"@angular/common": "^15.1.0",
|
"@angular/animations": "17.3.1",
|
||||||
"@angular/compiler": "^15.1.0",
|
"@angular/common": "17.3.1",
|
||||||
"@angular/core": "^15.1.0",
|
"@angular/compiler": "17.3.1",
|
||||||
"@angular/forms": "^15.1.0",
|
"@angular/core": "17.3.1",
|
||||||
"@angular/platform-browser": "^15.1.0",
|
"@angular/forms": "17.3.1",
|
||||||
"@angular/platform-browser-dynamic": "^15.1.0",
|
"@angular/platform-browser": "17.3.1",
|
||||||
"@angular/router": "^15.1.0",
|
"@angular/platform-browser-dynamic": "17.3.1",
|
||||||
"rxjs": "~7.8.0",
|
"@angular/router": "17.3.1",
|
||||||
"tslib": "^2.3.0",
|
"rxjs": "7.8.1",
|
||||||
"zone.js": "~0.12.0"
|
"tslib": "2.6.2",
|
||||||
|
"zone.js": "0.14.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-devkit/build-angular": "^15.1.0",
|
"@angular-devkit/build-angular": "17.3.2",
|
||||||
"@angular/cli": "~15.1.0",
|
"@angular/cli": "17.3.2",
|
||||||
"@angular/compiler-cli": "^15.1.0",
|
"@angular/compiler-cli": "17.3.1",
|
||||||
"typescript": "~4.9.4"
|
"typescript": "5.4.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import angular from '@analogjs/vite-plugin-angular';
|
||||||
import { defineConfig, devices } from '@playwright/experimental-ct-angular';
|
import { defineConfig, devices } from '@playwright/experimental-ct-angular';
|
||||||
import { resolve } from 'path';
|
import { resolve } from 'path';
|
||||||
|
|
||||||
|
|
@ -25,6 +26,9 @@ export default defineConfig({
|
||||||
use: {
|
use: {
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
ctViteConfig: {
|
ctViteConfig: {
|
||||||
|
plugins: [angular({
|
||||||
|
tsconfig: resolve('./tsconfig.spec.json'),
|
||||||
|
})],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': resolve('./src'),
|
'@': resolve('./src'),
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import { beforeMount, afterMount } from '@playwright/experimental-ct-angular/hooks';
|
import '@/assets/styles.css';
|
||||||
import { provideRouter } from '@angular/router';
|
|
||||||
import { ButtonComponent } from '@/components/button.component';
|
|
||||||
import { TOKEN } from '@/components/inject.component';
|
import { TOKEN } from '@/components/inject.component';
|
||||||
import { routes } from '@/router';
|
import { routes } from '@/router';
|
||||||
import '@/assets/styles.css';
|
import { APP_INITIALIZER, inject } from '@angular/core';
|
||||||
|
import { Router, provideRouter } from '@angular/router';
|
||||||
|
import { afterMount, beforeMount } from '@playwright/experimental-ct-angular/hooks';
|
||||||
|
import { BrowserPlatformLocation, PlatformLocation } from '@angular/common';
|
||||||
|
|
||||||
export type HooksConfig = {
|
export type HooksConfig = {
|
||||||
routing?: boolean;
|
routing?: boolean;
|
||||||
|
|
@ -11,13 +12,20 @@ export type HooksConfig = {
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeMount<HooksConfig>(async ({ hooksConfig, TestBed }) => {
|
beforeMount<HooksConfig>(async ({ hooksConfig, TestBed }) => {
|
||||||
TestBed.configureTestingModule({
|
|
||||||
imports: [ButtonComponent],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hooksConfig?.routing)
|
if (hooksConfig?.routing)
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [provideRouter(routes)],
|
providers: [
|
||||||
|
provideRouter(routes),
|
||||||
|
{ provide: PlatformLocation, useExisting: BrowserPlatformLocation },
|
||||||
|
{
|
||||||
|
provide: APP_INITIALIZER,
|
||||||
|
multi: true,
|
||||||
|
useFactory() {
|
||||||
|
const router = inject(Router);
|
||||||
|
return () => router.initialNavigation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (hooksConfig?.injectToken)
|
if (hooksConfig?.injectToken)
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { RouterModule } from '@angular/router';
|
import { RouterLink, RouterOutlet } from '@angular/router';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [RouterModule],
|
imports: [RouterLink, RouterOutlet],
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
template: `
|
template: `
|
||||||
<header>
|
<header>
|
||||||
|
|
|
||||||
|
|
@ -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>();
|
||||||
|
}
|
||||||
|
|
@ -2,10 +2,12 @@ import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
standalone: true,
|
standalone: true,
|
||||||
selector: 'button-component',
|
selector: 'app-button',
|
||||||
templateUrl: './button.component.html',
|
template: `
|
||||||
|
<button (click)="submit.emit('hello')">{{title}}</button>
|
||||||
|
`
|
||||||
})
|
})
|
||||||
export class ButtonComponent {
|
export class ButtonComponent {
|
||||||
@Input() title!: string;
|
@Input({required: true}) title!: string;
|
||||||
@Output() submit = new EventEmitter();
|
@Output() submit = new EventEmitter();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
<h1>Not Inlined</h1>
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: './not-inlined.component.html',
|
||||||
|
})
|
||||||
|
export class NotInlinedComponent {}
|
||||||
|
|
@ -7,7 +7,6 @@ test('navigate to a page by clicking a link', async ({ page, mount }) => {
|
||||||
hooksConfig: { routing: true },
|
hooksConfig: { routing: true },
|
||||||
});
|
});
|
||||||
await expect(component.getByRole('main')).toHaveText('Login');
|
await expect(component.getByRole('main')).toHaveText('Login');
|
||||||
await expect(page).toHaveURL('/');
|
|
||||||
await component.getByRole('link', { name: 'Dashboard' }).click();
|
await component.getByRole('link', { name: 'Dashboard' }).click();
|
||||||
await expect(component.getByRole('main')).toHaveText('Dashboard');
|
await expect(component.getByRole('main')).toHaveText('Dashboard');
|
||||||
await expect(page).toHaveURL('/dashboard');
|
await expect(page).toHaveURL('/dashboard');
|
||||||
|
|
|
||||||
|
|
@ -19,29 +19,27 @@ test('emit an submit event when the button is clicked', async ({ mount }) => {
|
||||||
test('replace existing listener when new listener is set', async ({
|
test('replace existing listener when new listener is set', async ({
|
||||||
mount,
|
mount,
|
||||||
}) => {
|
}) => {
|
||||||
let count = 0;
|
let called = false;
|
||||||
|
|
||||||
const component = await mount(ButtonComponent, {
|
const component = await mount(ButtonComponent, {
|
||||||
props: {
|
props: {
|
||||||
title: 'Submit',
|
title: 'Submit',
|
||||||
},
|
},
|
||||||
on: {
|
on: {
|
||||||
submit() {
|
submit() {},
|
||||||
count++;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
component.update({
|
component.update({
|
||||||
on: {
|
on: {
|
||||||
submit() {
|
submit() {
|
||||||
count++;
|
called = true;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await component.click();
|
await component.click();
|
||||||
expect(count).toBe(1);
|
expect(called).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('unsubscribe from events when the component is unmounted', async ({
|
test('unsubscribe from events when the component is unmounted', async ({
|
||||||
|
|
|
||||||
11
tests/components/ct-angular/tests/hooks.spec.ts
Normal file
11
tests/components/ct-angular/tests/hooks.spec.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { expect, test } from '@playwright/experimental-ct-angular';
|
||||||
|
import type { HooksConfig } from 'playwright';
|
||||||
|
import { InjectComponent } from '@/components/inject.component';
|
||||||
|
|
||||||
|
test('inject a token', async ({ mount }) => {
|
||||||
|
const component = await mount<HooksConfig>(InjectComponent, {
|
||||||
|
hooksConfig: { injectToken: true },
|
||||||
|
});
|
||||||
|
await expect(component).toHaveText('has been overwritten');
|
||||||
|
await expect(component).not.toHaveText('gets overwritten');
|
||||||
|
});
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { test, expect } from '@playwright/experimental-ct-angular';
|
import { InjectComponent, TOKEN } from '@/components/inject.component';
|
||||||
|
import { expect, test } from '@playwright/experimental-ct-angular';
|
||||||
import type { HooksConfig } from 'playwright';
|
import type { HooksConfig } from 'playwright';
|
||||||
import { InjectComponent } from '@/components/inject.component';
|
|
||||||
|
|
||||||
test('inject a token', async ({ page, mount }) => {
|
test('inject a token', async ({ mount }) => {
|
||||||
const component = await mount<HooksConfig>(InjectComponent, {
|
const component = await mount<HooksConfig>(InjectComponent, {
|
||||||
hooksConfig: { injectToken: true },
|
hooksConfig: { injectToken: true },
|
||||||
});
|
});
|
||||||
await expect(component).toHaveText('has been overwritten');
|
await expect(component).toHaveText('has been overwritten');
|
||||||
await expect(component).not.toHaveText('gets overwritten');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@ import { test, expect } from '@playwright/experimental-ct-angular';
|
||||||
import { ButtonComponent } from '@/components/button.component';
|
import { ButtonComponent } from '@/components/button.component';
|
||||||
import { EmptyComponent } from '@/components/empty.component';
|
import { EmptyComponent } from '@/components/empty.component';
|
||||||
import { ComponentComponent } from '@/components/component.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, {
|
const component = await mount(ButtonComponent, {
|
||||||
props: {
|
props: {
|
||||||
title: 'Submit',
|
title: 'Submit',
|
||||||
|
|
@ -12,6 +14,15 @@ test('render props', async ({ mount }) => {
|
||||||
await expect(component).toContainText('Submit');
|
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 }) => {
|
test('get textContent of the empty component', async ({ mount }) => {
|
||||||
const component = await mount(EmptyComponent);
|
const component = await mount(EmptyComponent);
|
||||||
expect(await component.allTextContents()).toEqual(['']);
|
expect(await component.allTextContents()).toEqual(['']);
|
||||||
|
|
@ -23,3 +34,8 @@ test('render a component without options', async ({ mount }) => {
|
||||||
const component = await mount(ComponentComponent);
|
const component = await mount(ComponentComponent);
|
||||||
await expect(component).toContainText('test');
|
await expect(component).toContainText('test');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('render component with not inlined template', async ({ mount }) => {
|
||||||
|
const component = await mount(NotInlinedComponent);
|
||||||
|
await expect(component).toContainText('Not Inlined');
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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: '<button-component 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');
|
|
||||||
});
|
|
||||||
6
tests/components/ct-angular/tests/unsupported.spec.tsx
Normal file
6
tests/components/ct-angular/tests/unsupported.spec.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { expect, test } from '@playwright/experimental-ct-angular';
|
||||||
|
|
||||||
|
test('should throw an error when mounting JSX', async ({ mount }) => {
|
||||||
|
// @ts-ignore
|
||||||
|
await expect(mount(<h1/> as any)).rejects.toThrow('JSX mount notation is not supported');
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue