@jscutlery/playwright-ct-angular 0.3.0 → 0.5.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/index.esm.js CHANGED
@@ -1,84 +1,33 @@
1
- import { fixtures } from '@playwright/experimental-ct-core/lib/mount';
2
- import * as playwright from '@playwright/test';
3
- import { fn } from 'jest-mock';
4
- import { defineConfig as defineConfig$1 } from '@playwright/experimental-ct-core';
5
- import * as path from 'path';
1
+ import * as path from 'node:path';
2
+ import * as playwrightCtCore from '@playwright/experimental-ct-core';
3
+ export { devices, expect } from '@playwright/test';
6
4
 
7
- const mountTest = playwright.test.extend(fixtures);
8
- const test = mountTest.extend({
9
- /**
10
- * Override mount to return output spies and enjoy type inference.
11
- * This allows this:
12
- *
13
- * const { spies } = mount(..., {outputs: {citySelect: jest.fn()}})
14
- *
15
- * instead of this:
16
- *
17
- * const citySelectSpy: jest.MockedFunction<(city: string) => void> = jest.fn();
18
- * mount(..., {outputs: {citySelect: citySelectSpy}});
19
- *
20
- * @hack we are willingly not using `async mount(...)` syntax here
21
- * because it would be transpiled to `_async(function mount() { ... }`
22
- * by Nx's default rollout config.
23
- * Playwright dynamically parses the given function so converting
24
- * async / await to a wrapped function would simply break the library causing
25
- * a `TypeError: mount is not a function` error.
26
- */
27
- mount({ mount, page }, use) {
28
- return use(async (component, options = {}) => {
29
- /* 1. Create a spy for each output in `spyOutputs`... */
30
- const spies = options.spyOutputs?.reduce((acc, key) => {
31
- return {
32
- ...acc,
33
- [key]: fn(),
34
- };
35
- }, {}) ?? {};
36
- /* 2. ...then use it as an output... */
37
- await mount(component, {
38
- ...options,
39
- outputs: {
40
- ...options.outputs,
41
- ...spies,
42
- },
43
- });
44
- /* Grab a locator to the TestBed's root element.
45
- * We are loading a fresh new page for each test
46
- * so the id should always be "root0".
47
- * Otherwise, we'd have to hack our way and somehow grab all
48
- * the `#root*`, sort them and grab the last one.
49
- * As a matter of fact, we can't grab a reference to the fixture. */
50
- const locator = page.locator('#root0');
51
- /* Add spies to the returned locator. */
52
- return Object.assign(locator, { spies });
53
- });
54
- },
55
- });
56
- const expect = playwright.expect;
57
- const devices = playwright.devices;
5
+ /* @hack `test` is not exported in the type definition of `@playwright/experimental-ct-core`. */
6
+ // @eslint-disable-next-line @typescript-eslint/no-explicit-any
7
+ const test = playwrightCtCore.test;
8
+ const defineConfig = (config, ...configs) => {
9
+ const original = playwrightCtCore.defineConfig({
10
+ ...config,
11
+ '@playwright/test': {
12
+ packageJSON: require.resolve('./package.json'),
13
+ },
14
+ '@playwright/experimental-ct-core': {
15
+ registerSourceFile: path.join(__dirname, 'register-source.mjs'),
16
+ },
17
+ }, ...configs);
18
+ /* @hack for some weird reason, if we do not require the babel plugin here,
19
+ * babel loads an empty object instead of the default exported function. */
20
+ require('@jscutlery/playwright-ct-angular/transform-angular');
21
+ return {
22
+ ...original,
23
+ '@playwright/test': {
24
+ ...original['@playwright/test'],
25
+ babelPlugins: [
26
+ ...original['@playwright/test'].babelPlugins,
27
+ [require.resolve('@jscutlery/playwright-ct-angular/transform-angular')],
28
+ ],
29
+ },
30
+ };
31
+ };
58
32
 
