feat(ct): angular component testing

This commit is contained in:
sand4rt 2023-10-24 22:29:29 +02:00
parent 53a78a315e
commit 8ccb3bb63a
48 changed files with 7811 additions and 5857 deletions

12433
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,11 @@
**/*
!README.md
!LICENSE
!register.d.ts
!register.mjs
!registerSource.mjs
!index.d.ts
!index.js
!hooks.d.ts
!hooks.mjs

View file

@ -0,0 +1,3 @@
> **BEWARE** This package is EXPERIMENTAL and does not respect semver.
Read more at https://playwright.dev/docs/test-components

View file

@ -0,0 +1,17 @@
#!/usr/bin/env node
/**
* 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.
*/
module.exports = require('@playwright/experimental-ct-core/cli');

View file

@ -0,0 +1,25 @@
/**
* 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.
*/
import type { TestBedStatic } from '@angular/core/testing';
import type { JsonObject } from '@playwright/experimental-ct-core/types/component';
export declare function beforeMount<HooksConfig extends JsonObject>(
callback: (params: { hooksConfig?: HooksConfig, TestBed: TestBedStatic }) => Promise<void>
): void;
export declare function afterMount<HooksConfig extends JsonObject>(
callback: (params: { hooksConfig?: HooksConfig }) => Promise<void>
): void;

View file

@ -0,0 +1,29 @@
/**
* 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.
*/
const __pw_hooks_before_mount = [];
const __pw_hooks_after_mount = [];
window.__pw_hooks_before_mount = __pw_hooks_before_mount;
window.__pw_hooks_after_mount = __pw_hooks_after_mount;
export const beforeMount = callback => {
__pw_hooks_before_mount.push(callback);
};
export const afterMount = callback => {
__pw_hooks_after_mount.push(callback);
};

View file

@ -0,0 +1,75 @@
/**
* 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.
*/
import type {
TestType,
PlaywrightTestArgs,
PlaywrightTestConfig as BasePlaywrightTestConfig,
PlaywrightTestOptions,
PlaywrightWorkerArgs,
PlaywrightWorkerOptions,
Locator,
} from '@playwright/test';
import type { JsonObject } from '@playwright/experimental-ct-core/types/component';
import type { InlineConfig } from 'vite';
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 interface MountOptions<HooksConfig extends JsonObject, Component> {
props?: Partial<Component>, // TODO: filter props
slots?: ComponentSlots;
on?: ComponentEvents;
hooksConfig?: HooksConfig;
}
interface MountResult<Component> extends Locator {
unmount(): Promise<void>;
update(options: {
props?: Partial<Component>,
on?: Partial<ComponentEvents>,
}): Promise<void>;
}
export interface ComponentFixtures {
mount<HooksConfig extends JsonObject, Component = unknown>(
component: Type<Component>,
options?: MountOptions<HooksConfig, Component>
): Promise<MountResult<Component>>;
}
export const test: TestType<
PlaywrightTestArgs & PlaywrightTestOptions & ComponentFixtures,
PlaywrightWorkerArgs & PlaywrightWorkerOptions
>;
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';

View file

@ -0,0 +1,38 @@
/**
* 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.
*/
const { test, expect, devices, defineConfig: originalDefineConfig } = require('@playwright/experimental-ct-core');
const path = require('path');
process.env['NODE_ENV'] = 'test';
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 };

View file

@ -0,0 +1,63 @@
{
"name": "@playwright/experimental-ct-angular",
"version": "1.40.0-next",
"description": "Playwright Component Testing for Angular",
"repository": {
"type": "git",
"url": "git+https://github.com/sand4rt/playwright-ct-angular.git"
},
"homepage": "https://playwright.dev",
"engines": {
"node": ">=16"
},
"author": {
"name": "Microsoft Corporation"
},
"license": "Apache-2.0",
"exports": {
".": {
"types": "./index.d.ts",
"default": "./index.js"
},
"./register": {
"types": "./register.d.ts",
"default": "./register.mjs"
},
"./hooks": {
"types": "./hooks.d.ts",
"default": "./hooks.mjs"
}
},
"dependencies": {
"@analogjs/vite-plugin-angular": "0.2.10",
"@angular-devkit/build-angular": "^16.1.0",
"@playwright/experimental-ct-core": "1.40.0-next",
"vite": "^4.4.9"
},
"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",
"rxjs": "~7.8.1",
"tslib": "^2.5.0",
"typescript": "^5.0.4",
"zone.js": "~0.13.1"
},
"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"
},
"bin": {
"playwright": "./cli.js"
}
}

