Skip to content

Commit 0f39e51

Browse files
authored
Merge pull request #3313 from Strategy11/cypress_sharding
Split cypress tests into shards and reduce e2e calls to form templates API
2 parents f8678b0 + b947d17 commit 0f39e51

6 files changed

Lines changed: 214 additions & 3 deletions

File tree

.github/workflows/cypress.yml

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,27 @@ jobs:
2020
if: contains(github.event.pull_request.labels.*.name, 'run e2e tests')
2121
runs-on: ubuntu-latest
2222

23-
name: Cypress
23+
strategy:
24+
# Report every shard's result. The default would cancel the sibling
25+
# shards as soon as one spec fails, hiding failures we want to see in
26+
# the same run.
27+
fail-fast: false
28+
matrix:
29+
# Each shard is a separate runner with its own wp-env stack. The
30+
# specs assert on global state (searchFunctionality.cy.js creates a
31+
# form named "Test Form" and asserts the list count is exactly 1), so
32+
# two Cypress processes must never share one WordPress instance -
33+
# parallelism has to be at the runner level, not within a job.
34+
#
35+
# Each shard re-pays the fixed setup cost (npm ci + wp-env booting
36+
# WordPress and MySQL), so raising this past ~6 buys little.
37+
shard: [ 0, 1, 2, 3 ]
38+
39+
name: Cypress (shard ${{ matrix.shard }})
40+
41+
env:
42+
SHARD_TOTAL: 4
43+
2444
steps:
2545
- name: Checkout
2646
uses: actions/checkout@v6
@@ -46,8 +66,53 @@ jobs:
4666
if: steps.cache-npm.outputs.cache-hit != 'true'
4767
run: npm ci --legacy-peer-deps --include=dev
4868

69+
# Serve the form-templates API from a fixture instead of calling
70+
# formidableforms.com on every run. The cache key is the UTC date, so
71+
# the fixture is refreshed at most once a day and all four shards share
72+
# the one copy - without this, each shard fetches it separately. If the
73+
# fetch fails the committed fixture is used, so the suite still runs
74+
# when the API is unreachable.
75+
- name: Get current date
76+
id: date
77+
run: echo "date=$( date -u +%Y-%m-%d )" >> "$GITHUB_OUTPUT"
78+
79+
- name: Cache form templates API response
80+
id: cache-templates
81+
uses: actions/cache@v4
82+
with:
83+
path: tests/mu-plugins/form-templates-api.json
84+
key: form-templates-${{ steps.date.outputs.date }}
85+
86+
- name: Refresh form templates fixture
87+
if: steps.cache-templates.outputs.cache-hit != 'true'
88+
run: |
89+
# Write to a temp file first so a partial or failed response never
90+
# clobbers the committed fallback.
91+
if curl -fsS --max-time 40 -A 'formidable-e2e' \
92+
'https://formidableforms.com/wp-json/form-templates/v1/list?l=RlJFRVRFTVBMQVRFUyEhIQ%3D%3D' \
93+
-o /tmp/form-templates-api.json && [ -s /tmp/form-templates-api.json ]; then
94+
mv /tmp/form-templates-api.json tests/mu-plugins/form-templates-api.json
95+
echo "Refreshed the form templates fixture from the API."
96+
else
97+
echo "Could not reach the templates API; using the committed fixture."
98+
fi
99+
49100
- name: Set up WP environment
50101
run: npm run env start
51102

52103
- name: Test
53-
run: npm run e2e:githubrun
104+
run: |
105+
specs="$( ./tests/bin/split-specs.sh "$SHARD_TOTAL" "${{ matrix.shard }}" )"
106+
107+
# An empty shard only happens if there are fewer specs than shards.
108+
# Bail out rather than calling Cypress with an empty --spec, which
109+
# would silently fall back to running the whole suite.
110+
if [ -z "$specs" ]; then
111+
echo "No specs assigned to shard ${{ matrix.shard }}; nothing to run."
112+
exit 0
113+
fi
114+
115+
echo "Shard ${{ matrix.shard }} of $SHARD_TOTAL running:"
116+
echo "$specs" | tr ',' '\n' | sed 's/^/ /'
117+
118+
npm run e2e:githubrun -- --spec "$specs"

.wp-env.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,15 @@
44
"plugins": [
55
"."
66
],
7+
"mappings": {
8+
"wp-content/mu-plugins": "./tests/mu-plugins"
9+
},
710
"env": {
811
"tests": {
912
"mappings": {
13+
"wp-content/mu-plugins": "./tests/mu-plugins",
1014
"wp-cli.yml": "./tests/bin/wp-cli.yml"
1115
}
1216
}
1317
}
14-
}
18+
}

