@jscutlery/playwright-ct-angular 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2020 jscutlery
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.
package/README.md ADDED
@@ -0,0 +1,289 @@
1
+ # Playwright Component Testing for Angular _(experimental)_
2
+
3
+ This library brings **Angular support** to [Playwright's **experimental** Component Testing](https://playwright.dev/docs/test-components).
4
+
5
+ This will allow us to test our Angular components with Playwright without building the whole app and with more control.
6
+
7
+ `@jscutlery/playwright-ct-angular` currently supports:
8
+
9
+ - ✅ **Testing [Versatile Angular Components](https://marmicode.io/blog/versatile-angular)**
10
+ - 🎛 **Passing type-safe inputs to the tested components**
11
+ - 🎭 **Spying on component outputs in a type-safe fashion**
12
+
13
+ # 🚀 Writing our first
14
+
15
+ First, we will have to set up Playwright Component Testing as [mentioned below](#setup).
16
+
17
+ ⚠️ Make sure to check the [known limitations](#-known-limitations) before writing more tests.
18
+
19
+ ## ✅ Basic Test
20
+
21
+ Then, we can write our first test in `.../src/greetings.component.pw.ts`:
22
+
23
+ ```ts
24
+ import { expect, test } from '@jscutlery/playwright-ct-angular';
25
+ import { GreetingsComponent } from './greetings.component';
26
+
27
+ test('<jc-greetings> should be polite', async ({ mount }) => {
28
+ const locator = await mount(GreetingsComponent);
29
+ expect(locator).toHaveText('👋 Hello!');
30
+ });
31
+ ```
32
+
33
+ ## 🎛 Testing Inputs
34
+
35
+ ```ts
36
+ import { expect, test } from '@jscutlery/playwright-ct-angular';
37
+ import { GreetingsComponent } from './greetings.component';
38
+
39
+ test('<jc-greetings> should be polite', async ({ mount }) => {
40
+ const locator = await mount(GreetingsComponent, {inputs: {name: 'Edouard'}});
41
+ expect(locator).toHaveText('👋 Hello Edouard!');
42
+ });
43
+ ```
44
+
45
+ ## 🎭 Spying on outputs
46
+
47
+ `mount()` will automatically create a `jest-mock`'s spy, subscribe to the outputs given through the `spyOutputs`
48
+ option and return them in the `spies` property, in a type-safe way showing only the outputs that are spied on.
49
+
50
+ Note that the `spyOutputs` is type-safe and will only allow properties that exist on the component.
51
+
52
+ ```ts
53
+ import { expect, test } from '@jscutlery/playwright-ct-angular';
54
+ import { NameEditorComponent } from './name-editor.component';
55
+
56
+ test('<jc-name-editor> should be polite', async ({ mount }) => {
57
+ const locator = await mount(NameEditorComponent, {spyOutputs: ['nameChange']});
58
+
59
+ await locator.getByLabel('Name').type('Edouard');
60
+
61
+ expect(locator.spies.nameChange).lastCalledWith('Edouard');
62
+ });
63
+ ```
64
+
65
+ ### Passing output callbacks
66
+
67
+ We can also pass custom output callback functions for some extreme cases or if we want to use a custom spy implementation for example or just debug.
68
+ ```ts
69
+ await mount(NameEditorComponent, {
70
+ outputs: {
71
+ nameChange(name) {
72
+ console.log(name);
73
+ }
74
+ }
75
+ });
76
+ ```
77
+
78
+ ## 🥸 Providing Test Doubles & Importing Additional Modules
79
+
80
+ Due to the [limitations described below](#-known-limitations), the recommended approach for providing test doubles
81
+ or importing additional modules is to create a test container component in another file.
82
+
83
+ ```ts
84
+ // recipe-search.component.pw.ts
85
+ import { defer } from 'rxjs';
86
+ import { RecipeSearchTestContainer } from './recipe-search.test-container';
87
+
88
+ test('...', async ({ mount }) => {
89
+ await mount(RecipeSearchTestContainer, {
90
+ inputs: {
91
+ recipes: [
92
+ beer,
93
+ burger
94
+ ]
95
+ }
96
+ })
97
+ })
98
+
99
+ // recipe-search.test-container.ts
100
+ @Component({
101
+ standalone: true,
102
+ imports: [RecipeSearchComponent],
103
+ template: '<jc-recipe-search></jc-recipe-search>',
104
+ providers: [
105
+ RecipeRepositoryFake,
106
+ {
107
+ provide: RecipeRepository,
108
+ useExisting: RecipeRepositoryFake,
109
+ },
110
+ ],
111
+ })
112
+ export class RecipeSearchTestContainer {
113
+ private _repo = inject(RecipeRepositoryFake);
114
+
115
+ @Input() set recipes(recipes: Recipe[]) {
116
+ this._repo.recipes = recipes;
117
+ }
118
+ }
119
+
120
+ /* Cf. https://github.com/jscutlery/devkit/tree/main/tests/playwright-ct-angular-wide/src/testing/recipe-repository.fake.ts
121
+ * for a better example. */
122
+ class RecipeRepositoryFake implements RecipeRepositoryDef {
123
+ recipes: Recipe[] = [];
124
+
125
+ searchRecipes() {
126
+ return defer(() => of(this.recipes));
127
+ }
128
+ }
129
+ ```
130
+
131
+ ## 🎨 Using Styles
132
+
133
+ ### Shared Styles
134
+
135
+ In order to import styles that are shared between our tests, we can do so by importing them in `playwright/index.ts`.
136
+ We can also customize the shared `playwright/index.html` nearby.
137
+
138
+ ### Specific Styles
139
+
140
+ If we want to load some specific styles for a single test, we might prefer using a test container component:
141
+ ```ts
142
+ import styles from './some-styles.css';
143
+ @Component({
144
+ template: '<jc-greetings></jc-greetings>',
145
+ encapsulation: ViewEncapsulation.None,
146
+ styles: [styles]
147
+ })
148
+ class GreetingsTestContainer {}
149
+ ```
150
+
151
+ ### Angular Material & Angular Libraries with styles
152
+
153
+ As mentioned in [Versatile Angular Style Blog Post](https://marmicode.io/blog/versatile-angular),
154
+ Angular Material and other Angular libraries might use a [Conditional "style" Export](https://nodejs.org/api/packages.html#conditional-exports)
155
+ that allows us to import prebuilt styles
156
+ _(Cf. [Angular Package Format](https://angular.io/guide/angular-package-format) [managing assets in a library](https://angular.io/guide/creating-libraries#managing-assets-in-a-library))_.
157
+
158
+ In that case, we can add the following configuration to our `playwright-ct.config.ts`:
159
+ ```ts
160
+ const config: PlaywrightTestConfig = {
161
+ // ...
162
+ use: {
163
+ // ...
164
+ ctViteConfig: {
165
+ resolve: {
166
+ /* @angular/material is using "style" as a Custom Conditional export to expose prebuilt styles etc... */
167
+ conditions: ['style']
168
+ }
169
+ }
170
+ }
171
+ };
172
+ ```
173
+
174
+ ## More examples
175
+ Cf. [/tests/playwright-ct-angular-wide/src](https://github.com/jscutlery/devkit/tree/main/tests/playwright-ct-angular-wide/src)
176
+
177
+ # ⚠️ Known Limitations
178
+
179
+ The way Playwright Component Testing works is different from the way things work with Karma, Jest, Vitest, Cypress etc...
180
+ Playwright Component Testing tests run in a Node.js environment while the component is rendered in a browser.
181
+
182
+ This causes a couple of limitations as we can't directly access the `TestBed's` or the component's internals,
183
+ and we can only exchange serializable data with the component.
184
+
185
+ ## 🪄 The Magic Behind the Scenes
186
+
187
+ The magical workaround behind the scenes is that at build time:
188
+ 1. Playwright analyses all the calls to `mount()`,
189
+ 2. it grabs the first parameter (the component class),
190
+ 3. replaces the component class with a unique string (constructed from the component class name and es-module),
191
+ 4. adds the component's es-module to Vite entrypoints,
192
+ 5. and finally creates a map matching each unique string to the right es-module.
193
+
194
+ This way, when calling `mount()`, Playwright with communicate the unique string to the browser who will know which es-module to load.
195
+
196
+ Cf. https://youtu.be/y3YxX4sFJbM
197
+ Cf. https://github.com/microsoft/playwright/blob/cac67fb94f2c8a0ee82878054c39790e660f17ca/packages/playwright-test/src/tsxTransform.ts#L153
198
+
199
+ ## It is currently impossible to...
200
+
201
+ ### ...hold the component type in a variable
202
+
203
+ ```ts
204
+ // 🛑 this won't work
205
+ const cmp = MyComponent;
206
+ await mount(cmp);
207
+ ```
208
+
209
+ ### ...use the component type elsewhere in the file.
210
+
211
+ ```ts
212
+ // 🛑 this won't work
213
+ test(MyComponent.name, async ({ mount }) => {});
214
+ ```
215
+
216
+ ### ...declare components in the same file.
217
+
218
+ ```ts
219
+ // 🛑 this won't work
220
+ @Component({...})
221
+ class GreetingsComponent {}
222
+
223
+ test('<jc-greetings>', async ({ mount }) => {
224
+ await mount(GreetingsComponent);
225
+ });
226
+ ```
227
+
228
+ ### ...pass any symbol other than a component class
229
+ This makes the following impossible:
230
+ - passing providers to the `mount()` function
231
+ - passing modules to the `mount()` function (this is what currently makes the usage of mounting templates impossible)
232
+ - use non-standalone components
233
+
234
+ ### ...use non-[Versatile Angular Style](https://marmicode.io/blog/versatile-angular)
235
+ We'll need a vite plugin to support traditional DI, external templates and stylesheets etc...
236
+
237
+ ## 🔮 Future workarounds
238
+
239
+ If you really can't make it to [Versatile Angular Style](https://marmicode.io/blog/versatile-angular),
240
+ we can think of a couple of workarounds like using a vite plugin like **Brandon Roberts' [vite-plugin-angular](https://github.com/analogjs/analog/tree/main/packages/vite-plugin-angular)**
241
+
242
+ We could also implement some custom transform that registers the providers and import modules, like Playwright does for the component class.
243
+
244
+ # Setup
245
+
246
+ ## 📦 Install
247
+
248
+ ```sh
249
+ # You can run this command in an existing workspace.
250
+ yarn create playwright --ct # or npm init playwright@latest -- --ct
251
+
252
+ # Choose React
253
+
254
+ # ? Which framework do you use? (experimental) …
255
+ # ❯ react
256
+ # vue
257
+ # svelte
258
+ # solid
259
+
260
+ yarn add -D @jscutlery/playwright-ct-angular @playwright/test # or npm install -D @jscutlery/playwright-ct-angular @playwright/test
261
+ ```
262
+
263
+ ## 🛠 Configure
264
+
265
+ Update `playwright-ct-config.ts` and replace:
266
+ ```ts
267
+ import type { PlaywrightTestConfig } from '@playwright/experimental-ct-react';
268
+ import { devices } from '@playwright/experimental-ct-react';
269
+ ```
270
+
271
+ with
272
+
273
+ ```ts
274
+ import type { PlaywrightTestConfig } from '@jscutlery/playwright-ct-angular';
275
+ import { devices } from '@jscutlery/playwright-ct-angular';
276
+ ```
277
+
278
+ ### Change tests extension
279
+
280
+ In order to avoid collisions with other tests (e.g. Jest / Vitest),
281
+ We can replace the default matching extension `.spec.ts` with `.pw.ts`:
282
+
283
+ ```ts
284
+ const config: PlaywrightTestConfig = {
285
+ testDir: './',
286
+ testMatch: /pw\.ts$/,
287
+ ...
288
+ }
289
+ ```
package/index.cjs ADDED
@@ -0,0 +1,119 @@
1
+ 'use strict';
2
+
3
+ Object.defineProperty(exports, '__esModule', { value: true });
4
+
5
+ var playwright = require('@playwright/test');
6
+ var mount = require('@playwright/test/lib/mount');
7
+ var jestMock = require('jest-mock');
8
+ var path = require('path');
9
+
10
+ function _interopNamespace(e) {
11
+ if (e && e.__esModule) return e;
12
+ var n = Object.create(null);
13
+ if (e) {
14
+ Object.keys(e).forEach(function (k) {
15
+ if (k !== 'default') {
16
+ var d = Object.getOwnPropertyDescriptor(e, k);
17
+ Object.defineProperty(n, k, d.get ? d : {
18
+ enumerable: true,
19
+ get: function () { return e[k]; }
20
+ });
21
+ }
22
+ });
23
+ }
24
+ n["default"] = e;
25
+ return Object.freeze(n);
26
+ }
27
+
28
+ var playwright__namespace = /*#__PURE__*/_interopNamespace(playwright);
29
+ var path__namespace = /*#__PURE__*/_interopNamespace(path);
30
+
31
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
32
+
33
+ function _await(value, then, direct) {
34
+ if (direct) {
35
+ return then ? then(value) : value;
36
+ }
37
+ if (!value || !value.then) {
38
+ value = Promise.resolve(value);
39
+ }
40
+ return then ? value.then(then) : value;
41
+ }
42
+ const noop = () => {};
43
+ function _async(f) {
44
+ return function () {
45
+ for (var args = [], i = 0; i < arguments.length; i++) {
46
+ args[i] = arguments[i];
47
+ }
48
+ try {
49
+ return Promise.resolve(f.apply(this, args));
50
+ } catch (e) {
51
+ return Promise.reject(e);
52
+ }
53
+ };
54
+ }
55
+ playwright__namespace['_addRunnerPlugin'](() => {
56
+ /* Only fetch upon request to avoid resolution in workers. */
57
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
58
+ const {
59
+ createPlugin
60
+ } = require('@playwright/test/lib/plugins/vitePlugin');
61
+ return createPlugin(path__namespace.join(__dirname, 'register-source.mjs'), noop);
62
+ });
63
+ const mountTest = playwright__namespace.test.extend(mount.fixtures);
64
+ const test = mountTest.extend({
65
+ /**
66
+ * Override mount to return output spies and enjoy type inference.
67
+ * This allows this:
68
+ *
69
+ * const { spies } = mount(..., {outputs: {citySelect: jest.fn()}})
70
+ *
71
+ * instead of this:
72
+ *
73
+ * const citySelectSpy: jest.MockedFunction<(city: string) => void> = jest.fn();
74
+ * mount(..., {outputs: {citySelect: citySelectSpy}});
75
+ *
76
+ * @hack we are willingly not using `async mount(...)` syntax here
77
+ * because it would be transpiled to `_async(function mount() { ... }`
78
+ * by Nx's default rollout config.
79
+ * Playwright dynamically parses the given function so converting
80
+ * async / await to a wrapped function would simply break the library causing
81
+ * a `TypeError: mount is not a function` error.
82
+ */
83
+ mount({
84
+ mount,
85
+ page
86
+ }, use) {
87
+ return use(_async(function (component, options = {}) {
88
+ var _options$spyOutputs$r, _options$spyOutputs;
89
+ /* 1. Create a spy for each output in `spyOutputs`... */
90
+ const spies = (_options$spyOutputs$r = (_options$spyOutputs = options.spyOutputs) == null ? void 0 : _options$spyOutputs.reduce((acc, key) => {
91
+ return Object.assign({}, acc, {
92
+ [key]: jestMock.fn()
93
+ });
94
+ }, {})) != null ? _options$spyOutputs$r : {};
95
+ /* 2. ...then use it as an output... */
96
+ return _await(mount(component, Object.assign({}, options, {
97
+ outputs: Object.assign({}, options.outputs, spies)
98
+ })), function () {
99
+ /* Grab a locator to the TestBed's root element.
100
+ * We are loading a fresh new page for each test
101
+ * so the id should always be "root0".
102
+ * Otherwise, we'd have to hack our way and somehow grab all
103
+ * the `#root*`, sort them and grab the last one.
104
+ * As a matter of fact, we can't grab a reference to the fixture. */
105
+ const locator = page.locator('#root0');
106
+ /* Add spies to the returned locator. */
107
+ return Object.assign(locator, {
108
+ spies
109
+ });
110
+ });
111
+ }));
112
+ }
113
+ });
114
+ const expect = playwright__namespace.expect;
115
+ const devices = playwright__namespace.devices;
116
+
117
+ exports.devices = devices;
118
+ exports.expect = expect;
119
+ exports.test = test;
package/index.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { devices, expect, test } from './lib/playwright-ct-angular';
2
+ export type { ComponentFixtures, PlaywrightTestConfig } from './lib/playwright-ct-angular';
package/index.js ADDED
@@ -0,0 +1,69 @@
1
+ import * as playwright from '@playwright/test';
2
+ import { fixtures } from '@playwright/test/lib/mount';
3
+ import { fn } from 'jest-mock';
4
+ import * as path from 'path';
5
+
6
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
7
+ const noop = () => {};
8
+ playwright['_addRunnerPlugin'](() => {
9
+ /* Only fetch upon request to avoid resolution in workers. */
10
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
11
+ const {
12
+ createPlugin
13
+ } = require('@playwright/test/lib/plugins/vitePlugin');
14
+ return createPlugin(path.join(__dirname, 'register-source.mjs'), noop);
15
+ });
16
+ const mountTest = playwright.test.extend(fixtures);
17
+ const test = mountTest.extend({
18
+ /**
19
+ * Override mount to return output spies and enjoy type inference.
20
+ * This allows this:
21
+ *
22
+ * const { spies } = mount(..., {outputs: {citySelect: jest.fn()}})
23
+ *
24
+ * instead of this:
25
+ *
26
+ * const citySelectSpy: jest.MockedFunction<(city: string) => void> = jest.fn();
27
+ * mount(..., {outputs: {citySelect: citySelectSpy}});
28
+ *
29
+ * @hack we are willingly not using `async mount(...)` syntax here
30
+ * because it would be transpiled to `_async(function mount() { ... }`
31
+ * by Nx's default rollout config.
32
+ * Playwright dynamically parses the given function so converting
33
+ * async / await to a wrapped function would simply break the library causing
34
+ * a `TypeError: mount is not a function` error.
35
+ */
36
+ mount({
37
+ mount,
38
+ page
39
+ }, use) {
40
+ return use(async (component, options = {}) => {
41
+ var _options$spyOutputs$r, _options$spyOutputs;
42
+ /* 1. Create a spy for each output in `spyOutputs`... */
43
+ const spies = (_options$spyOutputs$r = (_options$spyOutputs = options.spyOutputs) == null ? void 0 : _options$spyOutputs.reduce((acc, key) => {
44
+ return Object.assign({}, acc, {
45
+ [key]: fn()
46
+ });
47
+ }, {})) != null ? _options$spyOutputs$r : {};
48
+ /* 2. ...then use it as an output... */
49
+ await mount(component, Object.assign({}, options, {
50
+ outputs: Object.assign({}, options.outputs, spies)
51
+ }));
52
+ /* Grab a locator to the TestBed's root element.
53
+ * We are loading a fresh new page for each test
54
+ * so the id should always be "root0".
55
+ * Otherwise, we'd have to hack our way and somehow grab all
56
+ * the `#root*`, sort them and grab the last one.
57
+ * As a matter of fact, we can't grab a reference to the fixture. */
58
+ const locator = page.locator('#root0');
59
+ /* Add spies to the returned locator. */
60
+ return Object.assign(locator, {
61
+ spies
62
+ });
63
+ });
64
+ }
65
+ });
66
+ const expect = playwright.expect;
67
+ const devices = playwright.devices;
68
+
69
+ export { devices, expect, test };