View file

@ -0,0 +1,17 @@
/**
* 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.
*/
export default function pwRegister(components: Record<string, any>): void

View file

@ -0,0 +1,21 @@
/**
* 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.
*/
import { pwRegister } from './registerSource.mjs';
export default components => {
pwRegister(components);
};

View file

@ -0,0 +1,237 @@
/**
* 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.
import 'zone.js';
import { getTestBed, TestBed } from '@angular/core/testing';
import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing';
import { EventEmitter, reflectComponentType, Component as defineComponent } from '@angular/core';
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();
getTestBed().initTestEnvironment(
BrowserDynamicTestingModule,
platformBrowserDynamicTesting(),
);
/**
* @param {{[key: string]: () => Promise<FrameworkComponent>}} components
*/
export function pwRegister(components) {
for (const [name, value] of Object.entries(components))
__pwLoaderRegistry.set(name, value);
}
/**
* @param {Component} component
* @returns {component is JsxComponent | ObjectComponent}
*/
function isComponent(component) {
return !(typeof component !== 'object' || Array.isArray(component));
}
/**
* @param {Component} component
*/
async function __pwResolveComponent(component) {
if (!isComponent(component))
return;
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;
}
}
}
if (!componentFactory && component.type[0].toUpperCase() === component.type[0])
throw new Error(`Unregistered component: ${component.type}. Following components are registered: ${[...__pwRegistry.keys()]}`);
if(componentFactory)
__pwRegistry.set(component.type, await componentFactory())
if ('children' in component)
await Promise.all(component.children.map(child => __pwResolveComponent(child)))
}
/**
* @param {import('@angular/core/testing').ComponentFixture} fixture
*/
function __pwUpdateProps(fixture, props = {}) {
for (const [name, value] of Object.entries(props))
fixture.debugElement.children[0].context[name] = value;
}
/**
* @param {import('@angular/core/testing').ComponentFixture} fixture
*/
function __pwUpdateEvents(fixture, events = {}) {
for (const [name, value] of Object.entries(events)) {
fixture.debugElement.children[0].componentInstance[name] = {
...new EventEmitter(),
emit: event => value(event)
};
}
}
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')
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 });
const fixture = await __pwRenderComponent(component);
for (const hook of window.__pw_hooks_after_mount || [])
await hook({ hooksConfig });
__pwFixtureRegistry.set(rootElement.id, fixture);
};
window.playwrightUnmount = async rootElement => {
const fixture = __pwFixtureRegistry.get(rootElement.id);
if (!fixture)
throw new Error('Component was not mounted');
fixture.destroy();
fixture.nativeElement.replaceChildren();
};
window.playwrightUpdate = async (rootElement, component) => {
await __pwResolveComponent(component);
if (component.kind === '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);
fixture.detectChanges();
};

45
tests/components/ct-angular/.gitignore vendored Normal file
View file

@ -0,0 +1,45 @@
# See http://help.github.com/ignore-files/ for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
# System files
.DS_Store
Thumbs.db
/test-results/
/playwright-report/
/playwright/.cache/

View file

@ -0,0 +1,19 @@
# ct-angular
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 15.0.0.
## Development server
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files.
## Code scaffolding
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
## Build
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory.
## Further help
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page.

View file

@ -0,0 +1,80 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"ct-angular": {
"projectType": "application",
"schematics": {},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist/ct-angular",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": [
"zone.js"
],
"tsConfig": "tsconfig.app.json",
"assets": [
"src/assets/favicon.ico",
"src/assets"
],
"styles": [
"src/assets/styles.css"
],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "1mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kb",
"maximumError": "4kb"
}
],
"outputHashing": "all"
},
"development": {
"buildOptimizer": false,
"optimization": false,
"vendorChunk": true,
"extractLicenses": false,
"sourceMap": true,
"namedChunks": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"browserTarget": "ct-angular:build:production"
},
"development": {
"browserTarget": "ct-angular:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "ct-angular:build"
}
}
}
}
}
}