tests/bin/split-specs.sh

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Print the comma-separated Cypress `--spec` list for one shard of a
4+
# parallel E2E run.
5+
#
6+
# Usage: tests/bin/split-specs.sh <total_shards> <shard_index>
7+
# total_shards - how many shards the run is split across
8+
# shard_index - 0-based index of the shard to print
9+
#
10+
# Specs are discovered by globbing, not listed explicitly, so a newly added
11+
# spec is always picked up by some shard instead of being silently dropped.
12+
#
13+
# Balancing uses line count as a stand-in for runtime, assigning the largest
14+
# spec first to whichever shard is currently lightest (greedy longest-
15+
# processing-time). Plain round-robin lands the two biggest specs on the same
16+
# shard and leaves it running ~4.5x longer than the shortest one; since the
17+
# whole job is only as fast as its slowest shard, that wastes most of the
18+
# parallelism. Line count is a rough proxy - swap in real per-spec durations
19+
# here if the shards drift out of balance.
20+
21+
set -euo pipefail
22+
23+
total="${1:?usage: split-specs.sh <total_shards> <shard_index>}"
24+
index="${2:?usage: split-specs.sh <total_shards> <shard_index>}"
25+
26+
if [ "$total" -lt 1 ] || [ "$index" -lt 0 ] || [ "$index" -ge "$total" ]; then
27+
echo "split-specs.sh: shard index $index out of range for $total shards" >&2
28+
exit 1
29+
fi
30+
31+
cd "$( dirname "$0" )/../.."
32+
33+
# Keep the sort locale-independent so every shard in a run agrees on the
34+
# ordering, and therefore on the partition.
35+
export LC_ALL=C
36+
37+
find tests/cypress/e2e -type f \
38+
\( -name '*.cy.js' -o -name '*.cy.jsx' -o -name '*.cy.ts' -o -name '*.cy.tsx' \) \
39+
-exec sh -c 'for f do printf "%s\t%s\n" "$( wc -l < "$f" )" "$f"; done' sh {} + |
40+
sort -t"$( printf '\t' )" -k1,1nr -k2,2 |
41+
awk -F"$( printf '\t' )" -v total="$total" -v want="$index" '
42+
{
43+
best = 0;
44+
for ( s = 1; s < total; s++ ) {
45+
if ( load[ s ] < load[ best ] ) {
46+
best = s;
47+
}
48+
}
49+
load[ best ] += $1;
50+
if ( best == want ) {
51+
out = ( out == "" ? $2 : out "," $2 );
52+
}
53+
}
54+
END { print out }
55+
'

tests/mu-plugins/form-templates-api.json

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
// Forces the welcome-tour checklist off in the e2e test environment.
3+
//
4+
// On a fresh install the checklist renders expanded on its 'create-form'
5+
// step and its header overlays the form builder, covering
6+
// #frm-save-form-name-button so `cy.click()` fails actionability.
7+
//
8+
// The suite used to get away with this by accident: Cypress runs root-level
9+
// specs first, so admin-a11y.cy.js was always spec 1 of 18 and clicked the
10+
// checklist away, and because FrmWelcomeTourController stores its state in
11+
// the site-wide `frm-welcome-tour` option, every later spec inherited a
12+
// dismissed tour. Sharding removes that guarantee - whichever spec runs
13+
// first on a given shard now meets a virgin site - so the state has to be
14+
// set deterministically instead of inherited from a neighbour.
15+
//
16+
// Filtering rather than seeding the option means save_checklist() can't
17+
// write over it mid-run. `active_step_key` is required alongside
18+
// `dismissed`: get_usage_data() reads it whenever `dismissed` is truthy and
19+
// would otherwise emit an undefined-index warning into debug.log.
20+
add_filter(
21+
'pre_option_frm-welcome-tour',
22+
function () {
23+
return array(
24+
'completed_steps' => array(),
25+
'active_step_key' => 'create-form',
26+
'dismissed' => true,
27+
);
28+
}
29+
);
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php
2+
// Serves the form-templates API from a local fixture in the e2e test
3+
// environment, so the suite never depends on a live call to
4+
// formidableforms.com.
5+
//
6+
// Two things go wrong without this. A fresh wp-env has an empty template
7+
// cache, and FrmFormApi::get_api_info() returns an empty array outright when
8+
// another request for the same data is already in flight (the is_running()
9+
// guard) rather than waiting for it - so the templates list renders empty and
10+
// assertions like [frm-search-text="user registration"] never match. The
11+
// `Get Instant Access` signup flow then calls reset_cached(), clearing the
12+
// cache mid-run and re-opening the same window for whatever runs next.
13+
//
14+
// Sharding sharpened this: every shard is its own wp-env with its own cold
15+
// cache, so one live fetch per run became one per shard.
16+
//
17+
// Filtering pre_http_request rather than seeding the cache option keeps this
18+
// independent of FrmFormApi's cache internals - the option key is derived
19+
// from the license, and reset_cached() deletes it - and it is the standard
20+
// WordPress way to stub an outbound request.
21+
//
22+
// The fixture sits beside this file on purpose. The whole mu-plugins
23+
// directory is mapped to a known path inside the container, whereas the
24+
// plugin directory is named `formidable` locally and `formidable-forms` in
25+
// CI, so a path built from the plugin folder would break in one of the two.
26+
// WordPress only auto-loads .php from the mu-plugins root, so the .json
27+
// sibling is inert.
28+
add_filter(
29+
'pre_http_request',
30+
function ( $preempt, $args, $url ) {
31+
if ( false === strpos( $url, '/wp-json/form-templates/v1/list' ) ) {
32+
return $preempt;
33+
}
34+
35+
$fixture = __DIR__ . '/form-templates-api.json';
36+
37+
if ( ! is_readable( $fixture ) ) {
38+
// Let the real request through rather than failing the run with an
39+
// empty template list, which is the confusing symptom this exists
40+
// to prevent.
41+
return $preempt;
42+
}
43+
44+
return array(
45+
'headers' => array(),
46+
'body' => file_get_contents( $fixture ),
47+
'response' => array(
48+
'code' => 200,
49+
'message' => 'OK',
50+
),
51+
'cookies' => array(),
52+
'filename' => null,
53+
);
54+
},
55+
10,
56+
3
57+
);

0 commit comments

Comments
 (0)