diff --git a/docs/advanced.md b/docs/advanced.md index b11f1d821..f6b6739a9 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -154,3 +154,48 @@ You can use this options for build your own [plugins](https://codecept.io/hooks/ ... }); ``` + +## Direct Helper Access + +Some scenarios need the underlying SDK directly — a raw `page.evaluate`, a `page.on('request')` listener, an experimental Playwright API, or a wdio command the `WebDriver` helper doesn't expose. The `expose` plugin injects helper internals as scenario arguments so you can call them inline. + +```js +Scenario('intercept network', async ({ I, page }) => { + page.on('request', req => console.log(req.method(), req.url())) + I.amOnPage('/') + const title = await page.evaluate(() => document.title) + I.see(title) +}) +``` +Enable `expose` plugin in config and use public properties from a corresponding helper. +Map each injection name to `HelperName.propertyName`: + +```js +plugins: { + expose: { + enabled: true, + inject: { + page: 'Playwright.page', + browser: 'Playwright.browser', + browserContext: 'Playwright.browserContext', + wdio: 'WebDriver.browser', + } + } +} +``` + +There is a shorthand mode: + +```js +plugins: { + expose: { + enabled: true, + inject: { page: 'page' } // resolves Playwright.page or Puppeteer.page + } +} +``` +A value with no dot is shorthand for "the first configured browser helper that exposes this property". Allowed properties: `page`, `browser`, `browserContext`, `context`. + +The injected value is a live proxy. Every property access reads the current helper property at that moment, so tab switches (`I.openNewTab`, `I.switchToNextTab`) propagate automatically — the next call through `page` targets the new tab. + +Calls pass straight to the underlying SDK. They aren't wrapped as CodeceptJS steps and don't appear in step output, so `await page.evaluate(...)` behaves as native Playwright. diff --git a/lib/plugin/expose.js b/lib/plugin/expose.js new file mode 100644 index 000000000..fe93fcade --- /dev/null +++ b/lib/plugin/expose.js @@ -0,0 +1,159 @@ +import Container from '../container.js' + +const RESERVED_NAMES = new Set(['I', 'test', 'suite']) +const SHORTHAND_PROPERTIES = new Set(['page', 'browser', 'browserContext', 'context']) + +const defaultConfig = { + inject: {}, +} + +/** + * Exposes properties from helper instances as injectable test arguments. + * Use it to access the underlying Playwright/Puppeteer `page`, the wdio `browser` client, + * or any other helper internal directly from a Scenario: + * + * ```js + * Scenario('listen for requests', async ({ I, page, browser }) => { + * page.on('request', r => console.log(r.url())) + * await page.evaluate(() => 1 + 1) + * I.amOnPage('/') + * }) + * ``` + * + * The injected value is a live proxy: every property access reads the *current* + * helper property, so mid-test reassignments (popups, `switchToNextTab`, + * `openNewTab`) are reflected automatically. Calls are not wrapped as + * CodeceptJS steps — `await page.evaluate(...)` runs as native Playwright. + * + * #### Configuration + * + * `inject` maps an injection name to a `HelperName.propertyName` string. A + * value with no dot is shorthand for "first configured browser helper that + * exposes this property" (allowed properties: `page`, `browser`, + * `browserContext`, `context`). + * + * ```js + * plugins: { + * expose: { + * enabled: true, + * inject: { + * page: 'Playwright.page', + * browser: 'Playwright.browser', + * browserContext: 'Playwright.browserContext', + * frame: 'Playwright.context', // current frame set by switchTo + * wdio: 'WebDriver.browser', + * } + * } + * } + * ``` + * + * Shorthand: + * + * ```js + * plugins: { + * expose: { + * enabled: true, + * inject: { + * page: 'page', // resolves to Playwright.page or Puppeteer.page + * } + * } + * } + * ``` + * + * #### Caveats + * + * - The injected value is a `Proxy`, not the actual `Page`/`Browser` instance, + * so `page instanceof Page` is `false`. Use duck typing instead. + * - Cached method references lose the live binding. Call `page.click(...)`, + * not `const click = page.click; click(...)`. + * - In dry-run mode the underlying helper property is `undefined`; accessing + * any property on the proxy returns `undefined` rather than throwing. + */ +export default function (config = {}) { + config = { ...defaultConfig, ...config } + + const mappings = parseMappings(config.inject) + + const support = {} + for (const [name, { helperName, property }] of Object.entries(mappings)) { + support[name] = makeLiveProxy(helperName, property) + } + Container.append({ support }) +} + +function parseMappings(inject) { + const out = {} + for (const [name, value] of Object.entries(inject || {})) { + if (RESERVED_NAMES.has(name)) { + throw new Error(`expose plugin: inject name '${name}' is reserved`) + } + if (typeof value !== 'string' || !value) { + throw new Error(`expose plugin: inject value for '${name}' must be a non-empty string`) + } + + let helperName + let property + + if (value.includes('.')) { + const dot = value.indexOf('.') + helperName = value.slice(0, dot) + property = value.slice(dot + 1) + if (!helperName || !property) { + throw new Error(`expose plugin: invalid inject value '${value}' for '${name}' (expected 'HelperName.propertyName')`) + } + if (!Container.helpers(helperName)) { + throw new Error(`expose plugin: helper '${helperName}' is not configured (needed for inject '${name}')`) + } + } else { + property = value + if (!SHORTHAND_PROPERTIES.has(property)) { + throw new Error(`expose plugin: shorthand '${property}' is not a known helper property for '${name}' (use 'HelperName.${property}' instead)`) + } + helperName = Container.STANDARD_ACTING_HELPERS.find(h => Container.helpers(h)) + if (!helperName) { + throw new Error(`expose plugin: no standard browser helper configured (needed for inject '${name}')`) + } + } + + out[name] = { helperName, property } + } + return out +} + +function makeLiveProxy(helperName, property) { + const resolve = () => Container.helpers(helperName)?.[property] + return new Proxy(function () {}, { + get(_, prop) { + const target = resolve() + if (target == null) return undefined + const value = target[prop] + if (typeof value === 'function') return value.bind(target) + return value + }, + has(_, prop) { + const target = resolve() + return target != null && prop in target + }, + apply(_, thisArg, args) { + const target = resolve() + return target?.apply(thisArg, args) + }, + set(_, prop, value) { + const target = resolve() + if (target != null) target[prop] = value + return true + }, + getPrototypeOf() { + const target = resolve() + return target != null ? Object.getPrototypeOf(target) : null + }, + ownKeys() { + const target = resolve() + return target != null ? Reflect.ownKeys(target) : [] + }, + getOwnPropertyDescriptor(_, prop) { + const target = resolve() + return target != null ? Object.getOwnPropertyDescriptor(target, prop) : undefined + }, + }) +} diff --git a/test/unit/plugin/expose_test.js b/test/unit/plugin/expose_test.js new file mode 100644 index 000000000..c6c3ca20b --- /dev/null +++ b/test/unit/plugin/expose_test.js @@ -0,0 +1,115 @@ +import { expect } from 'chai' +import Container from '../../../lib/container.js' +import expose from '../../../lib/plugin/expose.js' + +async function setup(helpers) { + await Container.create({ helpers: {} }) + Object.assign(Container.helpers(), helpers) +} + +describe('expose plugin', () => { + afterEach(async () => { + await Container.clear() + }) + + describe('registration', () => { + it('registers each inject name as a function-typed support entry', async () => { + await setup({ Playwright: { page: null, browser: null } }) + expose({ inject: { page: 'Playwright.page', browser: 'Playwright.browser' } }) + expect(typeof Container.support('page')).to.equal('function') + expect(typeof Container.support('browser')).to.equal('function') + }) + + it('makes the injected proxy resolve to the helper property when present', async () => { + const fakePage = { url: () => 'http://example.com' } + await setup({ Playwright: { page: fakePage } }) + expose({ inject: { page: 'Playwright.page' } }) + const page = Container.support('page') + expect(page.url()).to.equal('http://example.com') + }) + }) + + describe('live proxy', () => { + it('reflects mid-test reassignment of helper.page (tab switch)', async () => { + const helper = { page: { url: () => 'http://first.com' } } + await setup({ Playwright: helper }) + expose({ inject: { page: 'Playwright.page' } }) + const page = Container.support('page') + expect(page.url()).to.equal('http://first.com') + helper.page = { url: () => 'http://second.com' } + expect(page.url()).to.equal('http://second.com') + }) + + it('returns undefined for any property when helper.page is null (post-cleanup, dry-run)', async () => { + await setup({ Playwright: { page: null } }) + expose({ inject: { page: 'Playwright.page' } }) + const page = Container.support('page') + expect(page.click).to.equal(undefined) + expect(page.evaluate).to.equal(undefined) + }) + + it('binds method calls to the current helper.page so `this` resolves correctly', async () => { + const helper = { + page: { + name: 'one', + who() { return this.name }, + }, + } + await setup({ Playwright: helper }) + expose({ inject: { page: 'Playwright.page' } }) + const page = Container.support('page') + expect(page.who()).to.equal('one') + helper.page = { name: 'two', who() { return this.name } } + expect(page.who()).to.equal('two') + }) + + it('does not wrap method results in MetaStep (returns raw values)', async () => { + const ctx = { kind: 'BrowserContext' } + await setup({ Playwright: { page: { context: () => ctx } } }) + expose({ inject: { page: 'Playwright.page' } }) + const page = Container.support('page') + expect(page.context()).to.equal(ctx) + }) + }) + + describe('shorthand', () => { + it('resolves to the first configured standard browser helper', async () => { + const fakePage = { mark: 'puppeteer' } + await setup({ Puppeteer: { page: fakePage } }) + expose({ inject: { page: 'page' } }) + expect(Container.support('page').mark).to.equal('puppeteer') + }) + + it('rejects unknown shorthand properties', async () => { + await setup({ Playwright: {} }) + expect(() => expose({ inject: { x: 'unknownProp' } })).to.throw(/shorthand 'unknownProp' is not a known helper property/) + }) + }) + + describe('validation', () => { + it('throws when injection name is reserved', async () => { + await setup({ Playwright: { page: null } }) + expect(() => expose({ inject: { I: 'Playwright.page' } })).to.throw(/inject name 'I' is reserved/) + }) + + it('throws when explicit helper is not configured', async () => { + await setup({}) + expect(() => expose({ inject: { page: 'Playwright.page' } })).to.throw(/helper 'Playwright' is not configured/) + }) + + it('throws when shorthand has no candidate helper', async () => { + await setup({}) + expect(() => expose({ inject: { page: 'page' } })).to.throw(/no standard browser helper configured/) + }) + + it('throws on malformed value', async () => { + await setup({ Playwright: {} }) + expect(() => expose({ inject: { page: 'Playwright.' } })).to.throw(/invalid inject value/) + }) + + it('accepts empty inject', async () => { + await setup({ Playwright: { page: null } }) + expect(() => expose({})).not.to.throw() + }) + }) +})