View file

@ -0,0 +1,32 @@
{
"name": "ct-angular",
"version": "0.0.0",
"private": true,
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "npx playwright test",
"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"
},
"devDependencies": {
"@angular-devkit/build-angular": "^15.1.0",
"@angular/cli": "~15.1.0",
"@angular/compiler-cli": "^15.1.0",
"typescript": "~4.9.4"
}
}

View file

@ -0,0 +1,49 @@
/**
* 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.
*/
import { defineConfig, devices } from '@playwright/experimental-ct-angular';
import { resolve } from 'path';
export default defineConfig({
testDir: 'tests',
forbidOnly: !!process.env['CI'],
retries: process.env['CI'] ? 2 : 0,
reporter: 'html',
use: {
trace: 'on-first-retry',
ctViteConfig: {
resolve: {
alias: {
'@': resolve('./src'),
}
}
}
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
});

View file

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Testing Page</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./index.ts"></script>
</body>
</html>

View file

@ -0,0 +1,31 @@
import { beforeMount, afterMount } from '@playwright/experimental-ct-angular/hooks';
import { provideRouter } from '@angular/router';
import { ButtonComponent } from '@/components/button.component';
import { TOKEN } from '@/components/inject.component';
import { routes } from '@/router';
import '@/assets/styles.css';
export type HooksConfig = {
routing?: boolean;
injectToken?: boolean;
};
beforeMount<HooksConfig>(async ({ hooksConfig, TestBed }) => {
TestBed.configureTestingModule({
imports: [ButtonComponent],
});
if (hooksConfig?.routing)
TestBed.configureTestingModule({
providers: [provideRouter(routes)],
});
if (hooksConfig?.injectToken)
TestBed.configureTestingModule({
providers: [{ provide: TOKEN, useValue: { text: 'has been overwritten' }}]
})
});
afterMount<HooksConfig>(async () => {
console.log('After mount');
});

View file

@ -0,0 +1,17 @@
import { Component } from '@angular/core';
import { RouterModule } from '@angular/router';
@Component({
standalone: true,
imports: [RouterModule],
selector: 'app-root',
template: `
<header>
<img alt="Angular logo" class="logo" src="./assets/logo.svg" width="125" height="125" />
<a routerLink="/">Login</a>
<a routerLink="/dashboard">Dashboard</a>
</header>
<router-outlet></router-outlet>
`
})
export class AppComponent {}

Binary file not shown.

After

Width:  |  Height:  |  Size: 948 B

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 335.88126 355.80875" width="335" xml:space="preserve">
<g transform="matrix(1.25,0,0,-1.25,-188.84625,636.71875)">
<g transform="scale(0.1,0.1)">
<path d="M 2850,5093.75 1510.77,4622.45 1722.39,2867.82 2851.42,2247.28 3986.27,2876.23 4197.82,4630.79 2850,5093.75 z" style="fill:#b3b3b3;fill-opacity:1;fill-rule:nonzero;stroke:none" />
<path d="m 4064.25,4529.39 -1217.38,415.13 0,-2548.98 1020.22,564.62 197.16,1569.23 z" style="fill:#af2b2d;fill-opacity:1;fill-rule:nonzero;stroke:none" />
<path d="m 1661.05,4521.89 181.34,-1569.22 1004.47,-557.13 0,2549.03 -1185.81,-422.68 z" style="fill:#df2e31;fill-opacity:1;fill-rule:nonzero;stroke:none" />
<path d="m 3129.05,3676.7 -279.96,584.64 -246.46,-584.64 526.42,0 z m 106.74,-245.83 -742.11,0 -166.02,-415.26 -308.82,-5.71 828.04,1842.06 856.9,-1842.06 -286.23,0 -181.76,420.97 z" style="fill:#f2f2f2;fill-opacity:1;fill-rule:nonzero;stroke:none" />
<path d="m 2846.87,4851.96 2.21,-590.62 279.67,-585.09 -281.25,0 -0.63,-245 388.9,-0.38 181.77,-421.04 295.49,-5.48 -866.16,1847.61 z" style="fill:#b3b3b3;fill-opacity:1;fill-rule:nonzero;stroke:none" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -0,0 +1,20 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
@media (prefers-color-scheme: light) {
:root {
color: #e3e3e3;
background-color: #1b1b1d;
}
}

View file

@ -0,0 +1 @@
<button (click)="submit.emit('hello')">{{title}}</button>

View file

@ -0,0 +1,11 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
@Component({
standalone: true,
selector: 'button-component',
templateUrl: './button.component.html',
})
export class ButtonComponent {
@Input() title!: string;
@Output() submit = new EventEmitter();
}

View file

@ -0,0 +1,7 @@
import { Component } from '@angular/core';
@Component({
standalone: true,
template: `<div>test</div>`,
})
export class ComponentComponent {}

View file

@ -0,0 +1,23 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
@Component({
standalone: true,
template: `
<div (click)="submit.emit('hello')">
<div data-testid="props">{{ count }}</div>
<div data-testid="remount-count">{{ this.remountCount }}</div>
<ng-content select="[main]"></ng-content>
<ng-content></ng-content>
</div>
`,
})
export class CounterComponent {
remountCount = Number(localStorage.getItem('remountCount'));
@Input() count!: number;
@Output() submit = new EventEmitter();
constructor() {
localStorage.setItem('remountCount', String(this.remountCount++))
}
}

View file

@ -0,0 +1,17 @@
import { Component } from '@angular/core';
@Component({
standalone: true,
template: `
<div>
<h1>Welcome!</h1>
<main>
<ng-content></ng-content>
</main>
<footer>
Thanks for visiting.
</footer>
</div>
`,
})
export class DefaultSlotComponent {}

View file

@ -0,0 +1,7 @@
import { Component } from '@angular/core';
@Component({
standalone: true,
template: ``,
})
export class EmptyComponent {}

View file

@ -0,0 +1,11 @@
import { Component, inject, InjectionToken } from '@angular/core';
export const TOKEN = new InjectionToken<{ text: string }>('gets overwritten');
@Component({
standalone: true,
template: `<div>{{ data.text }}</div>`,
})
export class InjectComponent {
public data = inject(TOKEN);
}

View file

@ -0,0 +1,7 @@
import { Component } from '@angular/core';
@Component({
standalone: true,
template: `<div>root 1</div><div>root 2</div>`,
})
export class MultiRootComponent {}

View file

@ -0,0 +1,19 @@
import { Component } from '@angular/core';
@Component({
standalone: true,
template: `
<div>
<header>
<ng-content select="[header]"></ng-content>
</header>
<main>
<ng-content select="[main]"></ng-content>
</main>
<footer>
<ng-content select="[footer]"></ng-content>
</footer>
</div>
`,
})
export class NamedSlotsComponent {}

View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Angular</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="assets/favicon.ico">
</head>
<body>
<app-root></app-root>
</body>
</html>

View file

@ -0,0 +1,10 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter } from '@angular/router';
import { AppComponent } from '@/app.component';
import { routes } from '@/router';
bootstrapApplication(AppComponent, {
providers: [
provideRouter(routes)
]
}).catch(err => console.error(err));

