Skip to content

Commit c769274

Browse files
DavertMikDavertMikclaude
authored
feat(plugins): add expose plugin for direct helper-internals injection (#5549)
Exposes Playwright/Puppeteer/WebDriver helper properties (page, browser, browserContext, wdio client) as scenario arguments via a live proxy. Reads helper.[property] on every access so mid-test reassignment from switchToNextTab/openNewTab is reflected automatically. Bypasses MetaStep wrapping so calls run as native SDK calls. Co-authored-by: DavertMik <davert@testomat.io> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent caf1a79 commit c769274

3 files changed

Lines changed: 319 additions & 0 deletions

File tree

docs/advanced.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,3 +154,48 @@ You can use this options for build your own [plugins](https://codecept.io/hooks/
154154
...
155155
});
156156
```
157+
158+
## Direct Helper Access
159+
160+
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.
161+
162+
```js
163+
Scenario('intercept network', async ({ I, page }) => {
164+
page.on('request', req => console.log(req.method(), req.url()))
165+
I.amOnPage('/')
166+
const title = await page.evaluate(() => document.title)
167+
I.see(title)
168+
})
169+
```
170+
Enable `expose` plugin in config and use public properties from a corresponding helper.
171+
Map each injection name to `HelperName.propertyName`:
172+
173+
```js
174+
plugins: {
175+
expose: {
176+
enabled: true,
177+
inject: {
178+
page: 'Playwright.page',
179+
browser: 'Playwright.browser',
180+
browserContext: 'Playwright.browserContext',
181+
wdio: 'WebDriver.browser',
182+
}
183+
}
184+
}
185+
```
186+
187+
There is a shorthand mode:
188+
189+
```js
190+
plugins: {
191+
expose: {
192+
enabled: true,
193+
inject: { page: 'page' } // resolves Playwright.page or Puppeteer.page
194+
}
195+
}
196+
```
197+
A value with no dot is shorthand for "the first configured browser helper that exposes this property". Allowed properties: `page`, `browser`, `browserContext`, `context`.
198+
199+
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.
200+
201+
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.

lib/plugin/expose.js

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import Container from '../container.js'
2+
3+
const RESERVED_NAMES = new Set(['I', 'test', 'suite'])
4+
const SHORTHAND_PROPERTIES = new Set(['page', 'browser', 'browserContext', 'context'])
5+
6+
const defaultConfig = {
7+
inject: {},
8+
}
9+
10+
/**
11+
* Exposes properties from helper instances as injectable test arguments.
12+
* Use it to access the underlying Playwright/Puppeteer `page`, the wdio `browser` client,
13+
* or any other helper internal directly from a Scenario:
14+
*
15+
* ```js
16+
* Scenario('listen for requests', async ({ I, page, browser }) => {
17+
* page.on('request', r => console.log(r.url()))
18+
* await page.evaluate(() => 1 + 1)
19+
* I.amOnPage('/')
20+
* })
21+
* ```
22+
*
23+
* The injected value is a live proxy: every property access reads the *current*
24+
* helper property, so mid-test reassignments (popups, `switchToNextTab`,
25+
* `openNewTab`) are reflected automatically. Calls are not wrapped as
26+
* CodeceptJS steps — `await page.evaluate(...)` runs as native Playwright.
27+
*
28+
* #### Configuration
29+
*
30+
* `inject` maps an injection name to a `HelperName.propertyName` string. A
31+
* value with no dot is shorthand for "first configured browser helper that
32+
* exposes this property" (allowed properties: `page`, `browser`,
33+
* `browserContext`, `context`).
34+
*
35+
* ```js
36+
* plugins: {
37+
* expose: {
38+
* enabled: true,
39+
* inject: {
40+
* page: 'Playwright.page',
41+
* browser: 'Playwright.browser',
42+
* browserContext: 'Playwright.browserContext',
43+
* frame: 'Playwright.context', // current frame set by switchTo
44+
* wdio: 'WebDriver.browser',
45+
* }
46+
* }
47+
* }
48+
* ```
49+
*
50+
* Shorthand:
51+
*
52+
* ```js
53+
* plugins: {
54+
* expose: {
55+
* enabled: true,
56+
* inject: {
57+
* page: 'page', // resolves to Playwright.page or Puppeteer.page
58+
* }
59+
* }
60+
* }
61+
* ```
62+
*
63+
* #### Caveats
64+
*
65+
* - The injected value is a `Proxy`, not the actual `Page`/`Browser` instance,
66+
* so `page instanceof Page` is `false`. Use duck typing instead.
67+
* - Cached method references lose the live binding. Call `page.click(...)`,
68+
* not `const click = page.click; click(...)`.
69+
* - In dry-run mode the underlying helper property is `undefined`; accessing
70+
* any property on the proxy returns `undefined` rather than throwing.
71+
*/
72+
export default function (config = {}) {
73+
config = { ...defaultConfig, ...config }
74+
75+
const mappings = parseMappings(config.inject)
76+
77+
const support = {}
78+
for (const [name, { helperName, property }] of Object.entries(mappings)) {
79+
support[name] = makeLiveProxy(helperName, property)
80+
}
81+
Container.append({ support })
82+
}
83+
84+
function parseMappings(inject) {
85+
const out = {}
86+
for (const [name, value] of Object.entries(inject || {})) {
87+
if (RESERVED_NAMES.has(name)) {
88+
throw new Error(`expose plugin: inject name '${name}' is reserved`)
89+
}
90+
if (typeof value !== 'string' || !value) {
91+
throw new Error(`expose plugin: inject value for '${name}' must be a non-empty string`)
92+
}
93+
94+
let helperName
95+
let property
96+
97+
if (value.includes('.')) {
98+
const dot = value.indexOf('.')
99+
helperName = value.slice(0, dot)
100+
property = value.slice(dot + 1)
101+
if (!helperName || !property) {
102+
throw new Error(`expose plugin: invalid inject value '${value}' for '${name}' (expected 'HelperName.propertyName')`)
103+
}
104+
if (!Container.helpers(helperName)) {
105+
throw new Error(`expose plugin: helper '${helperName}' is not configured (needed for inject '${name}')`)
106+
}
107+
} else {
108+
property = value
109+
if (!SHORTHAND_PROPERTIES.has(property)) {
110+
throw new Error(`expose plugin: shorthand '${property}' is not a known helper property for '${name}' (use 'HelperName.${property}' instead)`)
111+
}
112+
helperName = Container.STANDARD_ACTING_HELPERS.find(h => Container.helpers(h))
113+
if (!helperName) {
114+
throw new Error(`expose plugin: no standard browser helper configured (needed for inject '${name}')`)
115+
}
116+
}
117+
118+
out[name] = { helperName, property }
119+
}
120+
return out
121+
}
122+
123+
function makeLiveProxy(helperName, property) {
124+
const resolve = () => Container.helpers(helperName)?.[property]
125+
return new Proxy(function () {}, {
126+
get(_, prop) {
127+
const target = resolve()
128+
if (target == null) return undefined
129+
const value = target[prop]
130+
if (typeof value === 'function') return value.bind(target)
131+
return value
132+
},
133+
has(_, prop) {
134+
const target = resolve()
135+
return target != null && prop in target
136+
},
137+
apply(_, thisArg, args) {
138+
const target = resolve()
139+
return target?.apply(thisArg, args)
140+
},
141+
set(_, prop, value) {
142+
const target = resolve()
143+
if (target != null) target[prop] = value
144+
return true
145+
},
146+
getPrototypeOf() {
147+
const target = resolve()
148+
return target != null ? Object.getPrototypeOf(target) : null
149+
},
150+
ownKeys() {
151+
const target = resolve()
152+
return target != null ? Reflect.ownKeys(target) : []
153+
},
154+
getOwnPropertyDescriptor(_, prop) {
155+
const target = resolve()
156+
return target != null ? Object.getOwnPropertyDescriptor(target, prop) : undefined
157+
},
158+
})
159+
}

test/unit/plugin/expose_test.js

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { expect } from 'chai'
2+
import Container from '../../../lib/container.js'
3+
import expose from '../../../lib/plugin/expose.js'
4+
5+
async function setup(helpers) {
6+
await Container.create({ helpers: {} })
7+
Object.assign(Container.helpers(), helpers)
8+
}
9+
10+
describe('expose plugin', () => {
11+
afterEach(async () => {
12+
await Container.clear()
13+
})
14+
15+
describe('registration', () => {
16+
it('registers each inject name as a function-typed support entry', async () => {
17+
await setup({ Playwright: { page: null, browser: null } })
18+
expose({ inject: { page: 'Playwright.page', browser: 'Playwright.browser' } })
19+
expect(typeof Container.support('page')).to.equal('function')
20+
expect(typeof Container.support('browser')).to.equal('function')
21+
})
22+
23+
it('makes the injected proxy resolve to the helper property when present', async () => {
24+
const fakePage = { url: () => 'http://example.com' }
25+
await setup({ Playwright: { page: fakePage } })
26+
expose({ inject: { page: 'Playwright.page' } })
27+
const page = Container.support('page')
28+
expect(page.url()).to.equal('http://example.com')
29+
})
30+
})
31+
32+
describe('live proxy', () => {
33+
it('reflects mid-test reassignment of helper.page (tab switch)', async () => {
34+
const helper = { page: { url: () => 'http://first.com' } }
35+
await setup({ Playwright: helper })
36+
expose({ inject: { page: 'Playwright.page' } })
37+
const page = Container.support('page')
38+
expect(page.url()).to.equal('http://first.com')
39+
helper.page = { url: () => 'http://second.com' }
40+
expect(page.url()).to.equal('http://second.com')
41+
})
42+
43+
it('returns undefined for any property when helper.page is null (post-cleanup, dry-run)', async () => {
44+
await setup({ Playwright: { page: null } })
45+
expose({ inject: { page: 'Playwright.page' } })
46+
const page = Container.support('page')
47+
expect(page.click).to.equal(undefined)
48+
expect(page.evaluate).to.equal(undefined)
49+
})
50+
51+
it('binds method calls to the current helper.page so `this` resolves correctly', async () => {
52+
const helper = {
53+
page: {
54+
name: 'one',
55+
who() { return this.name },
56+
},
57+
}
58+
await setup({ Playwright: helper })
59+
expose({ inject: { page: 'Playwright.page' } })
60+
const page = Container.support('page')
61+
expect(page.who()).to.equal('one')
62+
helper.page = { name: 'two', who() { return this.name } }
63+
expect(page.who()).to.equal('two')
64+
})
65+
66+
it('does not wrap method results in MetaStep (returns raw values)', async () => {
67+
const ctx = { kind: 'BrowserContext' }
68+
await setup({ Playwright: { page: { context: () => ctx } } })
69+
expose({ inject: { page: 'Playwright.page' } })
70+
const page = Container.support('page')
71+
expect(page.context()).to.equal(ctx)
72+
})
73+
})
74+
75+
describe('shorthand', () => {
76+
it('resolves to the first configured standard browser helper', async () => {
77+
const fakePage = { mark: 'puppeteer' }
78+
await setup({ Puppeteer: { page: fakePage } })
79+
expose({ inject: { page: 'page' } })
80+
expect(Container.support('page').mark).to.equal('puppeteer')
81+
})
82+
83+
it('rejects unknown shorthand properties', async () => {
84+
await setup({ Playwright: {} })
85+
expect(() => expose({ inject: { x: 'unknownProp' } })).to.throw(/shorthand 'unknownProp' is not a known helper property/)
86+
})
87+
})
88+
89+
describe('validation', () => {
90+
it('throws when injection name is reserved', async () => {
91+
await setup({ Playwright: { page: null } })
92+
expect(() => expose({ inject: { I: 'Playwright.page' } })).to.throw(/inject name 'I' is reserved/)
93+
})
94+
95+
it('throws when explicit helper is not configured', async () => {
96+
await setup({})
97+
expect(() => expose({ inject: { page: 'Playwright.page' } })).to.throw(/helper 'Playwright' is not configured/)
98+
})
99+
100+
it('throws when shorthand has no candidate helper', async () => {
101+
await setup({})
102+
expect(() => expose({ inject: { page: 'page' } })).to.throw(/no standard browser helper configured/)
103+
})
104+
105+
it('throws on malformed value', async () => {
106+
await setup({ Playwright: {} })
107+
expect(() => expose({ inject: { page: 'Playwright.' } })).to.throw(/invalid inject value/)
108+
})
109+
110+
it('accepts empty inject', async () => {
111+
await setup({ Playwright: { page: null } })
112+
expect(() => expose({})).not.to.throw()
113+
})
114+
})
115+
})

0 commit comments

Comments
 (0)