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
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import type {
|
||||
TestType,
|
||||
PlaywrightTestArgs,
|
||||
PlaywrightTestConfig as BasePlaywrightTestConfig,
|
||||
PlaywrightTestOptions,
|
||||
PlaywrightWorkerArgs,
|
||||
PlaywrightWorkerOptions,
|
||||
Locator,
|
||||
} from '@playwright/test';
|
||||
import type { Locator } from 'playwright/test';
|
||||
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';
|
||||
|
||||
export type PlaywrightTestConfig<T = {}, W = {}> = Omit<BasePlaywrightTestConfig<T, W>, 'use'> & {
|
||||
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 type ComponentEvents = Record<string, Function>;
|
||||
|
||||
export interface MountOptions<HooksConfig extends JsonObject, Component> {
|
||||
props?: Partial<Component>, // TODO: filter props
|
||||
slots?: ComponentSlots;
|
||||
props?: Partial<Component> | Record<string, unknown>, // TODO: filter props and handle signals
|
||||
on?: ComponentEvents;
|
||||
hooksConfig?: HooksConfig;
|
||||
}
|
||||
|
||||
interface MountResult<Component> extends Locator {
|
||||
export interface MountResult<Component> extends Locator {
|
||||
unmount(): Promise<void>;
|
||||
update(options: {
|
||||
props?: Partial<Component>,
|
||||
|
|
@ -63,13 +42,7 @@ export interface ComponentFixtures {
|
|||
): Promise<MountResult<Component>>;
|
||||
}
|
||||
|
||||
export const test: TestType<
|
||||
PlaywrightTestArgs & PlaywrightTestOptions & ComponentFixtures,
|
||||
PlaywrightWorkerArgs & PlaywrightWorkerOptions
|
||||
>;
|
||||
export const test: TestType<ComponentFixtures>;
|
||||
|
||||
export function defineConfig(config: PlaywrightTestConfig): PlaywrightTestConfig;
|
||||
export function defineConfig<T>(config: PlaywrightTestConfig<T>): PlaywrightTestConfig<T>;
|
||||
export function defineConfig<T, W>(config: PlaywrightTestConfig<T, W>): PlaywrightTestConfig<T, W>;
|
||||
|
||||
export { expect, devices } from '@playwright/test';
|
||||
export { defineConfig, PlaywrightTestConfig } from '@playwright/experimental-ct-core';
|
||||
export { expect, devices } from 'playwright/test';
|
||||
|
|
|
|||
|
|
@ -14,25 +14,19 @@
|
|||
* 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');
|
||||
|
||||
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() {
|
||||
// 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 };
|
||||
module.exports = { defineConfig, devices, expect, test };
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"name": "@playwright/experimental-ct-angular",
|
||||
"version": "1.42.0-next",
|
||||
"version": "1.43.0-next",
|
||||
"description": "Playwright Component Testing for Angular",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/sand4rt/playwright-ct-angular.git"
|
||||
"url": "git+https://github.com/microsoft/playwright.git"
|
||||
},
|
||||
"homepage": "https://playwright.dev",
|
||||
"engines": {
|
||||
|
|
@ -29,35 +29,23 @@
|
|||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@analogjs/vite-plugin-angular": "0.2.28",
|
||||
"@angular-devkit/build-angular": "^16.1.0",
|
||||
"@playwright/experimental-ct-core": "1.42.0-next",
|
||||
"vite": "^4.4.9"
|
||||
"@playwright/experimental-ct-core": "1.43.0-next"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular/animations": "^16.1.7",
|
||||
"@angular/common": "^16.1.7",
|
||||
"@angular/compiler": "^16.1.7",
|
||||
"@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",
|
||||
"@angular/compiler": "^17.0.0",
|
||||
"@angular/core": "^17.0.0",
|
||||
"@angular/platform-browser-dynamic": "^17.0.0",
|
||||
"rxjs": "~7.8.1",
|
||||
"tslib": "^2.5.0",
|
||||
"typescript": "^5.0.4",
|
||||
"zone.js": "~0.13.1"
|
||||
"typescript": "~5.2.0",
|
||||
"zone.js": "~0.14.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@playwright/test": ">=1.38.1",
|
||||
"typescript": ">=4.9.3",
|
||||
"@angular/common": ">=15.1.0 || >=16.0.0",
|
||||
"@angular/platform-browser": ">=15.1.0 || >=16.0.0",
|
||||
"@angular/router": ">=15.1.0 || >=16.0.0",
|
||||
"@angular/core": ">=15.1.0 || >=16.0.0"
|
||||
"@angular/compiler": "^17.0.0",
|
||||
"@angular/core": "^17.0.0",
|
||||
"@angular/platform-browser-dynamic": "^17.0.0",
|
||||
"typescript": ">=5.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "./cli.js"
|
||||
"playwright": "cli.js"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@
|
|||
// @ts-check
|
||||
// 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 {
|
||||
Component as defineComponent,
|
||||
|
|
@ -27,69 +29,41 @@ import {
|
|||
BrowserDynamicTestingModule,
|
||||
platformBrowserDynamicTesting,
|
||||
} 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>>} */
|
||||
const __pwOutputSubscriptionRegistry = new WeakMap();
|
||||
|
||||
/** @type {Map<string, import('@angular/core/testing').ComponentFixture>} */
|
||||
const __pwFixtureRegistry = new Map();
|
||||
|
||||
getTestBed().initTestEnvironment(
|
||||
BrowserDynamicTestingModule,
|
||||
platformBrowserDynamicTesting(),
|
||||
);
|
||||
|
||||
/**
|
||||
* @param {{[key: string]: () => Promise<FrameworkComponent>}} components
|
||||
* @param {ObjectComponent} component
|
||||
*/
|
||||
export function pwRegister(components) {
|
||||
for (const [name, value] of Object.entries(components))
|
||||
__pwLoaderRegistry.set(name, value);
|
||||
}
|
||||
async function __pwRenderComponent(component) {
|
||||
const componentMetadata = reflectComponentType(component.type);
|
||||
if (!componentMetadata?.isStandalone)
|
||||
throw new Error('Only standalone components are supported');
|
||||
|
||||
/**
|
||||
* @param {Component} component
|
||||
* @returns {component is JsxComponent | ObjectComponent}
|
||||
*/
|
||||
function isComponent(component) {
|
||||
return !(typeof component !== 'object' || Array.isArray(component));
|
||||
}
|
||||
TestBed.configureTestingModule({
|
||||
imports: [component.type],
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {Component} component
|
||||
*/
|
||||
async function __pwResolveComponent(component) {
|
||||
if (!isComponent(component))
|
||||
return;
|
||||
await TestBed.compileComponents();
|
||||
|
||||
let componentFactory = __pwLoaderRegistry.get(component.type);
|
||||
if (!componentFactory) {
|
||||
// Lookup by shorthand.
|
||||
for (const [name, value] of __pwLoaderRegistry) {
|
||||
if (component.type.endsWith(`_${name}`)) {
|
||||
componentFactory = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const fixture = TestBed.createComponent(component.type);
|
||||
fixture.nativeElement.id = 'root';
|
||||
|
||||
if (!componentFactory && component.type[0].toUpperCase() === component.type[0])
|
||||
throw new Error(`Unregistered component: ${component.type}. Following components are registered: ${[...__pwRegistry.keys()]}`);
|
||||
__pwUpdateProps(fixture, component.props);
|
||||
__pwUpdateEvents(fixture, component.on);
|
||||
|
||||
if(componentFactory)
|
||||
__pwRegistry.set(component.type, await componentFactory())
|
||||
fixture.autoDetectChanges();
|
||||
|
||||
if ('children' in component)
|
||||
await Promise.all(component.children.map(child => __pwResolveComponent(child)))
|
||||
return fixture;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -97,7 +71,7 @@ async function __pwResolveComponent(component) {
|
|||
*/
|
||||
function __pwUpdateProps(fixture, 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. */
|
||||
outputSubscriptionRecord[name]?.unsubscribe();
|
||||
|
||||
const subscription = fixture.debugElement.children[0].componentInstance[
|
||||
name
|
||||
].subscribe((event) => listener(event));
|
||||
const subscription = fixture.componentInstance[
|
||||
name
|
||||
].subscribe((/** @type {unknown} */ event) => listener(event));
|
||||
|
||||
/* Store new subscription. */
|
||||
outputSubscriptionRecord[name] = subscription;
|
||||
|
|
@ -122,100 +96,10 @@ function __pwUpdateEvents(fixture, events = {}) {
|
|||
__pwOutputSubscriptionRegistry.set(fixture, outputSubscriptionRecord);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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')
|
||||
window.playwrightMount = async (component, rootElement, hooksConfig) => {
|
||||
if (component.__pw_type === 'jsx')
|
||||
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 || [])
|
||||
await hook({ hooksConfig, TestBed });
|
||||
|
||||
|
|
@ -229,7 +113,8 @@ window.playwrightMount = async (component, rootElement, hooksConfig) => {
|
|||
|
||||
window.playwrightUnmount = async rootElement => {
|
||||
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. */
|
||||
for (const subscription of Object.values(__pwOutputSubscriptionRegistry.get(fixture) ?? {}))
|
||||
|
|
@ -241,19 +126,15 @@ window.playwrightUnmount = async rootElement => {
|
|||
};
|
||||
|
||||
window.playwrightUpdate = async (rootElement, component) => {
|
||||
await __pwResolveComponent(component);
|
||||
if (component.kind === 'jsx')
|
||||
if (component.__pw_type === 'jsx')
|
||||
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);
|
||||
if (!fixture)
|
||||
throw new Error('Component was not mounted');
|
||||
|
||||
__pwUpdateProps(fixture, component.options?.props);
|
||||
__pwUpdateEvents(fixture, component.options?.on);
|
||||
__pwUpdateProps(fixture, component.props);
|
||||
__pwUpdateEvents(fixture, component.on);
|
||||
|
||||
fixture.detectChanges();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { setTransformData } from 'playwright/lib/transform/transform';
|
|||
const t: typeof T = types;
|
||||
|
||||
let jsxComponentNames: Set<string>;
|
||||
let classComponentNames: Set<string>;
|
||||
let importInfos: Map<string, ImportInfo>;
|
||||
|
||||
export default declare((api: BabelAPI) => {
|
||||
|
|
@ -32,6 +33,7 @@ export default declare((api: BabelAPI) => {
|
|||
Program: {
|
||||
enter(path) {
|
||||
jsxComponentNames = collectJsxComponentUsages(path.node);
|
||||
classComponentNames = collectClassMountUsages(path.node);
|
||||
importInfos = new Map();
|
||||
},
|
||||
exit(path) {
|
||||
|
|
@ -93,7 +95,7 @@ export default declare((api: BabelAPI) => {
|
|||
if (t.isImportNamespaceSpecifier(specifier))
|
||||
continue;
|
||||
const { localName, info } = importInfo(importNode, specifier, this.filename!);
|
||||
if (jsxComponentNames.has(localName)) {
|
||||
if (jsxComponentNames.has(localName) || classComponentNames.has(localName)) {
|
||||
importInfos.set(localName, info);
|
||||
++importCount;
|
||||
}
|
||||
|
|
@ -141,6 +143,24 @@ function collectJsxComponentUsages(node: T.Node): Set<string> {
|
|||
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 = {
|
||||
id: string;
|
||||
filename: string;
|
||||
|
|
|
|||
|
|
@ -11,22 +11,23 @@
|
|||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@angular/animations": "^15.1.0",
|
||||
"@angular/common": "^15.1.0",
|
||||
"@angular/compiler": "^15.1.0",
|
||||
"@angular/core": "^15.1.0",
|
||||
"@angular/forms": "^15.1.0",
|
||||
"@angular/platform-browser": "^15.1.0",
|
||||
"@angular/platform-browser-dynamic": "^15.1.0",
|
||||
"@angular/router": "^15.1.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
"zone.js": "~0.12.0"
|
||||
"@analogjs/vite-plugin-angular": "1.0.2",
|
||||
"@angular/animations": "17.3.1",
|
||||
"@angular/common": "17.3.1",
|
||||
"@angular/compiler": "17.3.1",
|
||||
"@angular/core": "17.3.1",
|
||||
"@angular/forms": "17.3.1",
|
||||
"@angular/platform-browser": "17.3.1",
|
||||
"@angular/platform-browser-dynamic": "17.3.1",
|
||||
"@angular/router": "17.3.1",
|
||||
"rxjs": "7.8.1",
|
||||
"tslib": "2.6.2",
|
||||
"zone.js": "0.14.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^15.1.0",
|
||||
"@angular/cli": "~15.1.0",
|
||||
"@angular/compiler-cli": "^15.1.0",
|
||||
"typescript": "~4.9.4"
|
||||
"@angular-devkit/build-angular": "17.3.2",
|
||||
"@angular/cli": "17.3.2",
|
||||
"@angular/compiler-cli": "17.3.1",
|
||||
"typescript": "5.4.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import angular from '@analogjs/vite-plugin-angular';
|
||||
import { defineConfig, devices } from '@playwright/experimental-ct-angular';
|
||||
import { resolve } from 'path';
|
||||
|
||||
|
|
@ -25,6 +26,9 @@ export default defineConfig({
|
|||
use: {
|
||||
trace: 'on-first-retry',
|
||||
ctViteConfig: {
|
||||
plugins: [angular({
|
||||
tsconfig: resolve('./tsconfig.spec.json'),
|
||||
})],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve('./src'),
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
import { beforeMount, afterMount } from '@playwright/experimental-ct-angular/hooks';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { ButtonComponent } from '@/components/button.component';
|
||||
import '@/assets/styles.css';
|
||||
import { TOKEN } from '@/components/inject.component';
|
||||
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 = {
|
||||
routing?: boolean;
|
||||
|
|
@ -11,13 +12,20 @@ export type HooksConfig = {
|
|||
};
|
||||
|
||||
beforeMount<HooksConfig>(async ({ hooksConfig, TestBed }) => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [ButtonComponent],
|
||||
});
|
||||
|
||||
if (hooksConfig?.routing)
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { Component } from '@angular/core';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { RouterLink, RouterOutlet } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [RouterModule],
|
||||
imports: [RouterLink, RouterOutlet],
|
||||
selector: 'app-root',
|
||||
template: `
|
||||
<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({
|
||||
standalone: true,
|
||||
selector: 'button-component',
|
||||
templateUrl: './button.component.html',
|
||||
selector: 'app-button',
|
||||
template: `
|
||||
<button (click)="submit.emit('hello')">{{title}}</button>
|
||||
`
|
||||
})
|
||||
export class ButtonComponent {
|
||||
@Input() title!: string;
|
||||
@Input({required: true}) title!: string;
|
||||
@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 },
|
||||
});
|
||||
await expect(component.getByRole('main')).toHaveText('Login');
|
||||
await expect(page).toHaveURL('/');
|
||||
await component.getByRole('link', { name: 'Dashboard' }).click();
|
||||
await expect(component.getByRole('main')).toHaveText('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 ({
|
||||
mount,
|
||||
}) => {
|
||||
let count = 0;
|
||||
let called = false;
|
||||
|
||||
const component = await mount(ButtonComponent, {
|
||||
props: {
|
||||
title: 'Submit',
|
||||
},
|
||||
on: {
|
||||
submit() {
|
||||
count++;
|
||||
},
|
||||
submit() {},
|
||||
},
|
||||
});
|
||||
|
||||
component.update({
|
||||
on: {
|
||||
submit() {
|
||||
count++;
|
||||
called = true;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await component.click();
|
||||
expect(count).toBe(1);
|
||||
expect(called).toBe(true);
|
||||
});
|
||||
|
||||
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 { InjectComponent } from '@/components/inject.component';
|
||||
|
||||
test('inject a token', async ({ page, mount }) => {
|
||||
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');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ import { test, expect } from '@playwright/experimental-ct-angular';
|
|||
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',
|
||||
|
|
@ -12,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(['']);
|
||||
|
|
@ -23,3 +34,8 @@ test('render a component without options', async ({ mount }) => {
|
|||
const component = await mount(ComponentComponent);
|
||||
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