View file

@ -0,0 +1,8 @@
import { Component } from '@angular/core';
@Component({
standalone: true,
selector: 'app-dashboard',
template: `<main>Dashboard</main>`
})
export class DashboardComponent {}

View file

@ -0,0 +1,8 @@
import { Component } from '@angular/core';
@Component({
standalone: true,
selector: 'app-login',
template: `<main>Login</main>`
})
export class LoginComponent {}

View file

@ -0,0 +1,8 @@
import { Routes } from '@angular/router';
import { DashboardComponent } from '@/pages/dashboard.component';
import { LoginComponent } from '@/pages/login.component';
export const routes: Routes = [
{ path: '', component: LoginComponent },
{ path: 'dashboard', component: DashboardComponent },
];

View file

@ -0,0 +1,14 @@
import { test, expect } from '@playwright/experimental-ct-angular';
import type { HooksConfig } from 'playwright';
import { AppComponent } from '@/app.component';
test('navigate to a page by clicking a link', async ({ page, mount }) => {
const component = await mount<HooksConfig>(AppComponent, {
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');
});

View file

@ -0,0 +1,16 @@
import { test, expect } from '@playwright/experimental-ct-angular';
import { ButtonComponent } from '@/components/button.component';
test('emit an submit event when the button is clicked', async ({ mount }) => {
const messages: string[] = [];
const component = await mount(ButtonComponent, {
props: {
title: 'Submit',
},
on: {
submit: (data: string) => messages.push(data),
},
});
await component.click();
expect(messages).toEqual(['hello']);
});

View file

@ -0,0 +1,11 @@
import { test, expect } from '@playwright/experimental-ct-angular';
import type { HooksConfig } from 'playwright';
import { InjectComponent } from '@/components/inject.component';
test('inject a token', async ({ page, mount }) => {
const component = await mount<HooksConfig>(InjectComponent, {
hooksConfig: { injectToken: true },
});
await expect(component).toHaveText('has been overwritten');
await expect(component).not.toHaveText('gets overwritten');
});

View file

@ -0,0 +1,25 @@
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';
test('render props', async ({ mount }) => {
const component = await mount(ButtonComponent, {
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(['']);
expect(await component.textContent()).toBe('');
await expect(component).toHaveText('');
});
test('render a component without options', async ({ mount }) => {
const component = await mount(ComponentComponent);
await expect(component).toContainText('test');
});

View file

@ -0,0 +1,47 @@
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');
});

View file

@ -0,0 +1,23 @@
import { test, expect } from '@playwright/experimental-ct-angular';
import { ButtonComponent } from '@/components/button.component';
import { MultiRootComponent } from '@/components/multi-root.component';
test('unmount', async ({ page, mount }) => {
const component = await mount(ButtonComponent, {
props: {
title: 'Submit',
},
});
await expect(page.locator('#root')).toContainText('Submit');
await component.unmount();
await expect(page.locator('#root')).not.toContainText('Submit');
});
test('unmount a multi root component', async ({ mount, page }) => {
const component = await mount(MultiRootComponent);
await expect(page.locator('#root')).toContainText('root 1');
await expect(page.locator('#root')).toContainText('root 2');
await component.unmount();
await expect(page.locator('#root')).not.toContainText('root 1');
await expect(page.locator('#root')).not.toContainText('root 2');
});

View file

@ -0,0 +1,32 @@
import { test, expect } from '@playwright/experimental-ct-angular';
import { CounterComponent } from '@/components/counter.component';
test('update props without remounting', async ({ mount }) => {
const component = await mount(CounterComponent, {
props: { count: 9001 },
});
await expect(component.getByTestId('props')).toContainText('9001');
await component.update({
props: { count: 1337 },
});
await expect(component).not.toContainText('9001');
await expect(component.getByTestId('props')).toContainText('1337');
await expect(component.getByTestId('remount-count')).toContainText('1');
});
test('update event listeners without remounting', async ({ mount }) => {
const component = await mount(CounterComponent);
const messages: string[] = [];
await component.update({
on: {
submit: (data: string) => messages.push(data),
},
});
await component.click();
expect(messages).toEqual(['hello']);
await expect(component.getByTestId('remount-count')).toContainText('1');
});

View file

@ -0,0 +1,14 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": [
"src/main.ts"
],
"include": [
"src/**/*.d.ts"
]
}

View file

@ -0,0 +1,36 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"compileOnSave": false,
"compilerOptions": {
"baseUrl": "./",
"outDir": "./dist/out-tsc",
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"sourceMap": true,
"declaration": false,
"downlevelIteration": true,
"experimentalDecorators": true,
"moduleResolution": "node",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022",
"useDefineForClassFields": false,
"lib": [
"ES2022",
"dom"
],
"paths": {
"@/*": ["./src/*"]
}
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true,
}
}

View file

@ -0,0 +1,9 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": ["node"]
},
"include": ["tests/**/*.spec.ts", "tests/**/*.d.ts"]
}

View file

@ -199,6 +199,11 @@ const workspace = new Workspace(ROOT_PATH, [
path: path.join(ROOT_PATH, 'packages', 'playwright-ct-core'),
files: ['LICENSE'],
}),
new PWPackage({
name: '@playwright/experimental-ct-angular',
path: path.join(ROOT_PATH, 'packages', 'playwright-ct-angular'),
files: ['LICENSE'],
}),
new PWPackage({
name: '@playwright/experimental-ct-react',
path: path.join(ROOT_PATH, 'packages', 'playwright-ct-react'),