├── .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 |
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 |
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 |
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 |
10 |
11 |
12 |
13 |
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 |
--------------------------------------------------------------------------------