Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
42.59% covered (danger)
42.59%
23 / 54
0.00% covered (danger)
0.00%
0 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
Test_Environment
42.59% covered (danger)
42.59%
23 / 54
0.00% covered (danger)
0.00%
0 / 5
113.57
0.00% covered (danger)
0.00%
0 / 1
 find_autoloader
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 make_temp_dir
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 rmrf
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
30
 init
63.64% covered (warning)
63.64%
14 / 22
0.00% covered (danger)
0.00%
0 / 1
12.89
 reduce_password_cost
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Provides a shared WordPress test environment for Jetpack packages
4 *
5 * @package automattic/jetpack-test-environment
6 */
7
8namespace Automattic\Jetpack;
9
10/**
11 * Provides a shared WordPress test environment for Jetpack packages.
12 * This ensures WordPress is only loaded once across all packages using this environment.
13 */
14class Test_Environment {
15
16    const PACKAGE_VERSION = '0.3.1';
17
18    /**
19     * Whether the environment has been initialized.
20     *
21     * @var bool
22     */
23    private static $initialized = false;
24
25    /**
26     * Find the test environment autoloader.
27     *
28     * @throws \RuntimeException If the autoloader cannot be found.
29     * @return string Path to the test environment autoloader.
30     */
31    public static function find_autoloader() {
32        // Try the simple path first (works for symlinked/development case).
33        $simple_path = dirname( __DIR__, 4 ) . '/tools/php-test-env/vendor/autoload.php';
34        if ( file_exists( $simple_path ) ) {
35            return $simple_path;
36        }
37
38        // If simple path fails, search recursively for the autoloader.
39        $dir = __DIR__;
40        while ( ! file_exists( $dir . '/tools/php-test-env/vendor/autoload.php' ) ) {
41            $parent = dirname( $dir );
42            if ( $parent === $dir ) {
43                throw new \RuntimeException( 'Could not locate test environment autoloader' );
44            }
45            $dir = $parent;
46        }
47        return $dir . '/tools/php-test-env/vendor/autoload.php';
48    }
49
50    /**
51     * Create a temporary directory.
52     *
53     * @param string $prefix Directory name prefix. The created directory will be under `sys_get_temp_dir()` with a random suffix added to this prefix.
54     * @return string Created path.
55     * @throws \RuntimeException On failure to create any directory.
56     */
57    private static function make_temp_dir( $prefix ) {
58        // phpcs:ignore WordPress.WP.AlternativeFunctions.rand_rand -- Not security sensitive, and `wp_rand` isn't loaded yet.
59        $mask = rand( 0, 0xffffff );
60        for ( $i = 0; $i < 0xffffff; $i++ ) {
61            $tmpdir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . $prefix . sprintf( '%06x', $i ^ $mask );
62            // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_mkdir -- WP isn't loaded yet, and we're handling errors here.
63            if ( @mkdir( $tmpdir, 0700 ) ) {
64                return $tmpdir;
65            }
66        }
67
68        throw new \RuntimeException( 'Failed to create temporary directory' );
69    }
70
71    /**
72     * Remove a directory, recursively.
73     *
74     * @private
75     * @param string $dir Directory to remove.
76     */
77    public static function rmrf( $dir ) {
78        if ( ! file_exists( $dir ) ) {
79            return;
80        }
81        if ( ! is_dir( $dir ) ) {
82            // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- WP isn't loaded yet.
83            unlink( $dir );
84            return;
85        }
86
87        $iter = new \RecursiveIteratorIterator(
88            new \RecursiveDirectoryIterator( $dir, \FilesystemIterator::CURRENT_AS_PATHNAME | \FilesystemIterator::SKIP_DOTS ),
89            \RecursiveIteratorIterator::CHILD_FIRST
90        );
91        foreach ( $iter as $path ) {
92            if ( is_dir( $path ) ) {
93                // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- WP isn't loaded yet.
94                rmdir( $path );
95            } else {
96                // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- WP isn't loaded yet.
97                unlink( $path );
98            }
99        }
100        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- WP isn't loaded yet.
101        rmdir( $dir );
102    }
103
104    /**
105     * Initialize the shared WordPress test environment.
106     *
107     * This ensures we only load WordPress once across all packages.
108     *
109     * @param string|null $package_slug Optional package slug for custom upload directory.
110     * @param string      $db_engine Optional database engine to use. Current options are 'dbless' and 'sqlite'.
111     *
112     * @throws \RuntimeException If WordPress test environment fails to initialize.
113     */
114    public static function init( $package_slug = null, $db_engine = 'dbless' ) {
115        if ( self::$initialized ) {
116            return;
117        }
118
119        // phpcs:ignore WordPressVIPMinimum.Files.IncludingFile.NotAbsolutePath
120        require_once self::find_autoloader();
121
122        $tmpdir = null;
123
124        try {
125            if ( ! class_exists( '\WorDBless\Load' ) ) {
126                throw new \RuntimeException( 'WorDBless not found. Please ensure automattic/wordbless is installed in tools/php-test-env/composer.json' );
127            }
128
129            // If a package slug is provided, use it for a custom upload dir.
130            if ( $package_slug && ! defined( 'dbless_UPLOADS' ) ) {
131                define( 'dbless_UPLOADS', 'uploads-' . $package_slug ); // phpcs:ignore Generic.NamingConventions.UpperCaseConstantName.ConstantNotUpperCase
132            }
133
134            // If using sqlite, point FQDBDIR at a unique directory so concurrent runs don't interfere with each other (sqlite does poorly with multiple writers).
135            $persist = false;
136            if ( $db_engine === 'sqlite' ) {
137                $tmpdir = self::make_temp_dir( 'test-sqlite-' . ( $package_slug ?? 'null' ) . '-' );
138                define( 'FQDBDIR', "$tmpdir/" );
139                $persist = true;
140            }
141
142            \WorDBless\Load::load( $db_engine, $persist );
143
144            // WorDBless's normal cleanup won't handle cleanup when the above is redefined.
145            // Note this must be registered after `WorDBless\Load::load()` registers the exit handler for `shutdown_action_hook`.
146            if ( $db_engine === 'sqlite' ) {
147                register_shutdown_function( array( self::class, 'rmrf' ), $tmpdir );
148            }
149        } catch ( \Exception $e ) {
150            if ( $tmpdir ) {
151                self::rmrf( $tmpdir );
152            }
153            throw new \RuntimeException( 'Failed to initialize WordPress test environment: ' . $e->getMessage() );
154        }
155
156        // For various tests using WorDBless, speed things up by reducing the password hashing cost.
157        add_filter( 'wp_hash_password_options', array( __CLASS__, 'reduce_password_cost' ) );
158
159        self::$initialized = true;
160    }
161
162    /**
163     * Hook for `wp_hash_password_options` to reduce the password hashing cost.
164     *
165     * Since WordPress 6.8, `wp_hash_password` uses PHP's `password_hash`, which has settings that use a lot
166     * of CPU power to hash passwords to make them harder to crack. We don't care about password security for
167     * the dummy users created in tests using WorDBless, so turn it way down to speed up the test runs.
168     *
169     * @param array $options Password options.
170     * @return array Modified options.
171     */
172    public static function reduce_password_cost( $options ) {
173        $options['cost'] = 4;
174        return $options;
175    }
176}