feat(ct): allow configuring apps per test (#15551)

This commit is contained in:
Pavel Feldman 2022-07-12 08:37:33 -08:00 committed by GitHub
parent f76fb3e08a
commit f3d3231b29
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 320 additions and 33 deletions

View File

@ -7,3 +7,5 @@
!registerSource.mjs !registerSource.mjs
!index.d.ts !index.d.ts
!index.js !index.js
!hooks.d.ts
!hooks.mjs

18
packages/playwright-ct-react/hooks.d.ts vendored Normal file
View File

@ -0,0 +1,18 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export declare function beforeMount(callback: (params: { hooksConfig: any }) => Promise<void>): void;
export declare function afterMount(callback: (params: { hooksConfig: any }) => Promise<void>): void;

View File

@ -0,0 +1,29 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const __pw_hooks_before_mount = [];
const __pw_hooks_after_mount = [];
window.__pw_hooks_before_mount = __pw_hooks_before_mount;
window.__pw_hooks_after_mount = __pw_hooks_after_mount;
export const beforeMount = callback => {
__pw_hooks_before_mount.push(callback);
};
export const afterMount = callback => {
__pw_hooks_after_mount.push(callback);
};

View File

@ -35,7 +35,7 @@ export type PlaywrightTestConfig = Omit<BasePlaywrightTestConfig, 'use'> & {
}; };
interface ComponentFixtures { interface ComponentFixtures {
mount(component: JSX.Element): Promise<Locator>; mount(component: JSX.Element, options?: { hooksConfig?: any }): Promise<Locator>;
} }
export const test: TestType< export const test: TestType<

View File

@ -19,6 +19,10 @@
"./register": { "./register": {
"types": "./register.d.ts", "types": "./register.d.ts",
"default": "./register.mjs" "default": "./register.mjs"
},
"./hooks": {
"types": "./hooks.d.ts",
"default": "./hooks.mjs"
} }
}, },
"dependencies": { "dependencies": {

View File

@ -68,6 +68,12 @@ function render(component) {
})); }));
} }
window.playwrightMount = async component => { window.playwrightMount = async (component, rootElement, hooksConfig) => {
ReactDOM.render(render(component), document.getElementById('root')); for (const hook of /** @type {any} */(window).__pw_hooks_before_mount || [])
await hook({ hooksConfig });
ReactDOM.render(render(component), rootElement);
for (const hook of /** @type {any} */(window).__pw_hooks_after_mount || [])
await hook({ hooksConfig });
}; };

View File

@ -7,3 +7,5 @@
!registerSource.mjs !registerSource.mjs
!index.d.ts !index.d.ts
!index.js !index.js
!hooks.d.ts
!hooks.mjs

View File

@ -0,0 +1,18 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export declare function beforeMount(callback: (params: { hooksConfig: any }) => Promise<void>): void;
export declare function afterMount(callback: (params: { hooksConfig: any }) => Promise<void>): void;

View File

@ -0,0 +1,29 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const __pw_hooks_before_mount = [];
const __pw_hooks_after_mount = [];
window.__pw_hooks_before_mount = __pw_hooks_before_mount;
window.__pw_hooks_after_mount = __pw_hooks_after_mount;
export const beforeMount = callback => {
__pw_hooks_before_mount.push(callback);
};
export const afterMount = callback => {
__pw_hooks_after_mount.push(callback);
};

View File

@ -39,6 +39,7 @@ interface ComponentFixtures {
props?: Props, props?: Props,
slots?: { [key: string]: any }, slots?: { [key: string]: any },
on?: { [key: string]: Function }, on?: { [key: string]: Function },
hooksConfig: any,
}): Promise<Locator>; }): Promise<Locator>;
} }

View File