59
- /**
60
- * Copyright (c) Microsoft Corporation.
61
- *
62
- * Licensed under the Apache License, Version 2.0 (the "License");
63
- * you may not use this file except in compliance with the License.
64
- * You may obtain a copy of the License at
65
- *
66
- * http://www.apache.org/licenses/LICENSE-2.0
67
- *
68
- * Unless required by applicable law or agreed to in writing, software
69
- * distributed under the License is distributed on an "AS IS" BASIS,
70
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
71
- * See the License for the specific language governing permissions and
72
- * limitations under the License.
73
- */
74
- function plugin() {
75
- /* Only fetch upon request to avoid resolution in workers. */
76
- const { createPlugin,
77
- // eslint-disable-next-line @typescript-eslint/no-var-requires
78
- } = require('@playwright/experimental-ct-core/lib/vitePlugin');
79
- // eslint-disable-next-line @typescript-eslint/no-empty-function
80
- return createPlugin(path.join(__dirname, 'register-source.mjs'), () => { });
81
- }
82
- const defineConfig = (config) => defineConfig$1({ ...config, _plugins: [plugin] });
83
-
84
- export { defineConfig, devices, expect, test };
33
+ export { defineConfig, test };
package/package.json CHANGED
@@ -1,22 +1,22 @@
1
1
  {
2
2
  "name": "@jscutlery/playwright-ct-angular",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "dependencies": {
5
- "jest-mock": "^28.1.3",
6
- "rxjs": "^7.5.7",
7
- "vite": "^3.2.1"
5
+ "@babel/core": "7.23.9",
6
+ "@playwright/experimental-ct-core": "1.43.1",
7
+ "@playwright/test": "1.43.1"
8
8
  },
9
9
  "peerDependencies": {
10
10
  "@angular/compiler": "^16.0.0 || ^17.0.0",
11
11
  "@angular/core": "^16.0.0 || ^17.0.0",
12
12
  "@angular/platform-browser": "^16.0.0 || ^17.0.0",
13
13
  "@angular/platform-browser-dynamic": "^16.0.0 || ^17.0.0",
14
- "@playwright/experimental-ct-core": "^1.39.0",
15
- "@playwright/test": "^1.39.0",
16
14
  "rxjs": "^7.0.0",
17
- "tslib": "*",
18
15
  "zone.js": "*"
19
16
  },
17
+ "bin": {
18
+ "playwright": "cli.js"
19
+ },
20
20
  "exports": {
21
21
  "./package.json": "./package.json",
22
22
  ".": {
@@ -29,10 +29,10 @@
29
29
  "import": "./hooks.cjs.mjs",
30
30
  "default": "./hooks.cjs.js"
31
31
  },
32
- "./register": {
33
- "module": "./register.esm.js",
34
- "import": "./register.cjs.mjs",
35
- "default": "./register.cjs.js"
32
+ "./transform-angular": {
33
+ "module": "./transform-angular.esm.js",
34
+ "import": "./transform-angular.cjs.mjs",
35
+ "default": "./transform-angular.cjs.js"
36
36
  }
37
37
  },
38
38
  "module": "./index.esm.js",
@@ -1,68 +1,148 @@
1
1
  import '@angular/compiler';
2
2
  import 'zone.js';
3
+
3
4
  import { getTestBed, TestBed } from '@angular/core/testing';
4
- import {
5
- BrowserDynamicTestingModule,
6
- platformBrowserDynamicTesting,
7
- } from '@angular/platform-browser-dynamic/testing';
8
- import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
5
+ import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing';
9
6
  import { Subscription } from 'rxjs';
7
+ import { Component, reflectComponentType } from '@angular/core';
10
8
 
11
- const _componentLoaderMap = new Map();
9
+ /**
10
+ * @typedef {{type: string} & import('./index').MountTemplateOptions} TemplateInfo
11
+ * @typedef {{type: import('@angular/core').Type<unknown>} & import('./index').MountOptions | TemplateInfo} ComponentInfo
12
+ */
12
13
 
13
14
  getTestBed().initTestEnvironment(
14
15
  BrowserDynamicTestingModule,
15
16
  platformBrowserDynamicTesting()
16
17
  );
17
18
 
19
+ window.playwrightMount = async (component, rootElement, hooksConfig) => {
20
+ for (const hook of window.__pw_hooks_before_mount || [])
21
+ await hook({ hooksConfig, TestBed });
22
+
23
+ const fixture = await __pwRenderComponent(component);
24
+
25
+ fixture.autoDetectChanges();
26
+ await fixture.whenStable();
27
+
28
+ for (const hook of window.__pw_hooks_after_mount || [])
29
+ await hook({ hooksConfig });
30
+
31
+ __pwFixtureRegistry.set(rootElement.id, fixture);
32
+ };
33
+
34
+ window.playwrightUnmount = async (rootElement) => {
35
+ const fixture = __pwFixtureRegistry.get(rootElement.id);
36
+ if (!fixture)
37
+ throw new Error('Component was not mounted');
38
+
39
+ /* Unsubscribe from all outputs. */
40
+ for (const subscription of Object.values(__pwOutputSubscriptionRegistry.get(fixture) ?? {}))
41
+ subscription?.unsubscribe();
42
+ __pwOutputSubscriptionRegistry.delete(fixture);
43
+
44
+ fixture.destroy();
45
+ fixture.nativeElement.replaceChildren();
46
+ };
47
+
18
48
  /**
19
- * This is the function used by Playwright CT to map each component to its symbol.
20
- * The key here is a slug generated by Playwright CT based on the component's module path + component's name.
49
+ * @param {{type: import('@angular/core').Type<unknown>} & import('./index').MountOptions | {type: string} & import('./index').MountTemplateOptions} component
21
50
  */
22
- export function pwRegister(components) {
23
- for (const [name, value] of Object.entries(components)) {
24
- _componentLoaderMap.set(name, value);
25
- }
26
- }
51
+ window.playwrightUpdate = async (rootElement, component) => {
52
+ const fixture = __pwFixtureRegistry.get(rootElement.id);
53
+ if (!fixture)
54
+ throw new Error('Component was not mounted');
55
+
56
+ __pwUpdateProps(fixture, component);
57
+ __pwUpdateEvents(fixture, component.on);
58
+
59
+ fixture.autoDetectChanges();
60
+ await fixture.whenStable();
61
+ };
62
+
63
+ /** @type {WeakMap<import('@angular/core/testing').ComponentFixture, Record<string, import('rxjs').Subscription>>} */
64
+ const __pwOutputSubscriptionRegistry = new WeakMap();
65
+
66
+ /** @type {Map<string, import('@angular/core/testing').ComponentFixture>} */
67
+ const __pwFixtureRegistry = new Map();
27
68
 
28
69
  /**
29
- * Container subscription holding all outputs subscription
30
- * in order to unsubscribe when component is unmounted.
70
+ * @param {ComponentInfo} component
31
71
  */
32
- let subscription;
72
+ async function __pwRenderComponent(component) {
73
+ /** @type {import('@angular/core').Type<unknown>} */
74
+ let componentClass;
33
75
 
34
- globalThis.playwrightMount = async (component, rootElement, hooksConfig) => {
35
- const loader = _componentLoaderMap.get(component.type);
36
- const cmpType = await loader();
76
+ if (__pwIsTemplate(component)) {
77
+ const templateInfo = /** @type {TemplateInfo} */(component);
78
+ componentClass = Component({
79
+ standalone: true,
80
+ selector: 'pw-template-component',
81
+ imports: templateInfo.imports,
82
+ template: templateInfo.type
83
+ })(class {
84
+ });
85
+ } else {
86
+ componentClass = /** @type {import('@angular/core').Type<unknown>} */(component.type);
87
+ }
37
88
 
38
- subscription = new Subscription();
89
+ const componentMetadata = reflectComponentType(componentClass);
90
+ if (!componentMetadata?.isStandalone)
91
+ throw new Error('Only standalone components are supported');
39
92
 
40
93
  TestBed.configureTestingModule({
41
- imports: [BrowserAnimationsModule],
94
+ imports: [componentClass],
95
+ providers: component.providers
42
96
  });
43
- const fixture = TestBed.createComponent(cmpType);
44
97
 
45
- /* Set inputs. */
46
- for (const [name, value] of Object.entries(component.options.inputs ?? {})) {
47
- fixture.componentInstance[name] = value;
48
- }
98
+ const fixture = TestBed.createComponent(componentClass);
99
+ fixture.nativeElement.id = 'root';
100
+
101
+ __pwUpdateProps(fixture, component);
102
+ __pwUpdateEvents(fixture, component.on);
103
+
104
+ return fixture;
105
+ }
49
106
 
50
- /* Subscribe to outputs. */
51
- for (const [name, callback] of Object.entries(
52
- component.options.outputs ?? {}
53
- )) {
54
- subscription.add(fixture.componentInstance[name].subscribe(callback));
107
+ /**
108
+ * @param {import('@angular/core/testing').ComponentFixture} fixture
109
+ * @param {ComponentInfo} componentInfo
110
+ */
111
+ function __pwUpdateProps(fixture, componentInfo) {
112
+ if (!componentInfo.props)
113
+ return;
114
+
115
+ if (__pwIsTemplate(componentInfo)) {
116
+ Object.assign(fixture.componentInstance, componentInfo.props);
117
+ } else {
118
+ for (const [name, value] of Object.entries(componentInfo.props))
119
+ fixture.componentRef.setInput(name, value);
55
120
  }
121
+ }
56
122
 
57
- fixture.autoDetectChanges();
58
- };
123
+ /**
124
+ * @param {import('@angular/core/testing').ComponentFixture} fixture
125
+ */
126
+ function __pwUpdateEvents(fixture, events = {}) {
127
+ const outputSubscriptionRecord =
128
+ __pwOutputSubscriptionRegistry.get(fixture) ?? {};
129
+ for (const [name, listener] of Object.entries(events)) {
130
+ /* Unsubscribe previous listener. */
131
+ outputSubscriptionRecord[name]?.unsubscribe();
59
132
 
60
- globalThis.playwrightUnmount = async (rootElement) => {
61
- subscription.unsubscribe();
62
- subscription = null;
63
- getTestBed().resetTestingModule();
64
- };
133
+ /* Store new subscription. */
134
+ outputSubscriptionRecord[name] = fixture.componentInstance[
135
+ name
136
+ ].subscribe((/** @type {unknown} */ event) => listener(event));
137
+ }
65
138
 
66
- globalThis.playwrightUpdate = async (rootElement, component) => {
67
- rootElement.innerHTML = component;
68
- };
139
+ /* Update output subscription registry. */
140
+ __pwOutputSubscriptionRegistry.set(fixture, outputSubscriptionRecord);
141
+ }
142
+
143
+ /**
144
+ * @param {ComponentInfo} component
145
+ */
146
+ function __pwIsTemplate(component) {
147
+ return typeof component.type === 'string';
148
+ }
package/src/hooks.d.ts CHANGED
@@ -13,5 +13,12 @@
13
13
  * See the License for the specific language governing permissions and
14
14
  * limitations under the License.
15
15
  */
16
- export declare const beforeMount: (callback: any) => void;
17
- export declare const afterMount: (callback: any) => void;
16
+ import { TestBedStatic } from '@angular/core/testing';
17
+ import { JsonObject } from '@playwright/experimental-ct-core/types/component';
18
+ export declare function beforeMount<HOOKS extends JsonObject>(callback: (params: {
19
+ hooksConfig?: HOOKS;
20
+ TestBed: TestBedStatic;
21
+ }) => Promise<void>): void;
22
+ export declare function afterMount<HOOKS extends JsonObject>(callback: (params: {
23
+ hooksConfig?: HOOKS;
24
+ }) => Promise<void>): void;
package/src/index.d.ts CHANGED
@@ -1,19 +1,39 @@
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
- export { devices, expect, test } from './lib/playwright-ct-angular';
17
- export type { ComponentFixtures } from './lib/playwright-ct-angular';
18
- import { defineConfig as originalDefineConfig } from '@playwright/experimental-ct-core';
19
- export declare const defineConfig: typeof originalDefineConfig;
1
+ import { EnvironmentProviders, InputSignal, Provider, Type } from '@angular/core';
2
+ import type { PlaywrightTestConfig, TestType } from '@playwright/experimental-ct-core';
3
+ import * as playwrightCtCore from '@playwright/experimental-ct-core';
4
+ import { JsonObject } from '@playwright/experimental-ct-core/types/component';
5
+ import * as playwright from '@playwright/test';
6
+ export type { PlaywrightTestConfig };
7
+ export { expect, devices } from '@playwright/test';
8
+ export interface ComponentFixtures {
9
+ mount<COMPONENT, HOOKS extends JsonObject>(template: string, options?: MountTemplateOptions<COMPONENT, HOOKS>): Promise<MountResult<COMPONENT>>;
10
+ mount<COMPONENT, HOOKS extends JsonObject>(component: Type<COMPONENT>, options?: MountOptions<COMPONENT, HOOKS>): Promise<MountResult<COMPONENT>>;
11
+ }
12
+ export interface MountOptions<COMPONENT, HOOKS> {
13
+ hooksConfig?: HOOKS;
14
+ providers?: Array<Provider | EnvironmentProviders>;
15
+ props?: Inputs<COMPONENT>;
16
+ on?: OutputListeners<COMPONENT>;
17
+ }
18
+ export interface MountTemplateOptions<COMPONENT, HOOKS extends JsonObject> extends MountOptions<COMPONENT, HOOKS> {
19
+ imports?: Type<unknown>[];
20
+ }
21
+ export interface MountResult<COMPONENT> extends playwright.Locator {
22
+ unmount(): Promise<void>;
23
+ update(options: {
24
+ props?: Inputs<COMPONENT>;
25
+ on?: OutputListeners<COMPONENT>;
26
+ }): Promise<void>;
27
+ }
28
+ export type Inputs<COMPONENT> = Partial<{
29
+ [PROPERTY in keyof COMPONENT]: COMPONENT[PROPERTY] extends InputSignal<infer VALUE> ? VALUE : COMPONENT[PROPERTY];
30
+ }>;
31
+ export type OutputListeners<COMPONENT> = {
32
+ [PROPERTY in keyof COMPONENT as COMPONENT[PROPERTY] extends Subscribable<any> ? PROPERTY : never]: (value: Emitted<COMPONENT[PROPERTY]>) => void;
33
+ };
34
+ interface Subscribable<T> {
35
+ subscribe(subscriber: (value: T) => void): void;
36
+ }
37
+ type Emitted<SUBSCRIBABLE> = SUBSCRIBABLE extends Subscribable<infer EMITTED> ? EMITTED : SUBSCRIBABLE;
38
+ export declare const test: TestType<ComponentFixtures>;
39
+ export declare const defineConfig: typeof playwrightCtCore.defineConfig;
@@ -0,0 +1,29 @@
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
+ * Original file: https://github.com/microsoft/playwright/blob/8173cdc4857f8e0a98047e02e692c5da6544a170/packages/playwright-ct-core/src/tsxTransform.ts
17
+ */
18
+ import { NodePath, types as t } from '@babel/core';
19
+ declare const _default: (api: object, options: Record<string, any>, dirname: string) => {
20
+ name: string;
21
+ visitor: {
22
+ Program: {
23
+ enter(this: import("@babel/core").PluginPass, path: NodePath<t.Program>): void;
24
+ exit(this: import("@babel/core").PluginPass, path: NodePath<t.Program>): void;
25
+ };
26
+ ImportDeclaration(this: import("@babel/core").PluginPass, p: NodePath<t.ImportDeclaration>): void;
27
+ };
28
+ };
29
+ export default _default;
@@ -0,0 +1,2 @@
1
+ export * from "./src/transform-angular";
2
+ export { default } from "./src/transform-angular";
@@ -0,0 +1 @@
1
+ exports._default = require('./transform-angular.cjs.js').default;