Skip to content

Commit 2245e7e

Browse files
committed
Add declarative performance test scenarios
1 parent a369601 commit 2245e7e

4 files changed

Lines changed: 314 additions & 0 deletions

File tree

README.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,11 @@ See [action.yml](action.yml)
8686
# Default: ''
8787
blueprint: ''
8888

89+
# JSON file defining browser scenarios to test.
90+
#
91+
# Default: ''
92+
scenarios: ''
93+
8994
# WordPress version to use.
9095
#
9196
# Loads the specified WordPress version.
@@ -202,6 +207,58 @@ Add a blueprint (`my-custom-blueprint.json`):
202207
203208
```
204209

210+
Add browser scenarios (`my-scenarios.json`):
211+
212+
```json
213+
{
214+
"scenarios": [
215+
{
216+
"name": "Homepage LCP",
217+
"steps": [
218+
{
219+
"step": "visit",
220+
"url": "/"
221+
}
222+
]
223+
},
224+
{
225+
"name": "Search form INP",
226+
"steps": [
227+
{
228+
"step": "visit",
229+
"url": "/sample-page"
230+
},
231+
{
232+
"step": "getByLabel",
233+
"arg": "Search"
234+
},
235+
{
236+
"step": "fill",
237+
"arg": "Lorem ipsum"
238+
},
239+
{
240+
"step": "getByRole",
241+
"arg": "button",
242+
"options": {
243+
"name": "/Search/i"
244+
}
245+
},
246+
{
247+
"step": "click"
248+
}
249+
]
250+
}
251+
]
252+
}
253+
```
254+
255+
Use the scenario file in the workflow:
256+
257+
```yaml
258+
with:
259+
scenarios: ./my-scenarios.json
260+
```
261+
205262
### Running tests in parallel (sharding)
206263

207264
```yaml

action.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ inputs:
4040
required: false
4141
description: 'Blueprint to use for setting up the environment.'
4242
default: ''
43+
scenarios:
44+
required: false
45+
description: 'JSON file defining browser scenarios to test.'
46+
default: ''
4347
wp-version:
4448
required: false
4549
description: 'WordPress version to use.'
@@ -204,6 +208,7 @@ runs:
204208
SHARD: ${{ inputs.shard != '' && inputs.shard || '' }}
205209
ADDITIONAL_ARGS: ${{ inputs.shard != '' && format('-- --shard={0}', inputs.shard) || '' }}
206210
URLS_TO_TEST: ${{ inputs.urls }}
211+
SCENARIOS_FILE: ${{ inputs.scenarios }}
207212
DEBUG: ${{ inputs.debug == 'true' }}
208213
TEST_ITERATIONS: ${{ inputs.iterations }}
209214
TEST_REPETITIONS: ${{ inputs.repetitions }}

env/tests/performance/specs/main.spec.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { test } from '@wordpress/e2e-test-utils-playwright';
22
import { camelCaseDashes } from '../utils';
3+
import { loadScenarios, runScenario } from '../utils/scenarios';
34

45
const results: Record< string, Record< string, number[] > > = {};
56

