diff --git a/.github/workflows/phpunit-test.yml b/.github/workflows/phpunit-test.yml index 3fdf8f9e..d2789e82 100644 --- a/.github/workflows/phpunit-test.yml +++ b/.github/workflows/phpunit-test.yml @@ -44,6 +44,9 @@ jobs: if [ -f "src/composer.lock" ]; then cat "src/composer.lock" >> "$tmpfile" fi + if [ -f "src/composer.json" ]; then + cat "src/composer.json" >> "$tmpfile" + fi if [ -s "$tmpfile" ]; then deps_hash=$(shasum -a 1 "$tmpfile" | awk '{print $1}' | cut -c1-8) else diff --git a/.gitignore b/.gitignore index b07c6dfb..f6c368e0 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,6 @@ Thumbs.db .tmp tmp .continue -.codex \ No newline at end of file +.codex +# Local environment overrides (license keys, secrets) +/.env diff --git a/config/playwright/playwright.config.ts b/config/playwright/playwright.config.ts index b9b5cf6c..2f0ecff0 100644 --- a/config/playwright/playwright.config.ts +++ b/config/playwright/playwright.config.ts @@ -4,6 +4,9 @@ import { defineConfig, devices } from '@playwright/test' const WORKERS = 1 +const CI_RETRIES = 2 +const LOCAL_RETRIES = 1 + const TEST_TIMEOUT_SECONDS = 60 const ASSERT_TIMEOUT_SECONDS = 30 @@ -20,8 +23,8 @@ export default defineConfig({ snapshotPathTemplate: '{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}-{platform}{ext}', fullyParallel: true, forbidOnly: !!process.env.CI, - retries: 0, - workers: process.env.CI ? WORKERS : WORKERS, + retries: process.env.CI ? CI_RETRIES : LOCAL_RETRIES, + workers: WORKERS, reporter: process.env.CI ? [ ['line'], diff --git a/config/webpack/webpack-js.ts b/config/webpack/webpack-js.ts index fe741841..5c88861e 100644 --- a/config/webpack/webpack-js.ts +++ b/config/webpack/webpack-js.ts @@ -35,7 +35,7 @@ export const jsWebpackConfig: Configuration = { 'welcome': `${SOURCE_DIR}/welcome.ts` }, output: { - path: join(resolve(__dirname), '..', DEST_DIR), + path: join(resolve(__dirname), '..', '..', DEST_DIR), filename: '[name].js', clean: true }, @@ -58,7 +58,7 @@ export const jsWebpackConfig: Configuration = { ) }, resolve: { - modules: [resolve(__dirname, '..', 'node_modules')], + modules: [resolve(__dirname, '..', '..', 'node_modules')], extensions: ['.ts', '.tsx', '.js', '.jsx', '.json'] }, module: { diff --git a/package.json b/package.json index 3222febf..0fac543e 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ }, "scripts": { "prepare": "husky install", - "test:php": "WP_TESTS_DIR=./.wp-tests-lib WP_DEVELOP_DIR=./.wp-core src/vendor/bin/phpunit -c phpunit.xml", + "test:php": "WP_TESTS_DIR=./.wp-tests-lib src/vendor/bin/phpunit -c phpunit.xml", "test:php:watch": "npm run test:php -- --testdox", "test:playwright": "playwright test -c config/playwright/playwright.config.ts", "test:playwright:debug": "npm run test:playwright -- --debug", diff --git a/scripts/test-setup-playwright.ts b/scripts/test-setup-playwright.ts index 340f3baf..7ebdcd3b 100644 --- a/scripts/test-setup-playwright.ts +++ b/scripts/test-setup-playwright.ts @@ -1,6 +1,8 @@ #!/usr/bin/env ts-node import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' const run = (cmd: string, args: readonly string[]) => { execFileSync(cmd, args, { stdio: 'inherit' }) @@ -8,6 +10,18 @@ const run = (cmd: string, args: readonly string[]) => { const runWpEnvCli = (args: readonly string[]) => run('npx', ['wp-env', 'run', 'cli', ...args]) +const getPluginSlug = (): string => { + const prefix = 'wp-content/plugins/' + const config = <{ mappings?: Record }>JSON.parse(readFileSync(resolve(process.cwd(), '.wp-env.json'), 'utf8')) + const mapping = Object.keys(config.mappings ?? {}).find(key => key.startsWith(prefix)) + + if (!mapping) { + throw new Error('No plugin mapping found in .wp-env.json') + } + + return mapping.slice(prefix.length) +} + const main = () => { // Ensure a clean slate for file-based execution tests: // - remove flat-file execution directory (stale indexes can break the WP site) @@ -16,7 +30,7 @@ const main = () => { // - delete all DB snippets with an E2E prefix (keeps list clean across runs) runWpEnvCli(['sh', '-lc', 'rm -rf wp-content/code-snippets']) - runWpEnvCli(['wp', 'plugin', 'activate', 'code-snippets']) + runWpEnvCli(['wp', 'plugin', 'activate', getPluginSlug()]) runWpEnvCli([ 'wp', diff --git a/src/php/Admin/Menus/Manage_Menu.php b/src/php/Admin/Menus/Manage_Menu.php index b9e82a00..bf7252cc 100644 --- a/src/php/Admin/Menus/Manage_Menu.php +++ b/src/php/Admin/Menus/Manage_Menu.php @@ -467,7 +467,8 @@ public function handle_bulk_download_request(): void { $this->send_download_error( __( 'The download request is no longer valid. Please refresh and try again.', 'code-snippets' ), 403 ); } - $snippets = $this->get_requested_download_snippets(); + $snippets_json = wp_unslash( filter_input( INPUT_POST, 'snippets', FILTER_DEFAULT ) ?? '' ); + $snippets = $this->get_requested_download_snippets( $snippets_json ); if ( $snippets instanceof WP_Error ) { $status = $snippets->get_error_data( 'status' ); @@ -505,10 +506,11 @@ private function is_bulk_download_request(): bool { /** * Resolve the snippets requested for download. * + * @param string $snippets_json JSON-encoded list of requested snippets. + * * @return Snippet[]|WP_Error */ - private function get_requested_download_snippets() { - $snippets_json = wp_unslash( filter_input( INPUT_POST, 'snippets', FILTER_DEFAULT ) ?? '' ); + private function get_requested_download_snippets( string $snippets_json ) { $payload = '' === $snippets_json ? [] : json_decode( $snippets_json, true ); if ( ! is_array( $payload ) ) { diff --git a/src/php/REST_API/Snippets/Snippets_REST_Controller.php b/src/php/REST_API/Snippets/Snippets_REST_Controller.php index f5823db3..1117ce41 100644 --- a/src/php/REST_API/Snippets/Snippets_REST_Controller.php +++ b/src/php/REST_API/Snippets/Snippets_REST_Controller.php @@ -241,7 +241,7 @@ private function is_network_scoped_request( $request ): bool { } if ( is_string( $network ) ) { - return in_array( strtolower( $network ), [ '1', 'true', 'yes' ], true ); + return ! in_array( strtolower( $network ), [ '0', 'false', 'no', '' ], true ); } return (bool) $network; diff --git a/src/php/snippet-ops.php b/src/php/snippet-ops.php index 4f537566..e495774e 100644 --- a/src/php/snippet-ops.php +++ b/src/php/snippet-ops.php @@ -688,7 +688,7 @@ function save_snippet( $snippet ): ?Snippet { } $snippet->id = $wpdb->insert_id; - $updated = get_snippet( $snippet->id ); + $updated = get_snippet( $snippet->id, $snippet->network ); $updated->code_error = $snippet->code_error; $updated->code_error_trace = $snippet->code_error_trace; do_action( 'code_snippets/create_snippet', $updated, $table ); diff --git a/tests/README.md b/tests/README.md index c28e119d..d1b51e49 100644 --- a/tests/README.md +++ b/tests/README.md @@ -46,7 +46,7 @@ npm run test:php:watch Or run PHPUnit directly: ```bash -WP_TESTS_DIR=./.wp-tests-lib WP_DEVELOP_DIR=./.wp-core src/vendor/bin/phpunit -c phpunit.xml +WP_TESTS_DIR=./.wp-tests-lib src/vendor/bin/phpunit -c phpunit.xml ``` ## What Gets Installed diff --git a/tests/bootstrap.php b/tests/bootstrap.php index c71de6ea..0160bb1a 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -14,9 +14,6 @@ function _get_tests_dir(): string { case (bool) getenv( 'WP_TESTS_DIR' ): return getenv( 'WP_TESTS_DIR' ); - case (bool) getenv( 'WP_DEVELOP_DIR' ): - return getenv( 'WP_DEVELOP_DIR' ) . '/tests/phpunit'; - case (bool) getenv( 'WP_PHPUNIT__DIR' ): return getenv( 'WP_PHPUNIT__DIR' ); diff --git a/tests/e2e/code-snippets-edit.spec.ts b/tests/e2e/code-snippets-edit.spec.ts index c04bab30..389b85c9 100644 --- a/tests/e2e/code-snippets-edit.spec.ts +++ b/tests/e2e/code-snippets-edit.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test' -import { SnippetsTestHelper } from './helpers/SnippetsTestHelper' +import { DEFAULT_E2E_SNIPPET_BASE_NAME, SnippetsTestHelper } from './helpers/SnippetsTestHelper' import { MESSAGES, SELECTORS, TIMEOUTS } from './helpers/constants' test.describe('Code Snippets Admin', () => { @@ -7,6 +7,7 @@ test.describe('Code Snippets Admin', () => { test.beforeEach(async ({ page }) => { helper = new SnippetsTestHelper(page) + await SnippetsTestHelper.cleanupSnippetsByPrefix(DEFAULT_E2E_SNIPPET_BASE_NAME) await helper.navigateToSnippetsAdmin() }) diff --git a/tests/e2e/code-snippets-evaluation.spec.ts b/tests/e2e/code-snippets-evaluation.spec.ts index 8f962c0f..7c65f3d8 100644 --- a/tests/e2e/code-snippets-evaluation.spec.ts +++ b/tests/e2e/code-snippets-evaluation.spec.ts @@ -92,21 +92,9 @@ test.describe('Code Snippets Evaluation', () => { test.beforeEach(async ({ page }) => { helper = new SnippetsTestHelper(page) snippetName = SnippetsTestHelper.makeUniqueSnippetName() - await helper.navigateToSnippetsAdmin() - // Ensure isolation: file-based execution runs from flat files, so we must delete old snippets - // via plugin operations (to keep flat files in sync), not via direct SQL. - await wpCli([ - 'eval', - ` - global $wpdb; - $table = $wpdb->prefix . 'snippets'; - $ids = $wpdb->get_col( - $wpdb->prepare( "SELECT id FROM {$table} WHERE name LIKE %s", "${DEFAULT_E2E_SNIPPET_BASE_NAME}%" ) - ); - foreach ( $ids as $id ) { \\Code_Snippets\\delete_snippet( intval( $id ), false ); } - ` - ]) + await SnippetsTestHelper.cleanupSnippetsByPrefix(DEFAULT_E2E_SNIPPET_BASE_NAME) + await helper.navigateToSnippetsAdmin() }) test('PHP snippet is evaluating correctly', async () => { diff --git a/tests/e2e/code-snippets-list.spec.ts b/tests/e2e/code-snippets-list.spec.ts index 24f8fd09..0875a080 100644 --- a/tests/e2e/code-snippets-list.spec.ts +++ b/tests/e2e/code-snippets-list.spec.ts @@ -1,6 +1,6 @@ import { readFileSync } from 'fs' import { expect, test } from '@playwright/test' -import { SnippetsTestHelper } from './helpers/SnippetsTestHelper' +import { DEFAULT_E2E_SNIPPET_BASE_NAME, SnippetsTestHelper } from './helpers/SnippetsTestHelper' import { SELECTORS } from './helpers/constants' import type { Page } from '@playwright/test' @@ -12,6 +12,7 @@ test.describe('Code Snippets List Page Actions', () => { test.beforeEach(async ({ page }) => { helper = new SnippetsTestHelper(page) snippetName = SnippetsTestHelper.makeUniqueSnippetName() + await SnippetsTestHelper.cleanupSnippetsByPrefix(DEFAULT_E2E_SNIPPET_BASE_NAME) await helper.navigateToSnippetsAdmin() await helper.createAndActivateSnippet({ @@ -325,6 +326,7 @@ test.describe('Manage table Screen Options', () => { test.beforeEach(async ({ page }) => { helper = new SnippetsTestHelper(page) snippetName = SnippetsTestHelper.makeUniqueSnippetName('E2E Screen Options') + await SnippetsTestHelper.cleanupSnippetsByPrefix(DEFAULT_E2E_SNIPPET_BASE_NAME) await helper.createAndActivateSnippet({ name: snippetName, code: "add_filter('show_admin_bar', '__return_false');" diff --git a/tests/e2e/code-snippets-quicknav-admin-bar.spec.ts b/tests/e2e/code-snippets-quicknav-admin-bar.spec.ts index 366f979d..cb662cb6 100644 --- a/tests/e2e/code-snippets-quicknav-admin-bar.spec.ts +++ b/tests/e2e/code-snippets-quicknav-admin-bar.spec.ts @@ -16,6 +16,7 @@ test.describe('Admin Bar Snippets QuickNav', () => { let inactiveA: string test.beforeAll(async () => { + test.setTimeout(QUICKNAV_TEST_TIMEOUT_MS) await SnippetsTestHelper.setAdminBarQuickNavSettings({ enabled: true, perPage: QUICKNAV_PER_PAGE }) await SnippetsTestHelper.cleanupSnippetsByPrefix(QUICKNAV_PREFIX) diff --git a/tests/e2e/helpers/SnippetsTestHelper.ts b/tests/e2e/helpers/SnippetsTestHelper.ts index 90eb1179..23b006e6 100644 --- a/tests/e2e/helpers/SnippetsTestHelper.ts +++ b/tests/e2e/helpers/SnippetsTestHelper.ts @@ -19,6 +19,7 @@ const RANDOM_RADIX = 36 const RANDOM_SLICE_START = 2 const RANDOM_SLICE_END = 7 const CLICK_RETRIES = 3 +const SAVE_CONFIRM_RETRIES = 3 const AT_LEAST_ONE = 1 const getErrorMessage = (error: unknown): string => { @@ -288,7 +289,7 @@ export class SnippetsTestHelper { if ('save_and_activate' === action) { const activateButton = this.page.locator(BUTTONS.SAVE_AND_ACTIVATE).first() if (await activateButton.isVisible().catch(() => false)) { - await this.clickButton(/^Save and Activate$/i) + await this.clickSaveAndConfirm(/^Save and Activate$/i) return } @@ -297,7 +298,7 @@ export class SnippetsTestHelper { if (await inactiveToggle.isVisible().catch(() => false)) { await inactiveToggle.click({ timeout: TIMEOUTS.DEFAULT, force: true }) } - await this.clickButton(/^Save Snippet$/i) + await this.clickSaveAndConfirm(/^Save Snippet$/i) return } @@ -312,11 +313,34 @@ export class SnippetsTestHelper { await statusToggle.click({ timeout: TIMEOUTS.DEFAULT, force: true }) } } - await this.clickButton(/^Save Snippet$/i) + await this.clickSaveAndConfirm(/^Save Snippet$/i) return } - await this.clickButton(/^Save Snippet$/i) + await this.clickSaveAndConfirm(/^Save Snippet$/i) + } + + private async clickSaveAndConfirm(name: RegExp): Promise { + for (let attempt = 0; SAVE_CONFIRM_RETRIES > attempt; attempt++) { + await this.clickButton(name) + + const settled = await this.page.locator(SELECTORS.SAVE_SETTLED_NOTICE).first() + .waitFor({ state: 'visible', timeout: TIMEOUTS.DEFAULT }) + .then(() => true) + .catch(() => false) + + if (settled) { + return + } + + const buttonStillPresent = await this.page.getByRole('button', { name }).first() + .isVisible() + .catch(() => false) + + if (!buttonStillPresent) { + return + } + } } /** diff --git a/tests/e2e/helpers/constants.ts b/tests/e2e/helpers/constants.ts index a56135e4..abce8e89 100644 --- a/tests/e2e/helpers/constants.ts +++ b/tests/e2e/helpers/constants.ts @@ -5,6 +5,7 @@ export const SELECTORS = { LOCATION_SELECT: '.code-snippets-select-location', SUCCESS_MESSAGE: '.snippet-editor-sidebar .notice.updated', + SAVE_SETTLED_NOTICE: '.snippet-editor-sidebar .notice.updated, .code-snippets-notice.error', SNIPPETS_TABLE: '.wp-list-table', SNIPPET_ROW: '.wp-list-table tbody tr', diff --git a/tests/unit/Admin/Menus/Manage_Menu_Test.php b/tests/unit/Admin/Menus/Manage_Menu_Test.php index b94bb1fd..d46a82ce 100644 --- a/tests/unit/Admin/Menus/Manage_Menu_Test.php +++ b/tests/unit/Admin/Menus/Manage_Menu_Test.php @@ -234,7 +234,7 @@ public function test_network_bulk_download_requires_network_cap(): void { ) ); - $_POST['snippets'] = wp_json_encode( + $snippets_json = wp_json_encode( array( array( 'id' => $snippet->id, @@ -246,7 +246,7 @@ public function test_network_bulk_download_requires_network_cap(): void { $menu = new Manage_Menu(); $method = new ReflectionMethod( $menu, 'get_requested_download_snippets' ); $method->setAccessible( true ); - $result = $method->invoke( $menu ); + $result = $method->invoke( $menu, $snippets_json ); $this->assertInstanceOf( WP_Error::class, $result ); $this->assertSame( 'code_snippets_forbidden_network_download', $result->get_error_code() ); diff --git a/tests/unit/REST_API/REST_API_Cloud_Test.php b/tests/unit/REST_API/REST_API_Cloud_Test.php index 2fd99e45..409a76db 100644 --- a/tests/unit/REST_API/REST_API_Cloud_Test.php +++ b/tests/unit/REST_API/REST_API_Cloud_Test.php @@ -151,7 +151,7 @@ public function mock_cloud_search_request( $preempt, array $parsed_args, string */ private function make_request( array $params ): WP_REST_Response { $request = new WP_REST_Request( 'GET', $this->endpoint ); - $request->add_header( 'Access-Control', 'csc-1a2b3c4d5e6f7g8h9i0j' ); + $request->add_header( 'Access-Control', $this->get_connection_token() ); foreach ( $params as $key => $value ) { $request->set_param( $key, $value ); @@ -160,6 +160,20 @@ private function make_request( array $params ): WP_REST_Response { return rest_do_request( $request ); } + /** + * Read the active cloud connection's local token. + * + * @return string + */ + private function get_connection_token(): string { + $plugin = \Code_Snippets\code_snippets(); + + $property = new \ReflectionProperty( $plugin, 'cloud_connection' ); + $property->setAccessible( true ); + + return $property->getValue( $plugin )->get_local_token(); + } + /** * The cloud REST endpoint uses the snippets Screen Options value when per_page is omitted. * diff --git a/tests/unit/REST_API/REST_API_Snippets_Shared_Network_Toggle_Test.php b/tests/unit/REST_API/REST_API_Snippets_Shared_Network_Toggle_Test.php index 6a0fe687..703c8eb1 100644 --- a/tests/unit/REST_API/REST_API_Snippets_Shared_Network_Toggle_Test.php +++ b/tests/unit/REST_API/REST_API_Snippets_Shared_Network_Toggle_Test.php @@ -338,7 +338,7 @@ public function test_subsite_admin_can_read_shared_network_snippet() { $response = $this->dispatch( 'GET', - "/$this->namespace/$this->base_route/$this->shared_snippet_id}", + "/$this->namespace/$this->base_route/$this->shared_snippet_id", [ 'network' => true ] ); diff --git a/tests/unit/UnitTestCase.php b/tests/unit/UnitTestCase.php index 934b5b2a..17e411b6 100644 --- a/tests/unit/UnitTestCase.php +++ b/tests/unit/UnitTestCase.php @@ -9,4 +9,18 @@ */ class UnitTestCase extends WP_UnitTestCase { + /** + * Set up before each test. + * + * @return void + */ + public function set_up() { + parent::set_up(); + + if ( is_multisite() ) { + $menu_items = get_site_option( 'menu_items', [] ); + $menu_items['snippets'] = 1; + update_site_option( 'menu_items', $menu_items ); + } + } }