diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 2fa9ad7d1..d2d5e869b 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -47,6 +47,7 @@ composer lint:fix # PHPCS auto-fix yarn wp-env:e2e start # Start dedicated E2E environment (port 8702) yarn test:e2e # Run all Playwright tests (headless) yarn test:e2e:debug # Open Playwright UI for interactive debugging +yarn test:e2e:multisite # Run tests/playwright/multisite/ on its own Multisite wp-env (port 8704), started by the command ``` E2E tests live in `tests/playwright/` and run against a single wp-env instance on port 8702, in one Playwright invocation with every test in parallel (`fullyParallel`, 4 workers). The `setup-core` project (`tools/playwright/global-setup.ts`) logs in and puts the site on `/%postname%/` permalinks, then: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d4325d66e..e8a269bbd 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -313,13 +313,18 @@ jobs: signature: ":robot: PHPUnit coverage report" e2e-playwright: - name: Playwright E2E Tests (shard ${{ matrix.shard }}/${{ strategy.job-total }}) + name: Playwright E2E Tests (${{ matrix.name }}) needs: prebuild runs-on: ubuntu-latest strategy: fail-fast: false matrix: - shard: [ 1, 2, 3, 4 ] + include: + - { id: '1', name: 'shard 1/4', env: 'wp-env:e2e', config: 'e2e', test: 'test:e2e -- --shard=1/4' } + - { id: '2', name: 'shard 2/4', env: 'wp-env:e2e', config: 'e2e', test: 'test:e2e -- --shard=2/4' } + - { id: '3', name: 'shard 3/4', env: 'wp-env:e2e', config: 'e2e', test: 'test:e2e -- --shard=3/4' } + - { id: '4', name: 'shard 4/4', env: 'wp-env:e2e', config: 'e2e', test: 'test:e2e -- --shard=4/4' } + - { id: 'multisite', name: 'Multisite', env: 'wp-env:e2e-multisite', config: 'e2e-multisite', test: 'test:e2e:multisite' } steps: - name: Checkout repository @@ -329,6 +334,7 @@ jobs: uses: ./.github/actions/setup-e2e with: php-version: 7.4 + wp-env-config: tools/wp-env/${{ matrix.config }}.json - name: Log debug information run: | @@ -350,24 +356,24 @@ jobs: path: | ~/wp-env ~/.wp-env - key: wp-env-e2e-v2-${{ runner.os }}-${{ hashFiles('tools/wp-env/e2e.json', 'composer.lock') }} + key: wp-env-${{ matrix.config }}-v2-${{ runner.os }}-${{ hashFiles(format('tools/wp-env/{0}.json', matrix.config), 'composer.lock') }} - name: Install / Setup Gravity PDF + WordPress - run: yarn wp-env:e2e start + run: yarn ${{ matrix.env }} start # wp-env's work-dir is cached but its MySQL volume isn't, so a restored work-dir can pair with a fresh empty DB. # A bare `wp core install` leaves the env half-configured (GF REST never enables), so run wp-env's full reset. - name: Ensure WordPress is installed (wp-env cache fallback) run: | - if yarn wp-env:e2e run cli wp core is-installed >/dev/null 2>&1; then + if yarn ${{ matrix.env }} run cli wp core is-installed >/dev/null 2>&1; then echo "WordPress already installed." else echo "wp-env work-dir cache hit a fresh DB volume — running wp-env reset for a full install." - yarn wp-env:e2e reset + yarn ${{ matrix.env }} reset fi - name: Run the tests - run: yarn test:e2e -- --shard=${{ matrix.shard }}/${{ strategy.job-total }} + run: yarn ${{ matrix.test }} env: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: true @@ -376,24 +382,24 @@ jobs: if: success() run: | mkdir -p tmp/visual/current - tar -C tmp/visual -cf "$RUNNER_TEMP/visual-shots-${{ matrix.shard }}.tar" current + tar -C tmp/visual -cf "$RUNNER_TEMP/visual-shots-${{ matrix.id }}.tar" current - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: success() with: - name: visual-shots-${{ matrix.shard }} - path: ${{ runner.temp }}/visual-shots-${{ matrix.shard }}.tar + name: visual-shots-${{ matrix.id }} + path: ${{ runner.temp }}/visual-shots-${{ matrix.id }}.tar compression-level: 0 - name: Dump log files on failure if: failure() - run: yarn wp-env:e2e run wordpress "cp" "-r" "/var/www/html/wp-content/uploads/gravity_forms/logs/" "/var/www/html/wp-content/plugins/gravity-pdf/tmp/artifacts/logs" + run: yarn ${{ matrix.env }} run wordpress "cp" "-r" "/var/www/html/wp-content/uploads/gravity_forms/logs/" "/var/www/html/wp-content/plugins/gravity-pdf/tmp/artifacts/logs" - name: Upload artifacts on failure uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: failure() with: - name: failure-artifacts-shard-${{ matrix.shard }} + name: failure-artifacts-shard-${{ matrix.id }} path: tmp/artifacts addon-smoke: diff --git a/.github/workflows/update-dependencies.yml b/.github/workflows/update-dependencies.yml index 15a2825e2..aef655e58 100644 --- a/.github/workflows/update-dependencies.yml +++ b/.github/workflows/update-dependencies.yml @@ -115,6 +115,7 @@ jobs: tools/wp-env/development.json tools/wp-env/integration.json tools/wp-env/e2e.json + tools/wp-env/e2e-multisite.json commit-message: "chore(deps): weekly bump of WordPress core + Gravity Forms" title: "chore(deps): weekly bump of WordPress core + Gravity Forms" body: ${{ steps.body.outputs.text }} diff --git a/package.json b/package.json index 06f71ae8f..8689633dd 100644 --- a/package.json +++ b/package.json @@ -64,12 +64,14 @@ "wp-env": "WP_ENV_PORT=8700 wp-env --config=./tools/wp-env/development.json", "wp-env:integration": "WP_ENV_PORT=8701 wp-env --config=./tools/wp-env/integration.json", "wp-env:e2e": "WP_ENV_PORT=8702 wp-env --config=./tools/wp-env/e2e.json", + "wp-env:e2e-multisite": "WP_ENV_PORT=8704 wp-env --config=./tools/wp-env/e2e-multisite.json", "wp-env:addons": "WP_ENV_PORT=8703 wp-env --config=./tmp/addons/wp-env.json", "test:php": "yarn wp-env:integration run wordpress env PHP_IDE_CONFIG=serverName=localhost /var/www/html/wp-content/plugins/gravity-pdf/vendor/bin/phpunit -- -c /var/www/html/wp-content/plugins/gravity-pdf/tools/phpunit/config.xml", "test:php:snapshots": "yarn wp-env:integration run wordpress env GPDF_UPDATE_SNAPSHOTS=1 /var/www/html/wp-content/plugins/gravity-pdf/vendor/bin/phpunit -- -c /var/www/html/wp-content/plugins/gravity-pdf/tools/phpunit/config.xml --group snapshot", "test:php:multisite": "yarn wp-env:integration run wordpress env PHP_IDE_CONFIG=serverName=localhost /var/www/html/wp-content/plugins/gravity-pdf/vendor/bin/phpunit -- -c /var/www/html/wp-content/plugins/gravity-pdf/tools/phpunit/config-multisite.xml", "test:e2e": "wp-scripts test-playwright --config tools/playwright/config.ts", "test:e2e:debug": "wp-scripts test-playwright --config tools/playwright/config.ts --ui", + "test:e2e:multisite": "wp-scripts test-playwright --config tools/playwright/config-multisite.ts", "visual:compare": "LOST_PIXEL_CONFIG_DIR=tools/lost-pixel LOST_PIXEL_DISABLE_TELEMETRY=1 lost-pixel", "visual:prune": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON tools/lost-pixel/prune-unchanged-baselines.mts", "test:js": "jest --verbose", diff --git a/src/Controller/Controller_Font_Downloader.php b/src/Controller/Controller_Font_Downloader.php index a68870750..442334c9a 100644 --- a/src/Controller/Controller_Font_Downloader.php +++ b/src/Controller/Controller_Font_Downloader.php @@ -72,6 +72,7 @@ public function init() { */ public function add_actions() { add_action( Model_Font_Downloader::SYNC_EVENT, [ $this->model, 'sync' ] ); + add_action( Model_Font_Downloader::CLEANUP_EVENT, [ $this->model, 'cleanup' ] ); add_action( 'gfpdf_plugin_installed', [ $this->model, 'schedule_sync' ] ); add_action( 'gfpdf_version_changed', [ $this->model, 'schedule_sync' ] ); diff --git a/src/Controller/Controller_Upgrade_Routines.php b/src/Controller/Controller_Upgrade_Routines.php index 3fa02e57d..b0ff4ad9c 100644 --- a/src/Controller/Controller_Upgrade_Routines.php +++ b/src/Controller/Controller_Upgrade_Routines.php @@ -102,11 +102,10 @@ public function maybe_run_upgrade( $old_version, $current_version ): void { /* A site upgraded from before Arimo was the default keeps DejaVu Sans Condensed, even if it never saved one */ if ( $old_version !== '' && version_compare( $old_version, '6.19.0', '<' ) ) { - $this->save_unset_default_font( 'dejavusanscondensed' ); + $this->save_unset_default_font( Model_Font_Downloader::PRE_6_19_DEFAULT_FONT ); $this->adopt_font_source_records(); - /* After the default font save, so the font it saves is kept */ - $this->remove_unneeded_core_fonts(); + $this->schedule_core_font_cleanup(); } if ( version_compare( $current_version, '7.0.0-beta1', '>=' ) && version_compare( $old_version, '7.0.0-beta1', '<' ) ) { @@ -180,22 +179,16 @@ protected function adopt_font_source_records(): void { } /** - * Delete the core fonts a new install wouldn't have; any still needed download again on use. A network shares one - * fonts folder, so a Multisite prunes it once, for the site whose request runs the upgrade. + * Queue the removal of the core fonts a new install wouldn't have. It keeps the fonts every site needs and runs in + * the background, as every site on every network shares the fonts folder. * * @since 6.19.0 */ - protected function remove_unneeded_core_fonts(): void { + protected function schedule_core_font_cleanup(): void { $downloader = \GPDFAPI::get_mvc_class( 'Model_Font_Downloader' ); - if ( ! $downloader instanceof Model_Font_Downloader ) { - return; + if ( $downloader instanceof Model_Font_Downloader ) { + $downloader->schedule_cleanup(); } - - if ( is_multisite() && ! add_site_option( Model_Font_Downloader::PRUNED, true ) ) { - return; - } - - $downloader->prune(); } /** diff --git a/src/Model/Model_Actions.php b/src/Model/Model_Actions.php index 2975bf5ed..8bc3f3fe2 100644 --- a/src/Model/Model_Actions.php +++ b/src/Model/Model_Actions.php @@ -57,13 +57,6 @@ protected static function get_removed_methods(): array { */ const UNWRITABLE_FOLDER_DISMISSAL = 'unwritable_folder_'; - /** - * The prefix each failed core font's dismissal is recorded under in `action_dismissal` - * - * @since 6.19.0 - */ - const FAILED_CORE_FONT_DISMISSAL = 'failed_core_font_'; - /** * How long dismissing the unwritable folders notice hides a folder that keeps failing * @@ -296,22 +289,13 @@ protected function get_unwritable_folder_dismissal( string $dir ): string { } /** - * The core fonts that failed to download after their retry, less the ones dismissed since + * The core fonts that failed to download after their retry, less the ones dismissed * * @return string[] * @since 6.19.0 */ public function get_undismissed_failed_core_fonts(): array { - $files = []; - - /* A dismissal covers only the failure it followed */ - foreach ( $this->get_downloader()->get_failed_files() as $file => $gave_up ) { - if ( ! $this->is_notice_already_dismissed( $this->get_failed_core_font_dismissal( $file ), $gave_up ) ) { - $files[] = $file; - } - } - - return $files; + return $this->get_downloader()->get_undismissed_failed_files(); } /** @@ -324,12 +308,12 @@ public function has_failed_core_fonts(): bool { } /** - * Dismiss each listed font; one that fails later raises the notice again + * Dismiss the listed fonts for every site, as they share the fonts folder; one that fails later raises it again * * @since 6.19.0 */ public function dismiss_failed_core_fonts(): void { - $this->dismiss_notices( array_map( [ $this, 'get_failed_core_font_dismissal' ], $this->get_undismissed_failed_core_fonts() ) ); + $this->get_downloader()->dismiss_failed_files(); } /** @@ -341,15 +325,6 @@ protected function get_downloader(): Model_Font_Downloader { return $this->downloader ?? GPDFAPI::get_mvc_class( 'Model_Font_Downloader' ); } - /** - * The ID a failed core font's dismissal is recorded under in `action_dismissal` - * - * @since 6.19.0 - */ - protected function get_failed_core_font_dismissal( string $file ): string { - return static::FAILED_CORE_FONT_DISMISSAL . md5( $file ); - } - /** * Send the user to the system report, which lists every detection behind the notice * diff --git a/src/Model/Model_Font_Downloader.php b/src/Model/Model_Font_Downloader.php index 9f3b0bc64..4e9cd1f32 100644 --- a/src/Model/Model_Font_Downloader.php +++ b/src/Model/Model_Font_Downloader.php @@ -45,14 +45,16 @@ class Model_Font_Downloader extends Helper_Abstract_Model { ]; /** - * Failed files awaiting their one retry, and those that gave up, which the admin notice lists + * Failed files awaiting their one retry, and those that gave up, which the admin notice lists. An option of the + * main network, as every site shares the fonts folder * * @since 6.19.0 */ const FAILURES = 'gfpdf_core_font_failures'; /** - * Prefix of the per-file transient holding when a download last failed, which skips the network until it expires + * Prefix of the per-file site transient holding when a download last failed, which skips the network until it + * expires * * @since 6.19.0 */ @@ -92,11 +94,25 @@ class Model_Font_Downloader extends Helper_Abstract_Model { const SYNC_BUDGET = 20; /** - * Set once a Multisite network's shared fonts folder is pruned on the upgrade to 6.19 + * Main network option holding the 6.19 cleanup's progress while it runs, then true once it's done * * @since 6.19.0 */ - const PRUNED = 'gfpdf_core_font_pruned'; + const CLEANUP = 'gfpdf_core_font_cleanup'; + + /** + * The default font of a site upgraded from before 6.19 with none saved + * + * @since 6.19.0 + */ + const PRE_6_19_DEFAULT_FONT = 'dejavusanscondensed'; + + /** + * Cron event that reads every site's fonts a batch at a time, then cleans the shared fonts folder + * + * @since 6.19.0 + */ + const CLEANUP_EVENT = 'gfpdf_core_font_cleanup_run'; /** * @var Model_Font_Sources @@ -129,9 +145,10 @@ class Model_Font_Downloader extends Helper_Abstract_Model { protected $log; /** - * When each failed file's retry is due (else 0) and, once the retry also failed, when it gave up (else 0) + * When each failed file's retry is due (else 0), once the retry also failed when it gave up (else 0), and whether + * an admin dismissed the notice listing it * - * @var array|null + * @var array|null * @since 6.19.0 */ protected $failures; @@ -298,13 +315,14 @@ public function download( string $file ): bool { * Delete the unmodified files (fonts and licences) the Core Font installer downloaded that aren't kept. A file whose * download is turned off stays, as it couldn't come back. Only files of the installer's size are hashed. * - * @param string[]|null $keep File names to keep, else the fonts the site's PDFs need + * @param string[]|null $keep File names to keep, else the fonts the site's PDFs need + * @param int $since Also keep files written after this time, as a download since then may be needed * * @return string[] The files removed * * @since 6.19.0 */ - public function prune( ?array $keep = null ): array { + public function prune( ?array $keep = null, int $since = 0 ): array { $manifest = $this->font_sources->get_legacy_manifest( 'core' ); /* Without the manifest nothing is known to be unmodified */ @@ -327,7 +345,7 @@ public function prune( ?array $keep = null ): array { $path = $this->get_font_path( $file ); clearstatcache( true, $path ); - if ( ! is_file( $path ) || filesize( $path ) !== $entry['size'] ) { + if ( ! is_file( $path ) || filesize( $path ) !== $entry['size'] || ( $since > 0 && filemtime( $path ) > $since ) ) { continue; } @@ -860,6 +878,40 @@ public function get_failed_files(): array { return $failed; } + /** + * Failed files no admin has dismissed the notice for + * + * @return string[] + * + * @since 6.19.0 + */ + public function get_undismissed_failed_files(): array { + $undismissed = array_filter( + $this->get_failures(), + static function ( array $failure ): bool { + return $failure['gave_up'] > 0 && ! $failure['dismissed']; + } + ); + + return array_map( 'strval', array_keys( $undismissed ) ); + } + + /** + * Dismiss the notice for every failed file, on every site. A file that downloads, then fails again, is listed anew + * + * @since 6.19.0 + */ + public function dismiss_failed_files(): void { + $failures = $this->get_failures(); + foreach ( $failures as $file => $failure ) { + if ( $failure['gave_up'] > 0 ) { + $failures[ $file ]['dismissed'] = true; + } + } + + $this->save_failures( $failures ); + } + /** * Forget the failures read this request * @@ -887,11 +939,6 @@ public function schedule_sync(): void { /* The trigger may have changed the required set */ delete_transient( self::SYNC_REMAINING ); - /* Stored empty, so the notice check reads it from the autoloaded options rather than querying for it */ - if ( get_site_option( self::FAILURES ) === false ) { - $this->save_failures( [] ); - } - $this->schedule_by( time() ); } @@ -910,7 +957,7 @@ public function sync(): void { array_map( [ $this, 'delete_back_off' ], $due ); $this->syncing = true; - $left = $this->download_within_budget( array_merge( $due, $missing ) ); + $left = $this->within_budget( array_merge( $due, $missing ), [ $this, 'download' ] ); $this->syncing = false; /* Includes the retries this run's first failures added */ @@ -931,45 +978,108 @@ public function sync(): void { } /** - * Have the sync run by the time given: queue it, or move one queued later forward. Never two, never later. + * Queue the 6.19 cleanup of the shared fonts folder, once per install + * + * @since 6.19.0 + */ + public function schedule_cleanup(): void { + if ( get_network_option( get_main_network_id(), self::CLEANUP ) !== false ) { + return; + } + + update_network_option( + get_main_network_id(), + self::CLEANUP, + [ + 'started' => time(), + 'sites' => $this->required_fonts->get_site_ids(), + 'keys' => [], + ] + ); + + $this->schedule_by( time(), self::CLEANUP_EVENT ); + } + + /** + * Read the fonts of the sites left until the time budget runs out, then clean the fonts folder once all are read * * @since 6.19.0 */ - protected function schedule_by( int $at ): void { - $next = wp_next_scheduled( self::SYNC_EVENT ); + public function cleanup(): void { + $state = get_network_option( get_main_network_id(), self::CLEANUP ); + if ( ! is_array( $state ) ) { + return; + } + + if ( ! is_array( $state['sites'] ?? null ) || ! is_array( $state['keys'] ?? null ) || ! is_int( $state['started'] ?? null ) ) { + $this->log->warning( 'Core font cleanup skipped: its stored progress is malformed' ); + + return; + } + + $keys = array_fill_keys( $state['keys'], true ); + $left = $this->within_budget( + $state['sites'], + function ( $site_id ) use ( &$keys ) { + /* Sites yet to upgrade will save it as their default when they do */ + $keys += array_fill_keys( $this->required_fonts->get_site_font_keys( (int) $site_id, self::PRE_6_19_DEFAULT_FONT ), true ); + } + ); + $keys = array_map( 'strval', array_keys( $keys ) ); + + if ( $left !== [] ) { + $state['sites'] = $left; + $state['keys'] = $keys; + update_network_option( get_main_network_id(), self::CLEANUP, $state ); + $this->schedule_by( time(), self::CLEANUP_EVENT ); + + return; + } + + $this->prune( $this->required_fonts->get_install_font_files( $keys ), $state['started'] ); + update_network_option( get_main_network_id(), self::CLEANUP, true ); + } + + /** + * Have the event run by the time given: queue it, or move one queued later forward. Never two, never later. + * + * @since 6.19.0 + */ + protected function schedule_by( int $at, string $event = self::SYNC_EVENT ): void { + $next = wp_next_scheduled( $event ); if ( $next !== false && $next <= $at ) { return; } if ( $next !== false ) { - wp_unschedule_event( $next, self::SYNC_EVENT ); + wp_unschedule_event( $next, $event ); } - if ( wp_schedule_single_event( $at, self::SYNC_EVENT ) !== true ) { - $this->log->warning( 'Font sync could not be scheduled' ); + if ( wp_schedule_single_event( $at, $event ) !== true ) { + $this->log->warning( 'Font event could not be scheduled', [ 'event' => $event ] ); } } /** - * Download each file until the time budget runs out + * Run the callback on each item until the time budget runs out * - * @param string[] $files + * @param mixed[] $items * - * @return string[] The files it didn't reach + * @return mixed[] The items it didn't reach * * @since 6.19.0 */ - protected function download_within_budget( array $files ): array { - $files = array_values( $files ); + protected function within_budget( array $items, callable $each ): array { + $items = array_values( $items ); $deadline = microtime( true ) + $this->sync_budget; - foreach ( $files as $i => $file ) { - /* At least one file per run, so slow hosts make progress */ + foreach ( $items as $i => $item ) { + /* At least one per run, so slow hosts make progress */ if ( $i > 0 && microtime( true ) >= $deadline ) { - return array_slice( $files, $i ); + return array_slice( $items, $i ); } - $this->download( $file ); + $each( $item ); } return []; @@ -1164,8 +1274,9 @@ protected function record_failure( string $file ): void { if ( $failure === null ) { $failures[ $file ] = [ - 'retry_at' => time() + wp_rand( 0, $this->retry_jitter ), - 'gave_up' => 0, + 'retry_at' => time() + wp_rand( 0, $this->retry_jitter ), + 'gave_up' => 0, + 'dismissed' => false, ]; $this->save_failures( $failures ); @@ -1179,10 +1290,11 @@ protected function record_failure( string $file ): void { } if ( $failure['gave_up'] === 0 ) { - /* First give-up time, which notice dismissals are compared against */ + /* The first give-up, which the notice lists */ $failures[ $file ] = [ - 'retry_at' => 0, - 'gave_up' => time(), + 'retry_at' => 0, + 'gave_up' => time(), + 'dismissed' => false, ]; $this->save_failures( $failures ); @@ -1214,23 +1326,20 @@ protected function delete_back_off( string $file ): void { } /** - * Network-wide like the fonts folder, and kept when empty - * - * @param array $failures + * @param array $failures * * @since 6.19.0 */ protected function save_failures( array $failures ): void { $this->failures = $failures; - /* Autoloaded on a single site, as the notice checks it on most admin pages */ - is_multisite() ? update_site_option( self::FAILURES, $failures ) : update_option( self::FAILURES, $failures, true ); + update_network_option( get_main_network_id(), self::FAILURES, $failures ); } /** * Failures by file, read once per request * - * @return array + * @return array * * @since 6.19.0 */ @@ -1238,7 +1347,7 @@ protected function get_failures(): array { if ( $this->failures === null ) { $this->failures = []; - $stored = get_site_option( self::FAILURES, [] ); + $stored = get_network_option( get_main_network_id(), self::FAILURES, [] ); foreach ( is_array( $stored ) ? $stored : [] as $file => $failure ) { $retry_at = is_array( $failure ) && is_int( $failure['retry_at'] ?? null ) ? $failure['retry_at'] : 0; $gave_up = is_array( $failure ) && is_int( $failure['gave_up'] ?? null ) ? $failure['gave_up'] : 0; @@ -1246,8 +1355,9 @@ protected function get_failures(): array { /* An entry with neither is waiting on nothing */ if ( is_string( $file ) && ( $retry_at > 0 || $gave_up > 0 ) ) { $this->failures[ $file ] = [ - 'retry_at' => $gave_up > 0 ? 0 : $retry_at, - 'gave_up' => $gave_up, + 'retry_at' => $gave_up > 0 ? 0 : $retry_at, + 'gave_up' => $gave_up, + 'dismissed' => ( $failure['dismissed'] ?? false ) === true, ]; } } diff --git a/src/Model/Model_Font_Sources.php b/src/Model/Model_Font_Sources.php index 730166388..c3bb0d38a 100644 --- a/src/Model/Model_Font_Sources.php +++ b/src/Model/Model_Font_Sources.php @@ -61,7 +61,7 @@ class Model_Font_Sources extends Helper_Abstract_Model { public const SUBSTITUTION_CORE_FONTS = [ 'dejavusanscondensed', 'freesans', 'garuda', 'khmeros', 'padaukbook', 'kaputaunicode', 'abyssinicasil' ]; /** - * The transient holding the language font keys with the locale they were mapped for + * The site transient holding the installed language packs' font keys, which every site shares * * @since 6.19.0 */ @@ -350,41 +350,30 @@ public function flush(): void { $this->font_data = null; $this->language_font_keys = null; $this->language_font_locales = null; - delete_transient( self::LANGUAGE_FONT_KEYS ); + delete_site_transient( self::LANGUAGE_FONT_KEYS ); } /** - * The mPDF font keys for the installed languages and the locale, cached for a day so a render doesn't scan the - * languages folder + * The mPDF font keys for the installed languages (cached for a day, network-wide) and this site's locale * * @return string[] * * @since 6.19.0 */ public function get_language_font_keys(): array { - if ( $this->language_font_keys !== null ) { - return $this->language_font_keys; - } + if ( $this->language_font_keys === null ) { + $keys = get_site_transient( self::LANGUAGE_FONT_KEYS ); + if ( ! is_array( $keys ) ) { + $keys = array_keys( $this->map_language_font_locales( (array) get_available_languages() ) ); + set_site_transient( self::LANGUAGE_FONT_KEYS, $keys, DAY_IN_SECONDS ); + } - $locale = get_locale(); - $cached = get_transient( self::LANGUAGE_FONT_KEYS ); - if ( is_array( $cached ) && ( $cached['locale'] ?? null ) === $locale && is_array( $cached['keys'] ?? null ) ) { - $this->language_font_keys = array_values( array_filter( $cached['keys'], 'is_string' ) ); + /* Per site, and may be set without its language pack */ + $locale_keys = array_keys( $this->map_language_font_locales( [ get_locale() ] ) ); - return $this->language_font_keys; + $this->language_font_keys = array_values( array_unique( array_merge( array_filter( $keys, 'is_string' ), $locale_keys ) ) ); } - $this->language_font_keys = array_map( 'strval', array_keys( $this->get_language_font_locales() ) ); - - set_transient( - self::LANGUAGE_FONT_KEYS, - [ - 'locale' => $locale, - 'keys' => $this->language_font_keys, - ], - DAY_IN_SECONDS - ); - return $this->language_font_keys; } diff --git a/src/Model/Model_Required_Fonts.php b/src/Model/Model_Required_Fonts.php index b17218bb5..fdbd7fd84 100644 --- a/src/Model/Model_Required_Fonts.php +++ b/src/Model/Model_Required_Fonts.php @@ -114,8 +114,34 @@ public function get_font_files( $key ): array { * @since 6.19.0 */ public function get_required_font_files(): array { + return $this->get_key_files( array_keys( $this->get_font_reasons() ) ); + } + + /** + * The files of the fonts sites need, plus the installed languages', as every site shares the fonts folder + * + * @param string[] $keys Font keys from get_site_font_keys() + * + * @return string[] + * + * @since 6.19.0 + */ + public function get_install_font_files( array $keys ): array { + return $this->get_key_files( array_merge( $this->font_sources->get_language_font_keys(), $keys ) ); + } + + /** + * The files of the font keys, plus every required source's + * + * @param array $keys + * + * @return string[] + * + * @since 6.19.0 + */ + protected function get_key_files( array $keys ): array { $files = []; - foreach ( array_keys( $this->get_font_reasons() ) as $key ) { + foreach ( $keys as $key ) { $files = array_merge( $files, $this->get_font_files( (string) $key ) ); } @@ -435,47 +461,38 @@ protected function get_other_site_font_counts(): array { } /** - * @return int[] Every other site of the network, or none when not a Multisite + * @return int[] Every other site, across all networks * * @since 6.19.0 */ protected function get_other_site_ids(): array { - if ( ! is_multisite() ) { - return []; - } - - return array_map( - 'intval', - get_sites( - [ - 'fields' => 'ids', - 'number' => 0, - 'site__not_in' => [ get_current_blog_id() ], - ] - ) - ); + return array_values( array_diff( $this->get_site_ids(), [ get_current_blog_id() ] ) ); } /** * The fonts a site uses (its default font, locale and PDF fonts), read from its tables by prefix rather than by * switching to it * + * @param string $unset_default The default font of a site with none saved + * * @return string[] Font keys * * @since 6.19.0 */ - public function get_site_font_keys( int $site_id ): array { - return array_map( 'strval', array_keys( iterator_to_array( $this->each_site_font_key( $site_id ) ) ) ); + public function get_site_font_keys( int $site_id, string $unset_default = '' ): array { + return array_map( 'strval', array_keys( iterator_to_array( $this->each_site_font_key( $site_id, $unset_default ) ) ) ); } /** * Each font key a site uses, the settings' before its forms' so a caller can stop without reading them * + * @param string $unset_default The default font of a site with none saved + * * @return \Generator Keyed by font key, which may repeat * * @since 6.19.0 */ - protected function each_site_font_key( int $site_id ): \Generator { + protected function each_site_font_key( int $site_id, string $unset_default = '' ): \Generator { global $wpdb; $prefix = $wpdb->get_blog_prefix( $site_id ); @@ -483,11 +500,10 @@ protected function each_site_font_key( int $site_id ): \Generator { $options = wp_list_pluck( (array) $options, 'option_value' ); $settings = maybe_unserialize( $options['gfpdf_settings'] ?? '' ); - $default = is_array( $settings ) ? ( $settings['default_font'] ?? '' ) : ''; + $default = $this->normalise_font( is_array( $settings ) ? ( $settings['default_font'] ?? '' ) : '' ); $locales = $this->font_sources->map_language_font_locales( [ $options['WPLANG'] ?? '' ] ); - foreach ( array_merge( array_map( 'strval', array_keys( $locales ) ), [ $default ] ) as $key ) { - $key = $this->normalise_font( $key ); + foreach ( array_merge( array_map( 'strval', array_keys( $locales ) ), [ $default !== '' ? $default : $unset_default ] ) as $key ) { if ( $key !== '' ) { yield $key => true; } @@ -499,6 +515,29 @@ protected function each_site_font_key( int $site_id ): \Generator { } } + /** + * Every site's ID, across all networks + * + * @return int[] + * + * @since 6.19.0 + */ + public function get_site_ids(): array { + if ( ! is_multisite() ) { + return [ get_current_blog_id() ]; + } + + return array_map( + 'intval', + get_sites( + [ + 'fields' => 'ids', + 'number' => 0, + ] + ) + ); + } + /** * A page of the stored meta of the active forms with PDFs, read raw so Gravity Forms doesn't build their fields. * Pages by ID, as OFFSET would run the LIKE over every form it skips. The LIKE compares bytes, about 6x faster than diff --git a/src/Model/Model_Uninstall.php b/src/Model/Model_Uninstall.php index 2f14e10c5..61850d8b0 100644 --- a/src/Model/Model_Uninstall.php +++ b/src/Model/Model_Uninstall.php @@ -114,14 +114,16 @@ public function uninstall_plugin() { $this->remove_plugin_network_options(); } else { $this->remove_plugin_transients(); - $this->remove_font_back_offs(); $this->remove_plugin_options(); $this->remove_plugin_form_settings(); } + $this->remove_font_state(); + /* Removes background processes */ $this->queue->clear_scheduled_events(); wp_clear_scheduled_hook( Model_Font_Downloader::SYNC_EVENT ); + wp_clear_scheduled_hook( Model_Font_Downloader::CLEANUP_EVENT ); $this->queue->clear_queue( true ); $this->queue->unlock_process(); @@ -166,27 +168,30 @@ public function remove_plugin_transients() { } delete_transient( Model_Font_Downloader::SYNC_REMAINING ); - delete_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS ); $templates = \GPDFAPI::get_templates_class(); $templates->flush_template_transient_cache(); } /** - * Remove the font downloads' back-offs, which a single site keeps in its options table (a Multisite in sitemeta) + * Remove the font state every site shares: the failed downloads and their back-offs, the language fonts and the + * 6.19 cleanup's progress * * @since 6.19.0 */ - public function remove_font_back_offs() { - global $wpdb; + public function remove_font_state(): void { + delete_network_option( get_main_network_id(), Model_Font_Downloader::FAILURES ); + delete_network_option( get_main_network_id(), Model_Font_Downloader::CLEANUP ); + delete_site_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS ); - /* phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching */ - $keys = $wpdb->get_col( - $wpdb->prepare( "SELECT option_name FROM $wpdb->options WHERE option_name LIKE %s", $wpdb->esc_like( '_site_transient_' . Model_Font_Downloader::BACK_OFF_KEY ) . '%' ) - ); + $font_sources = \GPDFAPI::get_mvc_class( 'Model_Font_Sources' ); + if ( ! $font_sources instanceof Model_Font_Sources ) { + return; + } - foreach ( $keys as $key ) { - delete_site_transient( substr( $key, strlen( '_site_transient_' ) ) ); + /* Only the manifest's files are downloaded, so only they back off */ + foreach ( array_keys( $font_sources->get_manifest() ) as $file ) { + delete_site_transient( Model_Font_Downloader::back_off_key( (string) $file ) ); } } @@ -205,7 +210,6 @@ public function remove_plugin_options() { delete_option( Model_Pdf_Cache::SWEEP_STATE_OPTION ); delete_option( Model_Pdf_Cache::PURGE_REQUEST_OPTION ); delete_option( Helper_Abstract_Fields::PROCESS_SUBMITTED_RICH_TEXT_MERGE_TAGS ); /* A Multisite keeps it in sitemeta */ - delete_option( Model_Font_Downloader::FAILURES ); /* A Multisite keeps it in sitemeta */ Deprecation::delete_stored_data(); /* Remove license API data. Deleting one by one, not with a raw DELETE, lets WordPress drop its cached copies */ @@ -222,8 +226,8 @@ public function remove_plugin_options() { } /** - * Remove the network options stored in sitemeta on a Multisite: license package caches, the rich text merge - * tags flag, the failed font downloads and their back-offs, and the 6.19 core font prune flag + * Remove the network options stored in sitemeta on a Multisite: license package caches and the rich text merge + * tags flag. * * Every extension keeps its own license cache entry (see EDD_SL_Plugin_Updater::get_network_cache_key()), but all * are network-scoped rather than per-site, so one pass clears them, and the flag's exact key, without walking the @@ -241,13 +245,9 @@ public function remove_plugin_network_options() { /* phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching */ $rows = $wpdb->get_results( $wpdb->prepare( - "SELECT site_id, meta_key FROM $wpdb->sitemeta WHERE meta_key LIKE %s OR meta_key LIKE %s OR meta_key LIKE %s OR meta_key IN ( %s, %s, %s )", + "SELECT site_id, meta_key FROM $wpdb->sitemeta WHERE meta_key LIKE %s OR meta_key = %s", $wpdb->esc_like( 'gpdf_sl_net_' ) . '%', - $wpdb->esc_like( '_site_transient_' . Model_Font_Downloader::BACK_OFF_KEY ) . '%', - $wpdb->esc_like( '_site_transient_timeout_' . Model_Font_Downloader::BACK_OFF_KEY ) . '%', - Helper_Abstract_Fields::PROCESS_SUBMITTED_RICH_TEXT_MERGE_TAGS, - Model_Font_Downloader::FAILURES, - Model_Font_Downloader::PRUNED + Helper_Abstract_Fields::PROCESS_SUBMITTED_RICH_TEXT_MERGE_TAGS ) ); diff --git a/tests/phpunit/Concerns/RecordsFontFailures.php b/tests/phpunit/Concerns/RecordsFontFailures.php index 98253f8aa..acb5f062a 100644 --- a/tests/phpunit/Concerns/RecordsFontFailures.php +++ b/tests/phpunit/Concerns/RecordsFontFailures.php @@ -19,22 +19,23 @@ trait RecordsFontFailures { * @param int $gave_up When its retry also failed, or 0 while the retry is pending * @param int|null $retry_at When the pending retry is due, defaulting to now * - * @return array{retry_at: int, gave_up: int} + * @return array{retry_at: int, gave_up: int, dismissed: bool} */ protected function failure( int $gave_up = 0, ?int $retry_at = null ): array { return [ - 'retry_at' => $gave_up > 0 ? 0 : ( $retry_at ?? time() ), - 'gave_up' => $gave_up, + 'retry_at' => $gave_up > 0 ? 0 : ( $retry_at ?? time() ), + 'gave_up' => $gave_up, + 'dismissed' => false, ]; } /** - * Store the record where the downloader keeps it, and have the shared downloader read it afresh + * Store the record where the downloader keeps it, on the main network, and have the shared downloader read it afresh * * @param mixed $failures */ protected function set_font_failures( $failures ): void { - update_site_option( Model_Font_Downloader::FAILURES, $failures ); + update_network_option( get_main_network_id(), Model_Font_Downloader::FAILURES, $failures ); GPDFAPI::get_mvc_class( 'Model_Font_Downloader' )->flush(); } @@ -44,7 +45,7 @@ protected function set_font_failures( $failures ): void { * @return mixed The stored record, or false */ protected function get_font_failures() { - return get_site_option( Model_Font_Downloader::FAILURES ); + return get_network_option( get_main_network_id(), Model_Font_Downloader::FAILURES ); } /** @@ -75,11 +76,43 @@ protected function get_back_off_expiry( string $file ): int { return (int) get_site_option( '_site_transient_timeout_' . Model_Font_Downloader::back_off_key( $file ) ); } + /** + * The stored 6.19 cleanup progress + * + * @return mixed + */ + protected function get_font_cleanup() { + return get_network_option( get_main_network_id(), Model_Font_Downloader::CLEANUP ); + } + + /** + * @param mixed $state + */ + protected function set_font_cleanup( $state ): void { + update_network_option( get_main_network_id(), Model_Font_Downloader::CLEANUP, $state ); + } + + /** + * Cleanup progress for the current site with nothing found yet, started after any fixture was written + * + * @param array $state Replaces the fields given + */ + protected function font_cleanup_state( array $state = [] ): array { + return array_merge( + [ + 'started' => time(), + 'sites' => [ get_current_blog_id() ], + 'keys' => [], + ], + $state + ); + } + /** * Delete the failure record, and have the shared downloader read it afresh */ protected function delete_font_failures(): void { - delete_site_option( Model_Font_Downloader::FAILURES ); + delete_network_option( get_main_network_id(), Model_Font_Downloader::FAILURES ); GPDFAPI::get_mvc_class( 'Model_Font_Downloader' )->flush(); } } diff --git a/tests/phpunit/Concerns/StubsFontSources.php b/tests/phpunit/Concerns/StubsFontSources.php index 847764344..95d5cd2de 100644 --- a/tests/phpunit/Concerns/StubsFontSources.php +++ b/tests/phpunit/Concerns/StubsFontSources.php @@ -19,7 +19,7 @@ trait StubsFontSources { * @param mixed $languages What get_available_languages() returns */ protected function set_languages( $languages ): void { - delete_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS ); + delete_site_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS ); add_filter( 'get_available_languages', static function () use ( $languages ) { @@ -34,7 +34,7 @@ static function () use ( $languages ) { * @param mixed $locale What get_locale() returns */ protected function set_locale( $locale ): void { - delete_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS ); + delete_site_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS ); add_filter( 'locale', static function () use ( $locale ) { diff --git a/tests/phpunit/Concerns/SwitchesNetwork.php b/tests/phpunit/Concerns/SwitchesNetwork.php new file mode 100644 index 000000000..30527a4b3 --- /dev/null +++ b/tests/phpunit/Concerns/SwitchesNetwork.php @@ -0,0 +1,34 @@ +network->create( + [ + 'domain' => 'other-network.test', + 'path' => '/', + ] + ); + $original = $current_site; + + $current_site = get_network( $network ); + + try { + $callback( $network ); + } finally { + $current_site = $original; + } + } +} diff --git a/tests/phpunit/integration/Controller/Test_Controller_Actions.php b/tests/phpunit/integration/Controller/Test_Controller_Actions.php index e95396daf..5cf43f396 100644 --- a/tests/phpunit/integration/Controller/Test_Controller_Actions.php +++ b/tests/phpunit/integration/Controller/Test_Controller_Actions.php @@ -37,9 +37,14 @@ public function set_up(): void { } public function tear_down(): void { - unset( $_POST['gfpdf_action'], $_POST['gfpdf-dismiss-notice'], $_GET['page'] ); + unset( $_POST['gfpdf_action'], $_POST['gfpdf_action_failed_core_fonts'], $_POST['gfpdf-dismiss-notice'], $_GET['page'] ); $this->delete_font_failures(); + /* A subsite leaves these behind */ + global $wp_settings_errors, $wp_rewrite; + $wp_settings_errors = []; + $wp_rewrite->init(); + parent::tear_down(); } @@ -233,9 +238,12 @@ function () { $model->dismiss_failed_core_fonts(); $this->assertStringNotContainsString( 'could not download these fonts', $this->render_notices() ); + $this->assertTrue( $this->get_font_failures()['Garuda.ttf']['dismissed'] ); - /* A font that fails later raises it again, for that font alone */ - $this->set_font_failures( [ 'Garuda.ttf' => $this->failure( time() - 10 ), 'FreeSans.ttf' => $this->failure( time() + 1 ) ] ); + /* A font that gives up later raises it again, for that font alone */ + $failures = $this->get_font_failures(); + $failures['FreeSans.ttf'] = $this->failure( time() ); + $this->set_font_failures( $failures ); $this->assertSame( [ 'FreeSans.ttf' ], $model->get_undismissed_failed_core_fonts() ); /* A font that downloads leaves the notice, which goes once none are left */ @@ -276,6 +284,75 @@ function () { ); } + public function test_dismissing_the_failed_core_fonts_notice_dismisses_it_on_every_site() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite only' ); + } + + global $gfpdf; + + $model = $gfpdf->singleton->get_class( 'Model_Actions' ); + $this->set_font_failures( [ 'Garuda.ttf' => $this->failure( time() ) ] ); + $model->dismiss_failed_core_fonts(); + + switch_to_blog( self::factory()->blog->create() ); + try { + \GPDFAPI::get_mvc_class( 'Model_Font_Downloader' )->flush(); + $this->assertFalse( $model->has_failed_core_fonts() ); + } finally { + restore_current_blog(); + } + } + + public function test_the_dismiss_request_dismisses_the_failed_core_fonts_notice() { + $this->set_font_failures( [ 'Garuda.ttf' => $this->failure( time() ) ] ); + wp_set_current_user( self::factory()->user->create( [ 'role' => 'administrator' ] ) ); + + $this->post_failed_core_fonts_dismissal(); + $this->controller->route(); + + $this->assertTrue( $this->get_font_failures()['Garuda.ttf']['dismissed'] ); + } + + public function test_a_bad_nonce_leaves_the_failed_core_fonts_notice_undismissed() { + $this->set_font_failures( [ 'Garuda.ttf' => $this->failure( time() ) ] ); + wp_set_current_user( self::factory()->user->create( [ 'role' => 'administrator' ] ) ); + + $this->post_failed_core_fonts_dismissal( 'not-a-nonce' ); + $this->controller->route(); + + $this->assertFalse( $this->get_font_failures()['Garuda.ttf']['dismissed'] ); + } + + public function test_a_user_who_cannot_edit_the_settings_cannot_dismiss_the_failed_core_fonts_notice() { + $this->set_font_failures( [ 'Garuda.ttf' => $this->failure( time() ) ] ); + wp_set_current_user( self::factory()->user->create( [ 'role' => 'editor' ] ) ); + + $this->post_failed_core_fonts_dismissal(); + + try { + $this->controller->route(); + $this->fail( 'The request should have been refused' ); + } catch ( \WPDieException $e ) { + $this->assertFalse( $this->get_font_failures()['Garuda.ttf']['dismissed'] ); + } + } + + public function test_a_dismiss_request_with_nothing_failed_stores_nothing() { + wp_set_current_user( self::factory()->user->create( [ 'role' => 'administrator' ] ) ); + + $this->post_failed_core_fonts_dismissal(); + $this->controller->route(); + + $this->assertFalse( $this->get_font_failures() ); + } + + private function post_failed_core_fonts_dismissal( ?string $nonce = null ): void { + $_POST['gfpdf_action'] = 'gfpdf_failed_core_fonts'; + $_POST['gfpdf_action_failed_core_fonts'] = $nonce ?? wp_create_nonce( 'gfpdf_action_failed_core_fonts' ); + $_POST['gfpdf-dismiss-notice'] = '1'; + } + /** * Run $test as a new user with the role, on the admin screen given, then restore the screen and the notices */ diff --git a/tests/phpunit/integration/Controller/Test_Controller_Font_Downloader.php b/tests/phpunit/integration/Controller/Test_Controller_Font_Downloader.php index 20bf103ff..0b47f893c 100644 --- a/tests/phpunit/integration/Controller/Test_Controller_Font_Downloader.php +++ b/tests/phpunit/integration/Controller/Test_Controller_Font_Downloader.php @@ -70,6 +70,7 @@ public function tear_down(): void { public function test_hooks() { $this->assertInstanceOf( Model_Font_Downloader::class, $this->model ); $this->assertSame( 10, has_action( Model_Font_Downloader::SYNC_EVENT, [ $this->model, 'sync' ] ) ); + $this->assertSame( 10, has_action( Model_Font_Downloader::CLEANUP_EVENT, [ $this->model, 'cleanup' ] ) ); $this->assertSame( 10, has_action( 'upgrader_process_complete', [ $this->controller, 'maybe_sync_translations' ] ) ); $this->assertSame( 10, has_action( 'add_option_gfpdf_settings', [ $this->controller, 'maybe_sync_default_font' ] ) ); $this->assertSame( 10, has_action( 'update_option_gfpdf_settings', [ $this->controller, 'maybe_sync_default_font' ] ) ); diff --git a/tests/phpunit/integration/Controller/Test_Controller_Upgrade_Routines.php b/tests/phpunit/integration/Controller/Test_Controller_Upgrade_Routines.php index b1540e90a..43a0c71ce 100644 --- a/tests/phpunit/integration/Controller/Test_Controller_Upgrade_Routines.php +++ b/tests/phpunit/integration/Controller/Test_Controller_Upgrade_Routines.php @@ -12,6 +12,7 @@ use GFPDF\Statics\Deprecation; use GFPDF\Tests\Concerns\CopiesFontFixtures; use GFPDF\Tests\Concerns\CreatesLegacyDownloadUrls; +use GFPDF\Tests\Concerns\RecordsFontFailures; use GFPDF\Tests\Concerns\UnregistersMvcClasses; use GFPDF\Tests\Integration\TestCase; use GFPDF_Vendor\Monolog\Handler\TestHandler; @@ -35,6 +36,7 @@ class Test_Controller_Upgrade_Routines extends TestCase { use CopiesFontFixtures; use CreatesLegacyDownloadUrls; + use RecordsFontFailures; use UnregistersMvcClasses; /** @@ -51,6 +53,11 @@ public function set_up(): void { public function tear_down(): void { $this->remove_font_fixtures(); + /* A subsite leaves these behind */ + global $wp_settings_errors, $wp_rewrite; + $wp_settings_errors = []; + $wp_rewrite->init(); + parent::tear_down(); } @@ -458,7 +465,7 @@ public function test_core_fonts_outside_the_required_set_are_removed_on_the_upgr $this->copy_fixture( 'DejaVuSerifCondensed.ttf' ); $this->copy_fixture( 'DejaVuSansCondensed.ttf' ); - do_action( 'gfpdf_version_changed', $old_version, $new_version ); + $this->upgrade( $old_version, $new_version ); $this->assertSame( ! $removed, is_file( $this->font_path( 'DejaVuSerifCondensed.ttf' ) ) ); $this->assertFileExists( $this->font_path( 'DejaVuSansCondensed.ttf' ), 'The default font the upgrade saves is kept' ); @@ -473,12 +480,13 @@ public function data_core_font_cleanup(): array { ]; } - public function test_the_upgrade_removes_a_font_the_core_font_installer_downloaded() { - $this->copy_fixture( 'DejaVuSerifCondensed-v1.0.0.ttf', 'DejaVuSerifCondensed.ttf' ); + public function test_the_upgrade_leaves_the_core_font_cleanup_to_cron() { + $this->copy_fixture( 'DejaVuSerifCondensed.ttf' ); do_action( 'gfpdf_version_changed', '6.18.0', '6.19.0' ); - $this->assertFileDoesNotExist( $this->font_path( 'DejaVuSerifCondensed.ttf' ) ); + $this->assertNotFalse( wp_next_scheduled( Model_Font_Downloader::CLEANUP_EVENT ) ); + $this->assertFileExists( $this->font_path( 'DejaVuSerifCondensed.ttf' ) ); } public function test_the_core_font_cleanup_keeps_the_required_fonts() { @@ -487,7 +495,7 @@ public function test_the_core_font_cleanup_keeps_the_required_fonts() { $this->copy_fixture( 'DejaVuSerifCondensed.ttf' ); $this->copy_fixture( 'DejaVuSansCondensed.ttf' ); - do_action( 'gfpdf_version_changed', '6.18.0', '6.19.0' ); + $this->upgrade( '6.18.0', '6.19.0' ); $this->assertFileExists( $this->font_path( 'DejaVuSerifCondensed.ttf' ) ); $this->assertFileDoesNotExist( $this->font_path( 'DejaVuSansCondensed.ttf' ) ); @@ -498,40 +506,75 @@ public function test_the_core_font_cleanup_keeps_fonts_when_downloads_are_off() add_filter( 'gfpdf_auto_install_fonts', '__return_false' ); $this->copy_fixture( 'DejaVuSerifCondensed.ttf' ); - do_action( 'gfpdf_version_changed', '6.18.0', '6.19.0' ); + $this->upgrade( '6.18.0', '6.19.0' ); $this->assertFileExists( $this->font_path( 'DejaVuSerifCondensed.ttf' ) ); } - public function test_the_core_font_cleanup_runs_once_per_network() { - if ( ! is_multisite() ) { - $this->markTestSkipped( 'Multisite only' ); - } - + public function test_the_core_font_cleanup_runs_once() { $this->set_installer_manifest(); $this->copy_fixture( 'DejaVuSerifCondensed.ttf' ); - do_action( 'gfpdf_version_changed', '6.18.0', '6.19.0' ); + $this->upgrade( '6.18.0', '6.19.0' ); $this->assertFileDoesNotExist( $this->font_path( 'DejaVuSerifCondensed.ttf' ) ); - $this->assertTrue( get_site_option( Model_Font_Downloader::PRUNED ) ); + $this->assertTrue( $this->get_font_cleanup() ); - /* Another site upgrading finds the folder already pruned */ + /* Another site upgrading, or this one again, finds the folder already cleaned */ $this->copy_fixture( 'DejaVuSerifCondensed.ttf' ); - do_action( 'gfpdf_version_changed', '6.18.0', '6.19.0' ); + $this->upgrade( '6.18.0', '6.19.0' ); $this->assertFileExists( $this->font_path( 'DejaVuSerifCondensed.ttf' ) ); } - public function test_a_single_site_cleans_its_fonts_on_each_upgrade_to_6_19() { - if ( is_multisite() ) { - $this->markTestSkipped( 'Single site only' ); + public function test_the_core_font_cleanup_keeps_every_sites_fonts() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite only' ); } $this->set_installer_manifest(); - foreach ( [ 1, 2 ] as $run ) { - $this->copy_fixture( 'DejaVuSerifCondensed.ttf' ); - do_action( 'gfpdf_version_changed', '6.18.0', '6.19.0' ); - $this->assertFileDoesNotExist( $this->font_path( 'DejaVuSerifCondensed.ttf' ), "Run $run" ); - } + $this->options->update_option( 'default_font', 'arimo' ); + update_blog_option( self::factory()->blog->create(), 'gfpdf_settings', [ 'default_font' => 'dejavuserifcondensed' ] ); + + /* A site yet to upgrade, with no default font saved, will save DejaVu Sans Condensed */ + self::factory()->blog->create(); + + $this->copy_fixture( 'DejaVuSerifCondensed.ttf' ); + $this->copy_fixture( 'DejaVuSansCondensed.ttf' ); - $this->assertFalse( get_site_option( Model_Font_Downloader::PRUNED ) ); + $this->upgrade( '6.18.0', '6.19.0' ); + + $this->assertFileExists( $this->font_path( 'DejaVuSerifCondensed.ttf' ) ); + $this->assertFileExists( $this->font_path( 'DejaVuSansCondensed.ttf' ) ); + } + + public function test_the_upgrade_queues_no_cleanup_without_the_downloader() { + $this->without_mvc_class( + 'Model_Font_Downloader', + static function () { + do_action( 'gfpdf_version_changed', '6.18.0', '6.19.0' ); + } + ); + + $this->assertFalse( wp_next_scheduled( Model_Font_Downloader::CLEANUP_EVENT ) ); + $this->assertFalse( $this->get_font_cleanup() ); + } + + public function test_another_sites_upgrade_leaves_a_cleanup_under_way_alone() { + $state = $this->font_cleanup_state( [ 'keys' => [ 'arimo' ] ] ); + $queued = time() + HOUR_IN_SECONDS; + + $this->set_font_cleanup( $state ); + wp_schedule_single_event( $queued, Model_Font_Downloader::CLEANUP_EVENT ); + + do_action( 'gfpdf_version_changed', '6.18.0', '6.19.0' ); + + $this->assertSame( $state, $this->get_font_cleanup() ); + $this->assertSame( $queued, wp_next_scheduled( Model_Font_Downloader::CLEANUP_EVENT ) ); + } + + /** + * Run the version change, then the cleanup it queues + */ + private function upgrade( string $old_version, string $new_version ): void { + do_action( 'gfpdf_version_changed', $old_version, $new_version ); + do_action( Model_Font_Downloader::CLEANUP_EVENT ); } } diff --git a/tests/phpunit/integration/Model/Test_Model_Font_Downloader.php b/tests/phpunit/integration/Model/Test_Model_Font_Downloader.php index df06b8641..0c07c6566 100644 --- a/tests/phpunit/integration/Model/Test_Model_Font_Downloader.php +++ b/tests/phpunit/integration/Model/Test_Model_Font_Downloader.php @@ -7,6 +7,7 @@ use GFPDF\Tests\Concerns\CopiesFontFixtures; use GFPDF\Tests\Concerns\RecordsFontFailures; use GFPDF\Tests\Concerns\Seamed_Font_Downloader; +use GFPDF\Tests\Concerns\SwitchesNetwork; use GFPDF\Tests\Integration\TestCase; use GFPDF_Vendor\Monolog\Handler\TestHandler; use GFPDF_Vendor\Monolog\Logger; @@ -22,6 +23,7 @@ class Test_Model_Font_Downloader extends TestCase { use CopiesFontFixtures; use RecordsFontFailures; + use SwitchesNetwork; private const FONT = 'DejaVuSerifCondensed.ttf'; @@ -51,6 +53,7 @@ public function set_up(): void { } public function tear_down(): void { + wp_clear_scheduled_hook( Model_Font_Downloader::CLEANUP_EVENT ); wp_delete_file( $this->font_path( self::FONT ) ); $this->remove_font_fixtures(); $this->delete_font_failures(); @@ -533,6 +536,248 @@ public function test_prune_without_a_fonts_folder_removes_nothing() { } } + public function test_prune_keeps_files_written_since_the_time_given() { + $this->copy_fixture( self::FONT ); + $this->copy_fixture( 'DejaVuSans.ttf' ); + touch( $this->font_path( 'DejaVuSans.ttf' ), time() - HOUR_IN_SECONDS ); + + $this->assertSame( [ 'DejaVuSans.ttf' ], $this->downloader->prune( [], time() - MINUTE_IN_SECONDS ) ); + } + + public function test_the_cleanup_reads_a_site_per_run_once_the_budget_is_spent_then_cleans_the_folder() { + GPDFAPI::get_options_class()->update_option( 'default_font', 'arimo' ); + if ( is_multisite() ) { + self::factory()->blog->create_many( 2 ); + } + + $this->copy_fixture( self::FONT ); + touch( $this->font_path( self::FONT ), time() - HOUR_IN_SECONDS ); + + $downloader = $this->seamed( [ 'budget' => 0 ] ); + $sites = GPDFAPI::get_mvc_class( 'Model_Required_Fonts' )->get_site_ids(); + $downloader->schedule_cleanup(); + + foreach ( array_keys( $sites ) as $i ) { + $this->assertSame( array_slice( $sites, $i ), $this->get_font_cleanup()['sites'] ); + $this->assertNotFalse( wp_next_scheduled( Model_Font_Downloader::CLEANUP_EVENT ) ); + $this->assertFileExists( $this->font_path( self::FONT ) ); + wp_clear_scheduled_hook( Model_Font_Downloader::CLEANUP_EVENT ); + + $downloader->cleanup(); + } + + $this->assertFalse( wp_next_scheduled( Model_Font_Downloader::CLEANUP_EVENT ) ); + $this->assertTrue( $this->get_font_cleanup() ); + $this->assertFileDoesNotExist( $this->font_path( self::FONT ) ); + } + + /** + * @dataProvider provider_cleanup_states + * + * @param mixed $state + */ + public function test_the_cleanup_is_queued_once_per_install( $state ) { + $this->copy_fixture( self::FONT ); + $this->set_font_cleanup( $state ); + + $this->downloader->schedule_cleanup(); + $this->downloader->cleanup(); + + $this->assertFalse( wp_next_scheduled( Model_Font_Downloader::CLEANUP_EVENT ) ); + $this->assertFileExists( $this->font_path( self::FONT ) ); + } + + public function provider_cleanup_states(): array { + return [ + 'done' => [ true ], + 'corrupt' => [ 'yes' ], + ]; + } + + /** + * @dataProvider provider_prune_since + */ + public function test_prune_only_keeps_a_file_written_after_the_time_given( int $since ) { + $this->copy_fixture( self::FONT ); + touch( $this->font_path( self::FONT ), 1000000000 ); + + $this->assertSame( [ self::FONT ], $this->downloader->prune( [], $since ) ); + } + + public function provider_prune_since(): array { + return [ + 'written at that very time' => [ 1000000000 ], + 'no time given' => [ 0 ], + ]; + } + + public function test_queueing_the_cleanup_records_every_site_and_when_it_started() { + $before = time(); + $sync = wp_next_scheduled( Model_Font_Downloader::SYNC_EVENT ); + + $this->downloader->schedule_cleanup(); + + $state = $this->get_font_cleanup(); + $this->assertSame( GPDFAPI::get_mvc_class( 'Model_Required_Fonts' )->get_site_ids(), $state['sites'] ); + $this->assertSame( [], $state['keys'] ); + $this->assertGreaterThanOrEqual( $before, $state['started'] ); + $this->assertNotFalse( wp_next_scheduled( Model_Font_Downloader::CLEANUP_EVENT ) ); + $this->assertSame( $sync, wp_next_scheduled( Model_Font_Downloader::SYNC_EVENT ), 'The sync has its own event' ); + } + + public function test_queueing_a_cleanup_already_under_way_changes_nothing() { + $state = $this->font_cleanup_state( [ 'sites' => [ 7 ], 'keys' => [ 'arimo' ] ] ); + $this->set_font_cleanup( $state ); + + $this->downloader->schedule_cleanup(); + + $this->assertSame( $state, $this->get_font_cleanup() ); + $this->assertFalse( wp_next_scheduled( Model_Font_Downloader::CLEANUP_EVENT ) ); + } + + public function test_queueing_the_cleanup_moves_a_later_event_forward_rather_than_adding_one() { + wp_schedule_single_event( time() + HOUR_IN_SECONDS, Model_Font_Downloader::CLEANUP_EVENT ); + + $this->downloader->schedule_cleanup(); + + $this->assertLessThanOrEqual( time(), wp_next_scheduled( Model_Font_Downloader::CLEANUP_EVENT ) ); + $this->assertCount( 1, $this->get_scheduled_events( Model_Font_Downloader::CLEANUP_EVENT ) ); + } + + public function test_a_cleanup_that_cannot_be_queued_is_logged_with_its_event() { + add_filter( 'schedule_event', '__return_false' ); + + $this->downloader->schedule_cleanup(); + + $logged = array_values( $this->log_messages( 'Font event could not be scheduled' ) ); + $this->assertCount( 1, $logged ); + $this->assertSame( Model_Font_Downloader::CLEANUP_EVENT, $logged[0]['context']['event'] ); + } + + public function test_a_run_that_cannot_requeue_itself_keeps_its_progress() { + add_filter( 'schedule_event', '__return_false' ); + $this->set_font_cleanup( $this->font_cleanup_state( [ 'sites' => [ get_current_blog_id(), 999999 ] ] ) ); + + $this->seamed( [ 'budget' => 0 ] )->cleanup(); + + $this->assertSame( [ 999999 ], $this->get_font_cleanup()['sites'] ); + } + + public function test_a_run_with_nothing_queued_does_nothing() { + $this->copy_fixture( self::FONT ); + + $this->downloader->cleanup(); + + $this->assertFileExists( $this->font_path( self::FONT ) ); + $this->assertFalse( $this->get_font_cleanup() ); + $this->assertFalse( wp_next_scheduled( Model_Font_Downloader::CLEANUP_EVENT ) ); + } + + /** + * @dataProvider provider_malformed_cleanup_states + */ + public function test_a_malformed_cleanup_record_cleans_nothing( array $state ) { + $this->copy_fixture( self::FONT ); + $this->set_font_cleanup( $state ); + + $this->downloader->cleanup(); + + $this->assertFileExists( $this->font_path( self::FONT ) ); + $this->assertSame( $state, $this->get_font_cleanup() ); + $this->assertFalse( wp_next_scheduled( Model_Font_Downloader::CLEANUP_EVENT ) ); + $this->assertCount( 1, $this->log_messages( 'Core font cleanup skipped: its stored progress is malformed' ) ); + } + + public function provider_malformed_cleanup_states(): array { + $state = [ + 'started' => 1000000000, + 'sites' => [ 1 ], + 'keys' => [], + ]; + + return [ + 'empty' => [ [] ], + 'no sites' => [ array_diff_key( $state, [ 'sites' => true ] ) ], + 'no keys' => [ array_diff_key( $state, [ 'keys' => true ] ) ], + 'no start time' => [ array_diff_key( $state, [ 'started' => true ] ) ], + 'sites not a list' => [ array_merge( $state, [ 'sites' => '1' ] ) ], + 'keys not a list' => [ array_merge( $state, [ 'keys' => 'arimo' ] ) ], + 'start not a time' => [ array_merge( $state, [ 'started' => 'yesterday' ] ) ], + ]; + } + + public function test_the_last_run_keeps_the_fonts_earlier_runs_found() { + $this->copy_fixture( self::FONT ); + $this->copy_fixture( 'DejaVuSans.ttf' ); + touch( $this->font_path( self::FONT ), time() - HOUR_IN_SECONDS ); + touch( $this->font_path( 'DejaVuSans.ttf' ), time() - HOUR_IN_SECONDS ); + $this->set_font_cleanup( $this->font_cleanup_state( [ 'keys' => [ 'dejavuserifcondensed' ] ] ) ); + + $this->downloader->cleanup(); + + $this->assertFileExists( $this->font_path( self::FONT ) ); + $this->assertFileDoesNotExist( $this->font_path( 'DejaVuSans.ttf' ) ); + $this->assertTrue( $this->get_font_cleanup() ); + } + + public function test_a_partial_run_adds_the_fonts_it_found_to_those_already_found() { + GPDFAPI::get_options_class()->update_option( 'default_font', 'dejavuserifcondensed' ); + $this->set_font_cleanup( $this->font_cleanup_state( [ 'sites' => [ get_current_blog_id(), get_current_blog_id(), 999999 ], 'keys' => [ 'arimo' ] ] ) ); + + /* The second run finds the same font again */ + $downloader = $this->seamed( [ 'budget' => 0 ] ); + $downloader->cleanup(); + $downloader->cleanup(); + + $keys = $this->get_font_cleanup()['keys']; + $this->assertContains( 'arimo', $keys ); + $this->assertSame( 1, array_count_values( $keys )['dejavuserifcondensed'] ); + $this->assertNotFalse( wp_next_scheduled( Model_Font_Downloader::CLEANUP_EVENT ) ); + } + + public function test_the_cleanup_carries_on_past_a_site_deleted_since_it_was_queued() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite only' ); + } + + global $wpdb; + + $this->copy_fixture( self::FONT ); + touch( $this->font_path( self::FONT ), time() - HOUR_IN_SECONDS ); + $this->set_font_cleanup( $this->font_cleanup_state( [ 'sites' => [ 999999, get_current_blog_id() ] ] ) ); + + $suppress = $wpdb->suppress_errors(); + try { + $this->downloader->cleanup(); + } finally { + $wpdb->suppress_errors( $suppress ); + } + + $this->assertTrue( $this->get_font_cleanup() ); + $this->assertFileDoesNotExist( $this->font_path( self::FONT ) ); + } + + public function test_another_network_keeps_its_font_state_on_the_main_network() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite only' ); + } + + $this->on_another_network( + function ( int $network ) { + $site = self::factory()->blog->create( [ 'network_id' => $network ] ); + + $this->fail_font_downloads(); + $this->downloader->download( self::FONT ); + $this->downloader->schedule_cleanup(); + + $this->assertSame( [ self::FONT ], array_keys( $this->get_font_failures() ) ); + $this->assertContains( $site, $this->get_font_cleanup()['sites'] ); + $this->assertFalse( get_network_option( $network, Model_Font_Downloader::FAILURES ) ); + $this->assertFalse( get_network_option( $network, Model_Font_Downloader::CLEANUP ) ); + } + ); + } + /** * A deliberate trade-off: a Font Manager record pointing at a core font's file loses it, and mPDF downloads it * again by name when the font is next used @@ -572,7 +817,7 @@ public function test_a_failed_schedule_is_logged() { $this->downloader->schedule_sync(); $this->assertSame( [], $this->get_scheduled_args() ); - $this->assertCount( 1, $this->log_messages( 'Font sync could not be scheduled' ) ); + $this->assertCount( 1, $this->log_messages( 'Font event could not be scheduled' ) ); } public function test_sync_downloads_the_required_set_as_it_is_when_it_runs() { @@ -853,7 +1098,7 @@ public function test_a_retry_that_cannot_be_scheduled_is_logged() { $this->downloader->download( self::FONT ); - $this->assertCount( 1, $this->log_messages( 'Font sync could not be scheduled' ) ); + $this->assertCount( 1, $this->log_messages( 'Font event could not be scheduled' ) ); } /** @@ -920,36 +1165,89 @@ public function test_an_expired_back_off_leaves_the_retry_state() { $this->assertSame( [ self::FONT ], array_keys( $downloader->get_failed_files() ) ); } - public function test_a_single_site_autoloads_the_failures_but_not_the_back_off() { - if ( is_multisite() ) { - $this->markTestSkipped( 'A Multisite keeps them in sitemeta' ); - } - + public function test_the_failures_are_kept_on_the_main_network() { $this->fail_font_downloads(); $this->downloader->download( self::FONT ); - $this->assertContains( $this->get_autoload( Model_Font_Downloader::FAILURES ), [ 'yes', 'on' ] ); - $this->assertContains( $this->get_autoload( '_site_transient_' . Model_Font_Downloader::back_off_key( self::FONT ) ), [ 'no', 'off' ] ); + $this->assertSame( [ self::FONT ], array_keys( get_network_option( get_main_network_id(), Model_Font_Downloader::FAILURES ) ) ); } - public function test_scheduling_a_sync_stores_an_empty_failure_record() { - $this->delete_font_failures(); + public function test_dismissing_hides_the_failed_files_until_one_fails_again() { + $this->set_font_failures( + [ + self::FONT => $this->failure( time() - 10 ), + 'Garuda.ttf' => $this->failure(), + ] + ); - $this->downloader->schedule_sync(); + $this->assertSame( [ self::FONT ], $this->downloader->get_undismissed_failed_files() ); - $this->assertSame( [], $this->get_font_failures() ); + $this->downloader->dismiss_failed_files(); + $this->assertSame( [], $this->downloader->get_undismissed_failed_files() ); + $this->assertSame( [ self::FONT ], array_keys( $this->downloader->get_failed_files() ) ); + + /* Read afresh, as another site would */ + $this->assertSame( [], $this->new_downloader()->get_undismissed_failed_files() ); + + /* A font that gives up later is listed */ + $this->set_font_failures( $this->get_font_failures() + [ 'FreeSans.ttf' => $this->failure( time() ) ] ); + $this->assertSame( [ 'FreeSans.ttf' ], $this->new_downloader()->get_undismissed_failed_files() ); } - /** - * Read an option's autoload flag straight from the database - * - * @return string|null The option's autoload value - */ - private function get_autoload( string $option ): ?string { - global $wpdb; + public function test_only_a_true_dismissal_counts() { + $this->set_font_failures( [ self::FONT => [ 'retry_at' => 0, 'gave_up' => time(), 'dismissed' => 'yes' ] ] ); + + $this->assertSame( [ self::FONT ], $this->new_downloader()->get_undismissed_failed_files() ); + } + + public function test_dismissing_leaves_a_file_waiting_on_its_retry_to_be_listed_if_it_gives_up() { + $this->set_font_failures( + [ + self::FONT => $this->failure(), + 'Garuda.ttf' => $this->failure( time() - 10 ), + ] + ); + + $this->downloader->dismiss_failed_files(); + + $record = $this->get_font_failures(); + $this->assertFalse( $record[ self::FONT ]['dismissed'] ); + $this->assertTrue( $record['Garuda.ttf']['dismissed'] ); + + $this->fail_font_downloads(); + $downloader = $this->new_downloader(); + $downloader->download( self::FONT ); + + $this->assertSame( [ self::FONT ], $downloader->get_undismissed_failed_files() ); + } + + public function test_a_dismissed_file_that_keeps_failing_stays_dismissed() { + $this->set_font_failures( [ self::FONT => array_merge( $this->failure( time() - 10 ), [ 'dismissed' => true ] ) ] ); + + $this->fail_font_downloads(); + $downloader = $this->new_downloader(); + $downloader->download( self::FONT ); + + $this->assertSame( [], $downloader->get_undismissed_failed_files() ); + $this->assertSame( [ self::FONT ], array_keys( $downloader->get_failed_files() ) ); + } + + public function test_a_dismissed_file_that_downloads_then_gives_up_again_is_listed() { + $this->set_font_failures( [ self::FONT => array_merge( $this->failure( time() - 10 ), [ 'dismissed' => true ] ) ] ); + + $this->serve_font_downloads(); + $this->assertTrue( $this->new_downloader()->download( self::FONT ) ); + $this->assertSame( [], $this->get_font_failures() ); + wp_delete_file( $this->font_path( self::FONT ) ); + + remove_all_filters( 'pre_http_request' ); + $this->fail_font_downloads(); + $downloader = $this->new_downloader(); + $downloader->download( self::FONT ); + $this->delete_back_off( self::FONT ); + $downloader->download( self::FONT ); - /* phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching */ - return $wpdb->get_var( $wpdb->prepare( "SELECT autoload FROM $wpdb->options WHERE option_name = %s", $option ) ); + $this->assertSame( [ self::FONT ], $downloader->get_undismissed_failed_files() ); } /** @@ -1047,14 +1345,14 @@ private function get_scheduled(): array { } /** - * Each queued sync event, read from the cron array + * Each queued event of the hook, the sync's by default, read from the cron array * * @return array */ - private function get_scheduled_events(): array { + private function get_scheduled_events( string $hook = Model_Font_Downloader::SYNC_EVENT ): array { $events = []; foreach ( _get_cron_array() ?: [] as $time => $hooks ) { - foreach ( $hooks[ Model_Font_Downloader::SYNC_EVENT ] ?? [] as $event ) { + foreach ( $hooks[ $hook ] ?? [] as $event ) { $events[] = [ 'time' => (int) $time, 'args' => $event['args'], diff --git a/tests/phpunit/integration/Model/Test_Model_Font_Sources.php b/tests/phpunit/integration/Model/Test_Model_Font_Sources.php index 5e0f70112..43b24c111 100644 --- a/tests/phpunit/integration/Model/Test_Model_Font_Sources.php +++ b/tests/phpunit/integration/Model/Test_Model_Font_Sources.php @@ -532,7 +532,7 @@ public function test_the_locale_counts_as_a_language() { $this->assertSame( [ 'unbatang' ], $this->stub_font_sources()->get_language_font_keys() ); } - public function test_language_font_keys_are_cached_for_the_locale() { + public function test_the_installed_languages_font_keys_are_cached_for_every_site() { $this->set_languages( [ 'th' ] ); $this->stub_font_sources()->get_language_font_keys(); @@ -549,7 +549,9 @@ static function () use ( &$reads ) { $this->assertSame( [ 'garuda' ], $this->stub_font_sources()->get_language_font_keys() ); $this->assertSame( 0, $reads ); - /* Another locale maps again */ + $this->assertSame( [ 'garuda' ], get_site_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS ) ); + + /* The locale is per site, so it's mapped on top of the cache */ add_filter( 'locale', static function () { @@ -558,7 +560,51 @@ static function () { ); $this->assertSame( [ 'garuda', 'sun-exta' ], $this->stub_font_sources()->get_language_font_keys() ); - $this->assertSame( 1, $reads ); + $this->assertSame( 0, $reads ); + $this->assertSame( [ 'garuda' ], get_site_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS ) ); + } + + /** + * @dataProvider provider_corrupt_language_caches + * + * @param mixed $cached + */ + public function test_a_corrupt_language_font_cache_is_rebuilt( $cached ) { + $this->set_languages( [ 'th' ] ); + set_site_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS, $cached, DAY_IN_SECONDS ); + + $this->assertSame( [ 'garuda' ], $this->stub_font_sources()->get_language_font_keys() ); + $this->assertSame( [ 'garuda' ], get_site_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS ) ); + } + + public function provider_corrupt_language_caches(): array { + return [ + 'text' => [ 'garuda' ], + 'number' => [ 5 ], + 'true' => [ true ], + ]; + } + + public function test_only_font_keys_are_read_from_the_language_font_cache() { + $this->set_languages( [] ); + set_site_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS, [ 'garuda', 5, null, [ 'sun-exta' ] ], DAY_IN_SECONDS ); + + $this->assertSame( [ 'garuda' ], $this->stub_font_sources()->get_language_font_keys() ); + } + + public function test_a_locale_whose_font_is_already_cached_does_not_repeat_it() { + $this->set_locale( 'th' ); + set_site_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS, [ 'garuda' ], DAY_IN_SECONDS ); + + $this->assertSame( [ 'garuda' ], $this->stub_font_sources()->get_language_font_keys() ); + } + + public function test_a_locale_without_its_language_pack_still_counts_but_is_not_cached() { + $this->set_languages( [] ); + $this->set_locale( 'th' ); + + $this->assertSame( [ 'garuda' ], $this->stub_font_sources()->get_language_font_keys() ); + $this->assertSame( [], get_site_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS ) ); } public function test_a_language_pack_install_flushes_the_language_fonts() { @@ -566,7 +612,7 @@ public function test_a_language_pack_install_flushes_the_language_fonts() { } public function test_language_font_keys_are_read_once_until_flushed() { - delete_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS ); + delete_site_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS ); $reads = 0; add_filter( 'get_available_languages', diff --git a/tests/phpunit/integration/Model/Test_Model_Required_Fonts.php b/tests/phpunit/integration/Model/Test_Model_Required_Fonts.php index 4aea9bfe1..e273e93a6 100644 --- a/tests/phpunit/integration/Model/Test_Model_Required_Fonts.php +++ b/tests/phpunit/integration/Model/Test_Model_Required_Fonts.php @@ -6,6 +6,7 @@ use GFPDF\Tests\Concerns\CopiesFontFixtures; use GFPDF\Tests\Concerns\StubsFontSources; +use GFPDF\Tests\Concerns\SwitchesNetwork; use GFPDF\Tests\Integration\TestCase; use GFPDF_Vendor\Monolog\Handler\TestHandler; use GFPDF_Vendor\Monolog\Logger; @@ -20,9 +21,12 @@ class Test_Model_Required_Fonts extends TestCase { use CopiesFontFixtures; use StubsFontSources; + use SwitchesNetwork; private const FONT = 'DejaVuSerifCondensed.ttf'; + private const GARUDA = [ 'Garuda.ttf', 'Garuda-Bold.ttf', 'Garuda-Oblique.ttf', 'Garuda-BoldOblique.ttf' ]; + /** * @var Model_Required_Fonts */ @@ -38,6 +42,11 @@ public function tear_down(): void { $this->remove_font_fixtures(); GPDFAPI::get_options_class()->delete_option( 'default_font' ); + /* A subsite leaves these behind */ + global $wp_settings_errors, $wp_rewrite; + $wp_settings_errors = []; + $wp_rewrite->init(); + parent::tear_down(); } @@ -476,6 +485,219 @@ public function test_a_site_gravity_forms_has_not_run_on_has_no_pdf_fonts() { $this->assertSame( [ 'freeserif' ], $this->required_with( $this->stub_font_sources() )->get_site_font_keys( get_current_blog_id() ) ); } + public function test_on_its_own_a_site_requires_the_same_files_as_across_all_sites() { + GPDFAPI::get_options_class()->update_option( 'default_font', 'dejavuserif' ); + $this->set_languages( [ 'th' ] ); + $this->add_form_with_pdfs( [ [ 'font' => 'freeserif' ] ] ); + + $required = $this->required_with( $this->stub_font_sources() ); + $files = $required->get_required_font_files(); + + $this->assertCount( 12, $files ); + $this->assertEqualsCanonicalizing( $files, $this->all_sites_files( 'dejavusanscondensed', $required ) ); + } + + /** + * @dataProvider provider_unset_default_fonts + * + * @param mixed $settings + */ + public function test_a_site_without_a_saved_default_font_requires_the_one_given( $settings ) { + update_option( 'gfpdf_settings', $settings ); + + $this->assertSame( [], $this->all_sites_files() ); + $this->assertSame( + [ 'DejaVuSansCondensed.ttf', 'DejaVuSansCondensed-Bold.ttf', 'DejaVuSansCondensed-Oblique.ttf', 'DejaVuSansCondensed-BoldOblique.ttf' ], + $this->all_sites_files( 'dejavusanscondensed' ) + ); + } + + public function provider_unset_default_fonts(): array { + return [ + 'none saved' => [ [ 'debug_mode' => 'No' ] ], + 'an empty font' => [ [ 'default_font' => '' ] ], + 'corrupt settings' => [ 'arimo' ], + ]; + } + + public function test_every_sites_fonts_are_required_across_all_sites() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite only' ); + } + + GPDFAPI::get_options_class()->update_option( 'default_font', 'arimo' ); + update_blog_option( self::factory()->blog->create(), 'gfpdf_settings', [ 'default_font' => 'dejavuserif' ] ); + + /* A site Gravity Forms hasn't run on has no form tables; its locale has no language pack */ + $no_forms = self::factory()->blog->create(); + update_blog_option( $no_forms, 'gfpdf_settings', [ 'default_font' => 'arimo' ] ); + /* Raw, as saving WPLANG drops a locale without its pack */ + $GLOBALS['wpdb']->replace( $GLOBALS['wpdb']->get_blog_prefix( $no_forms ) . 'options', [ 'option_name' => 'WPLANG', 'option_value' => 'th' ] ); + + switch_to_blog( self::factory()->blog->create() ); + try { + gf_upgrade()->install(); + update_option( 'gfpdf_settings', [ 'default_font' => 'arimo' ] ); + $this->add_form_with_pdfs( [ [ 'font' => 'freeserif' ] ] ); + } finally { + restore_current_blog(); + } + + $this->assertSame( [], $this->required->get_required_font_files() ); + $this->assertSame( + array_merge( + [ 'DejaVuSerif.ttf', 'DejaVuSerif-Bold.ttf', 'DejaVuSerif-Italic.ttf', 'DejaVuSerif-BoldItalic.ttf' ], + self::GARUDA, + self::$freeserif + ), + $this->all_sites_files() + ); + } + + public function test_a_single_site_is_its_only_site() { + if ( is_multisite() ) { + $this->markTestSkipped( 'Single site only' ); + } + + $this->assertSame( [ get_current_blog_id() ], $this->required->get_site_ids() ); + $this->assertSame( [], $this->get_other_site_ids() ); + } + + public function test_the_site_ids_cover_every_network() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite only' ); + } + + $same_network = self::factory()->blog->create(); + + $this->on_another_network( + function ( int $network ) use ( $same_network ) { + $other_network = self::factory()->blog->create( [ 'network_id' => $network ] ); + $ids = $this->required->get_site_ids(); + + $this->assertContains( get_main_site_id( get_main_network_id() ), $ids ); + $this->assertContains( $same_network, $ids ); + $this->assertContains( $other_network, $ids ); + } + ); + } + + public function test_the_other_sites_leave_out_only_the_current_one() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite only' ); + } + + $site = self::factory()->blog->create(); + + $others = $this->get_other_site_ids(); + $this->assertContains( $site, $others ); + $this->assertNotContains( get_current_blog_id(), $others ); + $this->assertSame( array_keys( $others ), range( 0, count( $others ) - 1 ), 'A list, with no gap where the current site was' ); + } + + /** + * @dataProvider provider_saved_default_fonts + */ + public function test_a_saved_default_font_is_used_over_the_one_given( string $saved ) { + update_option( 'gfpdf_settings', [ 'default_font' => $saved ] ); + + $keys = $this->required->get_site_font_keys( get_current_blog_id(), 'dejavusanscondensed' ); + + $this->assertContains( 'dejavuserif', $keys ); + $this->assertNotContains( 'dejavusanscondensed', $keys ); + } + + public function provider_saved_default_fonts(): array { + return [ + 'as stored' => [ 'dejavuserif' ], + 'mixed case' => [ 'DejaVuSerif' ], + 'stray whitespace' => [ ' dejavuserif ' ], + ]; + } + + public function test_a_site_that_no_longer_exists_only_has_the_default_font_given() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite only' ); + } + + global $wpdb; + + $suppress = $wpdb->suppress_errors(); + try { + $this->assertSame( [ 'arimo' ], $this->required->get_site_font_keys( 999999, 'arimo' ) ); + $this->assertSame( [], $this->required->get_site_font_keys( 999999 ) ); + } finally { + $wpdb->suppress_errors( $suppress ); + } + } + + /** + * @dataProvider provider_install_font_keys + */ + public function test_the_install_font_files_are_those_of_the_keys_given( array $keys, array $expected ) { + $this->set_languages( [] ); + + $this->assertSame( $expected, $this->required_with( $this->stub_font_sources() )->get_install_font_files( $keys ) ); + } + + public function provider_install_font_keys(): array { + return [ + 'no keys' => [ [], [] ], + 'unknown font' => [ [ 'not-a-font' ], [] ], + 'a font' => [ [ 'garuda' ], self::GARUDA ], + 'a repeat' => [ [ 'garuda', 'garuda' ], self::GARUDA ], + 'mixed' => [ [ 'not-a-font', 'garuda' ], self::GARUDA ], + ]; + } + + public function test_the_install_font_files_include_the_installed_languages() { + $this->set_languages( [ 'th' ] ); + + $this->assertSame( + self::GARUDA, + $this->required_with( $this->stub_font_sources() )->get_install_font_files( [] ) + ); + } + + public function test_the_install_font_files_include_every_required_source() { + $this->set_languages( [] ); + + $sources = $this->stub_font_sources( + static function ( array $sources ): array { + $sources['fixture'] = $sources['core']; + $sources['fixture']['required'] = true; + + return $sources; + } + ); + + $this->assertContains( 'FreeMono.ttf', $this->required_with( $sources )->get_install_font_files( [] ) ); + } + + /** + * @return int[] + */ + private function get_other_site_ids(): array { + return ( function () { + return $this->get_other_site_ids(); + } )->call( $this->required ); + } + + /** + * The files every site needs, as the 6.19 cleanup gathers them + * + * @return string[] + */ + private function all_sites_files( string $unset_default = '', ?Model_Required_Fonts $required = null ): array { + $required = $required ?? $this->required; + $keys = []; + foreach ( $required->get_site_ids() as $site_id ) { + $keys = array_merge( $keys, $required->get_site_font_keys( $site_id, $unset_default ) ); + } + + return $required->get_install_font_files( $keys ); + } + /** * Each log record as its level name, message and context */ diff --git a/tests/phpunit/integration/Model/Test_Uninstaller.php b/tests/phpunit/integration/Model/Test_Uninstaller.php index 1fa3758c9..185696f8e 100644 --- a/tests/phpunit/integration/Model/Test_Uninstaller.php +++ b/tests/phpunit/integration/Model/Test_Uninstaller.php @@ -13,6 +13,8 @@ use GFPDF\Statics\Cache; use GFPDF\Statics\Deprecation_V3; use GFPDF\Tests\Concerns\RecordsFontFailures; +use GFPDF\Tests\Concerns\SwitchesNetwork; +use GFPDF\Tests\Concerns\UnregistersMvcClasses; use GFPDF\Tests\Integration\TestCase; /** @@ -33,6 +35,8 @@ class Test_Uninstaller extends TestCase { use RecordsFontFailures; + use SwitchesNetwork; + use UnregistersMvcClasses; /** * Our Controller @@ -133,14 +137,20 @@ public function test_uninstall_plugin() { $this->set_font_failures( [ 'Garuda.ttf' => $this->failure() ] ); $this->set_back_off( 'Garuda.ttf' ); set_transient( Model_Font_Downloader::SYNC_REMAINING, [ 'Garuda.ttf' ], DAY_IN_SECONDS ); + set_site_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS, [ 'garuda' ], DAY_IN_SECONDS ); + update_site_option( Model_Font_Downloader::CLEANUP, true ); + wp_schedule_single_event( time(), Model_Font_Downloader::CLEANUP_EVENT ); /* Uninstall */ $this->model->uninstall_plugin(); $this->assertFalse( wp_next_scheduled( Model_Font_Downloader::SYNC_EVENT ) ); + $this->assertFalse( wp_next_scheduled( Model_Font_Downloader::CLEANUP_EVENT ) ); $this->assertFalse( $this->get_font_failures() ); $this->assertFalse( $this->get_back_off( 'Garuda.ttf' ) ); $this->assertFalse( get_transient( Model_Font_Downloader::SYNC_REMAINING ) ); + $this->assertFalse( get_site_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS ) ); + $this->assertFalse( get_site_option( Model_Font_Downloader::CLEANUP ) ); /* Check software was uninstalled */ $this->assertDirectoryDoesNotExist( $gfpdf->data->template_location ); @@ -244,7 +254,6 @@ public function test_remove_plugin_network_options() { update_site_option( 'gpdf_sl_net_abc123', [ 'timeout' => time(), 'value' => '{}' ] ); update_site_option( Helper_Abstract_Fields::PROCESS_SUBMITTED_RICH_TEXT_MERGE_TAGS, true ); - update_site_option( Model_Font_Downloader::PRUNED, true ); $this->assertNotFalse( get_site_option( 'gpdf_sl_net_abc123' ) ); set_site_transient( 'gfpdf_cleanup_tmp_dir', 1 ); @@ -253,7 +262,6 @@ public function test_remove_plugin_network_options() { /* No cache flush here on purpose — the uninstall must invalidate what it deletes */ $this->assertFalse( get_site_option( 'gpdf_sl_net_abc123' ) ); $this->assertFalse( get_site_option( Helper_Abstract_Fields::PROCESS_SUBMITTED_RICH_TEXT_MERGE_TAGS ) ); - $this->assertFalse( get_site_option( Model_Font_Downloader::PRUNED ) ); $this->assertFalse( get_site_transient( 'gfpdf_cleanup_tmp_dir' ) ); } @@ -320,4 +328,82 @@ public function test_deactivate_plugin_wraps_string_basename_in_array() { $this->assertFalse( is_plugin_active( 'unknown/plugin.php' ) ); } + + public function test_removing_the_font_state_clears_what_every_site_shares() { + $this->store_font_state(); + + $this->model->remove_font_state(); + + $this->assert_font_records_removed(); + $this->assertFalse( $this->get_back_off( 'Garuda.ttf' ) ); + } + + public function test_removing_the_font_state_only_clears_the_back_offs_of_font_files() { + $this->set_back_off( 'not-on-the-manifest.ttf' ); + + $this->model->remove_font_state(); + + $this->assertNotFalse( $this->get_back_off( 'not-on-the-manifest.ttf' ) ); + } + + public function test_a_subsite_removes_the_font_state_every_site_shares() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite only' ); + } + + $this->store_font_state(); + + switch_to_blog( self::factory()->blog->create() ); + try { + $this->model->remove_font_state(); + } finally { + restore_current_blog(); + } + + $this->assert_font_records_removed(); + } + + public function test_another_network_removes_the_font_state_from_the_main_network() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite only' ); + } + + $this->store_font_state(); + + $this->on_another_network( + function () { + $this->model->remove_font_state(); + } + ); + + $this->assertFalse( $this->get_font_failures() ); + $this->assertFalse( $this->get_font_cleanup() ); + } + + public function test_removing_the_font_state_without_the_font_sources_still_clears_the_records() { + $this->store_font_state(); + + $this->without_mvc_class( + 'Model_Font_Sources', + function () { + $this->model->remove_font_state(); + } + ); + + $this->assert_font_records_removed(); + $this->assertNotFalse( $this->get_back_off( 'Garuda.ttf' ), 'Left to expire, as the files it could be for are unknown' ); + } + + private function store_font_state(): void { + $this->set_font_failures( [ 'Garuda.ttf' => $this->failure() ] ); + $this->set_back_off( 'Garuda.ttf' ); + $this->set_font_cleanup( $this->font_cleanup_state() ); + set_site_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS, [ 'garuda' ], DAY_IN_SECONDS ); + } + + private function assert_font_records_removed(): void { + $this->assertFalse( $this->get_font_failures() ); + $this->assertFalse( $this->get_font_cleanup() ); + $this->assertFalse( get_site_transient( Model_Font_Sources::LANGUAGE_FONT_KEYS ) ); + } } diff --git a/tests/playwright/core/system-status/core-fonts.spec.ts b/tests/playwright/core/system-status/core-fonts.spec.ts index 9afe54900..0dea23ce7 100644 --- a/tests/playwright/core/system-status/core-fonts.spec.ts +++ b/tests/playwright/core/system-status/core-fonts.spec.ts @@ -2,17 +2,13 @@ import type { Admin } from '@wordpress/e2e-test-utils-playwright'; import { expect } from '@wordpress/e2e-test-utils-playwright'; import type { Page } from '@playwright/test'; import { test } from '@self:playwright/fixtures/test'; +import { failedFontsNotice as notice } from '@self:playwright/utils/notices'; import { snapshot } from '@self:playwright/utils/snapshot'; import { wpCli } from '@self:playwright/utils/wp-cli'; // Matches the failures tools/mu-plugins reports for requests sending the header below const FAILED_FONTS = ['FreeSerif.ttf', 'Sun-ExtB.ttf']; -const notice = (page: Page) => - page.locator('.notice.notice-warning', { - hasText: 'Gravity PDF could not download these fonts.', - }); - // The fonts in FAILED_FONTS fail for the requests sending this, having given up at `timestamp` (see tools/mu-plugins) const giveUpAt = (page: Page, timestamp: number) => page.setExtraHTTPHeaders({ 'X-GPDF-E2E-Failed-Fonts': String(timestamp) }); @@ -56,7 +52,7 @@ test.describe('Core Fonts', () => { // Clears the dismissals stored by an earlier run (see tools/mu-plugins) test.beforeAll(() => wpCli( - 'wp option delete gfpdf_e2e_failed_fonts_dismissal --quiet || true' + 'wp option delete gfpdf_e2e_failed_fonts_dismissed --quiet || true' ) ); diff --git a/tests/playwright/multisite/failed-fonts-notice.spec.ts b/tests/playwright/multisite/failed-fonts-notice.spec.ts new file mode 100644 index 000000000..c64aaca06 --- /dev/null +++ b/tests/playwright/multisite/failed-fonts-notice.spec.ts @@ -0,0 +1,141 @@ +import { expect } from '@wordpress/e2e-test-utils-playwright'; +import type { Browser } from '@playwright/test'; +import { test } from '@self:playwright/fixtures/test'; +import { SUBSITE } from '@self:playwright/utils/multisite'; +import { failedFontsNotice as notice } from '@self:playwright/utils/notices'; +import { snapshot } from '@self:playwright/utils/snapshot'; +import { wpCli } from '@self:playwright/utils/wp-cli'; + +const MAIN_SITE = '/wp-admin/index.php'; +const SUBSITE_ADMIN = `/${SUBSITE}/wp-admin/index.php`; +const NETWORK_ADMIN = '/wp-admin/network/index.php'; + +const gaveUp = { retry_at: 0, gave_up: 1, dismissed: false }; + +// The real record every site reads, as base64 because wpCli() runs its commands inside double quotes +const saveFailures = (failures: Record) => + wpCli( + `echo ${Buffer.from(JSON.stringify(failures)).toString('base64')} | base64 -d | wp site option update gfpdf_core_font_failures --format=json --quiet` + ); + +// Logs in on the subsite and lands on its dashboard +const logIn = async (browser: Browser, username: string) => { + const context = await browser.newContext({ + storageState: { cookies: [], origins: [] }, + }); + const page = await context.newPage(); + + // As the page fixture does: wp-env can't reach Gravatar + await page.route('**://*.gravatar.com/**', (route) => route.abort()); + await page.goto(`/${SUBSITE}/wp-login.php`); + await page.locator('#user_login').fill(username); + await page.locator('#user_pass').fill('password'); + await page.locator('#wp-submit').click(); + await page.waitForURL(new RegExp(`/${SUBSITE}/wp-admin/`)); + + return page; +}; + +test.describe('Failed core fonts notice on a Multisite', () => { + // Every site shares the one record, and each test builds on the state the one before left it in + test.describe.configure({ mode: 'serial' }); + + test.beforeAll(() => + saveFailures({ + 'FreeSerif.ttf': gaveUp, + 'Sun-ExtB.ttf': gaveUp, + // Still waiting on its retry, so not listed + 'Garuda.ttf': { + retry_at: 4102444800, + gave_up: 0, + dismissed: false, + }, + }) + ); + + test.afterAll(() => + wpCli('wp site option delete gfpdf_core_font_failures --quiet') + ); + + test('should list the fonts that gave up on every site and in the network admin', async ({ + page, + }, testinfo) => { + const failed = notice(page); + + for (const url of [MAIN_SITE, SUBSITE_ADMIN, NETWORK_ADMIN]) { + await page.goto(url); + await expect(failed.locator('li code')).toHaveText([ + 'FreeSerif.ttf', + 'Sun-ExtB.ttf', + ]); + } + + await expect( + failed.getByRole('button', { name: 'Dismiss Notice' }) + ).toBeVisible(); + + await snapshot(page, testinfo, failed); + }); + + test('should open the system report from the notice in the network admin', async ({ + page, + }) => { + await page.goto(NETWORK_ADMIN); + await notice(page) + .getByRole('button', { name: 'View the system report' }) + .click(); + + await expect(page).toHaveURL(/admin\.php\?page=gf_system_status/); + await expect( + page.getByRole('row', { name: /^Core Fonts installed/ }) + ).toBeVisible(); + }); + + test('should not show the notice to a subsite user who cannot edit the settings', async ({ + browser, + }) => { + const page = await logIn(browser, 'siteeditor'); + + await expect(page.locator('#wpbody-content h1')).toBeVisible(); + await expect(notice(page)).toHaveCount(0); + + await page.context().close(); + }); + + test('should hide the notice everywhere once a subsite administrator dismisses it', async ({ + browser, + page, + }) => { + const siteAdmin = await logIn(browser, 'siteadmin'); + + await notice(siteAdmin) + .getByRole('button', { name: 'Dismiss Notice' }) + .click(); + await expect(notice(siteAdmin)).toHaveCount(0); + + await siteAdmin.context().close(); + + for (const url of [MAIN_SITE, NETWORK_ADMIN]) { + await page.goto(url); + await expect(page.locator('#wpbody-content h1')).toBeVisible(); + await expect(notice(page)).toHaveCount(0); + } + }); + + test('should raise the notice again for only the font that fails after a dismissal', async ({ + page, + }) => { + saveFailures({ + 'FreeSerif.ttf': { ...gaveUp, dismissed: true }, + 'Sun-ExtB.ttf': { ...gaveUp, dismissed: true }, + 'Garuda.ttf': gaveUp, + }); + + for (const url of [MAIN_SITE, SUBSITE_ADMIN, NETWORK_ADMIN]) { + await page.goto(url); + await expect(notice(page).locator('li code')).toHaveText([ + 'Garuda.ttf', + ]); + } + }); +}); diff --git a/tests/playwright/visual-baselines/multisite-multisite-failed-fonts-notice-failed-core-fonts-notice-on-a-multisite-should-list-the-fonts-that-gave-up-on-every-site-and-in-the-network-admin.png b/tests/playwright/visual-baselines/multisite-multisite-failed-fonts-notice-failed-core-fonts-notice-on-a-multisite-should-list-the-fonts-that-gave-up-on-every-site-and-in-the-network-admin.png new file mode 100644 index 000000000..09878aeda Binary files /dev/null and b/tests/playwright/visual-baselines/multisite-multisite-failed-fonts-notice-failed-core-fonts-notice-on-a-multisite-should-list-the-fonts-that-gave-up-on-every-site-and-in-the-network-admin.png differ diff --git a/tools/mu-plugins/gravitypdf.php b/tools/mu-plugins/gravitypdf.php index e17d18625..6722a6dc8 100644 --- a/tools/mu-plugins/gravitypdf.php +++ b/tools/mu-plugins/gravitypdf.php @@ -240,14 +240,15 @@ static function ( $value, $old_value ) { $gfpdf_e2e_own_dismissals( 'gfpdf_e2e_action_dismissal' ); } -/* A fixed set of failures, given up when the header says, and no writes over them */ +/* A fixed set of failures, given up when the header says; only the dismissal can change, kept apart from the real record */ if ( $gfpdf_e2e_fonts_gave_up > 0 ) { add_filter( 'pre_option_gfpdf_core_font_failures', static function () use ( $gfpdf_e2e_fonts_gave_up ) { $failure = [ - 'retry_at' => 0, - 'gave_up' => $gfpdf_e2e_fonts_gave_up, + 'retry_at' => 0, + 'gave_up' => $gfpdf_e2e_fonts_gave_up, + 'dismissed' => (int) get_option( 'gfpdf_e2e_failed_fonts_dismissed', 0 ) === $gfpdf_e2e_fonts_gave_up, ]; return [ @@ -259,14 +260,16 @@ static function () use ( $gfpdf_e2e_fonts_gave_up ) { add_filter( 'pre_update_option_gfpdf_core_font_failures', - static function ( $value, $old_value ) { + static function ( $value, $old_value ) use ( $gfpdf_e2e_fonts_gave_up ) { + if ( ( $value['FreeSerif.ttf']['dismissed'] ?? false ) === true ) { + update_option( 'gfpdf_e2e_failed_fonts_dismissed', $gfpdf_e2e_fonts_gave_up ); + } + return $old_value; }, 10, 2 ); - - $gfpdf_e2e_own_dismissals( 'gfpdf_e2e_failed_fonts_dismissal' ); } /* Adds an extension's warning to a settings save, for the spec checking its colour */ diff --git a/tools/playwright/config-multisite.ts b/tools/playwright/config-multisite.ts new file mode 100644 index 000000000..65f39609d --- /dev/null +++ b/tools/playwright/config-multisite.ts @@ -0,0 +1,51 @@ +import { defineConfig, devices } from '@playwright/test'; +import * as path from 'path'; +import config from './config'; + +import baseConfig = require('@wordpress/scripts/config/playwright.config.js'); + +// Read by wpCli(), so its commands run in this environment. Set here as workers load this file too +process.env.GPDF_WP_ENV = 'wp-env:e2e-multisite'; + +const BASE_URL = 'http://localhost:8704'; + +const STORAGE_STATE_PATH = path.join( + process.cwd(), + 'tmp/artifacts/storage-states/e2e-multisite.json' +); + +export default defineConfig({ + ...config, + + webServer: { + ...baseConfig.webServer, + command: 'yarn wp-env:e2e-multisite start', + name: 'Multisite', + port: 8704, + }, + + projects: [ + { + name: 'setup-multisite', + testDir: path.join(process.cwd(), 'tools/playwright'), + testMatch: /.*multisite-setup\.ts/, + use: { + baseURL: BASE_URL, + storageState: { cookies: [], origins: [] }, + }, + metadata: { storageStatePath: STORAGE_STATE_PATH }, + }, + + { + name: 'multisite', + dependencies: ['setup-multisite'], + testDir: path.join(process.cwd(), 'tests/playwright'), + testMatch: /multisite\/.*(test|spec).(js|ts|mjs)/, + use: { + ...devices['Desktop Chrome'], + baseURL: BASE_URL, + storageState: STORAGE_STATE_PATH, + }, + }, + ], +}); diff --git a/tools/playwright/multisite-setup.ts b/tools/playwright/multisite-setup.ts new file mode 100644 index 000000000..0fa14112c --- /dev/null +++ b/tools/playwright/multisite-setup.ts @@ -0,0 +1,32 @@ +import { test as setup } from '@playwright/test'; +import { SUBSITE } from '@self:playwright/utils/multisite'; +import { wpCli } from '@self:playwright/utils/wp-cli'; + +setup('setup', async ({ request }, testInfo) => { + const storageStatePath = testInfo.project.metadata + .storageStatePath as string; + const url = `${testInfo.project.use.baseURL}/${SUBSITE}/`.replace( + 'http://', + '' + ); + + process.env.WP_BASE_URL = testInfo.project.use.baseURL as string; + process.env.STORAGE_STATE_PATH = storageStatePath; + + const addUser = (name: string, role: string) => + `(wp user get ${name} --url=${url} --quiet || wp user create ${name} ${name}@example.com --role=${role} --user_pass=password --url=${url} --quiet)`; + + // Each command skips what an earlier local run already created + wpCli( + 'wp plugin activate gravityforms gravity-pdf --network --quiet', + `(wp site list --field=url | grep -q /${SUBSITE}/ || wp site create --slug=${SUBSITE} --title=Subsite --quiet)`, + addUser('siteadmin', 'administrator'), + addUser('siteeditor', 'editor') + ); + + const { RequestUtils } = + await import('@wordpress/e2e-test-utils-playwright'); + + // Authenticate and save the storageState to disk. + await new RequestUtils(request, { storageStatePath }).setupRest(); +}); diff --git a/tools/playwright/utils/multisite.ts b/tools/playwright/utils/multisite.ts new file mode 100644 index 000000000..adaaca5eb --- /dev/null +++ b/tools/playwright/utils/multisite.ts @@ -0,0 +1,2 @@ +// The subsite tools/playwright/multisite-setup.ts creates +export const SUBSITE = 'site2'; diff --git a/tools/playwright/utils/notices.ts b/tools/playwright/utils/notices.ts index 54dab3c45..d89f7e6c9 100644 --- a/tools/playwright/utils/notices.ts +++ b/tools/playwright/utils/notices.ts @@ -1,6 +1,15 @@ import type { Page } from '@playwright/test'; import { expect } from '@playwright/test'; +/** + * The notice listing the core fonts that failed to download + * @param page + */ +export const failedFontsNotice = (page: Page) => + page.locator('.notice.notice-warning', { + hasText: 'Gravity PDF could not download these fonts.', + }); + /** * Record any `.gf-notice` still visible outside `#gf-admin-notices-wrapper` on DOM ready, i.e. painted before it jumps * into place. Call before the navigation that shows the notice. diff --git a/tools/playwright/utils/wp-cli.ts b/tools/playwright/utils/wp-cli.ts index 96547dd07..dca72fd38 100644 --- a/tools/playwright/utils/wp-cli.ts +++ b/tools/playwright/utils/wp-cli.ts @@ -3,8 +3,11 @@ import { execSync } from 'node:child_process'; // Bounded: an unbounded execSync against a wedged Docker hangs the worker until the 6h job cap. const CLI_TIMEOUT = 120_000; +// Set by tools/playwright/config-multisite.ts +const WP_ENV = process.env.GPDF_WP_ENV ?? 'wp-env:e2e'; + /** - * Run one or more WP-CLI commands in the e2e wp-env container, stopping at the first that fails, and return their output + * Run one or more WP-CLI commands in the wp-env container under test, stopping at the first that fails, and return their output * * Pass every command to one call rather than calling this twice: each call pays a `wp-env run` round trip. * @param commands @@ -13,7 +16,7 @@ export function wpCli(...commands: string[]) { const command = commands.join(' && '); try { - return execSync(`yarn wp-env:e2e run cli bash -c "${command}"`, { + return execSync(`yarn ${WP_ENV} run cli bash -c "${command}"`, { stdio: ['ignore', 'pipe', 'pipe'], timeout: CLI_TIMEOUT, }).toString(); diff --git a/tools/wp-env/e2e-multisite.json b/tools/wp-env/e2e-multisite.json new file mode 100644 index 000000000..d5f64777c --- /dev/null +++ b/tools/wp-env/e2e-multisite.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://schemas.wp.org/trunk/wp-env.json", + "core": "WordPress/WordPress#7.1.3", + "phpVersion": "8.5", + "multisite": true, + "plugins": [], + "mappings": { + "wp-content/mu-plugins": "./tools/mu-plugins", + "wp-content/plugins/gravityforms": "./vendor/gravity/gravityforms", + "wp-content/plugins/gravity-pdf": "." + }, + "config": { + "WP_DEBUG": true, + "WP_DEBUG_DISPLAY": false, + "WP_DEBUG_LOG": "/var/www/html/wp-content/plugins/gravity-pdf/tmp/debug.log", + "TEST_SUITE": true, + "E2E_TEST_SUITE": true, + "SCRIPT_DEBUG": true, + "WP_HTTP_BLOCK_EXTERNAL": true + }, + "testsEnvironment": false, + "lifecycleScripts": { + "afterStart": "rm -rf ./tmp/artifacts", + "afterReset": "rm -rf ./tmp/artifacts" + } +} diff --git a/tools/wp-env/wp-version.mjs b/tools/wp-env/wp-version.mjs index f0670a18a..3911d82fd 100755 --- a/tools/wp-env/wp-version.mjs +++ b/tools/wp-env/wp-version.mjs @@ -17,13 +17,14 @@ import { readFileSync, writeFileSync } from 'node:fs'; const API = 'https://api.wordpress.org/core/version-check/1.7/?channel=beta&version=6.0'; -// integration.json is the canonical source for the current pin; the three configs are kept in lockstep. +// integration.json is the canonical source for the current pin; the configs are kept in lockstep. const CANONICAL = 'tools/wp-env/integration.json'; const CONFIGS = [ 'tools/wp-env/development.json', CANONICAL, 'tools/wp-env/e2e.json', + 'tools/wp-env/e2e-multisite.json', ]; const VERSION = /(\d+)\.(\d+)(?:\.(\d+))?(?:-([A-Za-z]+)(\d*))?/;