Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions docs/advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
159 changes: 159 additions & 0 deletions lib/plugin/expose.js
Original file line number Diff line number Diff line change
@@ -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
},
})
}
115 changes: 115 additions & 0 deletions test/unit/plugin/expose_test.js
Original file line number Diff line number Diff line change
@@ -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()
})
})
})