bin/test-php-ci.sh
Rama trunk · réplica de lectura del repositorio original.
#!/usr/bin/env bash
# Runs CI's PHP unit cells for the core plugin locally, one warm wp-env
# instance per cell:
#
# pnpm test:php:ci --list
# pnpm test:php:ci --cell 8.3 --cell 7.4
# pnpm test:php:ci --cell 8.3 --tests WC_Tests_Cart --tests 'WC_Abstract_Product_Test::test_on_sale'
# pnpm test:php:ci --cell 8.3 --tests tests/php/includes/abstracts/class-wc-abstract-product-test.php
# pnpm test:php:ci --cell 8.3 --tests suite:wc-phpunit-legacy
#
# Install mirrors the "backend" path of setup-woocommerce-monorepo; cells,
# versions and commands come from `pnpm utils ci-jobs`, as in CI. Cells start
# without `--update` so they stay warm, unless their WordPress is not the
# version the cell needs; `--fresh` starts them as CI does.
# One invocation at a time.
set -euo pipefail
project="@woocommerce/plugin-woocommerce"
project_dir="plugins/woocommerce"
list=0
all=0
fresh=0
jobs=2
cells=()
tests=()
phpunit_args=()
usage() {
cat >&2 <<'EOF'
usage: pnpm test:php:ci --list
pnpm test:php:ci (--cell <index|name fragment>)... [options]
pnpm test:php:ci --all [options]
options: --tests <Class|Class::method|regex|file|suite:name> (repeatable)
--jobs N --fresh [-- phpunit args...]
EOF
exit 2
}
while [[ $# -gt 0 ]]; do
case "$1" in
--list) list=1 ;;
--all) all=1 ;;
--fresh) fresh=1 ;;
--jobs) [[ $# -ge 2 ]] || usage; jobs="$2"; shift ;;
--cell) [[ $# -ge 2 ]] || usage; cells+=( "$2" ); shift ;;
--tests) [[ $# -ge 2 ]] || usage; tests+=( "$2" ); shift ;;
--) shift; phpunit_args=( ${@+"$@"} ); break ;;
*) echo "unknown argument: $1" >&2; usage ;;
esac
shift
done
if [[ $list -eq 0 && $all -eq 0 && ${#cells[@]} -eq 0 ]]; then
usage
fi
[[ "$jobs" =~ ^[1-9][0-9]*$ ]] || usage
# gh local-ci (https://github.com/woocommerce/gh-local-ci) runs CI jobs on a
# developer's machine and sets LOCAL_CI=1; its receipt lets CI skip the job
# (.github/actions/local-ci-receipt/README.md). A receipt says the whole CI
# job passed, so a run under it may not narrow the tests.
if [[ "${LOCAL_CI:-}" == "1" && ( ${#tests[@]} -gt 0 || ${#phpunit_args[@]} -gt 0 ) ]]; then
echo "error: test selection is not allowed under gh local-ci; a receipt covers the whole job" >&2
exit 2
fi
caller_dir="$PWD"
cd "$(dirname "$0")/.."
tmp="$(mktemp -d)"
keep_tmp=0
# shellcheck disable=SC2329 # invoked by the trap
cleanup() {
# Put back a jobs.json someone saved at the root before the planner overwrote it.
[[ ! -e "$tmp/jobs.json.saved" ]] || mv -f "$tmp/jobs.json.saved" jobs.json
[[ $keep_tmp -eq 1 ]] || rm -rf "$tmp"
}
trap cleanup EXIT
# --tests selectors -> phpunit arguments: an optional suite plus --filter
# patterns joined with |. A bare class or Class::method is anchored to the
# whole (possibly namespaced) name; anything else is passed as a regex.
selection=()
test_suite=""
filters=()
identifier='^[A-Za-z0-9_\\]+(::[A-Za-z0-9_]+)?$'
for t in ${tests[@]+"${tests[@]}"}; do
[[ -n "$t" ]] || usage
if [[ "$t" == suite:* ]]; then
[[ -z "$test_suite" ]] || { echo "error: only one suite: selector" >&2; usage; }
test_suite="${t#suite:}"
[[ -n "$test_suite" ]] || usage
continue
fi
# A path may be relative to where the script was run from, the repository root or the plugin.
path=""
for candidate in "$caller_dir/$t" "$t" "$project_dir/${t#"$project_dir"/}"; do
[[ "$t" == /* ]] && candidate="$t"
if [[ -e "$candidate" ]]; then
path="$candidate"
break
fi
done
if [[ -d "$path" ]]; then
# phpunit only picks *Test.php out of a directory and this repo's files end in -test.php.
echo "error: '$t' is a directory; select a test file, a class, or suite:<name>" >&2
usage
elif [[ -f "$path" ]]; then
# phpunit wants a file's class named after the file; this repo's are not, so select the classes it declares.
classes="$(grep -oE '^(final |abstract )?class +[A-Za-z0-9_]+' "$path" | awk '{print $NF}' || true)"
[[ -n "$classes" ]] || { echo "error: no test class found in '$t'" >&2; usage; }
for c in $classes; do filters+=( "(^|\\\\)$c\\b" ); done
elif [[ "$t" =~ $identifier ]]; then
filters+=( "(^|\\\\)${t//\\/\\\\}\\b" )
else
filters+=( "$t" )
fi
done
[[ -n "$test_suite" ]] && selection+=( --testsuite "$test_suite" )
if [[ ${#filters[@]} -gt 0 ]]; then
joined="$(printf '%s|' "${filters[@]}")"
selection+=( --filter "${joined%|}" )
fi
# phpunit exits 0 when a selection matches nothing; make that a failure.
[[ ${#tests[@]} -eq 0 ]] || selection+=( --fail-on-empty-test-suite )
phpunit_args=( ${selection[@]+"${selection[@]}"} ${phpunit_args[@]+"${phpunit_args[@]}"} )
run() {
printf '\n$' >&2
printf ' %q' "$@" >&2
printf '\n' >&2
"$@"
}
field() { printf '%s' "$1" | cut -f"$2"; }
# Cells, one per line: index, name, command, start, port, slug, env (k=v;k=v).
# The planner always writes jobs.json at the root.
[[ ! -e jobs.json ]] || mv jobs.json "$tmp/jobs.json.saved"
run pnpm utils ci-jobs --event pull_request --json >/dev/null
mv jobs.json "$tmp/jobs.json"
[[ ! -e "$tmp/jobs.json.saved" ]] || mv "$tmp/jobs.json.saved" jobs.json
# shellcheck disable=SC2016 # node source, not shell
node -e '
const jobs = require( process.argv[ 1 ] ).test.filter(
( j ) => j.projectName === process.argv[ 2 ] && j.testType === "unit:php"
);
jobs.forEach( ( j, i ) => {
const env = j.testEnv.envVars || {};
// Values come from api.wordpress.org; keep them out of shell syntax.
for ( const [ k, v ] of Object.entries( env ) ) {
if ( ! /^[A-Za-z_][A-Za-z0-9_]*$/.test( k ) || /[;\s]/.test( String( v ) ) ) {
throw new Error( `unexpected planner env ${ k }=${ v }` );
}
}
const php = ( env.WP_ENV_PHP_VERSION || "" ).replace( ".", "" );
if ( ! /^\d+$/.test( php ) || ! j.command ) {
throw new Error( `unexpected planner job ${ j.name }` );
}
const portBase = process.env.TEST_PHP_CI_PORT_BASE || "82";
if ( ! /^\d{2}$/.test( portBase ) ) {
throw new Error( `TEST_PHP_CI_PORT_BASE must be two digits, got ${ portBase }` );
}
const wp = /latest - 1/.test( j.name ) ? "latest-1" : /pre-release/.test( j.name ) ? "prerelease" : "latest";
const base = Number( portBase ) + { latest: 0, "latest-1": 1, prerelease: 2 }[ wp ];
const row = [
i + 1, j.name, j.command, j.testEnv.start, `${ base }${ php }`, `php${ php }-wp-${ wp }`,
Object.entries( env ).map( ( [ k, v ] ) => `${ k }=${ v }` ).join( ";" ),
];
process.stdout.write( row.join( "\t" ) + "\n" );
} );
' "$tmp/jobs.json" "$project" > "$tmp/cells.tsv"
cell_rows=()
while IFS= read -r line; do cell_rows+=( "$line" ); done < "$tmp/cells.tsv"
if [[ ${#cell_rows[@]} -eq 0 ]]; then
echo "error: the planner reports no unit:php cells for $project" >&2
exit 3
fi
if [[ $list -eq 1 ]]; then
printf '%s\n' "${cell_rows[@]}" | awk -F'\t' '{ printf "%2d %-88s port %s %s\n", $1, $2, $5, $7 }'
exit 0
fi
tab=$'\t'
selected=()
if [[ $all -eq 1 ]]; then
for row in "${cell_rows[@]}"; do selected+=( "${row%%"$tab"*}" ); done
else
for want in ${cells[@]+"${cells[@]}"}; do
matches=()
for row in "${cell_rows[@]}"; do
index="${row%%"$tab"*}"
name="$(field "$row" 2)"
if [[ "$want" =~ ^[0-9]+$ ]]; then
[[ "$want" == "$index" ]] && matches+=( "$index" )
elif [[ "$name" == *"$want"* ]]; then
matches+=( "$index" )
fi
done
if [[ ${#matches[@]} -ne 1 ]]; then
echo "error: --cell '$want' matches ${#matches[@]} cells (only @woocommerce/plugin-woocommerce cells are supported); use --list and pick one index" >&2
exit 2
fi
[[ " ${selected[*]:-} " == *" ${matches[0]} "* ]] || selected+=( "${matches[0]}" )
done
fi
# Install as CI does for a unit:php job.
run composer install --working-dir="$project_dir" --quiet &
composer_pid=$!
pnpm_status=0
run pnpm install --filter="$project" --frozen-lockfile --ignore-scripts || pnpm_status=$?
composer_status=0
wait "$composer_pid" || composer_status=$?
[[ $pnpm_status -eq 0 && $composer_status -eq 0 ]] || exit 1
run pnpm --filter=@woocommerce/admin-library build:project:feature-config
# A fresh CI database has no leftover scheduled actions; phpunit never drops these tables.
# wp-env writes wp-tests-config.php as a copy of wp-config.php, so PHPUnit uses the wp_ prefix, not wptests_.
truncate_action_scheduler() {
local config="$1" tables sql
tables="$(WC_TEST_ENV_CONFIG="$config" pnpm --filter="$project" --silent wp-env:test run cli wp db tables 'wp_actionscheduler_*' --all-tables-with-prefix --format=csv | tr -d '\r')" || return 1
# shellcheck disable=SC2016 # SQL identifier quotes, not shell
sql="$(printf '%s' "$tables" | tr ',' '\n' | { grep -E '^wp_actionscheduler_[a-z_]+$' || true; } | sed 's/.*/TRUNCATE TABLE `&`;/' | tr '\n' ' ')"
[[ -n "$sql" ]] || return 0
WC_TEST_ENV_CONFIG="$config" pnpm --filter="$project" --silent wp-env:test run cli wp db query "$sql" >/dev/null
}
cell_env() {
# k=v;k=v -> one line per pair, for `env`.
[[ -n "$1" ]] && printf '%s\n' "${1//;/$'\n'}"
}
# The WordPress version a cell must test. The planner gives "latest" cells a fixed latest.zip URL,
# so a warm instance keeps the release it first downloaded; ask the API what latest is today.
wanted_wp_version() {
local version
version="$(cell_env "$1" | sed -n 's/^WP_VERSION=//p')"
[[ "$version" == latest ]] || { printf '%s' "$version"; return; }
curl -fsS https://api.wordpress.org/core/version-check/1.7/ | node -e '
let s = ""; process.stdin.on( "data", ( c ) => ( s += c ) ).on( "end", () => process.stdout.write( JSON.parse( s ).offers[ 0 ].current ) );
'
}
installed_wp_version() {
WC_TEST_ENV_CONFIG="$1" pnpm --filter="$project" --silent wp-env:test run cli wp core version 2>/dev/null | tr -d '\r' | tail -n 1
}
# Start each distinct instance once. WP_ENV_PORT would override every cell's port.
unset WP_ENV_PORT WP_ENV_MYSQL_PORT
started=()
for index in "${selected[@]}"; do
row="${cell_rows[$(( index - 1 ))]}"
slug="$(field "$row" 6)"
[[ " ${started[*]:-} " == *" $slug "* ]] && continue
started+=( "$slug" )
config=".wp-env.test-${slug}.json"
node -e '
const fs = require( "fs" );
const base = JSON.parse( fs.readFileSync( process.argv[ 1 ], "utf8" ) );
fs.writeFileSync( process.argv[ 2 ], JSON.stringify( { ...base, port: Number( process.argv[ 3 ] ) }, null, "\t" ) + "\n" );
' "$project_dir/.wp-env.test.json" "$project_dir/$config" "$(field "$row" 5)"
envs=()
while IFS= read -r line; do envs+=( "$line" ); done < <( cell_env "$(field "$row" 7)" )
start_at=$(date +%s)
if [[ $fresh -eq 1 ]]; then
# shellcheck disable=SC2046,SC2086 # the planner's start string is a pnpm script name plus flags
CI=true WC_TEST_ENV_CONFIG="$config" run env ${envs[@]+"${envs[@]}"} pnpm --filter="$project" $(field "$row" 4)
else
CI=true WC_TEST_ENV_CONFIG="$config" run env ${envs[@]+"${envs[@]}"} pnpm --filter="$project" wp-env:test start
fi
want="$(wanted_wp_version "$(field "$row" 7)")" || { echo "error: could not resolve the WordPress version for $slug" >&2; exit 1; }
have="$(installed_wp_version "$config")"
if [[ -n "$want" && "$have" != "$want" ]]; then
echo "instance $slug has WordPress ${have:-unknown}, the cell needs $want; updating" >&2
CI=true WC_TEST_ENV_CONFIG="$config" run env ${envs[@]+"${envs[@]}"} pnpm --filter="$project" wp-env:test start --update
have="$(installed_wp_version "$config")"
if [[ "$have" != "$want" ]]; then
echo "error: instance $slug still has WordPress ${have:-unknown} after --update, not $want" >&2
exit 1
fi
fi
echo "instance $slug ready in $(( $(date +%s) - start_at ))s (port $(field "$row" 5))" >&2
done
( cd "$project_dir" && run sh ./client/blocks/bin/copy-blocks-json.sh )
# Run the selected cells, --jobs at a time; cells sharing an instance run in sequence.
group_slugs=()
for index in "${selected[@]}"; do
slug="$(field "${cell_rows[$(( index - 1 ))]}" 6)"
[[ " ${group_slugs[*]:-} " == *" $slug "* ]] || group_slugs+=( "$slug" )
done
run_group() {
local slug="$1"
local config=".wp-env.test-${slug}.json"
for index in "${selected[@]}"; do
local row="${cell_rows[$(( index - 1 ))]}"
[[ "$(field "$row" 6)" == "$slug" ]] || continue
local name command log start_at rc
name="$(field "$row" 2)"
command="$(field "$row" 3)"
command="${command/test:php:env/test:php:env:run}"
log="$tmp/cell-$index.log"
envs=()
while IFS= read -r line; do envs+=( "$line" ); done < <( cell_env "$(field "$row" 7)" )
start_at=$(date +%s)
rc=0
if truncate_action_scheduler "$config" > "$log" 2>&1; then
CI=true WC_TEST_ENV_CONFIG="$config" env ${envs[@]+"${envs[@]}"} pnpm --filter="$project" "$command" ${phpunit_args[@]+"${phpunit_args[@]}"} >> "$log" 2>&1 || rc=$?
else
rc=$?
echo "could not reset the Action Scheduler tables" >> "$log"
fi
printf '%s\t%s\t%s\t%s\n' "$index" "$rc" "$(( $(date +%s) - start_at ))" "$log" >> "$tmp/results.tsv"
if [[ $rc -ne 0 ]]; then
echo "FAIL $name (exit $rc), last lines of $log:" >&2
tail -n 40 "$log" >&2
fi
done
}
: > "$tmp/results.tsv"
# Start the next group as soon as any one finishes. macOS's bash 3.2 has no
# `wait -n`, so poll the running jobs instead.
for slug in "${group_slugs[@]}"; do
while [[ $(jobs -pr | wc -l) -ge $jobs ]]; do
sleep 1
done
run_group "$slug" &
done
wait
echo
echo "Results:"
failed=0
for index in "${selected[@]}"; do
row="${cell_rows[$(( index - 1 ))]}"
result="$(grep -E "^$index " "$tmp/results.tsv" || true)"
if [[ -z "$result" ]]; then
printf '%-5s %-88s %s\n' FAIL "$(field "$row" 2)" "no result recorded"
failed=1
continue
fi
rc="$(field "$result" 2)"; secs="$(field "$result" 3)"; log="$(field "$result" 4)"
summary="$(grep -aE '^(OK|Tests:|FAILURES|ERRORS)' "$log" | sed "s/$(printf '\033')\[[0-9;]*m//g" | tail -1 || true)"
[[ -n "$summary" ]] || summary="$(tail -n 1 "$log")"
printf '%-5s %-88s %4ss %s\n' "$([[ $rc -eq 0 ]] && echo PASS || echo FAIL)" "$(field "$row" 2)" "$secs" "$summary"
[[ $rc -eq 0 ]] || failed=1
done
if [[ $failed -eq 1 ]]; then
keep_tmp=1
echo "logs kept in $tmp"
fi
exit $failed