@ -19,6 +19,10 @@
"./register": { "./register": {
"types": "./register.d.ts", "types": "./register.d.ts",
"default": "./register.mjs" "default": "./register.mjs"
},
"./hooks": {
"types": "./hooks.d.ts",
"default": "./hooks.mjs"
} }
}, },
"dependencies": { "dependencies": {

View File

@ -32,7 +32,7 @@ export function register(components) {
registry.set(name, value); registry.set(name, value);
} }
window.playwrightMount = async (component, rootElement) => { window.playwrightMount = async (component, rootElement, hooksConfig) => {
let componentCtor = registry.get(component.type); let componentCtor = registry.get(component.type);
if (!componentCtor) { if (!componentCtor) {
// Lookup by shorthand. // Lookup by shorthand.
@ -50,10 +50,18 @@ window.playwrightMount = async (component, rootElement) => {
if (component.kind !== 'object') if (component.kind !== 'object')
throw new Error('JSX mount notation is not supported'); throw new Error('JSX mount notation is not supported');
for (const hook of /** @type {any} */(window).__pw_hooks_before_mount || [])
await hook({ hooksConfig });
const wrapper = new componentCtor({ const wrapper = new componentCtor({
target: rootElement, target: rootElement,
props: component.options?.props, props: component.options?.props,
}); });
for (const hook of /** @type {any} */(window).__pw_hooks_after_mount || [])
await hook({ hooksConfig });
for (const [key, listener] of Object.entries(component.options?.on || {})) for (const [key, listener] of Object.entries(component.options?.on || {}))
wrapper.$on(key, event => listener(event.detail)); wrapper.$on(key, event => listener(event.detail));
}; };

View File

@ -14,6 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
import { App } from 'vue'; import { App, ComponentPublicInstance } from 'vue';
export declare function onApp(callback: (app: App, appConfig: any) => Promise<void>): void; export declare function beforeMount(callback: (params: { app: App, hooksConfig: any }) => Promise<void>): void;
export declare function afterMount(callback: (params: { app: App, hooksConfig: any, instance: ComponentPublicInstance }) => Promise<void>): void;

View File

@ -14,9 +14,16 @@
* limitations under the License. * limitations under the License.
*/ */
const __pw_hooks_on_app = []; const __pw_hooks_before_mount = [];
window.__pw_hooks_on_app = __pw_hooks_on_app; const __pw_hooks_after_mount = [];
export const onApp = callback => { window.__pw_hooks_before_mount = __pw_hooks_before_mount;
__pw_hooks_on_app.push(callback); window.__pw_hooks_after_mount = __pw_hooks_after_mount;
export const beforeMount = callback => {
__pw_hooks_before_mount.push(callback);
};
export const afterMount = callback => {
__pw_hooks_after_mount.push(callback);
}; };

View File

@ -40,7 +40,7 @@ interface ComponentFixtures {
props?: Props, props?: Props,
slots?: { [key: string]: any }, slots?: { [key: string]: any },
on?: { [key: string]: Function }, on?: { [key: string]: Function },
appConfig?: any, hooksConfig?: any,
}): Promise<Locator>; }): Promise<Locator>;
} }

View File

@ -155,14 +155,15 @@ function createDevTools() {
}; };
} }
window.playwrightMount = async (component, rootElement) => { window.playwrightMount = async (component, rootElement, hooksConfig) => {
const app = createApp({ const app = createApp({
render: () => render(component) render: () => render(component)
}); });
setDevtoolsHook(createDevTools(), {}); setDevtoolsHook(createDevTools(), {});
if (component.kind === 'object') {
for (const onAppCallback of /** @type {any} */(window).__pw_hooks_on_app || []) for (const hook of /** @type {any} */(window).__pw_hooks_before_mount || [])
await onAppCallback(app, /** @type {any} */(component.options)?.appConfig) await hook({ app, hooksConfig });
} const instance = app.mount(rootElement);
app.mount(rootElement); for (const hook of /** @type {any} */(window).__pw_hooks_after_mount || [])
await hook({ app, hooksConfig, instance });
}; };

View File

@ -7,3 +7,5 @@
!registerSource.mjs !registerSource.mjs
!index.d.ts !index.d.ts
!index.js !index.js
!hooks.d.ts
!hooks.js

20
packages/playwright-ct-vue2/hooks.d.ts vendored Normal file
View File

@ -0,0 +1,20 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { CombinedVueInstance, Vue } from 'vue/types/vue';
export declare function beforeMount(callback: (params: { hooksConfig: any }) => Promise<void>): void;
export declare function afterMount(callback: (params: { hooksConfig: any, instance: CombinedVueInstance<Vue, object, object, object, Record<never, any>> }) => Promise<void>): void;

View File

@ -0,0 +1,29 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const __pw_hooks_before_mount = [];
const __pw_hooks_after_mount = [];
window.__pw_hooks_before_mount = __pw_hooks_before_mount;
window.__pw_hooks_after_mount = __pw_hooks_after_mount;
export const beforeMount = callback => {
__pw_hooks_before_mount.push(callback);
};
export const afterMount = callback => {
__pw_hooks_after_mount.push(callback);
};

View File

@ -40,6 +40,7 @@ interface ComponentFixtures {
props?: Props, props?: Props,
slots?: { [key: string]: any }, slots?: { [key: string]: any },
on?: { [key: string]: Function }, on?: { [key: string]: Function },
hooksConfig: any,
}): Promise<Locator>; }): Promise<Locator>;
} }

