├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml └── workflows │ ├── deploy.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── ct-angular ├── .gitignore ├── README.md ├── angular.json ├── package.json ├── playwright.config.mts ├── playwright │ ├── index.html │ └── index.ts ├── src │ ├── app.component.ts │ ├── assets │ │ ├── favicon.ico │ │ ├── logo.svg │ │ └── styles.css │ ├── components │ │ ├── button-signals.component.ts │ │ ├── button.component.html │ │ ├── button.component.ts │ │ ├── component.component.ts │ │ ├── counter.component.ts │ │ ├── default-slot.component.ts │ │ ├── empty.component.ts │ │ ├── inject.component.ts │ │ ├── multi-root.component.ts │ │ ├── named-slots.component.ts │ │ ├── not-inlined.component.html │ │ ├── not-inlined.component.ts │ │ └── output.component.ts │ ├── index.html │ ├── main.ts │ ├── pages │ │ ├── dashboard.component.ts │ │ └── login.component.ts │ └── router │ │ └── index.ts ├── tests │ ├── angular-router.spec.ts │ ├── events.spec.ts │ ├── hooks.spec.ts │ ├── render.spec.ts │ ├── unmount.spec.ts │ ├── unsupported.spec.tsx │ └── update.spec.ts ├── tsconfig.app.json ├── tsconfig.json └── tsconfig.spec.json ├── package.json ├── playwright-ct-angular ├── .npmignore ├── README.md ├── cli.js ├── hooks.d.ts ├── hooks.mjs ├── index.d.ts ├── index.js ├── package.json ├── register.d.ts ├── register.mjs ├── registerSource.mjs └── transform.js ├── pnpm-lock.yaml └── pnpm-workspace.yaml /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [sand4rt] 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Please see the documentation for all configuration options: 2 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 3 | 4 | version: 2 5 | updates: 6 | - package-ecosystem: "npm" 7 | directory: "/" 8 | schedule: 9 | interval: "daily" 10 | versioning-strategy: auto 11 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | on: 3 | release: 4 | types: [created] 5 | jobs: 6 | deploy: 7 | runs-on: ubuntu-latest 8 | permissions: 9 | contents: read 10 | packages: write 11 | steps: 12 | - uses: pnpm/action-setup@v2 13 | with: 14 | version: "9.x" 15 | 16 | - uses: actions/checkout@v4 17 | 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: "18.x" 21 | registry-url: "https://registry.npmjs.org" 22 | scope: "@sand4rt" 23 | 24 | - run: pnpm publish --filter @sand4rt/experimental-ct-angular --access public --no-git-checks 25 | env: 26 | NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} 27 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | branches: [main, master] 5 | pull_request: 6 | branches: [main, master] 7 | jobs: 8 | test: 9 | timeout-minutes: 60 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: pnpm/action-setup@v2 13 | with: 14 | version: "9.x" 15 | 16 | - uses: actions/checkout@v4 17 | 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: "18.x" 21 | 22 | - name: Install dependencies 23 | run: pnpm install --frozen-lockfile 24 | 25 | - name: Build packages 26 | run: pnpm build 27 | 28 | - name: Install Playwright Browsers 29 | run: pnpm dlx playwright install --with-deps 30 | 31 | - name: Run Playwright tests 32 | run: pnpm test 33 | 34 | - uses: actions/upload-artifact@v4 35 | if: always() 36 | with: 37 | name: playwright-report 38 | path: ./*/**/playwright-report/ 39 | retention-days: 30 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | *playwright-report 26 | *.cache 27 | *test-results 28 | /test-results/ 29 | /playwright-report/ 30 | /playwright/.cache/ 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 sand4rt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🎭 Playwright Angular component testing 2 | 3 | > **Note** 4 | > The API has been designed to closely resemble Playwright's API wherever applicable. This library is _(without guarantee)_ aimed at facilitating a smooth transition to Playwright once it offers official support for Angular components. It is important to take into account that this library will reach end of life when Playwright has official support for Angular component testing. 5 | > 6 | > To push for official support, feedback in the form of GitHub issues and or stars is appreciated! 7 | 8 | ## Capabilities 9 | 10 | - Run tests fast, in parallel and optionally over multiple machines with [sharding](https://playwright.dev/docs/test-sharding) or [Azure's Testing Service](https://www.youtube.com/watch?v=FvyYC2pxL_8). 11 | - Run the test headless or headed accross multiple _real_ desktop and/or mobile browser engines. 12 | - Full support for shadow DOM, multiple origins, (i)frames, browser tabs and contexts. 13 | - Minimizes flakyness, due to auto waiting, web first assertions and ensures that every test runs in [full isolation](https://playwright.dev/docs/browser-contexts). 14 | - Advanced [emulation capabilities](https://playwright.dev/docs/emulation) such as modifying screen size, geolocation, color scheme and [the network](https://playwright.dev/docs/mock-browser-apis). 15 | - Interactions with the components are indistinguishable from real user behavior. 16 | - [Visual regression / screenshot testing](https://playwright.dev/docs/api/class-pageassertions#page-assertions-to-have-screenshot-1). 17 | - Zero-configuration TypeScript support. 18 | 19 | Along with all these ✨ awesome capabilities ✨ that come with Playwright, you will also get: 20 | 21 | - [Watch mode _(BETA)_](https://github.com/microsoft/playwright/issues/21960#issuecomment-1483604692). 22 | - [Visual Studio Code intergration](https://playwright.dev/docs/getting-started-vscode). 23 | - [UI mode](https://playwright.dev/docs/test-ui-mode) for debuging tests with a time travel experience complete with watch mode. 24 | - [Playwright Tracing](https://playwright.dev/docs/trace-viewer-intro) for post-mortem debugging in CI. 25 | - [Playwright Test Code generation](https://playwright.dev/docs/codegen-intro) to record and generate tests suites. 26 | 27 | ## Usage 28 | 29 | Initialize Playwright Angular component testing with PNPM, NPM or Yarn and follow the installation steps: 30 | 31 | ```sh 32 | pnpm create playwright-sand4rt --ct 33 | ``` 34 | ```sh 35 | npm init playwright-sand4rt@latest -- --ct 36 | ``` 37 | ```sh 38 | yarn create playwright-sand4rt --ct 39 | ``` 40 | 41 | Now you can start creating your tests: 42 | 43 | ```ts 44 | // button.component.ts 45 | import { Component, Input } from '@angular/core'; 46 | 47 | @Component({ 48 | standalone: true, 49 | selector: 'button-component', 50 | template: ``, 51 | }) 52 | export class ButtonComponent { 53 | @Input() title!: string; 54 | } 55 | ``` 56 | 57 | ```jsx 58 | // button.component.test.ts 59 | import { test, expect } from '@sand4rt/experimental-ct-angular'; 60 | import { ButtonComponent } from './components/button.component'; 61 | 62 | test('render props', async ({ mount }) => { 63 | const component = await mount(ButtonComponent, { 64 | props: { 65 | title: 'Submit', 66 | }, 67 | }); 68 | await expect(component).toContainText('Submit'); 69 | }); 70 | ``` 71 | 72 | See the official [playwright component testing documentation](https://playwright.dev/docs/test-components) and the [tests](https://github.com/sand4rt/playwright-ct-angular/tree/main/ct-angular/tests) for more information on how to use it. 73 | -------------------------------------------------------------------------------- /ct-angular/.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # Compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | /bazel-out 8 | 9 | # Node 10 | /node_modules 11 | npm-debug.log 12 | yarn-error.log 13 | 14 | # IDEs and editors 15 | .idea/ 16 | .project 17 | .classpath 18 | .c9/ 19 | *.launch 20 | .settings/ 21 | *.sublime-workspace 22 | 23 | # Visual Studio Code 24 | .vscode/* 25 | !.vscode/settings.json 26 | !.vscode/tasks.json 27 | !.vscode/launch.json 28 | !.vscode/extensions.json 29 | .history/* 30 | 31 | # Miscellaneous 32 | /.angular/cache 33 | .sass-cache/ 34 | /connect.lock 35 | /coverage 36 | /libpeerconnection.log 37 | testem.log 38 | /typings 39 | 40 | # System files 41 | .DS_Store 42 | Thumbs.db 43 | /test-results/ 44 | /playwright-report/ 45 | /playwright/.cache/ 46 | -------------------------------------------------------------------------------- /ct-angular/README.md: -------------------------------------------------------------------------------- 1 | # ct-angular 2 | 3 | This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 15.0.0. 4 | 5 | ## Development server 6 | 7 | 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. 8 | 9 | ## Code scaffolding 10 | 11 | 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`. 12 | 13 | ## Build 14 | 15 | Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. 16 | 17 | ## Further help 18 | 19 | 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. 20 | -------------------------------------------------------------------------------- /ct-angular/angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "ct-angular": { 7 | "projectType": "application", 8 | "schematics": {}, 9 | "root": "", 10 | "sourceRoot": "src", 11 | "prefix": "app", 12 | "architect": { 13 | "build": { 14 | "builder": "@angular-devkit/build-angular:browser", 15 | "options": { 16 | "outputPath": "dist/ct-angular", 17 | "index": "src/index.html", 18 | "main": "src/main.ts", 19 | "polyfills": [ 20 | "zone.js" 21 | ], 22 | "tsConfig": "tsconfig.app.json", 23 | "assets": [ 24 | "src/assets/favicon.ico", 25 | "src/assets" 26 | ], 27 | "styles": [ 28 | "src/assets/styles.css" 29 | ], 30 | "scripts": [] 31 | }, 32 | "configurations": { 33 | "production": { 34 | "budgets": [ 35 | { 36 | "type": "initial", 37 | "maximumWarning": "500kb", 38 | "maximumError": "1mb" 39 | }, 40 | { 41 | "type": "anyComponentStyle", 42 | "maximumWarning": "2kb", 43 | "maximumError": "4kb" 44 | } 45 | ], 46 | "outputHashing": "all" 47 | }, 48 | "development": { 49 | "buildOptimizer": false, 50 | "optimization": false, 51 | "vendorChunk": true, 52 | "extractLicenses": false, 53 | "sourceMap": true, 54 | "namedChunks": true 55 | } 56 | }, 57 | "defaultConfiguration": "production" 58 | }, 59 | "serve": { 60 | "builder": "@angular-devkit/build-angular:dev-server", 61 | "configurations": { 62 | "production": { 63 | "browserTarget": "ct-angular:build:production" 64 | }, 65 | "development": { 66 | "browserTarget": "ct-angular:build:development" 67 | } 68 | }, 69 | "defaultConfiguration": "development" 70 | }, 71 | "extract-i18n": { 72 | "builder": "@angular-devkit/build-angular:extract-i18n", 73 | "options": { 74 | "browserTarget": "ct-angular:build" 75 | } 76 | } 77 | } 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /ct-angular/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ct-angular", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "ng": "ng", 7 | "start": "ng serve", 8 | "build": "ng build", 9 | "watch": "ng build --watch --configuration development", 10 | "test": "npx playwright test", 11 | "typecheck": "tsc --noEmit" 12 | }, 13 | "dependencies": { 14 | "@analogjs/vite-plugin-angular": "1.17.0", 15 | "@angular/animations": "17.3.8", 16 | "@angular/common": "17.3.8", 17 | "@angular/compiler": "17.3.8", 18 | "@angular/core": "17.3.8", 19 | "@angular/forms": "17.3.8", 20 | "@angular/platform-browser": "17.3.8", 21 | "@angular/platform-browser-dynamic": "17.3.8", 22 | "@angular/router": "17.3.8", 23 | "rxjs": "7.8.1", 24 | "tslib": "2.6.2", 25 | "zone.js": "0.14.5" 26 | }, 27 | "devDependencies": { 28 | "@sand4rt/experimental-ct-angular": "workspace:*", 29 | "@angular-devkit/build-angular": "17.3.7", 30 | "@angular/cli": "17.3.7", 31 | "@angular/compiler-cli": "17.3.8", 32 | "typescript": "5.4.3" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /ct-angular/playwright.config.mts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import angular from '@analogjs/vite-plugin-angular'; 18 | import { defineConfig, devices } from '@sand4rt/experimental-ct-angular'; 19 | import { resolve } from 'path'; 20 | 21 | export default defineConfig({ 22 | testDir: 'tests', 23 | forbidOnly: !!process.env['CI'], 24 | retries: process.env['CI'] ? 2 : 0, 25 | reporter: process.env['CI'] ? 'html' : 'line', 26 | use: { 27 | trace: 'on-first-retry', 28 | ctViteConfig: { 29 | plugins: [angular({ 30 | tsconfig: resolve('./tsconfig.spec.json'), 31 | }) as any], // TODO: remove any and resolve various installed conflicting Vite versions 32 | resolve: { 33 | alias: { 34 | '@': resolve('./src'), 35 | } 36 | } 37 | } 38 | }, 39 | projects: [ 40 | { 41 | name: 'chromium', 42 | use: { ...devices['Desktop Chrome'] }, 43 | }, 44 | { 45 | name: 'firefox', 46 | use: { ...devices['Desktop Firefox'] }, 47 | }, 48 | { 49 | name: 'webkit', 50 | use: { ...devices['Desktop Safari'] }, 51 | }, 52 | ], 53 | }); 54 | -------------------------------------------------------------------------------- /ct-angular/playwright/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Testing Page 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /ct-angular/playwright/index.ts: -------------------------------------------------------------------------------- 1 | import 'zone.js'; 2 | import '@/assets/styles.css'; 3 | import { TOKEN } from '@/components/inject.component'; 4 | import { routes } from '@/router'; 5 | import { APP_INITIALIZER, inject } from '@angular/core'; 6 | import { Router, provideRouter } from '@angular/router'; 7 | import { afterMount, beforeMount } from '@sand4rt/experimental-ct-angular/hooks'; 8 | import { BrowserPlatformLocation, PlatformLocation } from '@angular/common'; 9 | 10 | export type HooksConfig = { 11 | routing?: boolean; 12 | injectToken?: boolean; 13 | }; 14 | 15 | beforeMount(async ({ hooksConfig, TestBed }) => { 16 | if (hooksConfig?.routing) 17 | TestBed.configureTestingModule({ 18 | providers: [ 19 | provideRouter(routes), 20 | { provide: PlatformLocation, useExisting: BrowserPlatformLocation }, 21 | { 22 | provide: APP_INITIALIZER, 23 | multi: true, 24 | useFactory() { 25 | const router = inject(Router); 26 | return () => router.initialNavigation(); 27 | } 28 | } 29 | ], 30 | }); 31 | 32 | if (hooksConfig?.injectToken) 33 | TestBed.configureTestingModule({ 34 | providers: [{ provide: TOKEN, useValue: { text: 'has been overwritten' } }] 35 | }) 36 | }); 37 | 38 | afterMount(async () => { 39 | console.log('After mount'); 40 | }); 41 | -------------------------------------------------------------------------------- /ct-angular/src/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { RouterLink, RouterOutlet } from '@angular/router'; 3 | 4 | @Component({ 5 | standalone: true, 6 | imports: [RouterLink, RouterOutlet], 7 | selector: 'app-root', 8 | template: ` 9 |
10 | 11 | Login 12 | Dashboard 13 |
14 | 15 | ` 16 | }) 17 | export class AppComponent {} 18 | -------------------------------------------------------------------------------- /ct-angular/src/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sand4rt/playwright-ct-angular/14cf0001d01f232ba12bfbef347cc00c9df7a124/ct-angular/src/assets/favicon.ico -------------------------------------------------------------------------------- /ct-angular/src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /ct-angular/src/assets/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | 15 | @media (prefers-color-scheme: light) { 16 | :root { 17 | color: #e3e3e3; 18 | background-color: #1b1b1d; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /ct-angular/src/components/button-signals.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, input } from '@angular/core'; 2 | 3 | @Component({ 4 | standalone: true, 5 | selector: 'app-button-signals', 6 | template: ` 7 | 8 | ` 9 | }) 10 | export class ButtonSignalsComponent { 11 | title = input.required(); 12 | } 13 | -------------------------------------------------------------------------------- /ct-angular/src/components/button.component.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /ct-angular/src/components/button.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Input, Output } from '@angular/core'; 2 | 3 | @Component({ 4 | standalone: true, 5 | selector: 'app-button', 6 | templateUrl: './button.component.html', 7 | }) 8 | export class ButtonComponent { 9 | @Input({required: true}) title!: string; 10 | @Output() submit = new EventEmitter(); 11 | } 12 | -------------------------------------------------------------------------------- /ct-angular/src/components/component.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | standalone: true, 5 | template: `
test
`, 6 | }) 7 | export class ComponentComponent {} 8 | -------------------------------------------------------------------------------- /ct-angular/src/components/counter.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Input, Output } from '@angular/core'; 2 | 3 | @Component({ 4 | standalone: true, 5 | template: ` 6 |
7 |
{{ count }}
8 |
{{ this.remountCount }}
9 | 10 | 11 |
12 | `, 13 | }) 14 | export class CounterComponent { 15 | remountCount = Number(localStorage.getItem('remountCount')); 16 | @Input() count!: number; 17 | 18 | @Output() submit = new EventEmitter(); 19 | 20 | constructor() { 21 | localStorage.setItem('remountCount', String(this.remountCount++)) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /ct-angular/src/components/default-slot.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | standalone: true, 5 | template: ` 6 |
7 |

Welcome!

8 |
9 | 10 |
11 |
12 | Thanks for visiting. 13 |
14 |
15 | `, 16 | }) 17 | export class DefaultSlotComponent {} 18 | -------------------------------------------------------------------------------- /ct-angular/src/components/empty.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | standalone: true, 5 | template: ``, 6 | }) 7 | export class EmptyComponent {} 8 | -------------------------------------------------------------------------------- /ct-angular/src/components/inject.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, inject, InjectionToken } from '@angular/core'; 2 | 3 | export const TOKEN = new InjectionToken<{ text: string }>('gets overwritten'); 4 | 5 | @Component({ 6 | standalone: true, 7 | template: `
{{ data.text }}
`, 8 | }) 9 | export class InjectComponent { 10 | public data = inject(TOKEN); 11 | } 12 | -------------------------------------------------------------------------------- /ct-angular/src/components/multi-root.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | standalone: true, 5 | template: `
root 1
root 2
`, 6 | }) 7 | export class MultiRootComponent {} 8 | -------------------------------------------------------------------------------- /ct-angular/src/components/named-slots.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | standalone: true, 5 | template: ` 6 |
7 |
8 | 9 |
10 |
11 | 12 |
13 |
14 | 15 |
16 |
17 | `, 18 | }) 19 | export class NamedSlotsComponent {} 20 | -------------------------------------------------------------------------------- /ct-angular/src/components/not-inlined.component.html: -------------------------------------------------------------------------------- 1 |

Not Inlined

-------------------------------------------------------------------------------- /ct-angular/src/components/not-inlined.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | 3 | @Component({ 4 | changeDetection: ChangeDetectionStrategy.OnPush, 5 | standalone: true, 6 | templateUrl: './not-inlined.component.html', 7 | }) 8 | export class NotInlinedComponent {} -------------------------------------------------------------------------------- /ct-angular/src/components/output.component.ts: -------------------------------------------------------------------------------- 1 | import { DOCUMENT } from "@angular/common"; 2 | import { Component, Output, inject } from "@angular/core"; 3 | import { Subject, finalize } from "rxjs"; 4 | 5 | @Component({ 6 | standalone: true, 7 | template: `OutputComponent`, 8 | }) 9 | export class OutputComponent { 10 | @Output() answerChange = new Subject().pipe( 11 | /* Detect when observable is unsubscribed from, 12 | * and set a global variable `hasUnsubscribed` to true. */ 13 | finalize(() => ((this._window as any).hasUnsubscribed = true)) 14 | ); 15 | 16 | private _window = inject(DOCUMENT).defaultView; 17 | } 18 | -------------------------------------------------------------------------------- /ct-angular/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Angular 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /ct-angular/src/main.ts: -------------------------------------------------------------------------------- 1 | import { bootstrapApplication } from '@angular/platform-browser'; 2 | import { provideRouter } from '@angular/router'; 3 | import { AppComponent } from '@/app.component'; 4 | import { routes } from '@/router'; 5 | 6 | bootstrapApplication(AppComponent, { 7 | providers: [ 8 | provideRouter(routes) 9 | ] 10 | }).catch(err => console.error(err)); 11 | -------------------------------------------------------------------------------- /ct-angular/src/pages/dashboard.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | standalone: true, 5 | selector: 'app-dashboard', 6 | template: `
Dashboard
` 7 | }) 8 | export class DashboardComponent {} 9 | -------------------------------------------------------------------------------- /ct-angular/src/pages/login.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | standalone: true, 5 | selector: 'app-login', 6 | template: `
Login
` 7 | }) 8 | export class LoginComponent {} 9 | -------------------------------------------------------------------------------- /ct-angular/src/router/index.ts: -------------------------------------------------------------------------------- 1 | import { Routes } from '@angular/router'; 2 | import { DashboardComponent } from '@/pages/dashboard.component'; 3 | import { LoginComponent } from '@/pages/login.component'; 4 | 5 | export const routes: Routes = [ 6 | { path: '', component: LoginComponent }, 7 | { path: 'dashboard', component: DashboardComponent }, 8 | ]; 9 | -------------------------------------------------------------------------------- /ct-angular/tests/angular-router.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@sand4rt/experimental-ct-angular'; 2 | import type { HooksConfig } from 'playwright'; 3 | import { AppComponent } from '@/app.component'; 4 | 5 | test('navigate to a page by clicking a link', async ({ page, mount }) => { 6 | const component = await mount(AppComponent, { 7 | hooksConfig: { routing: true }, 8 | }); 9 | await expect(component.getByRole('main')).toHaveText('Login'); 10 | await component.getByRole('link', { name: 'Dashboard' }).click(); 11 | await expect(component.getByRole('main')).toHaveText('Dashboard'); 12 | await expect(page).toHaveURL('/dashboard'); 13 | }); 14 | -------------------------------------------------------------------------------- /ct-angular/tests/events.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@sand4rt/experimental-ct-angular'; 2 | import { ButtonComponent } from '@/components/button.component'; 3 | import { OutputComponent } from '@/components/output.component'; 4 | 5 | test('emit an submit event when the button is clicked', async ({ mount }) => { 6 | const messages: string[] = []; 7 | const component = await mount(ButtonComponent, { 8 | props: { 9 | title: 'Submit', 10 | }, 11 | on: { 12 | submit: (data: string) => messages.push(data), 13 | }, 14 | }); 15 | await component.click(); 16 | expect(messages).toEqual(['hello']); 17 | }); 18 | 19 | test('replace existing listener when new listener is set', async ({ 20 | mount, 21 | }) => { 22 | let called = false; 23 | 24 | const component = await mount(ButtonComponent, { 25 | props: { 26 | title: 'Submit', 27 | }, 28 | on: { 29 | submit() { }, 30 | }, 31 | }); 32 | 33 | component.update({ 34 | on: { 35 | submit() { 36 | called = true; 37 | }, 38 | }, 39 | }); 40 | 41 | await component.click(); 42 | expect(called).toBe(true); 43 | }); 44 | 45 | test('unsubscribe from events when the component is unmounted', async ({ 46 | mount, 47 | page, 48 | }) => { 49 | const component = await mount(OutputComponent, { 50 | on: { 51 | answerChange() { }, 52 | }, 53 | }); 54 | 55 | await component.unmount(); 56 | 57 | /* Check that the output observable had been unsubscribed from 58 | * as it sets a global variable `hasUnusbscribed` to true 59 | * when it detects unsubscription. Cf. OutputComponent. */ 60 | expect(await page.evaluate(() => (window as any).hasUnsubscribed)).toBe(true); 61 | }); 62 | -------------------------------------------------------------------------------- /ct-angular/tests/hooks.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@sand4rt/experimental-ct-angular'; 2 | import type { HooksConfig } from 'playwright'; 3 | import { InjectComponent } from '@/components/inject.component'; 4 | 5 | test('inject a token', async ({ mount }) => { 6 | const component = await mount(InjectComponent, { 7 | hooksConfig: { injectToken: true }, 8 | }); 9 | await expect(component).toHaveText('has been overwritten'); 10 | await expect(component).not.toHaveText('gets overwritten'); 11 | }); 12 | -------------------------------------------------------------------------------- /ct-angular/tests/render.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@sand4rt/experimental-ct-angular'; 2 | import { ButtonComponent } from '@/components/button.component'; 3 | import { EmptyComponent } from '@/components/empty.component'; 4 | import { ComponentComponent } from '@/components/component.component'; 5 | import { NotInlinedComponent } from '@/components/not-inlined.component'; 6 | import { ButtonSignalsComponent } from '@/components/button-signals.component'; 7 | 8 | test('render inputs', async ({ mount }) => { 9 | const component = await mount(ButtonComponent, { 10 | props: { 11 | title: 'Submit', 12 | }, 13 | }); 14 | await expect(component).toContainText('Submit'); 15 | }); 16 | 17 | test('render signal-based inputs', async ({ mount }) => { 18 | const component = await mount(ButtonSignalsComponent, { 19 | props: { 20 | title: 'Submit', 21 | }, 22 | }); 23 | await expect(component).toContainText('Submit'); 24 | }); 25 | 26 | test('get textContent of the empty component', async ({ mount }) => { 27 | const component = await mount(EmptyComponent); 28 | expect(await component.allTextContents()).toEqual(['']); 29 | expect(await component.textContent()).toBe(''); 30 | await expect(component).toHaveText(''); 31 | }); 32 | 33 | test('render a component without options', async ({ mount }) => { 34 | const component = await mount(ComponentComponent); 35 | await expect(component).toContainText('test'); 36 | }); 37 | 38 | test('render component with not inlined template', async ({ mount }) => { 39 | const component = await mount(NotInlinedComponent); 40 | await expect(component).toContainText('Not Inlined'); 41 | }); 42 | -------------------------------------------------------------------------------- /ct-angular/tests/unmount.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@sand4rt/experimental-ct-angular'; 2 | import { ButtonComponent } from '@/components/button.component'; 3 | import { MultiRootComponent } from '@/components/multi-root.component'; 4 | 5 | test('unmount', async ({ page, mount }) => { 6 | const component = await mount(ButtonComponent, { 7 | props: { 8 | title: 'Submit', 9 | }, 10 | }); 11 | await expect(page.locator('#root')).toContainText('Submit'); 12 | await component.unmount(); 13 | await expect(page.locator('#root')).not.toContainText('Submit'); 14 | }); 15 | 16 | test('unmount a multi root component', async ({ mount, page }) => { 17 | const component = await mount(MultiRootComponent); 18 | await expect(page.locator('#root')).toContainText('root 1'); 19 | await expect(page.locator('#root')).toContainText('root 2'); 20 | await component.unmount(); 21 | await expect(page.locator('#root')).not.toContainText('root 1'); 22 | await expect(page.locator('#root')).not.toContainText('root 2'); 23 | }); 24 | -------------------------------------------------------------------------------- /ct-angular/tests/unsupported.spec.tsx: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@sand4rt/experimental-ct-angular'; 2 | 3 | test('should throw an error when mounting JSX', async ({ mount }) => { 4 | // @ts-ignore 5 | await expect(mount(

as any)).rejects.toThrow('JSX mount notation is not supported'); 6 | }); 7 | -------------------------------------------------------------------------------- /ct-angular/tests/update.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@sand4rt/experimental-ct-angular'; 2 | import { CounterComponent } from '@/components/counter.component'; 3 | 4 | test('update props without remounting', async ({ mount }) => { 5 | const component = await mount(CounterComponent, { 6 | props: { count: 9001 }, 7 | }); 8 | await expect(component.getByTestId('props')).toContainText('9001'); 9 | 10 | await component.update({ 11 | props: { count: 1337 }, 12 | }); 13 | await expect(component).not.toContainText('9001'); 14 | await expect(component.getByTestId('props')).toContainText('1337'); 15 | 16 | await expect(component.getByTestId('remount-count')).toContainText('1'); 17 | }); 18 | 19 | test('update event listeners without remounting', async ({ mount }) => { 20 | const component = await mount(CounterComponent); 21 | 22 | const messages: string[] = []; 23 | await component.update({ 24 | on: { 25 | submit: (data: string) => messages.push(data), 26 | }, 27 | }); 28 | await component.click(); 29 | expect(messages).toEqual(['hello']); 30 | 31 | await expect(component.getByTestId('remount-count')).toContainText('1'); 32 | }); 33 | -------------------------------------------------------------------------------- /ct-angular/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/app", 6 | "types": [] 7 | }, 8 | "files": [ 9 | "src/main.ts" 10 | ], 11 | "include": [ 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /ct-angular/tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "compileOnSave": false, 4 | "compilerOptions": { 5 | "baseUrl": "./", 6 | "outDir": "./dist/out-tsc", 7 | "forceConsistentCasingInFileNames": true, 8 | "strict": true, 9 | "noImplicitOverride": true, 10 | "noPropertyAccessFromIndexSignature": true, 11 | "noImplicitReturns": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "sourceMap": true, 14 | "skipLibCheck": true, 15 | "declaration": false, 16 | "downlevelIteration": true, 17 | "experimentalDecorators": true, 18 | "moduleResolution": "node", 19 | "importHelpers": true, 20 | "target": "ES2022", 21 | "module": "ES2022", 22 | "useDefineForClassFields": false, 23 | "lib": [ 24 | "ES2022", 25 | "dom" 26 | ], 27 | "paths": { 28 | "@/*": ["./src/*"] 29 | } 30 | }, 31 | "angularCompilerOptions": { 32 | "enableI18nLegacyMessageIdFormat": false, 33 | "strictInjectionParameters": true, 34 | "strictInputAccessModifiers": true, 35 | "strictTemplates": true, 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /ct-angular/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/spec", 6 | "types": ["node"] 7 | }, 8 | "include": ["tests/**/*.spec.ts", "tests/**/*.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sand4rt", 3 | "version": "1.0.0", 4 | "repository": { 5 | "type": "git", 6 | "url": "git+https://github.com/sand4rt/playwright-ct-angular.git" 7 | }, 8 | "bugs": { 9 | "url": "https://github.com/sand4rt/playwright-ct-angular/issues" 10 | }, 11 | "homepage": "https://github.com/sand4rt/playwright-ct-angular#readme", 12 | "engines": { 13 | "node": ">=18", 14 | "pnpm": ">=7" 15 | }, 16 | "scripts": { 17 | "test": "pnpm --stream -r test", 18 | "build": "pnpm --stream -r build" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /playwright-ct-angular/.npmignore: -------------------------------------------------------------------------------- 1 | **/* 2 | 3 | !README.md 4 | !LICENSE 5 | !register.d.ts 6 | !register.mjs 7 | !registerSource.mjs 8 | !index.d.ts 9 | !index.js 10 | !hooks.d.ts 11 | !hooks.mjs 12 | -------------------------------------------------------------------------------- /playwright-ct-angular/README.md: -------------------------------------------------------------------------------- 1 | # 🎭 Playwright Angular component testing 2 | 3 | > **Note** 4 | > The API has been designed to closely resemble Playwright's API wherever applicable. This library is _(without guarantee)_ aimed at facilitating a smooth transition to Playwright once it offers official support for Angular components. It is important to take into account that this library will reach end of life when Playwright has official support for Angular component testing. 5 | > 6 | > To push for official support, feedback in the form of GitHub issues and or stars is appreciated! 7 | 8 | ## Capabilities 9 | 10 | - Run tests fast, in parallel and optionally over multiple machines with [sharding](https://playwright.dev/docs/test-sharding) or [Azure's Testing Service](https://www.youtube.com/watch?v=FvyYC2pxL_8). 11 | - Run the test headless or headed accross multiple _real_ desktop and/or mobile browser engines. 12 | - Full support for shadow DOM, multiple origins, (i)frames, browser tabs and contexts. 13 | - Minimizes flakyness, due to auto waiting, web first assertions and ensures that every test runs in [full isolation](https://playwright.dev/docs/browser-contexts). 14 | - Advanced [emulation capabilities](https://playwright.dev/docs/emulation) such as modifying screen size, geolocation, color scheme and [the network](https://playwright.dev/docs/mock-browser-apis). 15 | - Interactions with the components are indistinguishable from real user behavior. 16 | - [Visual regression / screenshot testing](https://playwright.dev/docs/api/class-pageassertions#page-assertions-to-have-screenshot-1). 17 | - Zero-configuration TypeScript support. 18 | 19 | Along with all these ✨ awesome capabilities ✨ that come with Playwright, you will also get: 20 | 21 | - [Watch mode _(BETA)_](https://github.com/microsoft/playwright/issues/21960#issuecomment-1483604692). 22 | - [Visual Studio Code intergration](https://playwright.dev/docs/getting-started-vscode). 23 | - [UI mode](https://playwright.dev/docs/test-ui-mode) for debuging tests with a time travel experience complete with watch mode. 24 | - [Playwright Tracing](https://playwright.dev/docs/trace-viewer-intro) for post-mortem debugging in CI. 25 | - [Playwright Test Code generation](https://playwright.dev/docs/codegen-intro) to record and generate tests suites. 26 | 27 | ## Usage 28 | 29 | Initialize Playwright Angular component testing with PNPM, NPM or Yarn and follow the installation steps: 30 | 31 | ```sh 32 | pnpm create playwright-sand4rt --ct 33 | ``` 34 | ```sh 35 | npm init playwright-sand4rt@latest -- --ct 36 | ``` 37 | ```sh 38 | yarn create playwright-sand4rt --ct 39 | ``` 40 | 41 | Now you can start creating your tests: 42 | 43 | ```ts 44 | // button.component.ts 45 | import { Component, Input } from '@angular/core'; 46 | 47 | @Component({ 48 | standalone: true, 49 | selector: 'button-component', 50 | template: ``, 51 | }) 52 | export class ButtonComponent { 53 | @Input() title!: string; 54 | } 55 | ``` 56 | 57 | ```ts 58 | // button.component.test.ts 59 | import { test, expect } from '@sand4rt/experimental-ct-angular'; 60 | import { ButtonComponent } from './components/button.component'; 61 | 62 | test('render props', async ({ mount }) => { 63 | const component = await mount(ButtonComponent, { 64 | props: { 65 | title: 'Submit', 66 | }, 67 | }); 68 | await expect(component).toContainText('Submit'); 69 | }); 70 | ``` 71 | 72 | See the official [playwright component testing documentation](https://playwright.dev/docs/test-components) and the [tests](https://github.com/sand4rt/playwright-ct-angular/tree/main/ct-angular/tests) for more information on how to use it. 73 | -------------------------------------------------------------------------------- /playwright-ct-angular/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /** 3 | * Copyright (c) Microsoft Corporation. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | const { program } = require('@playwright/experimental-ct-core/lib/program'); 19 | 20 | program.parse(process.argv); 21 | -------------------------------------------------------------------------------- /playwright-ct-angular/hooks.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import type { TestBedStatic } from '@angular/core/testing'; 18 | import type { JsonObject } from '@playwright/experimental-ct-core/types/component'; 19 | 20 | export declare function beforeMount( 21 | callback: (params: { hooksConfig?: HooksConfig, TestBed: TestBedStatic }) => Promise 22 | ): void; 23 | export declare function afterMount( 24 | callback: (params: { hooksConfig?: HooksConfig }) => Promise 25 | ): void; 26 | -------------------------------------------------------------------------------- /playwright-ct-angular/hooks.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | const __pw_hooks_before_mount = []; 18 | const __pw_hooks_after_mount = []; 19 | 20 | window.__pw_hooks_before_mount = __pw_hooks_before_mount; 21 | window.__pw_hooks_after_mount = __pw_hooks_after_mount; 22 | 23 | export const beforeMount = callback => { 24 | __pw_hooks_before_mount.push(callback); 25 | }; 26 | 27 | export const afterMount = callback => { 28 | __pw_hooks_after_mount.push(callback); 29 | }; 30 | -------------------------------------------------------------------------------- /playwright-ct-angular/index.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import type { JsonObject } from '@playwright/experimental-ct-core/types/component'; 18 | import type { TestType, Locator } from '@playwright/experimental-ct-core'; 19 | import type { Type } from '@angular/core'; 20 | 21 | export type ComponentEvents = Record; 22 | 23 | export interface MountOptions { 24 | props?: Partial | Record, // TODO: filter props and handle signals 25 | on?: ComponentEvents; 26 | hooksConfig?: HooksConfig; 27 | } 28 | 29 | export interface MountResult extends Locator { 30 | unmount(): Promise; 31 | update(options: { 32 | props?: Partial, 33 | on?: Partial, 34 | }): Promise; 35 | } 36 | 37 | export const test: TestType<{ 38 | mount( 39 | component: Type, 40 | options?: MountOptions 41 | ): Promise>; 42 | }>; 43 | 44 | export { defineConfig, PlaywrightTestConfig } from '@playwright/experimental-ct-core'; 45 | export { expect, devices } from 'playwright/test'; 46 | -------------------------------------------------------------------------------- /playwright-ct-angular/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | const { defineConfig: originalDefineConfig, devices, expect, test } = require('@playwright/experimental-ct-core'); 18 | const path = require('path'); 19 | 20 | const defineConfig = (config, ...configs) => { 21 | const originalConfig = originalDefineConfig({ 22 | ...config, 23 | '@playwright/test': { 24 | packageJSON: require.resolve('./package.json'), 25 | }, 26 | '@playwright/experimental-ct-core': { 27 | registerSourceFile: path.join(__dirname, 'registerSource.mjs') 28 | }, 29 | }, ...configs); 30 | originalConfig['@playwright/test'].babelPlugins = [[require.resolve('./transform')]]; 31 | return originalConfig; 32 | }; 33 | 34 | module.exports = { defineConfig, devices, expect, test }; 35 | -------------------------------------------------------------------------------- /playwright-ct-angular/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sand4rt/experimental-ct-angular", 3 | "version": "1.52.0", 4 | "description": "Playwright Component Testing for Angular", 5 | "homepage": "https://playwright.dev", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/sand4rt/playwright-ct-angular.git" 9 | }, 10 | "bugs": { 11 | "url": "https://github.com/sand4rt/playwright-ct-angular/issues" 12 | }, 13 | "engines": { 14 | "node": ">=18" 15 | }, 16 | "license": "MIT", 17 | "keywords": [ 18 | "testing", 19 | "angular components", 20 | "real browser", 21 | "playwright", 22 | "unit", 23 | "integration", 24 | "functional", 25 | "end to end", 26 | "e2e" 27 | ], 28 | "exports": { 29 | ".": { 30 | "types": "./index.d.ts", 31 | "default": "./index.js" 32 | }, 33 | "./register": { 34 | "types": "./register.d.ts", 35 | "default": "./register.mjs" 36 | }, 37 | "./hooks": { 38 | "types": "./hooks.d.ts", 39 | "default": "./hooks.mjs" 40 | } 41 | }, 42 | "dependencies": { 43 | "@playwright/experimental-ct-core": "1.52.0" 44 | }, 45 | "devDependencies": { 46 | "@angular/compiler": "^17.0.0", 47 | "@angular/core": "^17.0.0", 48 | "@angular/platform-browser-dynamic": "^17.0.0", 49 | "rxjs": "~7.8.1", 50 | "typescript": "~5.2.0" 51 | }, 52 | "peerDependencies": { 53 | "@angular/compiler": "^17.0.0", 54 | "@angular/core": "^17.0.0", 55 | "@angular/platform-browser-dynamic": "^17.0.0", 56 | "typescript": ">=5.2.0" 57 | }, 58 | "bin": { 59 | "playwright": "cli.js" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /playwright-ct-angular/register.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | export default function pwRegister(components: Record): void; 18 | -------------------------------------------------------------------------------- /playwright-ct-angular/register.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { pwRegister } from './registerSource.mjs'; 18 | 19 | export default components => { 20 | pwRegister(components); 21 | }; 22 | -------------------------------------------------------------------------------- /playwright-ct-angular/registerSource.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | // @ts-check 18 | // This file is injected into the registry as text, no dependencies are allowed. 19 | 20 | /** @typedef {import('@playwright/experimental-ct-core/types/component').ObjectComponent} ObjectComponent */ 21 | 22 | import { reflectComponentType } from '@angular/core'; 23 | import { getTestBed, TestBed } from '@angular/core/testing'; 24 | import { 25 | BrowserDynamicTestingModule, 26 | platformBrowserDynamicTesting, 27 | } from '@angular/platform-browser-dynamic/testing'; 28 | 29 | /** @type {WeakMap>} */ 30 | const __pwOutputSubscriptionRegistry = new WeakMap(); 31 | 32 | /** @type {Map} */ 33 | const __pwFixtureRegistry = new Map(); 34 | 35 | getTestBed().initTestEnvironment( 36 | BrowserDynamicTestingModule, 37 | platformBrowserDynamicTesting(), 38 | ); 39 | 40 | /** 41 | * @param {ObjectComponent} component 42 | */ 43 | async function __pwRenderComponent(component) { 44 | const componentMetadata = reflectComponentType(component.type); 45 | if (!componentMetadata?.isStandalone) 46 | throw new Error('Only standalone components are supported'); 47 | 48 | TestBed.configureTestingModule({ 49 | imports: [component.type], 50 | }); 51 | 52 | await TestBed.compileComponents(); 53 | 54 | const fixture = TestBed.createComponent(component.type); 55 | fixture.nativeElement.id = 'root'; 56 | 57 | __pwUpdateProps(fixture, component.props); 58 | __pwUpdateEvents(fixture, component.on); 59 | 60 | fixture.autoDetectChanges(); 61 | 62 | return fixture; 63 | } 64 | 65 | /** 66 | * @param {import('@angular/core/testing').ComponentFixture} fixture 67 | */ 68 | function __pwUpdateProps(fixture, props = {}) { 69 | for (const [name, value] of Object.entries(props)) 70 | fixture.componentRef.setInput(name, value); 71 | } 72 | 73 | /** 74 | * @param {import('@angular/core/testing').ComponentFixture} fixture 75 | */ 76 | function __pwUpdateEvents(fixture, events = {}) { 77 | const outputSubscriptionRecord = 78 | __pwOutputSubscriptionRegistry.get(fixture) ?? {}; 79 | for (const [name, listener] of Object.entries(events)) { 80 | /* Unsubscribe previous listener. */ 81 | outputSubscriptionRecord[name]?.unsubscribe(); 82 | 83 | const subscription = fixture.componentInstance[ 84 | name 85 | ].subscribe((/** @type {unknown} */ event) => listener(event)); 86 | 87 | /* Store new subscription. */ 88 | outputSubscriptionRecord[name] = subscription; 89 | } 90 | 91 | /* Update output subscription registry. */ 92 | __pwOutputSubscriptionRegistry.set(fixture, outputSubscriptionRecord); 93 | } 94 | 95 | window.playwrightMount = async (component, rootElement, hooksConfig) => { 96 | if (component.__pw_type === 'jsx') 97 | throw new Error('JSX mount notation is not supported'); 98 | 99 | for (const hook of window.__pw_hooks_before_mount || []) 100 | await hook({ hooksConfig, TestBed }); 101 | 102 | const fixture = await __pwRenderComponent(component); 103 | 104 | for (const hook of window.__pw_hooks_after_mount || []) 105 | await hook({ hooksConfig }); 106 | 107 | __pwFixtureRegistry.set(rootElement.id, fixture); 108 | }; 109 | 110 | window.playwrightUnmount = async rootElement => { 111 | const fixture = __pwFixtureRegistry.get(rootElement.id); 112 | if (!fixture) 113 | throw new Error('Component was not mounted'); 114 | 115 | /* Unsubscribe from all outputs. */ 116 | for (const subscription of Object.values(__pwOutputSubscriptionRegistry.get(fixture) ?? {})) 117 | subscription?.unsubscribe(); 118 | __pwOutputSubscriptionRegistry.delete(fixture); 119 | 120 | fixture.destroy(); 121 | fixture.nativeElement.replaceChildren(); 122 | }; 123 | 124 | window.playwrightUpdate = async (rootElement, component) => { 125 | if (component.__pw_type === 'jsx') 126 | throw new Error('JSX mount notation is not supported'); 127 | 128 | const fixture = __pwFixtureRegistry.get(rootElement.id); 129 | if (!fixture) 130 | throw new Error('Component was not mounted'); 131 | 132 | __pwUpdateProps(fixture, component.props); 133 | __pwUpdateEvents(fixture, component.on); 134 | 135 | fixture.detectChanges(); 136 | }; 137 | -------------------------------------------------------------------------------- /playwright-ct-angular/transform.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports.default = void 0; 7 | exports.importInfo = importInfo; 8 | var _path = _interopRequireDefault(require("path")); 9 | var _babelBundle = require("playwright/lib/transform/babelBundle"); 10 | var _transform = require("playwright/lib/transform/transform"); 11 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 12 | /** 13 | * Copyright (c) Microsoft Corporation. 14 | * 15 | * Licensed under the Apache License, Version 2.0 (the 'License'); 16 | * you may not use this file except in compliance with the License. 17 | * You may obtain a copy of the License at 18 | * 19 | * http://www.apache.org/licenses/LICENSE-2.0 20 | * 21 | * Unless required by applicable law or agreed to in writing, software 22 | * distributed under the License is distributed on an 'AS IS' BASIS, 23 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 24 | * See the License for the specific language governing permissions and 25 | * limitations under the License. 26 | */ 27 | 28 | const t = _babelBundle.types; 29 | let jsxComponentNames; 30 | let classComponentNames; 31 | let importInfos; 32 | var _default = exports.default = (0, _babelBundle.declare)(api => { 33 | api.assertVersion(7); 34 | const result = { 35 | name: 'playwright-debug-transform', 36 | visitor: { 37 | Program: { 38 | enter(path) { 39 | jsxComponentNames = collectJsxComponentUsages(path.node); 40 | classComponentNames = collectClassMountUsages(path.node); 41 | importInfos = new Map(); 42 | }, 43 | exit(path) { 44 | let firstDeclaration; 45 | let lastImportDeclaration; 46 | path.get('body').forEach(p => { 47 | if (p.isImportDeclaration()) lastImportDeclaration = p; else if (!firstDeclaration) firstDeclaration = p; 48 | }); 49 | const insertionPath = lastImportDeclaration || firstDeclaration; 50 | if (!insertionPath) return; 51 | for (const [localName, componentImport] of [...importInfos.entries()].reverse()) { 52 | insertionPath.insertAfter(t.variableDeclaration('const', [t.variableDeclarator(t.identifier(localName), t.objectExpression([t.objectProperty(t.identifier('__pw_type'), t.stringLiteral('importRef')), t.objectProperty(t.identifier('id'), t.stringLiteral(componentImport.id))]))])); 53 | } 54 | (0, _transform.setTransformData)('playwright-ct-core', [...importInfos.values()]); 55 | } 56 | }, 57 | ImportDeclaration(p) { 58 | const importNode = p.node; 59 | if (!t.isStringLiteral(importNode.source)) return; 60 | const ext = _path.default.extname(importNode.source.value); 61 | 62 | // Convert all non-JS imports into refs. 63 | if (artifactExtensions.has(ext)) { 64 | for (const specifier of importNode.specifiers) { 65 | if (t.isImportNamespaceSpecifier(specifier)) continue; 66 | const { 67 | localName, 68 | info 69 | } = importInfo(importNode, specifier, this.filename); 70 | importInfos.set(localName, info); 71 | } 72 | p.skip(); 73 | p.remove(); 74 | return; 75 | } 76 | 77 | // Convert JS imports that are used as components in JSX expressions into refs. 78 | let importCount = 0; 79 | for (const specifier of importNode.specifiers) { 80 | if (t.isImportNamespaceSpecifier(specifier)) continue; 81 | const { 82 | localName, 83 | info 84 | } = importInfo(importNode, specifier, this.filename); 85 | if (jsxComponentNames.has(localName) || classComponentNames.has(localName)) { 86 | importInfos.set(localName, info); 87 | ++importCount; 88 | } 89 | } 90 | 91 | // All the imports were from JSX => delete. 92 | if (importCount && importCount === importNode.specifiers.length) { 93 | p.skip(); 94 | p.remove(); 95 | } 96 | }, 97 | MemberExpression(path) { 98 | if (!t.isIdentifier(path.node.object)) return; 99 | if (!importInfos.has(path.node.object.name)) return; 100 | if (!t.isIdentifier(path.node.property)) return; 101 | path.replaceWith(t.objectExpression([t.spreadElement(t.identifier(path.node.object.name)), t.objectProperty(t.identifier('property'), t.stringLiteral(path.node.property.name))])); 102 | } 103 | } 104 | }; 105 | return result; 106 | }); 107 | function collectJsxComponentUsages(node) { 108 | const names = new Set(); 109 | (0, _babelBundle.traverse)(node, { 110 | enter: p => { 111 | // Treat JSX-everything as component usages. 112 | if (t.isJSXElement(p.node)) { 113 | if (t.isJSXIdentifier(p.node.openingElement.name)) names.add(p.node.openingElement.name.name); 114 | if (t.isJSXMemberExpression(p.node.openingElement.name) && t.isJSXIdentifier(p.node.openingElement.name.object) && t.isJSXIdentifier(p.node.openingElement.name.property)) names.add(p.node.openingElement.name.object.name); 115 | } 116 | } 117 | }); 118 | return names; 119 | } 120 | function collectClassMountUsages(node) { 121 | const names = new Set(); 122 | (0, _babelBundle.traverse)(node, { 123 | enter: p => { 124 | // Treat calls to mount and all identifiers in arguments as component usages e.g. mount(Component) 125 | if (t.isCallExpression(p.node) && t.isIdentifier(p.node.callee) && p.node.callee.name === 'mount') { 126 | p.traverse({ 127 | Identifier: p => { 128 | names.add(p.node.name); 129 | } 130 | }); 131 | } 132 | } 133 | }); 134 | return names; 135 | } 136 | function importInfo(importNode, specifier, filename) { 137 | const importSource = importNode.source.value; 138 | const idPrefix = _path.default.join(filename, '..', importSource).replace(/[^\w_\d]/g, '_'); 139 | const result = { 140 | id: idPrefix, 141 | filename, 142 | importSource, 143 | remoteName: undefined 144 | }; 145 | if (t.isImportDefaultSpecifier(specifier)) { } else if (t.isIdentifier(specifier.imported)) { 146 | result.remoteName = specifier.imported.name; 147 | } else { 148 | result.remoteName = specifier.imported.value; 149 | } 150 | if (result.remoteName) result.id += '_' + result.remoteName; 151 | return { 152 | localName: specifier.local.name, 153 | info: result 154 | }; 155 | } 156 | const artifactExtensions = new Set([ 157 | // Frameworks 158 | '.vue', '.svelte', 159 | // Images 160 | '.jpg', '.jpeg', '.png', '.gif', '.svg', '.bmp', '.webp', '.ico', 161 | // CSS 162 | '.css', 163 | // Fonts 164 | '.woff', '.woff2', '.ttf', '.otf', '.eot']); 165 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'ct-angular/**' 3 | - 'playwright-ct-angular/**' 4 | --------------------------------------------------------------------------------