@@ -32,6 +33,7 @@ test.describe( 'Tests', () => {
3233
.filter( Boolean );
3334

3435
const iterations = Number( process.env.TEST_ITERATIONS );
36+
const scenariosToTest = loadScenarios();
3537

3638
for ( const url of urlsToTest ) {
3739
for ( let i = 1; i <= iterations; i++ ) {
@@ -62,4 +64,36 @@ test.describe( 'Tests', () => {
6264
} );
6365
}
6466
}
67+
68+
for ( const scenario of scenariosToTest ) {
69+
for ( let i = 1; i <= iterations; i++ ) {
70+
test( `Scenario: "${ scenario.name }" (${ i } of ${ iterations })`, async ( {
71+
page,
72+
metrics,
73+
} ) => {
74+
await runScenario( page, scenario, i );
75+
76+
const serverTiming = await metrics.getServerTiming();
77+
78+
results[ scenario.name ] ??= {};
79+
80+
for ( const [ key, value ] of Object.entries( serverTiming ) ) {
81+
results[ scenario.name ][ camelCaseDashes( key ) ] ??= [];
82+
results[ scenario.name ][ camelCaseDashes( key ) ].push(
83+
value
84+
);
85+
}
86+
87+
const ttfb = await metrics.getTimeToFirstByte();
88+
const lcp = await metrics.getLargestContentfulPaint();
89+
90+
results[ scenario.name ].largestContentfulPaint ??= [];
91+
results[ scenario.name ].largestContentfulPaint.push( lcp );
92+
results[ scenario.name ].timeToFirstByte ??= [];
93+
results[ scenario.name ].timeToFirstByte.push( ttfb );
94+
results[ scenario.name ].lcpMinusTtfb ??= [];
95+
results[ scenario.name ].lcpMinusTtfb.push( lcp - ttfb );
96+
} );
97+
}
98+
}
6599
} );
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import { existsSync, readFileSync } from 'node:fs';
2+
import { isAbsolute, join } from 'node:path';
3+
import type { Locator, Page } from '@playwright/test';
4+
5+
type LocatorOptions = Record< string, unknown >;
6+
7+
export interface ScenarioStep {
8+
step: string;
9+
arg?: string;
10+
role?: string;
11+
selector?: string;
12+
url?: string;
13+
value?: string;
14+
state?: 'load' | 'domcontentloaded' | 'networkidle';
15+
timeout?: number;
16+
options?: LocatorOptions;
17+
}
18+
19+
export interface Scenario {
20+
name: string;
21+
steps: ScenarioStep[];
22+
}
23+
24+
interface ScenarioFile {
25+
scenarios?: Scenario[];
26+
}
27+
28+
export function loadScenarios( filePath = process.env.SCENARIOS_FILE || '' ) {
29+
if ( filePath.trim() === '' ) {
30+
return [];
31+
}
32+
33+
const resolvedPath = isAbsolute( filePath )
34+
? filePath
35+
: join( process.env.GITHUB_WORKSPACE || process.cwd(), filePath );
36+
37+
if ( ! existsSync( resolvedPath ) ) {
38+
throw new Error( `Scenario file not found: ${ resolvedPath }` );
39+
}
40+
41+
const data = JSON.parse(
42+
readFileSync( resolvedPath, 'utf-8' )
43+
) as ScenarioFile;
44+
45+
return ( data.scenarios || [] ).map( validateScenario );
46+
}
47+
48+
export async function runScenario(
49+
page: Page,
50+
scenario: Scenario,
51+
iteration: number
52+
) {
53+
let locator: Locator | undefined;
54+
55+
for ( const step of scenario.steps ) {
56+
locator = await runStep( page, step, iteration, locator );
57+
}
58+
}
59+
60+
async function runStep(
61+
page: Page,
62+
step: ScenarioStep,
63+
iteration: number,
64+
locator: Locator | undefined
65+
) {
66+
switch ( step.step ) {
67+
case 'visit':
68+
await page.goto(
69+
addIterationParam( getRequiredString( step, 'url' ), iteration )
70+
);
71+
return undefined;
72+
73+
case 'locator':
74+
return page.locator( getRequiredString( step, 'selector' ) );
75+
76+
case 'getByLabel':
77+
return page.getByLabel(
78+
getRequiredString( step, 'arg' ),
79+
normalizeOptions( step.options )
80+
);
81+
82+
case 'getByPlaceholder':
83+
return page.getByPlaceholder(
84+
getRequiredString( step, 'arg' ),
85+
normalizeOptions( step.options )
86+
);
87+
88+
case 'getByText':
89+
return page.getByText(
90+
getRequiredString( step, 'arg' ),
91+
normalizeOptions( step.options )
92+
);
93+
94+
case 'getByRole':
95+
return page.getByRole(
96+
getRole( step ),
97+
normalizeOptions( step.options ) as Parameters<
98+
Page[ 'getByRole' ]
99+
>[ 1 ]
100+
);
101+
102+
case 'click':
103+
await getLocator( locator, step ).click(
104+
normalizeOptions( step.options )
105+
);
106+
return locator;
107+
108+
case 'fill':
109+
await getLocator( locator, step ).fill(
110+
getRequiredString( step, 'arg' ),
111+
normalizeOptions( step.options )
112+
);
113+
return locator;
114+
115+
case 'press':
116+
await getLocator( locator, step ).press(
117+
getRequiredString( step, 'arg' ),
118+
normalizeOptions( step.options )
119+
);
120+
return locator;
121+
122+
case 'hover':
123+
await getLocator( locator, step ).hover(
124+
normalizeOptions( step.options )
125+
);
126+
return locator;
127+
128+
case 'waitFor':
129+
await getLocator( locator, step ).waitFor(
130+
normalizeOptions( step.options )
131+
);
132+
return locator;
133+
134+
case 'waitForLoadState':
135+
await page.waitForLoadState( step.state || 'load' );
136+
return locator;
137+
138+
case 'waitForTimeout':
139+
await page.waitForTimeout( step.timeout || 0 );
140+
return locator;
141+
142+
case 'reload':
143+
await page.reload();
144+
return undefined;
145+
146+
case 'clearCookies':
147+
await page.context().clearCookies();
148+
return locator;
149+
150+
default:
151+
throw new Error( `Unsupported scenario step: ${ step.step }` );
152+
}
153+
}
154+
155+
function validateScenario( scenario: Scenario ) {
156+
if ( typeof scenario.name !== 'string' || scenario.name.trim() === '' ) {
157+
throw new Error( 'Each scenario needs a non-empty name.' );
158+
}
159+
160+
if ( ! Array.isArray( scenario.steps ) || scenario.steps.length === 0 ) {
161+
throw new Error(
162+
`Scenario "${ scenario.name }" needs at least one step.`
163+
);
164+
}
165+
166+
return scenario;
167+
}
168+
169+
function getLocator( locator: Locator | undefined, step: ScenarioStep ) {
170+
if ( ! locator ) {
171+
throw new Error( `Step "${ step.step }" needs a locator first.` );
172+
}
173+
174+
return locator;
175+
}
176+
177+
function getRole( step: ScenarioStep ) {
178+
return getRequiredString( step, step.role ? 'role' : 'arg' ) as Parameters<
179+
Page[ 'getByRole' ]
180+
>[ 0 ];
181+
}
182+
183+
function getRequiredString( step: ScenarioStep, key: keyof ScenarioStep ) {
184+
const value = step[ key ];
185+
if ( typeof value !== 'string' || value.trim() === '' ) {
186+
throw new Error(
187+
`Step "${ step.step }" needs a non-empty "${ key }".`
188+
);
189+
}
190+
191+
return value;
192+
}
193+
194+
function normalizeOptions( options: LocatorOptions = {} ) {
195+
return Object.fromEntries(
196+
Object.entries( options ).map( ( [ key, value ] ) => [
197+
key,
198+
parseOptionValue( value ),
199+
] )
200+
) as LocatorOptions;
201+
}
202+
203+
function parseOptionValue( value: unknown ) {
204+
if ( typeof value !== 'string' ) {
205+
return value;
206+
}
207+
208+
const regex = value.match( /^\/(.+)\/([a-z]*)$/i );
209+
210+
return regex ? new RegExp( regex[ 1 ], regex[ 2 ] ) : value;
211+
}
212+
213+
function addIterationParam( url: string, iteration: number ) {
214+
const withoutTrailingSlash = url === '/' ? '/' : url.replace( /\/$/, '' );
215+
const separator = withoutTrailingSlash.includes( '?' ) ? '&' : '?';
216+
217+
return `${ withoutTrailingSlash }${ separator }i=${ iteration }`;
218+
}

0 commit comments

Comments
 (0)