View File

@ -19,6 +19,10 @@
"./register": { "./register": {
"types": "./register.d.ts", "types": "./register.d.ts",
"default": "./register.mjs" "default": "./register.mjs"
},
"./hooks": {
"types": "./hooks.d.ts",
"default": "./hooks.mjs"
} }
}, },
"dependencies": { "dependencies": {

View File

@ -134,9 +134,17 @@ function render(component, h) {
return wrapper; return wrapper;
} }
window.playwrightMount = async (component, rootElement) => { window.playwrightMount = async (component, rootElement, hooksConfig) => {
const mounted = new Vue({ const config = hooksConfig || /** @type {any} */(component).options?.hooksConfig;
for (const hook of /** @type {any} */(window).__pw_hooks_before_mount || [])
await hook({ hooksConfig });
const instance = new Vue({
render: h => render(component, h), render: h => render(component, h),
}).$mount(); }).$mount();
rootElement.appendChild(mounted.$el); rootElement.appendChild(instance.$el);
for (const hook of /** @type {any} */(window).__pw_hooks_after_mount || [])
await hook({ hooksConfig, instance });
}; };

View File

@ -88,7 +88,7 @@ async function innerMount(page: Page, jsxOrType: JsxComponent | string, options:
// WebKit does not wait for deferred scripts. // WebKit does not wait for deferred scripts.
await page.waitForFunction(() => !!window.playwrightMount); await page.waitForFunction(() => !!window.playwrightMount);
const selector = await page.evaluate(async ({ component }) => { const selector = await page.evaluate(async ({ component, hooksConfig }) => {
const unwrapFunctions = (object: any) => { const unwrapFunctions = (object: any) => {
for (const [key, value] of Object.entries(object)) { for (const [key, value] of Object.entries(object)) {
if (typeof value === 'string' && (value as string).startsWith('__pw_func_')) { if (typeof value === 'string' && (value as string).startsWith('__pw_func_')) {
@ -110,11 +110,11 @@ async function innerMount(page: Page, jsxOrType: JsxComponent | string, options:
document.body.appendChild(rootElement); document.body.appendChild(rootElement);
} }
await window.playwrightMount(component, rootElement); await window.playwrightMount(component, rootElement, hooksConfig);
// When mounting fragments, return selector pointing to the root element. // When mounting fragments, return selector pointing to the root element.
return rootElement.childNodes.length > 1 ? '#root' : '#root > *'; return rootElement.childNodes.length > 1 ? '#root' : '#root > *';
}, { component }); }, { component, hooksConfig: options.hooksConfig });
return selector; return selector;
} }

View File

@ -25,6 +25,7 @@ export type ObjectComponentOptions = {
props?: { [key: string]: any }, props?: { [key: string]: any },
slots?: { [key: string]: any }, slots?: { [key: string]: any },
on?: { [key: string]: Function }, on?: { [key: string]: Function },
hooksConfig?: any,
}; };
export type ObjectComponent = { export type ObjectComponent = {
@ -37,6 +38,6 @@ export type Component = JsxComponent | ObjectComponent;
declare global { declare global {
interface Window { interface Window {
playwrightMount(component: Component, rootElement: Element): Promise<void>; playwrightMount(component: Component, rootElement: Element, hooksConfig: any): Promise<void>;
} }
} }

View File

@ -1 +1,13 @@
//@ts-check
import '../src/index.css'; import '../src/index.css';
import { beforeMount, afterMount } from '@playwright/experimental-ct-react/hooks';
beforeMount(async ({ hooksConfig }) => {
console.log(`Before mount: ${JSON.stringify(hooksConfig)}`);
});
afterMount(async ({}) => {
console.log(`After mount`);
});

View File

@ -7,3 +7,14 @@ test('should work', async ({ mount }) => {
const component = await mount(<App></App>); const component = await mount(<App></App>);
await expect(component).toContainText('Hello Vite + React!'); await expect(component).toContainText('Hello Vite + React!');
}); });
test('should configure app', async ({ page, mount }) => {
const messages: string[] = [];
page.on('console', m => messages.push(m.text()));
await mount(<App></App>, {
hooksConfig: {
route: 'A'
}
});
expect(messages).toEqual(['Before mount: {\"route\":\"A\"}', 'After mount']);
});

View File

@ -0,0 +1,11 @@
//@ts-check
import { beforeMount, afterMount } from '@playwright/experimental-ct-svelte/hooks';
beforeMount(async ({ hooksConfig }) => {
console.log(`Before mount: ${JSON.stringify(hooksConfig)}`);
});
afterMount(async ({}) => {
console.log(`After mount`);
});

View File

@ -33,3 +33,17 @@ test('should work', async ({ mount }) => {
await component.click(); await component.click();
expect(values).toEqual([{ count: 1 }]); expect(values).toEqual([{ count: 1 }]);
}); });
test('should configure app', async ({ page, mount }) => {
const messages: string[] = [];
page.on('console', m => messages.push(m.text()));
await mount(Counter, {
props: {
units: 's',
},
hooksConfig: {
route: 'A'
}
});
expect(messages).toEqual(['Before mount: {\"route\":\"A\"}', 'After mount']);
});

View File

@ -1,5 +1,10 @@
import { onApp } from '@playwright/experimental-ct-vue/hooks'; //@ts-check
import { beforeMount, afterMount } from '@playwright/experimental-ct-vue/hooks';
onApp(async (app, addConfig) => { beforeMount(async ({ app, hooksConfig }) => {
console.log(`App ${!!app} configured with config: ${JSON.stringify(addConfig)}`); console.log(`Before mount: ${JSON.stringify(hooksConfig)}, app: ${!!app}`);
});
afterMount(async ({ instance }) => {
console.log(`After mount el: ${instance.$el.constructor.name}`);
}); });

View File

@ -60,3 +60,12 @@ test('slot should emit events', async ({ mount }) => {
await component.locator('text=Main Content').click(); await component.locator('text=Main Content').click();
expect(clickFired).toBeTruthy(); expect(clickFired).toBeTruthy();
}) })
test('should run hooks', async ({ page, mount }) => {
const messages = []
page.on('console', m => messages.push(m.text()))
await mount(<Button title='Submit'></Button>, {
hooksConfig: { route: 'A' }
})
expect(messages).toEqual(['Before mount: {\"route\":\"A\"}, app: true', 'After mount el: HTMLButtonElement'])
})

View File

@ -67,16 +67,14 @@ test('optionless should work', async ({ mount }) => {
await expect(component).toContainText('test') await expect(component).toContainText('test')
}) })
test('should configure app', async ({ page, mount }) => { test('should run hooks', async ({ page, mount }) => {
const messages = [] const messages = []
page.on('console', m => messages.push(m.text())) page.on('console', m => messages.push(m.text()))
const component = await mount(Button, { await mount(Button, {
props: { props: {
title: 'Submit' title: 'Submit'
}, },
appConfig: { hooksConfig: { route: 'A' }
route: 'A'
}
}) })
expect(messages).toEqual(['App true configured with config: {\"route\":\"A\"}']) expect(messages).toEqual(['Before mount: {\"route\":\"A\"}, app: true', 'After mount el: HTMLButtonElement'])
}) })

View File

@ -0,0 +1,11 @@
//@ts-check
import { beforeMount, afterMount } from '@playwright/experimental-ct-vue2/hooks';
beforeMount(async ({ hooksConfig }) => {
console.log(`Before mount: ${JSON.stringify(hooksConfig)}`);
});
afterMount(async ({ instance }) => {
console.log(`After mount el: ${instance.$el.constructor.name}`);
});

View File

@ -60,3 +60,12 @@ test('slot should emit events', async ({ mount }) => {
await component.locator('text=Main Content').click(); await component.locator('text=Main Content').click();
expect(clickFired).toBeTruthy(); expect(clickFired).toBeTruthy();
}) })
test('should run hooks', async ({ page, mount }) => {
const messages = []
page.on('console', m => messages.push(m.text()))
await mount(<Button title='Submit'></Button>, {
hooksConfig: { route: 'A' }
})
expect(messages).toEqual(['Before mount: {\"route\":\"A\"}', 'After mount el: HTMLButtonElement'])
})

View File

@ -60,3 +60,15 @@ test('named slots should work', async ({ mount }) => {
await expect(component).toContainText('Main Content') await expect(component).toContainText('Main Content')
await expect(component).toContainText('Footer') await expect(component).toContainText('Footer')
}) })
test('should run hooks', async ({ page, mount }) => {
const messages = []
page.on('console', m => messages.push(m.text()))
await mount(Button, {
props: {
title: 'Submit'
},
hooksConfig: { route: 'A' }
})
expect(messages).toEqual(['Before mount: {\"route\":\"A\"}', 'After mount el: HTMLButtonElement'])
})