diff --git a/.github/workflows/phpstan-pull-request-comments.yml b/.github/workflows/phpstan-pull-request-comments.yml new file mode 100644 index 0000000000000..aabe26def6414 --- /dev/null +++ b/.github/workflows/phpstan-pull-request-comments.yml @@ -0,0 +1,370 @@ +# Keeps a comment on a pull request up to date with the errors reported by PHPStan. +# +# The analysis runs in the PHPStan Static Analysis workflow, which has no permission to comment when the pull request +# comes from a fork. It uploads its results as an artifact instead, and this workflow picks them up once that run +# completes. +# +# A `workflow_run` workflow runs with write permissions even for a pull request from a fork, so nothing from the pull +# request is checked out or executed here. The results it uploaded are treated as untrusted data, and are only +# accepted for the pull request whose current head is the commit that was analyzed. +name: PHPStan Pull Request Comments + +on: # zizmor: ignore[dangerous-triggers] + workflow_run: + workflows: [ 'PHPStan Static Analysis' ] + types: + - completed + +# Runs for the same pull request are queued rather than run at the same time, so that two of them do not each create +# a comment. A queued run that is superseded by a newer one is cancelled, which is fine: only the newest results matter. +concurrency: + group: ${{ github.workflow }}-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }} + +# Disable permissions for all available scopes by default. +# Any needed permissions should be configured at the job level. +permissions: {} + +jobs: + # Creates, updates, or resolves the comment listing PHPStan errors. + # + # Performs the following steps: + # - Downloads the analysis results from the completed run. + # - Creates or updates the comment while there are errors, and marks it as resolved once there are none. + phpstan-results: + name: Comment on a pull request with PHPStan results + runs-on: ubuntu-24.04 + permissions: + actions: read + issues: write + pull-requests: write + timeout-minutes: 5 + # A run that found errors concludes in failure only when some of them cannot be reported as warnings, and those + # are the errors this comment matters most for, so both conclusions are handled. + if: > + github.repository == 'WordPress/wordpress-develop' && + github.event.workflow_run.event == 'pull_request' && + ( github.event.workflow_run.conclusion == 'success' || github.event.workflow_run.conclusion == 'failure' ) + steps: + # The artifact is missing when the analysis did not complete, in which case there is nothing to report and the + # comment is left as it is. + - name: Download the analysis results + id: download + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + continue-on-error: true + with: + name: phpstan-results + path: ${{ runner.temp }}/phpstan-results + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + - name: Create, update, or resolve the PHPStan comment + if: ${{ steps.download.outcome == 'success' }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const fs = require( 'fs' ); + + const { owner, repo } = context.repo; + const run = context.payload.workflow_run; + const marker = ''; + const maxRows = 50; + const readmeUrl = `https://github.com/${ owner }/${ repo }/blob/trunk/tests/phpstan/README.md`; + + // The results were written by a run on the pull request, whose code may have been changed to write + // anything at all. They are therefore checked for shape, and only accepted for the pull request whose + // current head is the commit that the run analyzed. That also discards the results of a run that was + // overtaken by a newer push. + const results = JSON.parse( fs.readFileSync( `${ process.env.RUNNER_TEMP }/phpstan-results/results.json`, 'utf8' ) ); + const issue_number = results.pull_request; + if ( ! Number.isSafeInteger( issue_number ) || issue_number <= 0 || ! Array.isArray( results.errors ) ) { + core.setFailed( 'The analysis results are malformed.' ); + return; + } + + const { data: pr } = await github.rest.pulls.get( { owner, repo, pull_number: issue_number } ); + if ( pr.head.sha !== run.head_sha ) { + core.info( `The results are not for the current head of pull request #${ issue_number }, so they are not reported.` ); + return; + } + if ( pr.state !== 'open' ) { + core.info( `Pull request #${ issue_number } is not open, so the results are not reported.` ); + return; + } + + const asString = ( value ) => ( typeof value === 'string' ? value : null ); + const asSha = ( value ) => ( typeof value === 'string' && /^[0-9a-f]{40}$/.test( value ) ? value : null ); + const normalize = ( error ) => ( { + file: asString( error.file ), + line: Number.isSafeInteger( error.line ) && error.line > 0 ? error.line : null, + message: ( asString( error.message ) ?? '' ).slice( 0, 1000 ), + identifier: /^[A-Za-z0-9.]+$/.test( error.identifier ) ? error.identifier : null, + ignorable: error.ignorable === true, + } ); + + // An error is recognized from one run to the next by its file, identifier and message, but not its line, + // which moves whenever lines above it are added or removed. The same error can occur more than once in a + // file, so each occurrence is told apart by its position among the others, in line order. When one of them + // is fixed or added, those after it shift to the history of the one before or after, so their first reports + // can be off, and the one shown as resolved or new is the last rather than the one that was. That is rarer + // than lines being added or removed above them, which would throw off matching them by line. + const errors = results.errors.map( normalize ); + const occurrences = new Map(); + for ( const error of [ ...errors ].sort( ( a, b ) => ( a.line ?? 0 ) - ( b.line ?? 0 ) ) ) { + const key = JSON.stringify( [ error.file, error.identifier, error.message ] ); + const occurrence = occurrences.get( key ) ?? 0; + occurrences.set( key, occurrence + 1 ); + error.key = `${ key }#${ occurrence }`; + } + + const comments = await github.paginate( github.rest.issues.listComments, { owner, repo, issue_number, per_page: 100 } ); + const comment = comments.find( ( item ) => item.user?.login === 'github-actions[bot]' && item.body?.includes( marker ) ); + + // The history of the errors is kept in the comment itself, compressed into a hidden HTML comment: each + // error, the commit it was first reported for, the last commit it was reported for, and the commit that + // resolved it, if one has. Base64 cannot contain `--`, so it cannot end the HTML comment early. + const zlib = require( 'zlib' ); + const historyPattern = //; + let history = []; + const historyMatch = comment?.body?.match( historyPattern ); + if ( historyMatch ) { + try { + const parsed = JSON.parse( zlib.inflateRawSync( Buffer.from( historyMatch[ 1 ], 'base64' ) ).toString( 'utf8' ) ); + history = ( Array.isArray( parsed ) ? parsed : [] ) + .map( ( entry ) => ( { + ...normalize( entry ), + key: asString( entry.key ), + introduced: asSha( entry.introduced ), + seen: asSha( entry.seen ), + fixed: asSha( entry.fixed ), + } ) ) + .filter( ( entry ) => entry.key && entry.introduced && entry.seen ); + } catch ( error ) { + core.warning( `The error history in the PHPStan comment could not be read, so it starts over: ${ error.message }` ); + } + } + + // An error that was already being reported keeps the commit it was first reported for. One that is new, + // or that comes back after being resolved, is attributed to this commit. One that is no longer reported + // is attributed as resolved by this commit, unless an earlier commit already resolved it. + const historyByKey = new Map( history.map( ( entry ) => [ entry.key, entry ] ) ); + const active = errors.map( ( error ) => { + const before = historyByKey.get( error.key ); + return { ...error, introduced: before && ! before.fixed ? before.introduced : run.head_sha, seen: run.head_sha, fixed: null }; + } ); + const activeKeys = new Set( active.map( ( error ) => error.key ) ); + let resolved = [ + ...history.filter( ( entry ) => entry.fixed && ! activeKeys.has( entry.key ) ), + ...history.filter( ( entry ) => ! entry.fixed && ! activeKeys.has( entry.key ) ).map( ( entry ) => ( { ...entry, fixed: run.head_sha } ) ), + ].slice( -maxRows ); + + if ( ! comment && active.length === 0 ) { + core.info( 'PHPStan reported no errors, and there is no comment to update.' ); + return; + } + + // Files the base branch has changed since the pull request branched from it, in which the line numbers + // reported can be off in the head commit, since the analysis ran on the head commit merged into the base. + const changedInBase = new Set( Array.isArray( results.changed_in_base ) ? results.changed_in_base.filter( ( file ) => typeof file === 'string' ) : [] ); + const lineMayBeOff = ( error ) => ! error.fixed && Boolean( error.line ) && changedInBase.has( error.file ); + const filesWithLinesOff = new Set( active.filter( lineMayBeOff ).map( ( error ) => error.file ) ); + + // Every message is rendered as a code span. Within one, Markdown is not interpreted, and neither are + // mentions nor references to issues, both of which PHPStan messages can resemble, as in `Parameter #1`. + // In a table, a pipe still ends the cell even within a code span, unless it is escaped. + const code = ( text ) => { + let content = text.replace( /\s+/g, ' ' ).trim(); + if ( content.length > 500 ) { + content = `${ content.slice( 0, 500 ) }…`; + } + content = content.replace( /\|/g, '\\|' ); + const longestBackticks = Math.max( 0, ...( content.match( /`+/g ) ?? [] ).map( ( backticks ) => backticks.length ) ); + const fence = '`'.repeat( longestBackticks + 1 ); + const padding = content.startsWith( '`' ) || content.endsWith( '`' ) ? ' ' : ''; + return `${ fence }${ padding }${ content }${ padding }${ fence }`; + }; + + // A bare URL to a commit is shortened by GitHub to its abbreviated hash, with a hovercard for the commit. + const commit = ( sha ) => `https://github.com/${ owner }/${ repo }/commit/${ sha }`; + + // A location links to its line in the last commit the error was reported for, rather than to the merge + // commit that was analyzed, which is replaced whenever either side changes, so links to it would not last. + // Where the base branch has changed the file, the line may be off, so only the file is linked. A resolved + // error is linked to its line as the place it was last reported, unless that line was off, in which case + // its line is not kept in the history. + const location = ( error ) => { + if ( ! error.file ) { + return '_Not specific to a file_'; + } + const label = code( error.line ? `${ error.file }:${ error.line }` : error.file ); + if ( ! /^[\w.\/-]+$/.test( error.file ) || error.file.split( '/' ).includes( '..' ) ) { + return label; + } + return `[${ label }](https://github.com/${ owner }/${ repo }/blob/${ error.seen }/${ error.file }${ error.line && ! lineMayBeOff( error ) ? `#L${ error.line }` : '' })`; + }; + + const identifier = ( error ) => ( error.identifier ? `[${ code( error.identifier ) }](https://phpstan.org/error-identifiers/${ error.identifier })` : '' ); + + // GitHub shows only the first few annotations of each kind from a step on the page of the workflow run, so + // errors left out of a table are pointed to in the log of the step that reported them, which has them all. + const where = `the log of the [workflow run](${ run.html_url }), in the “Report the analysis results” step`; + const table = ( rows, rowLimit, { withResolution = false } = {} ) => { + const shown = rows.slice( 0, rowLimit ); + if ( shown.length === 0 ) { + return rows.length === 1 + ? `The error is not listed here, to keep this comment short. It is listed in ${ where }.` + : `The ${ rows.length } errors are not listed here, to keep this comment short. They are listed in ${ where }.`; + } + const more = `…and ${ rows.length - shown.length } more, which are listed in ${ where }.`; + const lines = [ + `| Location | Error | Identifier | First reported |${ withResolution ? ' Resolved |' : '' }`, + `| --- | --- | --- | --- |${ withResolution ? ' --- |' : '' }`, + ...shown.map( ( error ) => `| ${ location( error ) } | ${ code( error.message ) } | ${ identifier( error ) } | ${ commit( error.introduced ) } |${ withResolution ? ` ${ commit( error.fixed ) } |` : '' }` ), + ]; + if ( shown.length < rows.length ) { + lines.push( '', more ); + } + return lines.join( '\n' ); + }; + + const unignorable = active.filter( ( error ) => ! error.ignorable ); + const ignorable = active.filter( ( error ) => error.ignorable ); + const hasIgnoreErrors = unignorable.some( ( error ) => error.identifier?.startsWith( 'ignore.' ) ); + + const render = ( trackedLimit ) => { + // The heading shows at a glance whether the check fails, has only warnings, or has nothing left to report. + let status = '✅'; + if ( unignorable.length > 0 ) { + status = '❌'; + } else if ( active.length > 0 ) { + status = '⚠️'; + } + const sections = [ marker, `## ${ status } PHPStan Static Analysis` ]; + + if ( active.length > 0 ) { + sections.push( `PHPStan reported ${ active.length === 1 ? '1 error' : `${ active.length } errors` } when analyzing ${ commit( run.head_sha ) } ([workflow run](${ run.html_url })). This comment is updated as new commits are pushed.` ); + if ( filesWithLinesOff.size > 0 ) { + sections.push( [ + '> [!NOTE]', + `> ${ filesWithLinesOff.size === 1 ? 'A file with errors has' : `${ filesWithLinesOff.size } files with errors have` } changed in ${ code( pr.base.ref ) } since this branch was last updated, so the line numbers reported in ${ filesWithLinesOff.size === 1 ? 'it' : 'them' } may be off and are not linked. Merge the latest ${ code( pr.base.ref ) } into this branch for accurate links.`, + ].join( '\n' ) ); + } + } else { + // Resolved by the last commit that resolved anything, so that later commits do not change the comment. + const resolvedBy = resolved.at( -1 )?.fixed ?? run.head_sha; + sections.push( `All errors reported by PHPStan were resolved as of ${ commit( resolvedBy ) }.` ); + } + + let rowsLeft = Math.min( maxRows, trackedLimit ); + if ( unignorable.length > 0 ) { + if ( hasIgnoreErrors ) { + sections.push( [ + '> [!IMPORTANT]', + '> **The PHPStan baselines are out of date, so the PHPStan check fails.**', + '>', + '> An error recorded in a baseline no longer occurs, or occurs a different number of times. If this pull request fixed it, that is expected: nothing needs to change in the code, but each baseline entry records an exact count for a specific file, and the baselines need to catch up. Regenerate them and commit the result:', + '>', + '> ```bash', + '> npm run typecheck:php:baselines', + '> ```', + '>', + '> or, outside the Docker environment:', + '>', + '> ```bash', + '> composer phpstan:baselines', + '> ```', + '>', + '> Where an error below names an `@phpstan-ignore` comment in the code rather than a baseline entry, remove that comment instead; regenerating will not clear it.', + '>', + `> See [Regenerating the baselines](${ readmeUrl }#regenerating-the-baselines) for details.`, + ].join( '\n' ) ); + } else { + sections.push( [ + '> [!IMPORTANT]', + '> **PHPStan reported errors that cannot be ignored, so the PHPStan check fails.**', + ].join( '\n' ) ); + } + sections.push( '### Errors that fail the check', table( unignorable, rowsLeft ) ); + rowsLeft = Math.max( 0, rowsLeft - unignorable.length ); + } + + if ( ignorable.length > 0 ) { + sections.push( + '### Errors', + `These are also shown as annotations on the changed files. They do not fail the check, but they should still be fixed before the pull request is committed. An error that is a false positive may instead warrant being [added to a baseline](${ readmeUrl }#ignoring-and-baselining-errors).`, + table( ignorable, rowsLeft ) + ); + } + + // Most recently resolved first. + if ( resolved.length > 0 ) { + sections.push( [ + '
', + `Resolved errors (${ resolved.length })`, + '', + table( [ ...resolved ].reverse(), maxRows, { withResolution: true } ), + '', + '
', + ].join( '\n' ) ); + } + + if ( active.length > 0 ) { + sections.push( + '### Running PHPStan locally', + `To [run the analysis](${ readmeUrl }#running-the-tests) on your own machine, use \`npm run typecheck:php\`, or \`composer phpstan\` outside the Docker environment.` + ); + } + + // The errors tracked are taken in the order they are listed, so that each one listed keeps its history. + const tracked = [ ...unignorable, ...ignorable ].slice( 0, trackedLimit ).concat( resolved ).map( ( error ) => { + const { key, file, line, message, identifier, ignorable, introduced, seen, fixed } = error; + return { key, file, line: lineMayBeOff( error ) ? null : line, message, identifier, ignorable, introduced, seen, fixed }; + } ); + sections.push( `` ); + + return sections.join( '\n\n' ) + '\n'; + }; + + // A comment is limited to 65,536 characters. Should the body exceed that, give up the oldest resolved + // errors first, and then list and track fewer of the current errors. The history of current errors is + // given up last, even for errors that are not listed: an error whose history is lost is taken to be + // introduced by whichever later commit it is reported for, which would then be shown as its first report. + const maxLength = 65000; + let trackedLimit = 200; + let body = render( trackedLimit ); + while ( body.length > maxLength && resolved.length > 0 ) { + resolved = resolved.slice( 1 ); + body = render( trackedLimit ); + } + while ( body.length > maxLength && trackedLimit > 0 ) { + trackedLimit = Math.floor( trackedLimit / 2 ); + body = render( trackedLimit ); + } + + if ( ! comment ) { + await github.rest.issues.createComment( { owner, repo, issue_number, body } ); + core.notice( `Created PHPStan comment for pull request: ${ pr.html_url }` ); + return; + } + + if ( comment.body !== body ) { + await github.rest.issues.updateComment( { owner, repo, comment_id: comment.id, body } ); + core.notice( `Updated PHPStan comment for pull request: ${ pr.html_url }` ); + } + + // The comment is collapsed as resolved while there are no errors, and expanded again if errors return. + const { node } = await github.graphql( + 'query( $id: ID! ) { node( id: $id ) { ... on IssueComment { isMinimized } } }', + { id: comment.node_id } + ); + if ( active.length === 0 && ! node.isMinimized ) { + await github.graphql( + 'mutation( $id: ID! ) { minimizeComment( input: { subjectId: $id, classifier: RESOLVED } ) { clientMutationId } }', + { id: comment.node_id } + ); + core.notice( `Resolved PHPStan comment for pull request: ${ pr.html_url }` ); + } else if ( active.length > 0 && node.isMinimized ) { + await github.graphql( + 'mutation( $id: ID! ) { unminimizeComment( input: { subjectId: $id } ) { clientMutationId } }', + { id: comment.node_id } + ); + } diff --git a/.github/workflows/reusable-phpstan-static-analysis-v1.yml b/.github/workflows/reusable-phpstan-static-analysis-v1.yml index 57a48d0e3d503..321f98189cdf9 100644 --- a/.github/workflows/reusable-phpstan-static-analysis-v1.yml +++ b/.github/workflows/reusable-phpstan-static-analysis-v1.yml @@ -21,7 +21,8 @@ permissions: {} jobs: # Runs PHP static analysis tests. # - # Violations are reported inline with annotations. + # Violations are reported inline with annotations. For pull requests, the results are also uploaded + # as an artifact, which the PHPStan Pull Request Comments workflow summarizes in a comment. # # Performs the following steps: # - Checks out the repository. @@ -31,8 +32,10 @@ jobs: # - Make Composer packages available globally. # - Builds WordPress. # - Configures caching for PHPStan static analysis scans. - # - Runs PHPStan static analysis (with Pull Request annotations). - # - Checks whether the baselines need regenerating. + # - Runs PHPStan static analysis. + # - Reports the results as annotations. + # - Uploads the results as an artifact (pull requests only). + # - Checks for errors that cannot be reported as warnings, such as out-of-date baselines. # - Saves the PHPStan result cache. # - Ensures version-controlled files are not modified or deleted. phpstan: @@ -43,18 +46,20 @@ jobs: timeout-minutes: 20 steps: + # For a pull request, the parents of the merge commit are fetched too, to find the files that the base branch + # has changed since the pull request branched from it. - name: Checkout repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} persist-credentials: false + fetch-depth: ${{ github.event_name == 'pull_request' && 2 || 1 }} - name: Set up PHP uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: ${{ inputs.php-version }} coverage: none - tools: cs2pr - name: General debug information run: | @@ -98,35 +103,125 @@ jobs: - name: Run PHP static analysis tests id: phpstan run: | - # The report is written to a file as well as piped to cs2pr, so that the step below - # can look at it. + # The report is written to a file for the steps below, which turn it into annotations, + # the pull request comment, and a check for errors that cannot be reported as warnings. + # Only the report goes to standard output; the debug output goes to standard error, so + # it stays in the log without corrupting the JSON. # - # cs2pr exits successfully so that reported errors annotate the pull request without - # failing the run. A pipeline reports only the status of its last command, so that - # also discards the status of the analysis itself. Recover it from PIPESTATUS. - composer run phpstan -- -vvv --error-format=checkstyle | tee "${RUNNER_TEMP}/phpstan-report.xml" | cs2pr --errors-as-warnings --graceful-warnings - status="${PIPESTATUS[0]}" + # The step shell exits on the first failing command, which would also end the step on + # the status of 1 that PHPStan uses for reporting errors. Capture the status instead. + status=0 + composer run phpstan -- -vvv --error-format=json > "${RUNNER_TEMP}/phpstan-report.json" || status=$? # PHPStan exits 1 when it has errors to report, which is the expected case here and - # is what the annotations are for. Anything higher means it did not finish at all, - # which would otherwise pass silently, since the discarded status was the only sign. + # is what the annotations are for. Anything higher means it did not finish at all. if [ "${status}" -gt 1 ]; then echo "::error title=PHPStan did not complete::The analysis exited with status ${status}, so the code was not fully checked. This is a failure of the run itself rather than a problem found in the code." exit "${status}" fi - # An ignored error that no longer occurs, or occurs a different number of times, is - # reported under an `ignore.*` identifier. That is not something to fix in the code: the - # usual cause is that the error *was* fixed, leaving a baseline describing a state that - # no longer exists. PHPStan does not allow those reports to be ignored or baselined. + # Errors are annotated as warnings, so that they are visible on the pull request without + # failing the run. The exception is an error that PHPStan does not allow to be ignored, + # such as an out-of-date baseline entry, or one that is not specific to a file. These are + # annotated as errors instead, and the run fails on them in the check further below. # - # The analysis above is reported as warnings, so this would otherwise surface as a - # passing run carrying an annotation that reads like a complaint about a fix. Call it - # out on its own, and say what to do about it. + # The results are also written out for the PHPStan Pull Request Comments workflow, which + # runs with the permissions needed to comment on the pull request. Only the facts are + # passed along: the comment is composed there, since a pull request can change anything + # that runs here. + - name: Report the analysis results + id: report + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const fs = require( 'fs' ); + + const report = JSON.parse( fs.readFileSync( `${ process.env.RUNNER_TEMP }/phpstan-report.json`, 'utf8' ) ); + const workspace = `${ process.env.GITHUB_WORKSPACE }/`; + + const errors = []; + for ( const [ absolutePath, file ] of Object.entries( report.files ) ) { + const relativePath = absolutePath.startsWith( workspace ) ? absolutePath.slice( workspace.length ) : absolutePath; + for ( const message of file.messages ) { + errors.push( { + file: relativePath, + line: message.line ?? null, + message: message.message, + identifier: message.identifier ?? null, + tip: message.tip ?? null, + ignorable: message.ignorable, + } ); + } + } + + // Errors that are not specific to a file, such as an `ignoreErrors` entry without a + // path that no longer matches. These cannot be ignored either. + for ( const message of report.errors ) { + errors.push( { file: null, line: null, message, identifier: null, tip: null, ignorable: false } ); + } + + for ( const error of errors ) { + const properties = { title: error.identifier ? `PHPStan: ${ error.identifier }` : 'PHPStan' }; + if ( error.file ) { + properties.file = error.file; + } + if ( error.line ) { + properties.startLine = error.line; + } + const message = error.tip ? `${ error.message }\n${ error.tip }` : error.message; + if ( error.ignorable ) { + core.warning( message, properties ); + } else { + core.error( message, properties ); + } + } + + // Tips are left out of the results, since they are only shown in the annotations, next to the code. + // + // The commit analyzed is the pull request merged into its base branch, whose second parent is the head + // commit. The files with errors that differ between the two are those the base branch has changed, in + // which the line numbers reported can be off in the head commit. + if ( context.eventName === 'pull_request' ) { + let changedInBase = []; + try { + const filesWithErrors = new Set( errors.map( ( error ) => error.file ) ); + changedInBase = require( 'child_process' ) + .execFileSync( 'git', [ 'diff', '--name-only', '--no-renames', 'HEAD^2', 'HEAD' ], { encoding: 'utf8' } ) + .split( '\n' ) + .filter( ( file ) => filesWithErrors.has( file ) ); + } catch ( error ) { + core.warning( `The files changed in the base branch could not be found: ${ error.message }` ); + } + fs.mkdirSync( `${ process.env.RUNNER_TEMP }/phpstan-results`, { recursive: true } ); + fs.writeFileSync( `${ process.env.RUNNER_TEMP }/phpstan-results/results.json`, JSON.stringify( { + pull_request: context.payload.pull_request.number, + changed_in_base: changedInBase, + errors: errors.map( ( { tip, ...error } ) => error ), + } ) ); + } + + # Uploads the results for the PHPStan Pull Request Comments workflow, which keeps a comment on the + # pull request up to date with them. + - name: Upload the analysis results as an artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: ${{ github.event_name == 'pull_request' && steps.report.outcome == 'success' }} + with: + name: phpstan-results + path: ${{ runner.temp }}/phpstan-results/ + + # Some errors cannot be ignored, so they cannot be reported as warnings either. Most often + # this is an ignored error that no longer occurs, or occurs a different number of times, + # which is reported under an `ignore.*` identifier. That is not something to fix in the + # code: the usual cause is that the error *was* fixed, leaving a baseline describing a state + # that no longer exists. + # + # Such an error is reported without a line number, so its annotation is not shown alongside + # the changes in the pull request, and one that is not specific to a file has nowhere to be + # shown at all. A passing run would let it go unnoticed, so fail the run on it instead, and + # say what to do about it. # - # Detection is on the identifier rather than the message, which is prose and may be - # reworded in any release. The checkstyle format carries it in the `source` attribute. - - name: Check whether the baselines need regenerating + # Detection is on PHPStan's own `ignorable` flag rather than on the identifier or message. + - name: Check for errors that cannot be reported as warnings if: ${{ !cancelled() }} env: BASELINES_URL: ${{ github.server_url }}/${{ github.repository }}/tree/${{ github.sha }}/tests/phpstan/baselines @@ -135,20 +230,28 @@ jobs: # This step runs even when the analysis above it failed, in which case the report may # never have been written. That failure is reported there, so there is nothing to add # here beyond staying quiet about a file that was never going to exist. - if [ ! -f "${RUNNER_TEMP}/phpstan-report.xml" ]; then + report="${RUNNER_TEMP}/phpstan-report.json" + if [ ! -f "${report}" ]; then exit 0 fi - # Leave the run alone unless PHPStan reported an ignore error, because everything - # below concerns an ignore configuration that no longer describes the code, and - # nothing else. Those errors are `ignore.unmatched`, where a pattern matched nothing - # at all, and `ignore.count`, where it matched a different number of times than the - # entry records. The `ignore.` prefix is matched rather than those two names so that - # any later addition to the group is caught as well. - if ! grep -q 'source="ignore\.' "${RUNNER_TEMP}/phpstan-report.xml"; then + # Errors that are not specific to a file are listed separately from the rest, and are + # never ignorable. + unignorable=$( jq '[ .files[].messages[] | select( .ignorable | not ) ] + .errors | length' "${report}" ) + if [ "${unignorable}" -eq 0 ]; then exit 0 fi + # Whether the errors include ignore errors decides what to say about them, but not whether to + # fail: every error that reaches this point fails the run. Ignore errors concern an ignore + # configuration that no longer describes the code. They are `ignore.unmatched`, where a + # pattern matched nothing at all, and `ignore.count`, where it matched a different number of + # times than the entry records. The `ignore.` prefix is matched rather than those two names + # so that any later addition to the group is caught as well. Any other error that cannot be + # ignored is something regenerating the baselines would not help with, so it is reported + # plainly instead. + ignore_errors=$( jq '[ .files[].messages[] | select( .identifier // "" | startswith( "ignore." ) ) ] | length' "${report}" ) + # The summary is Markdown, and its code spans and fences are written literally, so the # heredoc is quoted to keep the backticks out of the shell's hands. The links are # written in reference style for the same reason: the URLs are the only part needing @@ -156,7 +259,7 @@ jobs: # # A newline renders as a line break rather than a space, so each paragraph is one # line however long that makes it, and the rendered summary wraps to its own width. - cat >> "${GITHUB_STEP_SUMMARY}" <<'SUMMARY' + baselines_summary=$( cat <<'SUMMARY' ## PHPStan baselines are out of date An ignored error no longer occurs, or occurs a different number of times, so PHPStan reported it under an `ignore.unmatched` or `ignore.count` identifier. @@ -182,13 +285,20 @@ jobs: See [`tests/phpstan/README.md`][readme] for details. SUMMARY + ) + + if [ "${ignore_errors}" -gt 0 ]; then + { + printf '%s\n\n' "${baselines_summary}" + echo "[baselines]: ${BASELINES_URL}" + echo "[readme]: ${README_URL}" + } >> "${GITHUB_STEP_SUMMARY}" - { - echo "[baselines]: ${BASELINES_URL}" - echo "[readme]: ${README_URL}" - } >> "${GITHUB_STEP_SUMMARY}" + echo "::error title=PHPStan baselines are out of date::An ignored error no longer occurs, or occurs a different number of times. If you fixed it, that is expected: run \`npm run typecheck:php:baselines\` or \`composer phpstan:baselines\` and commit the updated baselines. See ${README_URL}" + else + echo "::error title=PHPStan reported errors that cannot be ignored::PHPStan reported ${unignorable} error(s) that cannot be ignored or reported as warnings. See the error annotations for details, and ${README_URL} for how to run the analysis locally." + fi - echo "::error title=PHPStan baselines are out of date::An ignored error no longer occurs, or occurs a different number of times. If you fixed it, that is expected: run \`npm run typecheck:php:baselines\` or \`composer phpstan:baselines\` and commit the updated baselines. See ${README_URL}" exit 1 - name: "Save result cache" diff --git a/Gruntfile.js b/Gruntfile.js index b720379a23101..4bcf906cc1871 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -531,6 +531,9 @@ module.exports = function(grunt) { [ WORKING_DIR + 'wp-admin/js/language-chooser.js' ]: [ './src/js/_enqueues/lib/language-chooser.js' ], [ WORKING_DIR + 'wp-admin/js/link.js' ]: [ './src/js/_enqueues/admin/link.js' ], [ WORKING_DIR + 'wp-admin/js/media-gallery.js' ]: [ './src/js/_enqueues/deprecated/media-gallery.js' ], + [ WORKING_DIR + 'wp-admin/js/media-frame-upload.js' ]: [ './src/js/_enqueues/admin/media-frame-upload.js' ], + [ WORKING_DIR + 'wp-admin/js/media-new-upload.js' ]: [ './src/js/_enqueues/admin/media-new-upload.js' ], + [ WORKING_DIR + 'wp-admin/js/media-upload-pipeline.js' ]: [ './src/js/_enqueues/admin/media-upload-pipeline.js' ], [ WORKING_DIR + 'wp-admin/js/media-upload.js' ]: [ './src/js/_enqueues/admin/media-upload.js' ], [ WORKING_DIR + 'wp-admin/js/media.js' ]: [ './src/js/_enqueues/admin/media.js' ], [ WORKING_DIR + 'wp-admin/js/nav-menu.js' ]: [ './src/js/_enqueues/lib/nav-menu.js' ], @@ -697,7 +700,7 @@ module.exports = function(grunt) { src: 'vendor/composer/ca-bundle/res/cacert.pem', dest: SOURCE_DIR + 'wp-includes/certificates/ca-bundle.crt' }, - // Gutenberg PHP infrastructure files (routes.php, pages.php, constants.php, pages/). + // Gutenberg PHP infrastructure files (routes.php, pages.php, constants.php, pages/, core actions, core fields). 'gutenberg-php': { options: { process: function( content ) { @@ -716,6 +719,10 @@ module.exports = function(grunt) { 'pages.php', 'constants.php', 'pages/**/*.php', + // The actions Core registers through the entity actions API. + 'scripts/core-actions/**/*.php', + // The fields Core registers through the entity fields API. + 'scripts/core-fields/**/*.php', ], dest: SOURCE_DIR + 'wp-includes/build/', } ], @@ -1321,6 +1328,9 @@ module.exports = function(grunt) { 'src/wp-admin/js/language-chooser.js': 'src/js/_enqueues/lib/language-chooser.js', 'src/wp-admin/js/link.js': 'src/js/_enqueues/admin/link.js', 'src/wp-admin/js/media-gallery.js': 'src/js/_enqueues/deprecated/media-gallery.js', + 'src/wp-admin/js/media-frame-upload.js': 'src/js/_enqueues/admin/media-frame-upload.js', + 'src/wp-admin/js/media-new-upload.js': 'src/js/_enqueues/admin/media-new-upload.js', + 'src/wp-admin/js/media-upload-pipeline.js': 'src/js/_enqueues/admin/media-upload-pipeline.js', 'src/wp-admin/js/media-upload.js': 'src/js/_enqueues/admin/media-upload.js', 'src/wp-admin/js/media.js': 'src/js/_enqueues/admin/media.js', 'src/wp-admin/js/nav-menu.js': 'src/js/_enqueues/lib/nav-menu.js', diff --git a/composer.json b/composer.json index ca41407b4241f..abc5335126f07 100644 --- a/composer.json +++ b/composer.json @@ -48,7 +48,7 @@ "ext-zlib": "*" }, "require-dev": { - "composer/ca-bundle": "1.5.13", + "composer/ca-bundle": "1.6.0", "squizlabs/php_codesniffer": "3.13.5", "wp-coding-standards/wpcs": "~3.4.1", "phpcompatibility/phpcompatibility-wp": "~2.1.3", diff --git a/package-lock.json b/package-lock.json index 698701f594f8e..2eb2e25893d72 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,6 +46,7 @@ "@napi-rs/image": "1.14.0", "@playwright/test": "1.61.1", "@pmmmwh/react-refresh-webpack-plugin": "0.6.2", + "@types/backbone": "1.4.23", "@types/codemirror": "5.60.17", "@types/espree": "10.1.0", "@types/htmlhint": "1.1.5", @@ -5875,6 +5876,17 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/backbone": { + "version": "1.4.23", + "resolved": "https://registry.npmjs.org/@types/backbone/-/backbone-1.4.23.tgz", + "integrity": "sha512-B/hN/DAJdWFOusEkEoa5xgfVuxJJPOR/6JQ2uwURPDyKL24PuC76IR6EcSRJ6lvGWtxHRQYMJQhJYm6KpMDtGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/jquery": "*", + "@types/underscore": "*" + } + }, "node_modules/@types/body-parser": { "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", diff --git a/package.json b/package.json index eb165bda5b0f0..ab44fd105f344 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "url": "https://develop.svn.wordpress.org/trunk" }, "gutenberg": { - "sha": "954fe7f9e04631c41aec4552acd627a7203a2aad", + "sha": "be6a50be6834e922b9db12c75c9a2cd817b2db59", "ghcrRepo": "WordPress/gutenberg/gutenberg-wp-develop-build" }, "engines": { @@ -44,6 +44,7 @@ "@napi-rs/image": "1.14.0", "@playwright/test": "1.61.1", "@pmmmwh/react-refresh-webpack-plugin": "0.6.2", + "@types/backbone": "1.4.23", "@types/codemirror": "5.60.17", "@types/espree": "10.1.0", "@types/htmlhint": "1.1.5", diff --git a/src/js/_enqueues/admin/link.js b/src/js/_enqueues/admin/link.js index ed4bd5e3efb94..b916021327160 100644 --- a/src/js/_enqueues/admin/link.js +++ b/src/js/_enqueues/admin/link.js @@ -30,7 +30,7 @@ jQuery( function($) { $(this).attr( 'aria-selected', 'true' ).removeAttr( 'tabindex' ); $(this).parent().addClass('tabs').siblings('li').removeClass('tabs'); $('.tabs-panel').hide(); - $(t).show(); + $( document ).find( t ).show(); if ( '#categories-all' == t ) { deleteUserSetting('cats'); } else { diff --git a/src/js/_enqueues/admin/media-frame-upload.js b/src/js/_enqueues/admin/media-frame-upload.js new file mode 100644 index 0000000000000..f5af17d7991d2 --- /dev/null +++ b/src/js/_enqueues/admin/media-frame-upload.js @@ -0,0 +1,271 @@ +/** + * Routes media frame uploads through the client-side media pipeline. + * + * Every wp.media frame - the Media Library grid on wp-admin/upload.php and + * the media modal the block editor, the site editor and the block widgets + * screen open - uploads via wp.Uploader / plupload to async-upload.php. + * When the browser is cross-origin isolated and supports the client-side + * pipeline, this script intercepts the uploader's FilesAdded handler and + * hands files to wp.mediaUploadPipeline (media-upload-pipeline.js) + * instead: the original image is uploaded via the REST API and thumbnails + * are generated in the browser (wasm-vips), then sideloaded and finalized. + * + * The frame's own UI is reused: the same placeholder tiles, progress bars, + * "Uploading n/m" status, and error list that wp-plupload.js drives. + * + * When client-side support is unavailable the script cleanly no-ops and + * the classic plupload flow is left untouched. + * + * @output wp-admin/js/media-frame-upload.js + */ + +/* global plupload */ + +( function () { + // Guard against double execution (e.g. duplicate enqueues). + if ( window.__wpMediaFrameUpload ) { + return; + } + + const pipeline = window.wp && wp.mediaUploadPipeline; + + // Bail unless the browser actually supports client-side processing. This + // is the clean no-op: when the isolation headers did not land, classic + // plupload keeps handling uploads. + if ( + ! pipeline || + typeof plupload === 'undefined' || + ! wp.Uploader || + ! wp.media || + ! pipeline.configure() + ) { + return; + } + + window.__wpMediaFrameUpload = true; + + /** + * Resets the upload queue once every attachment has finished uploading. + * + * Parity with wp-plupload.js so browse mode flips back when done. + */ + function maybeResetQueue() { + const complete = wp.Uploader.queue.all( function ( + /** @type {WPAttachment} */ attachment + ) { + return ! attachment.get( 'uploading' ); + } ); + + if ( complete ) { + wp.Uploader.queue.reset(); + } + } + + /** + * Handles a completed upload by syncing the grid tile with the server data. + * + * @param {WPUploader} wpUploader The wp.Uploader instance that queued the file. + * @param {WPAttachment} model The placeholder Attachment model. + * @param {PipelineAttachment} attachment The finalized attachment from the pipeline. + */ + function handleSuccess( wpUploader, model, attachment ) { + // Not silent: Attachments re-keys a model from its cid to its id when + // it sees the change event. Without that the collection cannot find the + // attachment by id, and refreshing the library duplicates its tile. + model.set( { id: attachment.id } ); + + // Register the model in Attachments.all (parity with wp-plupload.js). + wp.media.model.Attachment.get( attachment.id, model ); + + // Clear the uploading state before the refetch lands, not after. The + // attachment details view re-renders on the refetch's changes, so it has + // to see `uploading: false` by then or it keeps showing a progress bar. + // wp-plupload.js sets both in one call for the same reason. + [ 'file', 'loaded', 'size', 'percent' ].forEach( function ( key ) { + model.unset( key, { silent: true } ); + } ); + model.set( { uploading: false }, { silent: true } ); + + model + .fetch() + .fail( function () { + // Fetch failed, but the upload succeeded: fill the tile with what + // the pipeline gave us so it is not left empty. + model.set( attachment ); + } ) + .always( function () { + maybeResetQueue(); + + // Parity with wp-plupload.js, which exposes this callback so + // other code can react to a finished upload. + wpUploader.success( model ); + } ); + } + + /** + * Handles an upload error by removing the tile and surfacing the message. + * + * The error goes into wp.Uploader.errors exactly like a classic upload + * error, so the grid's error sidebar renders it, announces it, and + * moves focus to its Dismiss button. + * + * @param {WPUploader} wpUploader The wp.Uploader instance that queued the file. + * @param {WPAttachment} model The placeholder Attachment model. + * @param {UploadError} error The upload error. + * @param {string} fileName Name of the file (for the error label). + */ + function handleError( wpUploader, model, error, fileName ) { + const message = pipeline.getErrorText( error, fileName ); + const file = { name: fileName }; + + model.destroy(); + + wp.Uploader.errors.unshift( { + message: message, + data: {}, + file: file, + } ); + + maybeResetQueue(); + + // Parity with wp-plupload.js, which exposes this callback so other + // code can react to a failed upload. + wpUploader.error( message, {}, file ); + } + + /** + * Whether a plupload uploader posts attachments the way wp.Uploader does + * by default. + * + * The init wrapper below reaches every wp.Uploader built on the page, + * and a plugin can build one for its own purposes. Core's defaults tell + * an attachment upload apart: it posts to async-upload.php with the + * `upload-attachment` action. Anything a plugin pointed elsewhere, or + * gave another action, is uploading something else and is left alone. + * + * @param {plupload.Uploader} up The plupload uploader instance. + * @return {boolean} True when the uploader posts attachments. + */ + function isAttachmentUploader( up ) { + const settings = up.settings || {}; + const params = settings.multipart_params || {}; + const url = new URL( settings.url || '', location.href ); + + return ( + 'upload-attachment' === params.action && + url.pathname.endsWith( 'async-upload.php' ) + ); + } + + /** + * Intercepts files added to a plupload uploader. + * + * Builds the same placeholder tiles as wp-plupload for the files the + * pipeline takes (see wp.mediaUploadPipeline.routeBatch()). Returns + * undefined to leave the whole batch to plupload's built-in handler. + * + * @param {WPUploader} wpUploader The wp.Uploader instance. + * @param {plupload.Uploader} up The plupload uploader instance. + * @param {plupload.File[]} files Files added to the queue. + * @return {boolean|undefined} False to suppress the built-in handler. + */ + function handleFilesAdded( wpUploader, up, files ) { + if ( ! isAttachmentUploader( up ) ) { + return; + } + + // The classic flow attaches to `post_id` the same way, and rejects + // a post the user cannot edit just as the REST API does. + const params = ( up.settings && up.settings.multipart_params ) || {}; + + return pipeline.routeBatch( up, files, { + postId: parseInt( params.post_id, 10 ) || 0, + onQueue: function ( + /** @type {plupload.File} */ file, + /** @type {File} */ nativeFile + ) { + // Build the same placeholder attributes as wp-plupload.js so + // the grid's progress tiles and "Uploading n/m" status work + // unchanged. + /** @type {PlaceholderAttributes} */ + const attributes = { + file: file, + uploading: true, + date: new Date(), + filename: file.name, + menuOrder: 0, + uploadedTo: wp.media.model.settings.post.id, + loaded: file.loaded, + size: file.size, + percent: file.percent, + }; + + /* + * Early mime type scanning for images, as wp-plupload.js does, + * extended with the formats the client-side pipeline accepts. + */ + const image = /(?:jpe?g|png|gif|webp|avif|heic|heif)$/i.exec( + file.name + ); + if ( image ) { + attributes.type = 'image'; + // `jpg` is not a valid subtype, so map it to `jpeg`. + const subtype = image[ 0 ].toLowerCase(); + attributes.subtype = 'jpg' === subtype ? 'jpeg' : subtype; + } + + const model = wp.media.model.Attachment.create( attributes ); + wp.Uploader.queue.add( model ); + wpUploader.added( model ); + + return { + onSuccess: function ( + /** @type {PipelineAttachment} */ attachment + ) { + handleSuccess( wpUploader, model, attachment ); + }, + onError: function ( /** @type {UploadError} */ error ) { + handleError( wpUploader, model, error, nativeFile.name ); + }, + // plupload builds its own tile for the file it took over. + onHandOff: function () { + model.destroy(); + }, + onProgress: function ( /** @type {number} */ percent ) { + model.set( { percent: percent } ); + wpUploader.progress( model ); + }, + }; + }, + } ); + } + + // Wrap wp.Uploader.prototype.init (an empty stub called once per instance + // after plupload is initialized) to bind a higher-priority FilesAdded + // handler on every uploader instance: the grid's, and the one each media + // modal creates when it opens. + const originalInit = wp.Uploader.prototype.init; + wp.Uploader.prototype.init = function () { + originalInit.apply( this, arguments ); + + const wpUploader = /** @type {WPUploader} */ ( this ); + const up = /** @type {plupload.Uploader|undefined} */ ( this.uploader ); + + if ( ! up || up.__wpMediaFrameUploadBound ) { + return; + } + up.__wpMediaFrameUploadBound = true; + + // plupload sorts handlers by priority (descending) and a `false` + // return breaks the chain, so priority 100 runs before and suppresses + // the built-in FilesAdded handler. + up.bind( + 'FilesAdded', + function ( uploader, files ) { + return handleFilesAdded( wpUploader, uploader, files ); + }, + this, + 100 + ); + }; +} )(); diff --git a/src/js/_enqueues/admin/media-new-upload.js b/src/js/_enqueues/admin/media-new-upload.js new file mode 100644 index 0000000000000..e48c6900b8357 --- /dev/null +++ b/src/js/_enqueues/admin/media-new-upload.js @@ -0,0 +1,339 @@ +/** + * Routes "Add New Media File" uploads through the client-side media pipeline. + * + * On wp-admin/media-new.php WordPress uploads via a raw plupload.Uploader + * (created by plupload-handlers) posting to async-upload.php. When the + * browser is cross-origin isolated and supports the client-side pipeline, + * this script intercepts the uploader's FilesAdded handler and hands files + * to wp.mediaUploadPipeline (media-upload-pipeline.js) instead: the + * original image is uploaded via the REST API and thumbnails are generated + * in the browser (wasm-vips), then sideloaded and finalized. + * + * The screen's existing UI helpers from plupload-handlers are reused: + * fileQueued() builds the progress item, uploadSuccess() renders the + * finished attachment row (via the async-upload.php markup endpoint), and + * uploadComplete() runs when the queue drains. Failed uploads render the + * same notice async-upload.php returns for a server-side failure, so the + * screen looks and behaves unchanged. + * + * When client-side support is unavailable the script cleanly no-ops and + * the classic plupload flow is left untouched. + * + * @output wp-admin/js/media-new-upload.js + */ + +/* global plupload, pluploadL10n, wpUploaderInit, fileQueued, uploadStart, uploadSuccess, uploadComplete */ + +( function () { + // Guard against double execution (e.g. duplicate enqueues). + if ( window.__wpMediaNewUpload ) { + return; + } + + const pipeline = window.wp && wp.mediaUploadPipeline; + + // Bail unless the browser actually supports client-side processing. This + // is the clean no-op: when the isolation headers did not land, classic + // plupload keeps handling uploads. + if ( + ! pipeline || + typeof plupload === 'undefined' || + ! wp.a11y || + ! pipeline.configure() + ) { + return; + } + + window.__wpMediaNewUpload = true; + + const __ = wp.i18n.__; + const sprintf = wp.i18n.sprintf; + + // Number of pipeline uploads currently in flight, used to fire + // uploadComplete() once both the pipeline and plupload are done. + let inFlightCount = 0; + + /** + * Marks one pipeline upload as finished, firing uploadComplete() when + * nothing is left uploading. + * + * The built-in UploadComplete binding never fires for pipeline uploads + * because every file is removed from plupload before its queue starts. + * Files plupload is still uploading (a batch's classic share, a HEIC + * handed back) end with plupload's own UploadComplete instead. + * + * @param {plupload.Uploader} up The plupload uploader. + */ + function finishUpload( up ) { + inFlightCount--; + if ( inFlightCount === 0 && up.state !== plupload.STARTED ) { + uploadComplete(); + } + } + + /** + * Returns the post the screen attaches uploads to, or 0. + * + * media-new.php validates `post_id` (the post must exist and be + * editable by the user) before printing it into the form, whereas the + * raw request value is what plupload carries in its multipart params. + * async-upload.php re-validates that raw value and silently uploads + * unattached; the REST API rejects it instead, so read the validated one. + * + * @return {number} The parent post ID, or 0. + */ + function getParentPostId() { + const input = /** @type {HTMLInputElement|null} */ ( + document.getElementById( 'post_id' ) + ); + const postId = input ? parseInt( input.value, 10 ) : 0; + return postId > 0 ? postId : 0; + } + + /** + * Returns the screen's progress item for a plupload file. + * + * @param {plupload.File} file The plupload file. + * @return {HTMLElement|null} The item fileQueued() built, if it is still on the page. + */ + function getItem( file ) { + return document.getElementById( 'media-item-' + file.id ); + } + + /** + * Renders a failed upload the way async-upload.php does for a + * server-side failure: an error notice with a real Dismiss button + * described by the notice, a screen reader announcement, and focus + * returned to the browse button once dismissed. + * + * @param {plupload.File} file The plupload file that failed. + * @param {string} message The reason the upload failed. + */ + function renderError( file, message ) { + const item = getItem( file ); + if ( ! item ) { + return; + } + + const descriptionId = 'error-description-' + file.id; + + const button = document.createElement( 'button' ); + button.type = 'button'; + button.id = 'dismiss-' + file.id; + button.className = 'dismiss button-link'; + button.setAttribute( 'aria-describedby', descriptionId ); + button.textContent = pluploadL10n.dismiss; + + const heading = document.createElement( 'strong' ); + heading.textContent = pluploadL10n.error_uploading.replace( + '%s', + file.name + ); + + const notice = document.createElement( 'div' ); + notice.id = descriptionId; + notice.className = 'notice notice-error error-div error'; + notice.append( + button, + ' ', + heading, + document.createElement( 'br' ), + message + ); + + item.replaceChildren( notice ); + // Read by itemAjaxError() in plupload-handlers so a later server + // error for the same file is not rendered twice. + item.dataset.lastErr = file.id; + + setTimeout( function () { + wp.a11y.speak( + sprintf( + /* translators: %s: Name of the file that failed to upload. */ + __( '%s has failed to upload.' ), + file.name + ) + ); + }, 1500 ); + + button.addEventListener( 'click', function () { + item.remove(); + wp.a11y.speak( __( 'Error dismissed.' ) ); + const browseButton = document.getElementById( + 'plupload-browse-button' + ); + if ( browseButton ) { + browseButton.focus(); + } + } ); + } + + /** + * Reflects pipeline progress onto the screen's progress bar for a file. + * + * The bar is 200px wide at 100%, matching uploadProgress() in + * plupload-handlers. + * + * @param {plupload.File} file The plupload file being uploaded. + * @param {number} percent Progress percentage. + */ + function renderProgress( file, percent ) { + const item = getItem( file ); + if ( ! item ) { + return; + } + + const bar = /** @type {HTMLElement|null} */ ( + item.querySelector( '.bar' ) + ); + if ( bar ) { + bar.style.width = 2 * percent + 'px'; + } + + const label = item.querySelector( '.percent' ); + if ( label ) { + label.textContent = percent + '%'; + } + } + + /** + * Intercepts files added to the plupload uploader. + * + * Builds the screen's progress items for the files the pipeline takes + * (see wp.mediaUploadPipeline.routeBatch()). Returns undefined to leave + * the whole batch to plupload's built-in handler. + * + * @param {plupload.Uploader} up The plupload uploader instance. + * @param {plupload.File[]} files Files added to the queue. + * @return {boolean|undefined} False to suppress the built-in handler. + */ + function handleFilesAdded( up, files ) { + return pipeline.routeBatch( up, files, { + // Without the parent post a file uploaded from + // media-new.php?post_id=N would land unattached even though the + // screen counts it against that post. + postId: getParentPostId(), + onStart: function ( /** @type {plupload.File[]} */ queued ) { + // Parity with the built-in handler: clear stale queue errors + // and run the shared upload-start housekeeping. + const queueErrors = document.getElementById( 'media-upload-error' ); + if ( queueErrors ) { + queueErrors.replaceChildren(); + } + uploadStart(); + + // Count the whole batch first: addItems() can reject a file + // synchronously, which must not drain the count mid-batch. + inFlightCount += queued.length; + }, + onQueue: function ( + /** @type {plupload.File} */ file, + /** @type {File} */ nativeFile + ) { + // Build the screen's progress item for this file. + fileQueued( file ); + + return { + onSuccess: function ( + /** @type {PipelineAttachment} */ attachment + ) { + // uploadSuccess() renders the finished attachment row + // via the existing async-upload.php markup endpoint; + // the server normally returns the ID as a string. + uploadSuccess( file, String( attachment.id ) ); + finishUpload( up ); + }, + onError: function ( /** @type {UploadError} */ error ) { + renderError( + file, + pipeline.getErrorText( error, nativeFile.name ) + ); + finishUpload( up ); + }, + // plupload builds its own progress item for the file it + // took over, so this one goes away. + onHandOff: function () { + const item = getItem( file ); + if ( item ) { + item.remove(); + } + finishUpload( up ); + }, + onProgress: function ( /** @type {number} */ percent ) { + renderProgress( file, percent ); + }, + }; + }, + } ); + } + + /** + * Binds the interceptor to an uploader once it has initialized. + * + * plupload sorts handlers by priority (descending) and a `false` return + * breaks the chain, so priority 100 runs before and suppresses the + * built-in FilesAdded and UploadComplete handlers. + * + * @param {plupload.Uploader} up The uploader that initialized. + */ + function bindUploader( up ) { + if ( up.__wpMediaNewUploadBound ) { + return; + } + up.__wpMediaNewUploadBound = true; + + up.bind( + 'FilesAdded', + function ( up, files ) { + return handleFilesAdded( up, files ); + }, + null, + 100 + ); + + // Hold back the built-in uploadComplete() while pipeline uploads + // are still running; finishUpload() fires it once they are done. + up.bind( + 'UploadComplete', + function () { + return inFlightCount > 0 ? false : undefined; + }, + null, + 100 + ); + } + + // plupload-handlers creates its uploader from the `wpUploaderInit` + // settings once the DOM is ready, after this script has run, and + // plupload binds the handlers in a settings `init` map while the + // uploader initializes. Hooking PostInit there reaches the uploader + // without depending on ready-callback ordering. The settings are + // missing when the browser uploader is disabled (the html-uploader + // fallback), in which case there is nothing to intercept. + if ( typeof wpUploaderInit !== 'object' || ! wpUploaderInit ) { + return; + } + + // Either shape may already hold another script's handlers, which keep + // running ahead of the interceptor being bound. + const init = wpUploaderInit.init; + if ( typeof init === 'function' ) { + wpUploaderInit.init = function ( up ) { + init( up ); + bindUploader( up ); + }; + } else { + const postInit = init && init.PostInit; + wpUploaderInit.init = Object.assign( {}, init, { + PostInit: function ( + /** @type {plupload.Uploader} */ up, + /** @type {unknown[]} */ ...args + ) { + if ( typeof postInit === 'function' ) { + postInit.call( this, up, ...args ); + } + bindUploader( up ); + }, + } ); + } +} )(); diff --git a/src/js/_enqueues/admin/media-upload-pipeline.js b/src/js/_enqueues/admin/media-upload-pipeline.js new file mode 100644 index 0000000000000..58dceae7affcc --- /dev/null +++ b/src/js/_enqueues/admin/media-upload-pipeline.js @@ -0,0 +1,739 @@ +/** + * Shared glue for routing classic admin uploads through the client-side + * media pipeline. + * + * The media frames (media-frame-upload.js) and the Add New Media + * File screen (media-new-upload.js) both intercept plupload and hand files + * to @wordpress/upload-media instead. Everything that is not specific to + * one screen's UI lives here: feature detection, configuring the + * upload-media store once, the REST sideload/finalize/delete helpers the + * store needs, queueing a file with success, error, and progress + * callbacks, the error text shown for a failed upload, and the guard that + * warns before leaving while uploads are in flight. + * + * Exposed as wp.mediaUploadPipeline. + * + * @output wp-admin/js/media-upload-pipeline.js + */ + +/* global plupload */ + +window.wp = window.wp || {}; + +( function ( wp ) { + const __ = wp.i18n.__; + const settings = window._wpMediaUploadPipelineSettings || {}; + /** @type {any} */ + let uploadStore; + let configured = false; + + // Uploads in flight, keyed by the onSuccess callback queueFile() passes + // to addItems(). The store keeps that callback on the queue item, so it + // identifies the item even after HEIC conversion swaps its file. + const entries = new Map(); + + // Files handed back to classic plupload after the pipeline gave up on + // them, so the interceptors let plupload's own handler take them. + const classicFiles = new WeakSet(); + + const imageSizeCount = Object.keys( settings.allImageSizes || {} ).length; + + /** + * Recursively appends data to a FormData object, supporting nested objects. + * + * Mirrors flattenFormData() in the media-utils package. + * + * @param {FormData} formData The form data to append to. + * @param {string} key The key to append under. + * @param {*} data The value to append. + */ + function flattenFormData( formData, key, data ) { + if ( + data !== null && + typeof data === 'object' && + Object.getPrototypeOf( data ) === Object.prototype + ) { + Object.keys( data ).forEach( function ( name ) { + flattenFormData( formData, key + '[' + name + ']', data[ name ] ); + } ); + } else if ( data !== undefined ) { + formData.append( key, String( data ) ); + } + } + + /** + * Sideloads a client-generated thumbnail to an existing attachment. + * + * Reimplements the private sideloadMedia() helper from the media-utils + * package as a thin apiFetch wrapper. + * + * @param {Object} args The sideload arguments. + * @param {File} args.file The sub-size to sideload. + * @param {number} args.attachmentId The attachment to sideload it to. + * @param {Record} [args.additionalData] Extra fields to send with it. + * @param {AbortSignal} [args.signal] Signal aborting the request. + * @param {( subSize: Object ) => void} [args.onSuccess] Called with the sideloaded sub-size. + * @param {( error: Error ) => void} [args.onError] Called when the sideload failed. + */ + function mediaSideload( args ) { + const file = args.file; + const additionalData = args.additionalData || {}; + + const data = new FormData(); + data.append( 'file', file, file.name || file.type.replace( '/', '.' ) ); + Object.keys( additionalData ).forEach( function ( key ) { + flattenFormData( data, key, additionalData[ key ] ); + } ); + + wp.apiFetch( { + path: '/wp/v2/media/' + args.attachmentId + '/sideload', + body: data, + method: 'POST', + signal: args.signal, + } ) + .then( function ( /** @type {Object} */ subSize ) { + if ( args.onSuccess ) { + args.onSuccess( subSize ); + } + } ) + .catch( function ( /** @type {unknown} */ error ) { + if ( args.onError ) { + let normalized = error; + if ( ! ( error instanceof Error ) ) { + const rest = /** @type {UploadError} */ ( error ); + normalized = new Error( + rest && rest.message ? rest.message : String( error ) + ); + } + args.onError( /** @type {Error} */ ( normalized ) ); + } + } ); + } + + /** + * Finalizes an upload once all client-side processing is complete. + * + * Reimplements the private mediaFinalize() helper. The returned + * attachment is load-bearing: it carries the post-finalize (scaled) + * URL used for srcset. + * + * @param {number} id The parent attachment ID. + * @param {Object[]} subSizes Accumulated sub-size data. + * @return {Promise} Resolves with the transformed attachment. + */ + async function mediaFinalize( id, subSizes ) { + /** @type {Object|undefined} */ + const response = await wp.apiFetch( { + path: '/wp/v2/media/' + id + '/finalize', + method: 'POST', + data: { sub_sizes: subSizes || [] }, + } ); + if ( ! response ) { + return undefined; + } + return wp.mediaUtils.transformAttachment( response ); + } + + /** + * Deletes an attachment whose client-side processing failed outright. + * + * The queue calls this when every sub-size sideload for an upload fails: + * without it the original file is left behind as an attachment with no + * metadata, visible in the Media Library after the next page load. The + * block editor passes the same setting. + * + * @param {number} id The attachment ID to delete. + * @return {Promise} Resolves once the attachment is deleted. + */ + function mediaDelete( id ) { + return wp.apiFetch( { + path: '/wp/v2/media/' + id + '?force=true', + method: 'DELETE', + } ); + } + + /** + * Whether every script the pipeline relies on is present. + * + * @return {boolean} True when the dependencies loaded. + */ + function hasDependencies() { + return Boolean( + typeof plupload !== 'undefined' && + wp.uploadMedia && + wp.mediaUtils && + wp.data && + wp.element && + wp.apiFetch + ); + } + + /** + * Whether the browser can run the client-side pipeline on this page. + * + * False when the isolation headers did not land (the page is not + * cross-origin isolated) or the browser lacks the required features, + * in which case classic plupload keeps handling uploads. + * + * @return {boolean} True when client-side processing is available. + */ + function isSupported() { + return Boolean( + hasDependencies() && + wp.uploadMedia.detectClientSideMediaSupport && + wp.uploadMedia.detectClientSideMediaSupport().supported + ); + } + + /** + * Estimates the progress (0-100) of a queue item. + * + * The pipeline never reports a numeric `progress` on its queue items + * (nothing dispatches updateItemProgress), so estimate one from the + * item's operation queue instead: each finished operation (prepare, + * transcode, upload, thumbnails, finalize) advances the bar, and the + * sub-sizes sideloaded so far advance it within thumbnail generation. + * `item.progress` is preferred whenever it is present. + * + * @param {QueueItem} item The upload-media queue item. + * @param {UploadEntry} entry The pipeline's bookkeeping for the upload. + * @return {number} Estimated progress. + */ + function estimateProgress( item, entry ) { + if ( typeof item.progress === 'number' ) { + return item.progress; + } + + const remaining = item.operations ? item.operations.length : 0; + let totals = entry.totals; + if ( ! totals ) { + totals = { total: remaining, remaining: remaining }; + entry.totals = totals; + } + // Operations are appended after preparation, so grow the total. + if ( remaining > totals.remaining ) { + totals.total += remaining - totals.remaining; + } + totals.remaining = remaining; + + if ( totals.total === 0 ) { + return 0; + } + + const completed = totals.total - remaining; + let fraction = 0; + if ( + 'THUMBNAIL_GENERATION' === item.currentOperation && + imageSizeCount > 0 + ) { + fraction = Math.min( + 1, + ( item.subSizes || [] ).length / imageSizeCount + ); + } + + return ( ( completed + fraction ) / totals.total ) * 100; + } + + /** + * Tracks queued uploads on every change to the upload-media store. + * + * Reports progress, which holds at 99 until the upload's success + * callback has run so nothing looks finished before the screen has + * synced the result, and notes the attachment once the original is + * uploaded. Sub-size children are skipped; only top-level items matter. + * + * The store can also remove an item without calling either of its + * callbacks (it only calls onSuccess when the item has an attachment), + * so an upload that was seen in the store and is gone fails here + * instead of staying in flight, and holding the unload guard, for good. + */ + function onStoreChange() { + if ( entries.size === 0 ) { + return; + } + + const present = new Set(); + const items = wp.data.select( uploadStore ).getItems(); + items.forEach( function ( /** @type {QueueItem} */ item ) { + if ( item.parentId ) { + return; + } + + const entry = entries.get( item.onSuccess ); + if ( ! entry ) { + return; + } + + present.add( item.onSuccess ); + entry.seen = true; + if ( item.attachment && item.attachment.id ) { + entry.attachmentId = item.attachment.id; + } + + if ( ! entry.onProgress ) { + return; + } + + const percent = Math.min( + 99, + Math.round( estimateProgress( item, entry ) ) + ); + if ( percent !== entry.lastPercent ) { + entry.lastPercent = percent; + entry.onProgress( percent ); + } + } ); + + entries.forEach( function ( entry, key ) { + if ( entry.seen && ! present.has( key ) ) { + entry.fail( { + message: __( 'An error occurred while uploading the file.' ), + } ); + } + } ); + } + + /** + * Whether something else already configured the store on this page. + * + * The block editor renders its own MediaUploadProvider into the same + * store. Neither `mediaSideload` nor `mediaFinalize` has a default, so + * their presence proves a provider's settings landed, whichever one + * rendered them. + * + * @return {boolean} True when the store carries a provider's settings. + */ + function isStoreConfigured() { + const storeSettings = wp.data.select( uploadStore ).getSettings(); + return Boolean( + storeSettings && + storeSettings.mediaSideload && + storeSettings.mediaFinalize + ); + } + + /** + * Configures the upload-media store for this page, once. + * + * Rendering the provider with useSubRegistry: false wires the settings + * into the store that wp.data.dispatch/select address. The block editor + * does the same for its own uploads, so on its screens no second + * provider is rendered: whichever committed last would win, and this + * one could replace the editor's mediaUpload. Uploads wait for the + * editor's settings instead (see isReady()). + * + * @return {boolean} True when the pipeline is configured and usable. + */ + function configure() { + if ( configured ) { + return true; + } + + if ( ! isSupported() ) { + return false; + } + + configured = true; + uploadStore = wp.uploadMedia.store; + + if ( settings.isBlockEditor || isStoreConfigured() ) { + wp.data.subscribe( onStoreChange, uploadStore ); + addUnloadGuard(); + return true; + } + + /* + * The media-utils package branches on this flag: without it + * uploadMedia() creates and revokes a throwaway blob URL per file + * and emits an extra onFileChange carrying it. The block editor + * sets the same flag before configuring the same pipeline. + */ + window.__clientSideMediaProcessing = true; + + wp.element + .createRoot( document.createElement( 'div' ) ) + .render( + wp.element.createElement( wp.uploadMedia.MediaUploadProvider, { + settings: { + mediaUpload: wp.mediaUtils.uploadMedia, + mediaSideload: mediaSideload, + mediaFinalize: mediaFinalize, + mediaDelete: mediaDelete, + maxUploadFileSize: settings.maxUploadFileSize, + allowedMimeTypes: settings.allowedMimeTypes, + allImageSizes: settings.allImageSizes, + bigImageSizeThreshold: settings.bigImageSizeThreshold, + imageStripMeta: settings.imageStripMeta, + imageMaxBitDepth: settings.imageMaxBitDepth, + generateAnimatedImageSubsizes: + settings.generateAnimatedImageSubsizes, + }, + useSubRegistry: false, + } ) + ); + + // Only the upload-media store can change what this listener reads. + wp.data.subscribe( onStoreChange, uploadStore ); + addUnloadGuard(); + + return true; + } + + /** + * Warns before leaving while uploads are in flight. + * + * Thumbnails that have not been sideloaded yet are lost and the + * attachment is left unfinalized, unlike classic uploads that complete + * server-side once the bytes arrive. + */ + function addUnloadGuard() { + window.addEventListener( 'beforeunload', function ( event ) { + if ( entries.size > 0 ) { + event.preventDefault(); + } + } ); + } + + /** + * Whether the store has received a provider's settings. + * + * A provider renders asynchronously, so a file added before its + * settings land must be left to classic plupload - a degradation, + * never data loss. The store's default state already carries a no-op + * `mediaUpload`, so only settings a provider alone supplies prove the + * real ones have landed: a file queued before that would be handed to + * the no-op and never upload. On block editor screens only the + * editor's provider supplies them. + * + * @return {boolean} True when the store is ready to accept files. + */ + function isReady() { + return configured && isStoreConfigured(); + } + + /** + * Splits a batch of plupload files between the pipeline and classic + * plupload. + * + * Audio files stay classic so they keep the title and description that + * media_handle_upload() derives from their ID3 tags, which the REST + * endpoint does not do. So do JPEG and PNG images while plupload's own + * client-side resize is enabled (the "Scale images" setting, or a + * `resize` passed through `plupload_init`): plupload scales those down + * before uploading them, the pipeline would not. + * + * The whole batch stays classic when a file cannot be re-queued on + * plupload or is already plupload's: plupload returns no native File + * for sources it cannot expose as one (the html4 runtime, say), and a + * file the pipeline handed back (see handOffToClassic()) is plupload's + * to upload. + * + * @param {plupload.Uploader} up The plupload uploader the files were added to. + * @param {plupload.File[]} files Files added to the plupload queue. + * @return {{ pipeline: plupload.File[], classic: plupload.File[] }|null} The split, + * or null when the whole batch stays on the classic path. + */ + function partitionBatch( up, files ) { + const resize = up.settings && up.settings.resize; + const isResizing = Boolean( resize && resize.enabled ); + /** @type {{ pipeline: plupload.File[], classic: plupload.File[] }} */ + const batch = { pipeline: [], classic: [] }; + + for ( const file of files ) { + if ( plupload.FAILED === file.status ) { + continue; + } + const nativeFile = file.getNative && file.getNative(); + if ( ! nativeFile || classicFiles.has( nativeFile ) ) { + return null; + } + const type = file.type || ''; + if ( + /^audio\//i.test( type ) || + ( isResizing && /^image\/(?:jpeg|png)$/i.test( type ) ) + ) { + batch.classic.push( file ); + } else { + batch.pipeline.push( file ); + } + } + + return batch.pipeline.length ? batch : null; + } + + /** + * Hands a file to classic plupload, whose own FilesAdded handler queues + * and starts the upload. + * + * @param {plupload.Uploader} up The plupload uploader. + * @param {File} nativeFile The file to upload. + */ + function addClassicFile( up, nativeFile ) { + classicFiles.add( nativeFile ); + up.addFile( nativeFile ); + } + + /** + * Re-queues the classic share of a batch on plupload. + * + * Suppressing plupload's built-in FilesAdded handler covers the whole + * batch, so the files partitionBatch() left to plupload are removed and + * added back on their own. + * + * @param {plupload.Uploader} up The plupload uploader. + * @param {plupload.File[]} files The files to upload classically. + */ + function handOffBatchToClassic( up, files ) { + files.forEach( function ( file ) { + // partitionBatch() already established that every file has one. + const nativeFile = /** @type {File} */ ( file.getNative() ); + up.removeFile( file ); + addClassicFile( up, nativeFile ); + } ); + } + + /** + * Re-queues a file on classic plupload when the server can do what the + * browser could not. + * + * The pipeline converts HEIC in the browser and fails outright when the + * platform has no HEIC decoder (Linux, and Windows without the HEVC + * extension), whereas the classic upload lets the server convert it. + * When the server's image editor supports HEIC - plupload carries no + * `heic_upload_error` setting then - the file goes back to plupload, + * whose own FilesAdded handler queues and starts the classic upload. + * Nothing else can convert it, so any other failure stands. + * + * @param {plupload.Uploader} up The plupload uploader the file came from. + * @param {UploadError} error Why the pipeline gave up on it. + * @param {File} nativeFile The file the pipeline was given. + * @return {boolean} True when the file was handed to classic plupload. + */ + function handOffToClassic( up, error, nativeFile ) { + const errorCodes = wp.uploadMedia.ErrorCode || {}; + if ( + ! error || + ! errorCodes.HEIC_DECODE_ERROR || + error.code !== errorCodes.HEIC_DECODE_ERROR || + ! up || + typeof up.addFile !== 'function' || + ( up.settings && up.settings.heic_upload_error ) + ) { + return false; + } + + addClassicFile( up, nativeFile ); + return true; + } + + /** + * Builds the extra fields to send with an upload from plupload's + * multipart params. + * + * Anything a plugin added through the `plupload_default_params` filter + * or wp.Uploader.param() reached the classic upload as $_POST fields, + * so it is forwarded to the REST request the same way. The classic + * transport's own fields are dropped: `action` and `_wpnonce` belong to + * async-upload.php, and `post_id` is spelled `post` by the REST API and + * passed separately after the screen has validated it. + * + * @param {Record} params Plupload's multipart_params. + * @param {number} postId The validated post to attach the upload to, or 0. + * @return {Record} Additional data for the upload. + */ + function additionalDataFromParams( params, postId ) { + /** @type {Record} */ + const additionalData = {}; + Object.keys( params || {} ).forEach( function ( key ) { + if ( 'action' === key || '_wpnonce' === key || 'post_id' === key ) { + return; + } + additionalData[ key ] = params[ key ]; + } ); + if ( postId ) { + additionalData.post = postId; + } + return additionalData; + } + + /** + * Queues a file for client-side processing and upload. + * + * @param {File} nativeFile The file to upload. + * @param {Record} additionalData Extra fields to send with the attachment. + * @param {QueueFileCallbacks} [callbacks] Lifecycle callbacks. + */ + function queueFile( nativeFile, additionalData, callbacks = {} ) { + /** + * Handles successful upload. + * + * @param {PipelineAttachment[]} attachments The uploaded attachments. + */ + function onSuccess( attachments ) { + entries.delete( onSuccess ); + if ( callbacks.onSuccess ) { + callbacks.onSuccess( attachments[ 0 ] ); + } + } + + /** @type {UploadEntry} */ + const entry = { + onProgress: callbacks.onProgress, + lastPercent: -1, + totals: null, + seen: false, + attachmentId: 0, + fail: function ( error ) { + if ( ! entries.delete( onSuccess ) ) { + return; + } + // The original already reached the server (finalize failed, + // say), so delete it: otherwise a retry leaves a duplicate + // next to an attachment whose metadata was never finalized. + // The store only deletes it itself when every sub-size failed. + if ( entry.attachmentId ) { + mediaDelete( entry.attachmentId ).catch( function () {} ); + } + if ( callbacks.onError ) { + callbacks.onError( error ); + } + }, + }; + entries.set( onSuccess, entry ); + + wp.data.dispatch( uploadStore ).addItems( { + files: [ nativeFile ], + additionalData: additionalData || {}, + onSuccess: onSuccess, + onError: function ( /** @type {UploadError|string} */ error ) { + // Also reached for files addItems() rejects before queueing. + // The block editor's mediaUpload() setting, which the store + // uses once the editor has configured it, reports transport + // failures as a bare message string. + entry.fail( + typeof error === 'string' ? { message: error } : error + ); + }, + } ); + } + + /** + * Routes a batch of files added to a plupload uploader through the + * pipeline. + * + * Returns undefined (not false) when the pipeline cannot take the batch + * so plupload's built-in FilesAdded handler runs and uploads it + * server-side - a degradation, never data loss. Otherwise queues the + * files the pipeline can take, re-queues the rest on plupload, and + * returns false to suppress the built-in handler, which would otherwise + * start a classic upload of every file. + * + * The classic flow posts plupload's multipart params to + * async-upload.php, so they are forwarded to the REST request for + * plugins reading $_POST, with `post_id` replaced by `options.postId`. + * + * @param {plupload.Uploader} up The plupload uploader the files were added to. + * @param {plupload.File[]} files Files added to the queue. + * @param {RouteBatchOptions} options The screen's hooks. + * @return {boolean|undefined} False to suppress the built-in handler. + */ + function routeBatch( up, files, options ) { + if ( ! isReady() ) { + return; + } + + const batch = partitionBatch( up, files ); + if ( ! batch ) { + return; + } + + const params = ( up.settings && up.settings.multipart_params ) || {}; + const additionalData = additionalDataFromParams( + params, + options.postId + ); + + if ( options.onStart ) { + options.onStart( batch.pipeline ); + } + + batch.pipeline.forEach( function ( /** @type {plupload.File} */ file ) { + // partitionBatch() already established that every file has one. + const nativeFile = /** @type {File} */ ( file.getNative() ); + const callbacks = options.onQueue( file, nativeFile ); + + // Remove the file from plupload so it is not uploaded twice. + up.removeFile( file ); + + queueFile( nativeFile, additionalData, { + onSuccess: callbacks.onSuccess, + onProgress: callbacks.onProgress, + onError: function ( /** @type {UploadError} */ error ) { + // When the server can convert what the browser could + // not, plupload takes the file from here and shows its + // own progress. + if ( handOffToClassic( up, error, nativeFile ) ) { + callbacks.onHandOff(); + return; + } + callbacks.onError( error ); + }, + } ); + } ); + + handOffBatchToClassic( up, batch.classic ); + + up.refresh(); + + return false; + } + + /** + * Builds the display text for a failed upload. + * + * wp.uploadMedia.getErrorMessage() maps an error *code* and a file name + * to a { title, description, action } object, so it can neither be handed + * the Error itself nor used as a string. Only codes it actually maps are + * worth using: its fallback (and the GENERAL code) says nothing the + * error's own message does not, and preferring the message there keeps a + * server-supplied reason instead of replacing it with "Please try again." + * + * @param {UploadError} error The upload error. + * @param {string} fileName Name of the file that failed to upload. + * @return {string} A human-readable message. + */ + function getErrorText( error, fileName ) { + const errorCodes = wp.uploadMedia.ErrorCode || {}; + const code = error && error.code; + let details; + + if ( + code && + code !== errorCodes.GENERAL && + Object.prototype.hasOwnProperty.call( errorCodes, code ) && + wp.uploadMedia.getErrorMessage + ) { + details = wp.uploadMedia.getErrorMessage( code, fileName ); + + if ( details && details.description ) { + return details.action ? + details.description + ' ' + details.action : + details.description; + } + } + + return ( + ( error && error.message ) || + __( 'An error occurred while uploading the file.' ) + ); + } + + wp.mediaUploadPipeline = { + isSupported: isSupported, + configure: configure, + isReady: isReady, + routeBatch: routeBatch, + getErrorText: getErrorText, + }; +} )( window.wp ); diff --git a/src/js/_enqueues/admin/plugin-install.js b/src/js/_enqueues/admin/plugin-install.js index b79931a7e7791..61e6a2e1ed60b 100644 --- a/src/js/_enqueues/admin/plugin-install.js +++ b/src/js/_enqueues/admin/plugin-install.js @@ -218,7 +218,7 @@ jQuery( function( $ ) { // Flip the content. $( '#section-holder div.section' ).hide(); // Hide 'em all. - $( '#section-' + tab ).show(); + $( '#section-' + CSS.escape( tab ) ).show(); }); /* diff --git a/src/js/_enqueues/admin/post.js b/src/js/_enqueues/admin/post.js index 462523636c9ab..1c166a82cb32a 100644 --- a/src/js/_enqueues/admin/post.js +++ b/src/js/_enqueues/admin/post.js @@ -586,7 +586,7 @@ jQuery( function($) { $(this).attr( 'aria-selected', 'true' ).removeAttr( 'tabindex' ); $(this).parent().addClass('tabs').siblings('li').removeClass('tabs'); $('#' + taxonomy + '-tabs').siblings('.tabs-panel').hide(); - $(t).show(); + $( document ).find( t ).show(); if ( '#' + taxonomy + '-all' == t ) { deleteUserSetting( settingName ); } else { diff --git a/src/js/_enqueues/admin/site-health.js b/src/js/_enqueues/admin/site-health.js index 0a1b052e56f3b..8d8c40c70c3e9 100644 --- a/src/js/_enqueues/admin/site-health.js +++ b/src/js/_enqueues/admin/site-health.js @@ -66,7 +66,7 @@ jQuery( function( $ ) { var hash = window.location.hash; if ( hash ) { - var requestedPanel = $( hash ); + var requestedPanel = $( document ).find( hash ); if ( requestedPanel.is( '.health-check-accordion-trigger' ) ) { requestedPanel.trigger( 'click' ); diff --git a/src/js/_enqueues/admin/user-profile.js b/src/js/_enqueues/admin/user-profile.js index 3507b53287b0a..23d10404129fe 100644 --- a/src/js/_enqueues/admin/user-profile.js +++ b/src/js/_enqueues/admin/user-profile.js @@ -493,10 +493,15 @@ } ); $( function() { - var $colorpicker, $stylesheet, user_id, current_user_id, - select = $( '#display_name' ), - username = $( '#user_login' ).val(), - greeting = $( '#wp-admin-bar-my-account' ).find( '.display-name' ); + var $colorpicker, $stylesheet, $selectedMenuIconStyles, initialMenuIconStyles, user_id, current_user_id, + select = $( '#display_name' ), + username = $( '#user_login' ).val(), + myAccount = $( '#wp-admin-bar-my-account' ), + displayNames = myAccount.find( '.user-display-name' ), + usernameLine = myAccount.find( '#wp-admin-bar-user-info .username' ), + menus = myAccount.children( '.ab-sub-wrapper' ).children( '[role="menu"]' ).filter( function() { + return this.getAttribute( 'aria-label' ) === displayNames.first().text(); + } ); $( '#pass1' ).val( '' ).on( 'input' + ' pwupdate', check_pass_strength ); $('#pass-strength-result').show(); @@ -549,12 +554,18 @@ var display_name = this.value.trim() || username; - greeting.text( display_name ); + displayNames.text( display_name ); + menus.attr( 'aria-label', display_name ); + + // Only show the username when it differs from the display name. + usernameLine.prop( 'hidden', display_name === username ); } ); } $colorpicker = $( '#color-picker' ); $stylesheet = $( '#colors-css' ); + $selectedMenuIconStyles = $(); + initialMenuIconStyles = $colorpicker.find( '.selected .menu_icon_styles' ).val(); user_id = $( 'input#user_id' ).val(); current_user_id = $( 'input[name="checkuser_id"]' ).val(); @@ -590,6 +601,19 @@ } } + // Color the inline SVG menu icons. + if ( 0 === $selectedMenuIconStyles.length ) { + // Remove the color scheme styles that were already printed. + if ( initialMenuIconStyles ) { + $( 'style' ).each( function() { + this.textContent = this.textContent.replace( initialMenuIconStyles, '' ); + } ); + } + // Insert before the colors stylesheet, so that the stylesheet can override these styles. + $selectedMenuIconStyles = $( '' ); - $processor->next_tag(); - $style_tag_contents = "\n{$wp_styles->print_code}\n" - . sprintf( "/*# sourceURL=%s */\n", rawurlencode( $concat_source_url ) ); - $processor->set_modifiable_text( $style_tag_contents ); - echo "{$processor->get_updated_html()}\n"; - } - } - - if ( ! empty( $wp_styles->print_html ) ) { - echo $wp_styles->print_html; - } -} - -/** - * Determines the concatenation and compression settings for scripts and styles. - * - * @since 2.8.0 - * - * @global bool $concatenate_scripts - * @global bool $compress_scripts - * @global bool $compress_css - */ -function script_concat_settings() { - global $concatenate_scripts, $compress_scripts, $compress_css; - - $compressed_output = ( ini_get( 'zlib.output_compression' ) || 'ob_gzhandler' === ini_get( 'output_handler' ) ); - - $can_compress_scripts = ! wp_installing() && get_site_option( 'can_compress_scripts' ); - - if ( ! isset( $concatenate_scripts ) ) { - $concatenate_scripts = ( is_admin() || did_action( 'login_init' ) ) && wp_should_concatenate_admin_scripts(); - } - - if ( ! isset( $compress_scripts ) ) { - $compress_scripts = defined( 'COMPRESS_SCRIPTS' ) ? COMPRESS_SCRIPTS : true; - if ( $compress_scripts && ( ! $can_compress_scripts || $compressed_output ) ) { - $compress_scripts = false; - } - } - - if ( ! isset( $compress_css ) ) { - $compress_css = defined( 'COMPRESS_CSS' ) ? COMPRESS_CSS : true; - if ( $compress_css && ( ! $can_compress_scripts || $compressed_output ) ) { - $compress_css = false; - } - } -} - -/** - * Determines whether scripts and styles are concatenated on admin screens and the login screen. - * - * Concatenation is off unless the `CONCATENATE_SCRIPTS` constant turns it on, and `SCRIPT_DEBUG` - * turns it off regardless. Scripts and styles are never concatenated elsewhere. - * - * This is the default that script_concat_settings() gives the `$concatenate_scripts` global when - * the global has not already been set. It is also how wp_prefetch_admin_assets() predicts, from the - * login screen, what the admin screen the login leads to will do. - * - * This function and its filter are intended to be removed before 7.2-beta1. They exist only while - * concatenation is still an option, and once concatenation is retired, there is nothing left for - * them to decide. Do not rely on them; setting the `$concatenate_scripts` global remains the way - * to override concatenation on a request. - * - * @since 7.2.0 - * - * @return bool Whether scripts and styles are concatenated on admin screens and the login screen. - */ -function wp_should_concatenate_admin_scripts(): bool { - $concatenate = ( defined( 'CONCATENATE_SCRIPTS' ) ? (bool) CONCATENATE_SCRIPTS : false ) - && ! ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ); - - /** - * Filters whether scripts and styles are concatenated on admin screens and the login screen. - * - * Setting the `$concatenate_scripts` global directly still takes precedence over this filter on - * the request where it is set. - * - * An admin screen settles the global the first time scripts are registered, when the - * {@see 'wp_default_scripts'} action registers TinyMCE. That can be as early as while plugins - * load, before the theme's functions.php, whereas the login screen reads this filter only when - * it prints, to predict what the admin will do. So add a callback when a plugin loads, rather - * than from a theme or on a later hook, or the admin may not see it while the login screen does. - * - * @since 7.2.0 - * - * @param bool $concatenate Whether scripts and styles are concatenated. Default false, unless the - * `CONCATENATE_SCRIPTS` constant is true and `SCRIPT_DEBUG` is not. - */ - return (bool) apply_filters( 'wp_should_concatenate_admin_scripts', $concatenate ); -} - /** * Prints prefetch links for the assets of the screen the user is most likely to open next. * * Runs wherever the next screen can be predicted with confidence, and prefetches only what that * screen is certain to need. Two cases qualify today: * - * - The login screen, which is followed by an admin screen. With concatenation disabled that - * screen downloads each core script and stylesheet separately, which is what makes an uncached - * admin load slower than a concatenated one. Requesting the ones that block its first paint - * while the login form is on screen puts them in the HTTP cache during the time the user spends - * typing credentials, so the redirect that follows finds them already there. + * - The login screen, which is followed by an admin screen. That screen downloads each core script + * and stylesheet separately, which is what makes an uncached admin load slow. Requesting the + * ones that block its first paint while the login form is on screen puts them in the HTTP cache + * during the time the user spends typing credentials, so the redirect that follows finds them + * already there. * - The Dashboard and the post list tables, from which the editor is the usual next stop. Only the * editor's stylesheets are prefetched, not its scripts: the scripts run to well over a megabyte * compressed, which is far too much to spend on a screen the user may never open, whereas the @@ -2587,42 +2356,25 @@ function wp_should_concatenate_admin_scripts(): bool { * page, such as the password reset form, since those mostly lead to the admin as well. Nothing is * printed at all when `redirect_to` points outside this site's admin. * - * Nothing is printed when the next screen will concatenate its assets, since `load-scripts.php` - * and `load-styles.php` already collapse these handles into a handful of requests. - * * @since 7.2.0 * * @see wp_preload_resources() - * @see wp_should_concatenate_admin_scripts() * - * @global bool $concatenate_scripts Whether scripts and styles are concatenated. - * @global WP_Screen|null $current_screen The current admin screen, if any. + * @global WP_Screen|null $current_screen The current admin screen, if any. */ function wp_prefetch_admin_assets(): void { - global $concatenate_scripts, $current_screen; + global $current_screen; /* * The context is told apart by the request rather than by the hook this runs on, so it works * from whichever hook it is added to. The 'login_init' action is fired by wp-login.php however - * it is reached, which is also how script_concat_settings() recognizes it. The is_login() - * function would not do: it compares the login URL with the script handling the request, and a - * plugin serving the login screen at a URL of its own runs wp-login.php from another script, - * such as index.php. + * it is reached. The is_login() function would not do: it compares the login URL with the + * script handling the request, and a plugin serving the login screen at a URL of its own runs + * wp-login.php from another script, such as index.php. */ $on_login = (bool) did_action( 'login_init' ); - if ( $on_login ) { - /* - * Deliberately not the $concatenate_scripts global: script_concat_settings() often runs on a - * login request before 'login_init' fires — anything registering a script on 'init' is enough - * to trigger it — and at that point it evaluates is_admin() as false and settles the global on - * false whatever the constant says. What matters here is what the admin screen this login - * leads to will do, which is what wp_should_concatenate_admin_scripts() predicts. - */ - if ( wp_should_concatenate_admin_scripts() ) { - return; - } - } else { + if ( ! $on_login ) { /* * From the Dashboard and the post list tables, the editor is the usual next stop. Anywhere * else in the admin there is no destination worth guessing at. @@ -2634,16 +2386,6 @@ function wp_prefetch_admin_assets(): void { if ( ! $current_screen instanceof WP_Screen || ! in_array( $current_screen->base, array( 'dashboard', 'edit' ), true ) ) { return; } - - /* - * On an admin screen the global has settled by the time its head is printed, and the editor - * will be served the same way. - */ - script_concat_settings(); - - if ( $concatenate_scripts ) { - return; - } } $script_roots = array(); @@ -3051,7 +2793,7 @@ function wp_prefetch_admin_assets(): void { /** * Prefetches the page's rel=prefetch links with fetch() in browsers that do not support rel=prefetch. * - * @see https://caniuse.com/link-rel-prefetch + * @link https://caniuse.com/link-rel-prefetch */ () => { if ( document.createElement( 'link' ).relList?.supports?.( 'prefetch' ) ) { @@ -3616,6 +3358,12 @@ function wp_get_inline_script_tag( $data, $attributes = array() ) { /** * Filters attributes to be added to a script tag. * + * Escaping and normalization can make the text a browser parses from the printed tag differ + * from `$data`. A callback that needs that text, such as one computing a CSP hash, can set + * `$data` as the modifiable text of a SCRIPT tag in a `WP_HTML_Tag_Processor` with the + * filtered attributes applied and then read {@see WP_HTML_Tag_Processor::get_modifiable_text()}, + * at a late priority so that the attributes are final. + * * @since 5.7.0 * * @param array $attributes Key-value pairs representing `', + 'PluginURI' => 'javascript:alert(1)', + 'Version' => '1.0 beta bold', + 'Description' => 'A fine plugin cited & "quoted".', + 'Author' => 'Jane & Co', + 'AuthorURI' => 'https://example.org/author?a=1&b="2"', + ) + ); + + $this->assertSame( + array( + 'Name' => 'My Plugin Link', + 'PluginURI' => '', + 'Version' => '1.0 beta bold', + 'Description' => 'A fine plugin cited & "quoted".', + 'Author' => 'Jane & Co', + 'AuthorURI' => 'https://example.org/author?a=1&b=2', + 'TextDomain' => '', + 'DomainPath' => '', + 'Title' => 'My Plugin Link', + 'AuthorName' => 'Jane & Co', + ), + _get_plugin_data_markup_translate( WP_PLUGIN_DIR . '/my-plugin/my-plugin.php', $plugin_data, false, false ) + ); + } + + /** + * Tests that markup links the name and author and credits the author. + * + * @ticket 65819 + */ + public function test_should_add_markup() { + $plugin_data = $this->get_plugin_data( + array( + 'Name' => 'My Plugin', + 'PluginURI' => 'https://example.org/plugin/', + 'Description' => "It's a plugin -- really.", + 'Author' => 'Jane', + 'AuthorURI' => 'https://example.org/jane/', + ) + ); + + $result = _get_plugin_data_markup_translate( WP_PLUGIN_DIR . '/my-plugin/my-plugin.php', $plugin_data, true, false ); + + $this->assertSame( 'My Plugin', $result['Title'], 'The title should link to the plugin URI.' ); + $this->assertSame( 'Jane', $result['Author'], 'The author should link to the author URI.' ); + $this->assertSame( 'Jane', $result['AuthorName'], 'The author name should not be linked.' ); + $this->assertSame( 'My Plugin', $result['Name'], 'The name should not be linked.' ); + $this->assertSame( + 'It’s a plugin — really. By Jane.', + $result['Description'], + 'The description should be texturized and credit the author.' + ); + } + + /** + * Tests that markup leaves out links and the author credit when their fields are empty. + * + * @ticket 65819 + * + * @dataProvider data_empty_markup_fields + * + * @param array $fields The plugin data fields. + * @param array $expected The expected Title, Author and Description. + */ + public function test_should_skip_markup_for_empty_fields( $fields, $expected ) { + $result = _get_plugin_data_markup_translate( WP_PLUGIN_DIR . '/my-plugin/my-plugin.php', $this->get_plugin_data( $fields ), true, false ); + + $this->assertSame( + $expected, + array( + 'Title' => $result['Title'], + 'Author' => $result['Author'], + 'Description' => $result['Description'], + ) + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_empty_markup_fields() { + return array( + 'no plugin URI' => array( + array( + 'Name' => 'My Plugin', + 'Description' => 'Text.', + 'Author' => 'Jane', + ), + array( + 'Title' => 'My Plugin', + 'Author' => 'Jane', + 'Description' => 'Text. By Jane.', + ), + ), + 'no name' => array( + array( + 'PluginURI' => 'https://example.org/plugin/', + 'Description' => 'Text.', + ), + array( + 'Title' => '', + 'Author' => '', + 'Description' => 'Text.', + ), + ), + 'no author URI' => array( + array( + 'Name' => 'My Plugin', + 'Author' => 'Jane', + ), + array( + 'Title' => 'My Plugin', + 'Author' => 'Jane', + 'Description' => ' By Jane.', + ), + ), + 'author "0"' => array( + array( + 'Name' => 'My Plugin', + 'Author' => '0', + 'AuthorURI' => 'https://example.org/jane/', + ), + array( + 'Title' => 'My Plugin', + 'Author' => '0', + 'Description' => '', + ), + ), + ); + } + + /** + * Tests that the translatable fields are translated with the plugin's text domain. + * + * @ticket 65819 + */ + public function test_should_translate_fields_with_text_domain() { + $GLOBALS['l10n']['my-plugin'] = new MO(); + + add_filter( 'gettext', array( $this, 'translate_string' ), 10, 3 ); + + $result = _get_plugin_data_markup_translate( + WP_PLUGIN_DIR . '/my-plugin/my-plugin.php', + $this->get_plugin_data( + array( + 'Name' => 'My Plugin', + 'PluginURI' => 'https://example.org/plugin/', + 'Version' => '1.0', + 'Description' => 'Text.', + 'Author' => 'Jane', + 'TextDomain' => 'my-plugin', + ) + ), + false, + true + ); + + $this->assertSame( + array( + array( 'My Plugin', 'my-plugin' ), + array( 'https://example.org/plugin/', 'my-plugin' ), + array( 'Text.', 'my-plugin' ), + array( 'Jane', 'my-plugin' ), + array( '1.0', 'my-plugin' ), + ), + $this->translated, + 'Only the non-empty fields should be translated, with the plugin text domain.' + ); + $this->assertSame( '[My Plugin]', $result['Name'], 'The name should be translated.' ); + $this->assertSame( 'https://example.org/plugin/de/', $result['PluginURI'], 'The plugin URI should be translated.' ); + $this->assertSame( '[Text.]', $result['Description'], 'The description should be translated.' ); + $this->assertSame( '[Jane]', $result['Author'], 'The author should be translated.' ); + $this->assertSame( '[1.0]', $result['Version'], 'The version should be translated.' ); + } + + /** + * Tests that the fields are not translated when translation is disabled. + * + * @ticket 65819 + */ + public function test_should_not_translate_when_disabled() { + add_filter( 'gettext', array( $this, 'translate_string' ), 10, 3 ); + + $result = _get_plugin_data_markup_translate( + WP_PLUGIN_DIR . '/hello.php', + $this->get_plugin_data( + array( + 'Name' => 'My Plugin', + 'TextDomain' => 'my-plugin', + ) + ), + false, + false + ); + + $this->assertSame( array(), $this->translated, 'No field should be translated.' ); + $this->assertSame( 'My Plugin', $result['Name'], 'The name should not change.' ); + } + + /** + * Tests the text domain used for a plugin without one. + * + * @ticket 65819 + * + * @dataProvider data_plugins_without_text_domain + * + * @param string $plugin_file The plugin file, relative to the plugins directory. + * @param array $expected The expected translate() calls. + */ + public function test_should_handle_plugin_without_text_domain( $plugin_file, $expected ) { + add_filter( 'gettext', array( $this, 'translate_string' ), 10, 3 ); + + _get_plugin_data_markup_translate( + WP_PLUGIN_DIR . '/' . $plugin_file, + $this->get_plugin_data( array( 'Name' => 'My Plugin' ) ), + false, + true + ); + + $this->assertSame( $expected, $this->translated ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_plugins_without_text_domain() { + return array( + 'Hello Dolly uses the default text domain' => array( + 'hello.php', + array( array( 'My Plugin', 'default' ) ), + ), + 'Hello Dolly in a plugin directory' => array( + 'hello-dolly/hello.php', + array( array( 'My Plugin', 'default' ) ), + ), + 'other plugins are not translated' => array( + 'my-plugin/my-plugin.php', + array(), + ), + ); + } + + /** + * Tests that the plugin text domain is loaded from the plugin's directory. + * + * @ticket 65819 + * + * @dataProvider data_text_domain_paths + * + * @param string $domain_path The Domain Path header. + * @param string $expected_path The expected path, relative to the plugins directory. + */ + public function test_should_load_text_domain_from_plugin_directory( $domain_path, $expected_path ) { + $registry = $this->createMock( 'WP_Textdomain_Registry' ); + $registry->expects( $this->once() ) + ->method( 'set_custom_path' ) + ->with( 'my-plugin', WP_PLUGIN_DIR . '/' . $expected_path ); + + $GLOBALS['wp_textdomain_registry'] = $registry; + + _get_plugin_data_markup_translate( + WP_PLUGIN_DIR . '/my-plugin/my-plugin.php', + $this->get_plugin_data( + array( + 'TextDomain' => 'my-plugin', + 'DomainPath' => $domain_path, + ) + ), + false, + true + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_text_domain_paths() { + return array( + 'no domain path' => array( '', 'my-plugin' ), + 'domain path' => array( '/languages', 'my-plugin/languages' ), + 'zero domain path' => array( '0', 'my-plugin' ), + ); + } + + /** + * Tests that a text domain that is already loaded is not loaded again. + * + * @ticket 65819 + */ + public function test_should_not_reload_loaded_text_domain() { + $registry = $this->createMock( 'WP_Textdomain_Registry' ); + $registry->expects( $this->never() )->method( 'set_custom_path' ); + + $GLOBALS['wp_textdomain_registry'] = $registry; + $GLOBALS['l10n']['my-plugin'] = new MO(); + + _get_plugin_data_markup_translate( + WP_PLUGIN_DIR . '/my-plugin/my-plugin.php', + $this->get_plugin_data( array( 'TextDomain' => 'my-plugin' ) ), + false, + true + ); + } + + /** + * Returns plugin data with all fields the function reads. + * + * @param array $fields The fields to set. + * @return array The plugin data. + */ + private function get_plugin_data( $fields ) { + return array_merge( + array( + 'Name' => '', + 'PluginURI' => '', + 'Version' => '', + 'Description' => '', + 'Author' => '', + 'AuthorURI' => '', + 'TextDomain' => '', + 'DomainPath' => '', + ), + $fields + ); + } + + /** + * Records strings passed to translate() and returns a translation. + * + * URLs get a language path, other strings are wrapped in brackets. + * + * @param string $translation The translated text. + * @param string $text The text to translate. + * @param string $domain The text domain. + * @return string The translation. + */ + public function translate_string( $translation, $text, $domain ) { + $this->translated[] = array( $text, $domain ); + + if ( str_starts_with( $text, 'https://' ) ) { + return $text . 'de/'; + } + + return '[' . $text . ']'; + } +} diff --git a/tests/phpunit/tests/admin/includes/plugin/userCanAccessAdminPage.php b/tests/phpunit/tests/admin/includes/plugin/userCanAccessAdminPage.php new file mode 100644 index 0000000000000..dedf495573f82 --- /dev/null +++ b/tests/phpunit/tests/admin/includes/plugin/userCanAccessAdminPage.php @@ -0,0 +1,323 @@ +user->create( array( 'role' => $role ) ); + } + } + + public function set_up() { + parent::set_up(); + + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + + $this->orig_globals = array(); + + foreach ( self::GLOBALS_USED as $name ) { + if ( array_key_exists( $name, $GLOBALS ) ) { + $this->orig_globals[ $name ] = $GLOBALS[ $name ]; + } + + unset( $GLOBALS[ $name ] ); + } + + $GLOBALS['menu'] = array( + 2 => array( 'Dashboard', 'read', 'index.php' ), + 25 => array( 'My Plugin', 'manage_options', 'my-plugin' ), + 80 => array( 'Settings', 'manage_options', 'options-general.php' ), + ); + $GLOBALS['submenu'] = array( + 'index.php' => array( + 0 => array( 'Home', 'read', 'index.php' ), + ), + 'my-plugin' => array( + 0 => array( 'My Plugin', 'manage_options', 'my-plugin' ), + 1 => array( 'Sub Page', 'edit_posts', 'my-plugin-sub' ), + ), + 'options-general.php' => array( + 10 => array( 'General', 'manage_options', 'options-general.php' ), + 50 => array( 'My Settings', 'manage_options', 'my-settings' ), + ), + ); + $GLOBALS['admin_page_hooks'] = array( + 'my-plugin' => 'my-plugin', + 'options-general.php' => 'settings', + ); + $GLOBALS['_registered_pages'] = array(); + $GLOBALS['_wp_menu_nopriv'] = array(); + $GLOBALS['_wp_submenu_nopriv'] = array(); + } + + public function tear_down() { + foreach ( self::GLOBALS_USED as $name ) { + if ( array_key_exists( $name, $this->orig_globals ) ) { + $GLOBALS[ $name ] = $this->orig_globals[ $name ]; + } else { + unset( $GLOBALS[ $name ] ); + } + } + + parent::tear_down(); + } + + /** + * Tests whether the current user can access the current admin page. + * + * @ticket 65819 + * + * @dataProvider data_user_can_access_admin_page + * + * @param array $globals Globals describing the current request, merged into the defaults set up for each test. + * @param string $role Role of the current user. + * @param bool $expected Whether the user should be able to access the page. + */ + public function test_user_can_access_admin_page( $globals, $role, $expected ) { + foreach ( $globals as $name => $value ) { + if ( is_array( $value ) && isset( $GLOBALS[ $name ] ) && is_array( $GLOBALS[ $name ] ) && in_array( $name, array( 'menu', 'submenu' ), true ) ) { + $GLOBALS[ $name ] = array_merge( $GLOBALS[ $name ], $value ); + } else { + $GLOBALS[ $name ] = $value; + } + } + + wp_set_current_user( self::$user_ids[ $role ] ); + + $this->assertSame( $expected, user_can_access_admin_page() ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_user_can_access_admin_page() { + $book_menu = array( + 'menu' => array( 26 => array( 'Books', 'edit_posts', 'edit.php?post_type=book' ) ), + 'submenu' => array( + 'edit.php?post_type=book' => array( + 5 => array( 'All Books', 'manage_options', 'edit.php?post_type=book' ), + ), + ), + 'pagenow' => 'edit.php', + 'typenow' => 'book', + ); + + return array( + // Core pages in a submenu use the capability of the submenu item. + 'submenu page, user has the capability' => array( + array( 'pagenow' => 'options-general.php' ), + 'administrator', + true, + ), + 'submenu page, user lacks the capability' => array( + array( 'pagenow' => 'options-general.php' ), + 'editor', + false, + ), + 'submenu page marked as not accessible' => array( + array( + 'pagenow' => 'options-general.php', + '_wp_submenu_nopriv' => array( 'options-general.php' => array( 'options-general.php' => true ) ), + ), + 'administrator', + false, + ), + + // Plugin pages must be registered, and use the capability of their menu item. + 'plugin subpage, registered, user has the capability' => array( + array( + 'pagenow' => 'options-general.php', + 'plugin_page' => 'my-settings', + '_registered_pages' => array( 'settings_page_my-settings' => true ), + ), + 'administrator', + true, + ), + 'plugin subpage, registered, user lacks the capability' => array( + array( + 'pagenow' => 'options-general.php', + 'plugin_page' => 'my-settings', + '_registered_pages' => array( 'settings_page_my-settings' => true ), + ), + 'editor', + false, + ), + 'plugin subpage that is not registered' => array( + array( + 'pagenow' => 'options-general.php', + 'plugin_page' => 'my-settings', + ), + 'administrator', + false, + ), + 'plugin subpage marked as not accessible' => array( + array( + 'pagenow' => 'options-general.php', + 'plugin_page' => 'my-settings', + '_registered_pages' => array( 'settings_page_my-settings' => true ), + '_wp_submenu_nopriv' => array( 'options-general.php' => array( 'my-settings' => true ) ), + ), + 'administrator', + false, + ), + 'top-level plugin page, user has the capability' => array( + array( + 'pagenow' => 'admin.php', + 'plugin_page' => 'my-plugin', + '_registered_pages' => array( 'toplevel_page_my-plugin' => true ), + ), + 'administrator', + true, + ), + 'top-level plugin page, user lacks the capability' => array( + array( + 'pagenow' => 'admin.php', + 'plugin_page' => 'my-plugin', + '_registered_pages' => array( 'toplevel_page_my-plugin' => true ), + ), + 'editor', + false, + ), + 'plugin page under a top-level plugin menu uses its own capability' => array( + array( + 'pagenow' => 'admin.php', + 'plugin_page' => 'my-plugin-sub', + '_registered_pages' => array( 'my-plugin_page_my-plugin-sub' => true ), + ), + 'editor', + true, + ), + 'top-level plugin page marked as not accessible' => array( + array( + 'pagenow' => 'admin.php', + 'plugin_page' => 'hidden-plugin', + '_registered_pages' => array( 'admin_page_hidden-plugin' => true ), + '_wp_menu_nopriv' => array( 'hidden-plugin' => true ), + ), + 'administrator', + false, + ), + 'top-level plugin page not in a menu' => array( + array( + 'pagenow' => 'admin.php', + 'plugin_page' => 'hidden-plugin', + '_registered_pages' => array( 'admin_page_hidden-plugin' => true ), + ), + 'administrator', + true, + ), + + // Pages without a parent are accessible unless a menu marks them as not accessible. + 'page without a parent' => array( + array( 'pagenow' => 'profile.php' ), + 'subscriber', + true, + ), + 'page without a parent, not accessible as a menu' => array( + array( + 'pagenow' => 'profile.php', + '_wp_menu_nopriv' => array( 'profile.php' => true ), + ), + 'subscriber', + false, + ), + 'page without a parent, not accessible as its own submenu' => array( + array( + 'pagenow' => 'profile.php', + '_wp_submenu_nopriv' => array( 'profile.php' => array( 'profile.php' => true ) ), + ), + 'subscriber', + false, + ), + 'page without a parent, not accessible in another submenu' => array( + array( + 'pagenow' => 'profile.php', + '_wp_submenu_nopriv' => array( 'users.php' => array( 'profile.php' => true ) ), + ), + 'subscriber', + false, + ), + 'plugin page without a parent' => array( + array( + 'pagenow' => 'profile.php', + 'plugin_page' => 'orphan', + '_registered_pages' => array( 'admin_page_orphan' => true ), + ), + 'subscriber', + true, + ), + 'plugin page without a parent, not accessible as a menu' => array( + array( + 'pagenow' => 'profile.php', + 'plugin_page' => 'orphan', + '_registered_pages' => array( 'admin_page_orphan' => true ), + '_wp_menu_nopriv' => array( 'orphan' => true ), + ), + 'subscriber', + false, + ), + 'plugin page without a parent, not accessible in another submenu' => array( + array( + 'pagenow' => 'profile.php', + 'plugin_page' => 'orphan', + '_registered_pages' => array( 'admin_page_orphan' => true ), + '_wp_submenu_nopriv' => array( 'users.php' => array( 'orphan' => true ) ), + ), + 'subscriber', + false, + ), + + // When no submenu item matches the page, the capability of the parent menu item is used. + 'parent menu capability, user has it' => array( $book_menu, 'editor', true ), + 'parent menu capability, user lacks it' => array( $book_menu, 'subscriber', false ), + 'parent that is not in the menu' => array( + array_merge( $book_menu, array( 'menu' => array() ) ), + 'subscriber', + true, + ), + ); + } +} diff --git a/tests/phpunit/tests/admin/includes/template/getMediaStates.php b/tests/phpunit/tests/admin/includes/template/getMediaStates.php new file mode 100644 index 0000000000000..7f92b2269d084 --- /dev/null +++ b/tests/phpunit/tests/admin/includes/template/getMediaStates.php @@ -0,0 +1,331 @@ +orig_theme_features = $GLOBALS['_wp_theme_features']; + + remove_theme_support( 'custom-header' ); + remove_theme_support( 'custom-background' ); + + $this->attachment = get_post( + self::factory()->attachment->create_object( + 'media-state.jpg', + 0, + array( 'post_mime_type' => 'image/jpeg' ) + ) + ); + } + + public function tear_down() { + $GLOBALS['_wp_theme_features'] = $this->orig_theme_features; + + parent::tear_down(); + } + + /** + * Gets the URL of the test attachment. + * + * @return string Attachment URL. + */ + private function attachment_url() { + return wp_get_attachment_url( $this->attachment->ID ); + } + + /** + * Tests that an attachment without any role has no media states. + * + * @ticket 65819 + */ + public function test_returns_no_states_by_default() { + add_theme_support( 'custom-header' ); + add_theme_support( 'custom-background' ); + + $this->assertSame( array(), get_media_states( $this->attachment ) ); + } + + /** + * Tests the header image states. + * + * @ticket 65819 + * + * @dataProvider data_header_image_states + * + * @param bool $header_meta_for_theme Whether the attachment is marked as a header image of the current theme. + * @param bool $is_current_header Whether the attachment is the current header image. + * @param bool $theme_supports Whether the theme supports custom headers. + * @param string[] $expected Expected media states. + */ + public function test_header_image_states( $header_meta_for_theme, $is_current_header, $theme_supports, $expected ) { + if ( $theme_supports ) { + add_theme_support( 'custom-header' ); + } + + if ( $header_meta_for_theme ) { + update_post_meta( $this->attachment->ID, '_wp_attachment_is_custom_header', get_option( 'stylesheet' ) ); + } + + if ( $is_current_header ) { + set_theme_mod( 'header_image', $this->attachment_url() ); + } + + $this->assertSame( $expected, get_media_states( $this->attachment ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_header_image_states() { + return array( + 'previously used header image' => array( true, false, true, array( 'Header Image' ) ), + 'current header image' => array( false, true, true, array( 'Current Header Image' ) ), + 'current header image marked as header' => array( true, true, true, array( 'Current Header Image' ) ), + 'theme without custom headers' => array( true, true, false, array() ), + ); + } + + /** + * Tests that a header image of another theme is not marked as a header image. + * + * @ticket 65819 + */ + public function test_header_image_of_another_theme() { + add_theme_support( 'custom-header' ); + + update_post_meta( $this->attachment->ID, '_wp_attachment_is_custom_header', 'another-theme' ); + + $this->assertSame( array(), get_media_states( $this->attachment ) ); + } + + /** + * Tests the header image state when random uploaded header images are used. + * + * Runs in a separate process because get_media_states() caches the uploaded header images in a static variable. + * + * @ticket 65819 + * + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_header_image_state_with_random_header_images() { + add_theme_support( 'custom-header' ); + + $other = get_post( self::factory()->attachment->create_object( 'other-header.jpg', 0, array( 'post_mime_type' => 'image/jpeg' ) ) ); + + update_post_meta( $this->attachment->ID, '_wp_attachment_is_custom_header', get_option( 'stylesheet' ) ); + update_post_meta( $other->ID, '_wp_attachment_is_custom_header', 'another-theme' ); + + set_theme_mod( 'header_image', 'random-uploaded-image' ); + + $this->assertSame( array( 'Header Image' ), get_media_states( $this->attachment ), 'An uploaded header image of the current theme should be marked.' ); + $this->assertSame( array(), get_media_states( $other ), 'A header image of another theme should not be marked.' ); + } + + /** + * Tests the header video state. + * + * @ticket 65819 + * + * @dataProvider data_header_video_states + * + * @param bool $supports_video Whether the custom header supports video. + * @param bool $is_video Whether the attachment is the header video. + * @param string[] $expected Expected media states. + */ + public function test_header_video_state( $supports_video, $is_video, $expected ) { + add_theme_support( 'custom-header', array( 'video' => $supports_video ) ); + + $video = get_post( self::factory()->attachment->create_object( 'header-video.mp4', 0, array( 'post_mime_type' => 'video/mp4' ) ) ); + $other = get_post( self::factory()->attachment->create_object( 'other-video.mp4', 0, array( 'post_mime_type' => 'video/mp4' ) ) ); + + set_theme_mod( 'header_video', $video->ID ); + + $this->assertSame( $expected, get_media_states( $is_video ? $video : $other ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_header_video_states() { + return array( + 'current header video' => array( true, true, array( 'Current Header Video' ) ), + 'other video' => array( true, false, array() ), + 'header without video support' => array( false, true, array() ), + ); + } + + /** + * Tests the background image states. + * + * @ticket 65819 + * + * @dataProvider data_background_image_states + * + * @param string|false $background_meta Value of the background meta, or false for none. + * @param bool $is_current Whether the attachment is the current background image. + * @param bool $theme_supports Whether the theme supports custom backgrounds. + * @param string[] $expected Expected media states. + */ + public function test_background_image_states( $background_meta, $is_current, $theme_supports, $expected ) { + if ( $theme_supports ) { + add_theme_support( 'custom-background' ); + } + + if ( false !== $background_meta ) { + update_post_meta( $this->attachment->ID, '_wp_attachment_is_custom_background', 'current' === $background_meta ? get_option( 'stylesheet' ) : $background_meta ); + } + + if ( $is_current ) { + set_theme_mod( 'background_image', $this->attachment_url() ); + } + + $this->assertSame( $expected, get_media_states( $this->attachment ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_background_image_states() { + return array( + 'background image' => array( 'current', false, true, array( 'Background Image' ) ), + 'current background image' => array( 'current', true, true, array( 'Background Image', 'Current Background Image' ) ), + 'background image of another theme' => array( 'another-theme', true, true, array() ), + 'current background without meta' => array( false, true, true, array() ), + 'theme without custom backgrounds' => array( 'current', true, false, array() ), + ); + } + + /** + * Tests the site icon state. + * + * @ticket 65819 + * + * @dataProvider data_option_values + * + * @param bool $as_string Whether the ID is read back as a string. + */ + public function test_site_icon_state( $as_string ) { + update_option( 'site_icon', $this->attachment->ID ); + + if ( $as_string ) { + // Option values loaded from the database are strings. + add_filter( 'option_site_icon', 'strval' ); + } + + $this->assertSame( array( 'Site Icon' ), get_media_states( $this->attachment ) ); + } + + /** + * Tests the logo state. + * + * @ticket 65819 + * + * @dataProvider data_option_values + * + * @param bool $as_string Whether to store the ID as a string. + */ + public function test_logo_state( $as_string ) { + set_theme_mod( 'custom_logo', $as_string ? (string) $this->attachment->ID : $this->attachment->ID ); + + $this->assertSame( array( 'Logo' ), get_media_states( $this->attachment ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_option_values() { + return array( + 'integer ID' => array( false ), + 'string ID' => array( true ), + ); + } + + /** + * Tests that the site icon and logo states are not added for another attachment. + * + * @ticket 65819 + */ + public function test_site_icon_and_logo_of_another_attachment() { + $other = self::factory()->attachment->create_object( 'other.jpg', 0, array( 'post_mime_type' => 'image/jpeg' ) ); + + update_option( 'site_icon', $other ); + set_theme_mod( 'custom_logo', $other ); + + $this->assertSame( array(), get_media_states( $this->attachment ) ); + } + + /** + * Tests that all applicable states are returned in order. + * + * @ticket 65819 + */ + public function test_returns_all_states_in_order() { + add_theme_support( 'custom-header' ); + add_theme_support( 'custom-background' ); + + update_post_meta( $this->attachment->ID, '_wp_attachment_is_custom_header', get_option( 'stylesheet' ) ); + update_post_meta( $this->attachment->ID, '_wp_attachment_is_custom_background', get_option( 'stylesheet' ) ); + update_option( 'site_icon', $this->attachment->ID ); + set_theme_mod( 'custom_logo', $this->attachment->ID ); + + $this->assertSame( array( 'Header Image', 'Background Image', 'Site Icon', 'Logo' ), get_media_states( $this->attachment ) ); + } + + /** + * Tests that the media states are filterable. + * + * @ticket 65819 + */ + public function test_display_media_states_filter() { + update_option( 'site_icon', $this->attachment->ID ); + + $filter = new MockAction(); + add_filter( 'display_media_states', array( $filter, 'filter' ), 10, 2 ); + add_filter( + 'display_media_states', + static function ( $states ) { + $states[] = 'Custom State'; + + return $states; + }, + 20 + ); + + $this->assertSame( array( 'Site Icon', 'Custom State' ), get_media_states( $this->attachment ), 'The filtered states should be returned.' ); + $this->assertSame( array( array( array( 'Site Icon' ), $this->attachment ) ), $filter->get_args(), 'The filter should receive the states and the attachment.' ); + } +} diff --git a/tests/phpunit/tests/admin/includes/theme/wpPrepareThemesForJs.php b/tests/phpunit/tests/admin/includes/theme/wpPrepareThemesForJs.php new file mode 100644 index 0000000000000..373e4f84b991f --- /dev/null +++ b/tests/phpunit/tests/admin/includes/theme/wpPrepareThemesForJs.php @@ -0,0 +1,537 @@ +user->create( array( 'role' => 'administrator' ) ); + self::$subscriber_id = $factory->user->create( array( 'role' => 'subscriber' ) ); + + if ( is_multisite() ) { + grant_super_admin( self::$admin_id ); + } + } + + public static function wpTearDownAfterClass() { + if ( is_multisite() ) { + revoke_super_admin( self::$admin_id ); + } + } + + public function set_up() { + parent::set_up(); + + require_once ABSPATH . 'wp-admin/includes/theme.php'; + + $this->orig_request_uri = $_SERVER['REQUEST_URI'] ?? null; + $this->orig_theme_directories = $GLOBALS['wp_theme_directories']; + + $_SERVER['REQUEST_URI'] = '/wp-admin/themes.php'; + + $GLOBALS['wp_theme_directories'] = array( DIR_TESTDATA . '/themedir1' ); + wp_clean_themes_cache(); + + $this->set_current_theme( 'theme1', 'theme1' ); + + wp_set_current_user( self::$admin_id ); + } + + public function tear_down() { + if ( null === $this->orig_request_uri ) { + unset( $_SERVER['REQUEST_URI'] ); + } else { + $_SERVER['REQUEST_URI'] = $this->orig_request_uri; + } + + $GLOBALS['wp_theme_directories'] = $this->orig_theme_directories; + wp_clean_themes_cache(); + + parent::tear_down(); + } + + /** + * Makes a fixture theme the active theme. + * + * @param string $stylesheet Stylesheet of the theme. + * @param string $template Template of the theme. + */ + private function set_current_theme( $stylesheet, $template ) { + add_filter( + 'stylesheet', + static function () use ( $stylesheet ) { + return $stylesheet; + } + ); + add_filter( + 'template', + static function () use ( $template ) { + return $template; + } + ); + } + + /** + * Gets fixture themes by stylesheet. + * + * @param string ...$stylesheets Theme stylesheets. + * @return WP_Theme[] Themes, keyed by stylesheet. + */ + private function get_themes( ...$stylesheets ) { + $themes = array(); + + foreach ( $stylesheets as $stylesheet ) { + $themes[ $stylesheet ] = wp_get_theme( $stylesheet, DIR_TESTDATA . '/themedir1' ); + } + + return $themes; + } + + /** + * Gets the prepared data of each theme, keyed by ID. + * + * @param WP_Theme[]|null $themes Themes to prepare. + * @return array[] Prepared theme data, keyed by theme ID. + */ + private function prepare( $themes ) { + return array_column( wp_prepare_themes_for_js( $themes ), null, 'id' ); + } + + /** + * Asserts that a URL equals the expected URL plus a valid nonce for the action. + * + * @param string $expected Expected URL, without the nonce. + * @param string $action Nonce action. + * @param string $url Actual URL. + */ + private function assert_nonce_url( $expected, $action, $url ) { + $this->assertMatchesRegularExpression( '/&_wpnonce=([a-f0-9]+)$/', $url, 'The URL should end with a nonce.' ); + + preg_match( '/&_wpnonce=([a-f0-9]+)$/', $url, $matches ); + + $this->assertSame( $expected, substr( $url, 0, -strlen( $matches[0] ) ), 'The URL did not match.' ); + $this->assertSame( 1, wp_verify_nonce( $matches[1], $action ), 'The nonce should be valid for the action.' ); + } + + /** + * Tests that a non-empty value from the pre_prepare_themes_for_js filter is returned early. + * + * @ticket 65819 + */ + public function test_pre_prepare_themes_for_js_filter_short_circuits() { + $themes = $this->get_themes( 'theme1' ); + + $filter = new MockAction(); + add_filter( 'pre_prepare_themes_for_js', array( $filter, 'filter' ), 10, 3 ); + add_filter( + 'pre_prepare_themes_for_js', + static function () { + return array( 'custom' => array( 'id' => 'custom' ) ); + }, + 20 + ); + + $prepare_filter = new MockAction(); + add_filter( 'wp_prepare_themes_for_js', array( $prepare_filter, 'filter' ) ); + + $this->assertSame( array( 'custom' => array( 'id' => 'custom' ) ), wp_prepare_themes_for_js( $themes ), 'The filtered value should be returned as is.' ); + $this->assertSame( array( array( array(), $themes, 'theme1' ) ), $filter->get_args(), 'The filter should receive an empty array, the themes and the active theme.' ); + $this->assertSame( 0, $prepare_filter->get_call_count(), 'The themes should not be prepared.' ); + } + + /** + * Tests the data prepared for a classic theme that is not active. + * + * @ticket 65819 + * + * @group ms-excluded + */ + public function test_prepares_classic_theme_data() { + $prepared = $this->prepare( $this->get_themes( 'page-templates' ) ); + + $this->assertSame( array( 'page-templates' ), array_keys( $prepared ), 'Only the given theme should be prepared, without a placeholder for the active theme.' ); + + $theme = $prepared['page-templates']; + + $this->assertSame( 'Page Template Theme', $theme['name'], 'The name did not match.' ); + $this->assertFalse( $theme['parent'], 'The theme should have no parent.' ); + $this->assertFalse( $theme['active'], 'The theme should not be active.' ); + $this->assertFalse( $theme['blockTheme'], 'The theme should not be a block theme.' ); + $this->assertFalse( $theme['hasUpdate'], 'The theme should have no update.' ); + $this->assertFalse( $theme['hasPackage'], 'The theme should have no update package.' ); + $this->assertSame( + array( + 'enabled' => false, + 'supported' => false, + 'forced' => null, + ), + $theme['autoupdate'], + 'The auto-update state did not match.' + ); + + $this->assert_nonce_url( + 'http://' . WP_TESTS_DOMAIN . '/wp-admin/themes.php?action=activate&stylesheet=page-templates', + 'switch-theme_page-templates', + $theme['actions']['activate'] + ); + $this->assert_nonce_url( + 'http://' . WP_TESTS_DOMAIN . '/wp-admin/themes.php?action=delete&stylesheet=page-templates', + 'delete-theme_page-templates', + $theme['actions']['delete'] + ); + $this->assertSame( + 'http://' . WP_TESTS_DOMAIN . '/wp-admin/customize.php?theme=page-templates&return=%2Fwp-admin%2Fthemes.php', + $theme['actions']['customize'], + 'The customize action should open the Customizer for the theme and return to the current screen.' + ); + $this->assertNull( $theme['actions']['autoupdate'], 'There should be no auto-update action while auto-updates are disabled.' ); + } + + /** + * Tests that the active theme is listed first and the other themes are sorted by name. + * + * @ticket 65819 + */ + public function test_lists_active_theme_first_then_sorts_by_name() { + $this->set_current_theme( 'theme1', 'theme1' ); + + $prepared = wp_prepare_themes_for_js( $this->get_themes( 'page-templates', 'block-theme', 'theme1' ) ); + + $this->assertSame( array( 'theme1', 'block-theme', 'page-templates' ), wp_list_pluck( $prepared, 'id' ), 'The active theme should be first, then the themes by name.' ); + $this->assertSame( array( true, false, false ), wp_list_pluck( $prepared, 'active' ), 'Only the active theme should be marked as active.' ); + } + + /** + * Tests the parent of a child theme, and that the parent of the active child theme cannot be deleted. + * + * @ticket 65819 + * + * @group ms-excluded + */ + public function test_child_theme_parent_and_delete_action() { + $this->set_current_theme( 'page-templates-child', 'page-templates' ); + + $prepared = $this->prepare( $this->get_themes( 'page-templates', 'page-templates-child' ) ); + + $this->assertSame( 'Page Template Theme', $prepared['page-templates-child']['parent'], 'The child theme should name its parent.' ); + $this->assertFalse( $prepared['page-templates']['parent'], 'The parent theme should have no parent.' ); + $this->assertArrayNotHasKey( 'delete', $prepared['page-templates']['actions'], 'The parent of the active child theme should not be deletable.' ); + $this->assertArrayHasKey( 'delete', $prepared['page-templates-child']['actions'], 'The active child theme should keep its delete action.' ); + } + + /** + * Tests that the parent of an inactive child theme can still be deleted. + * + * @ticket 65819 + * + * @group ms-excluded + */ + public function test_parent_of_inactive_child_theme_can_be_deleted() { + $prepared = $this->prepare( $this->get_themes( 'page-templates', 'page-templates-child' ) ); + + $this->assertNotNull( $prepared['page-templates']['actions']['delete'] ); + } + + /** + * Tests the customize action of block themes, which opens the Site Editor. + * + * @ticket 65819 + * + * @dataProvider data_block_theme_customize_action + * + * @param bool $is_active Whether the block theme is active. + * @param string $expected Expected customize action, without the scheme and domain. + */ + public function test_block_theme_customize_action( $is_active, $expected ) { + if ( $is_active ) { + $this->set_current_theme( 'block-theme', 'block-theme' ); + } + + $theme = $this->prepare( $this->get_themes( 'block-theme' ) )['block-theme']; + + $this->assertTrue( $theme['blockTheme'], 'The theme should be a block theme.' ); + $this->assertSame( 'http://' . WP_TESTS_DOMAIN . $expected, $theme['actions']['customize'] ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_block_theme_customize_action() { + return array( + 'inactive block theme is previewed' => array( false, '/wp-admin/site-editor.php?wp_theme_preview=block-theme&return=%2Fwp-admin%2Fthemes.php' ), + 'active block theme' => array( true, '/wp-admin/site-editor.php?return=%2Fwp-admin%2Fthemes.php' ), + ); + } + + /** + * Tests that users without the capabilities get no theme actions. + * + * @ticket 65819 + */ + public function test_actions_for_user_without_capabilities() { + wp_set_current_user( self::$subscriber_id ); + add_filter( 'themes_auto_update_enabled', '__return_true' ); + + $no_actions = array( + 'activate' => null, + 'customize' => null, + 'delete' => null, + 'autoupdate' => null, + ); + $prepared = $this->prepare( $this->get_themes( 'page-templates', 'block-theme' ) ); + + $this->assertSame( $no_actions, $prepared['page-templates']['actions'], 'A classic theme should have no actions.' ); + $this->assertSame( $no_actions, $prepared['block-theme']['actions'], 'A block theme should have no actions.' ); + } + + /** + * Tests the update data taken from the update_themes transient. + * + * @ticket 65819 + * + * @group ms-excluded + */ + public function test_update_data_from_transient() { + set_site_transient( + 'update_themes', + (object) array( + 'response' => array( + 'page-templates' => array( + 'theme' => 'page-templates', + 'new_version' => '2.0', + 'package' => 'https://downloads.wordpress.org/theme/page-templates.2.0.zip', + 'url' => 'https://wordpress.org/themes/page-templates/', + 'requires' => '99.0', + 'requires_php' => '5.6', + ), + 'theme1' => array( + 'theme' => 'theme1', + 'new_version' => '2.0', + 'url' => 'https://wordpress.org/themes/theme1/', + 'package' => '', + ), + ), + 'no_update' => array( + 'block-theme' => array( + 'theme' => 'block-theme', + 'new_version' => '1.0', + ), + ), + ) + ); + + $prepared = $this->prepare( $this->get_themes( 'page-templates', 'theme1', 'block-theme', 'page-templates-child' ) ); + + $this->assertTrue( $prepared['page-templates']['hasUpdate'], 'A theme in the response should have an update.' ); + $this->assertTrue( $prepared['page-templates']['hasPackage'], 'An update with a package should be installable.' ); + $this->assertSame( + array( + 'compatibleWP' => false, + 'compatiblePHP' => true, + ), + $prepared['page-templates']['updateResponse'], + 'The update compatibility should use the requirements of the update.' + ); + $this->assertTrue( $prepared['theme1']['hasUpdate'], 'A theme in the response should have an update.' ); + $this->assertFalse( $prepared['theme1']['hasPackage'], 'An update without a package should not be installable.' ); + $this->assertFalse( $prepared['block-theme']['hasUpdate'], 'A theme without an update should have no update.' ); + + $this->assertSame( + array( true, true, true, false ), + array( + $prepared['page-templates']['autoupdate']['supported'], + $prepared['theme1']['autoupdate']['supported'], + $prepared['block-theme']['autoupdate']['supported'], + $prepared['page-templates-child']['autoupdate']['supported'], + ), + 'Auto-updates should be supported for themes known to the update API.' + ); + } + + /** + * Tests the auto-update state and action. + * + * @ticket 65819 + * + * @group ms-excluded + * + * @dataProvider data_auto_update_states + * + * @param bool $enabled_by_user Whether the user enabled auto-updates for the theme. + * @param mixed $forced Value returned by the auto_update_theme filter, or null to leave it unfiltered. + * @param array $expected Expected auto-update state. + * @param string $action Expected auto-update action. + */ + public function test_auto_update_state( $enabled_by_user, $forced, $expected, $action ) { + add_filter( 'themes_auto_update_enabled', '__return_true' ); + + if ( $enabled_by_user ) { + update_site_option( 'auto_update_themes', array( 'page-templates' ) ); + } + + if ( null !== $forced ) { + add_filter( + 'auto_update_theme', + static function () use ( $forced ) { + return $forced; + } + ); + } + + $theme = $this->prepare( $this->get_themes( 'page-templates' ) )['page-templates']; + + $this->assertSame( $expected, $theme['autoupdate'], 'The auto-update state did not match.' ); + $this->assert_nonce_url( + 'http://' . WP_TESTS_DOMAIN . '/wp-admin/themes.php?action=' . $action . '&stylesheet=page-templates', + 'updates', + $theme['actions']['autoupdate'] + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_auto_update_states() { + return array( + 'disabled' => array( + false, + null, + array( + 'enabled' => false, + 'supported' => false, + 'forced' => null, + ), + 'enable-auto-update', + ), + 'enabled by the user' => array( + true, + null, + array( + 'enabled' => true, + 'supported' => false, + 'forced' => null, + ), + 'disable-auto-update', + ), + 'forced on by a filter' => array( + false, + true, + array( + 'enabled' => true, + 'supported' => false, + 'forced' => true, + ), + 'enable-auto-update', + ), + 'forced off by a filter' => array( + true, + false, + array( + 'enabled' => true, + 'supported' => false, + 'forced' => false, + ), + 'disable-auto-update', + ), + ); + } + + /** + * Tests that the prepared themes are filterable and that empty entries are removed. + * + * @ticket 65819 + */ + public function test_wp_prepare_themes_for_js_filter() { + $filter = new MockAction(); + add_filter( 'wp_prepare_themes_for_js', array( $filter, 'filter' ) ); + add_filter( + 'wp_prepare_themes_for_js', + static function ( $prepared_themes ) { + $prepared_themes['block-theme'] = array(); + $prepared_themes['custom'] = array( 'id' => 'custom' ); + + return $prepared_themes; + }, + 20 + ); + + $prepared = wp_prepare_themes_for_js( $this->get_themes( 'block-theme', 'page-templates' ) ); + + $this->assertSame( array( 'theme1', 'block-theme', 'page-templates' ), array_keys( $filter->get_args()[0][0] ), 'The filter should receive the themes keyed by ID, starting with the active theme placeholder.' ); + $this->assertSame( array( 2, 3 ), array_keys( $prepared ), 'Empty entries should be removed without reindexing.' ); + $this->assertSame( array( 'page-templates', 'custom' ), array_values( wp_list_pluck( $prepared, 'id' ) ), 'The filtered themes should be returned.' ); + } + + /** + * Tests that the allowed themes and the active theme are prepared when no themes are given. + * + * @ticket 65819 + * + * @group ms-excluded + */ + public function test_prepares_allowed_themes_by_default() { + $prepared = wp_prepare_themes_for_js(); + $ids = wp_list_pluck( $prepared, 'id' ); + + $this->assertSame( 'theme1', $ids[0], 'The active theme should be first.' ); + $this->assertContains( 'page-templates', $ids, 'The other installed themes should be listed.' ); + $this->assertSame( count( $ids ), count( array_unique( $ids ) ), 'Each theme should be listed once.' ); + } + + /** + * Tests that only the allowed themes are prepared by default on multisite, with the active theme added first. + * + * @ticket 65819 + * + * @group ms-required + */ + public function test_prepares_allowed_themes_and_active_theme_on_multisite() { + add_filter( + 'network_allowed_themes', + static function () { + return array( 'page-templates' => true ); + } + ); + add_filter( 'site_allowed_themes', '__return_empty_array' ); + + $this->assertSame( array( 'theme1', 'page-templates' ), wp_list_pluck( wp_prepare_themes_for_js(), 'id' ) ); + } +} diff --git a/tests/phpunit/tests/admin/includes/update/wpGetAutoUpdateMessage.php b/tests/phpunit/tests/admin/includes/update/wpGetAutoUpdateMessage.php new file mode 100644 index 0000000000000..215c9c267fbd8 --- /dev/null +++ b/tests/phpunit/tests/admin/includes/update/wpGetAutoUpdateMessage.php @@ -0,0 +1,179 @@ +assertSame( 'Automatic update scheduled in 6 hours.', wp_get_auto_update_message( 'plugin' ) ); + } + + /** + * Tests that the schedule is replaced when the site is under version control. + * + * @ticket 27814 + * + * @dataProvider data_update_types + * + * @param string $type Type of update. + */ + public function test_should_return_version_control_message_when_under_version_control( $type ) { + add_filter( 'automatic_updates_is_vcs_checkout', '__return_true' ); + + $message = wp_get_auto_update_message( $type ); + + $this->assertStringStartsWith( 'Automatic update will not run because this site appears to be under version control.', $message ); + $this->assertStringNotContainsString( 'scheduled', $message ); + } + + /** + * Tests that the schedule is replaced when updating files requires FTP credentials. + * + * @ticket 27814 + * + * @dataProvider data_update_types + * + * @param string $type Type of update. + */ + public function test_should_return_credentials_message_when_credentials_are_required( $type ) { + remove_filter( 'request_filesystem_credentials', '__return_true' ); + add_filter( 'request_filesystem_credentials', '__return_false' ); + add_filter( 'automatic_updates_is_vcs_checkout', '__return_false' ); + + $message = wp_get_auto_update_message( $type ); + + $this->assertStringStartsWith( 'Automatic update will not run because WordPress needs FTP credentials to update files.', $message ); + $this->assertStringNotContainsString( 'scheduled', $message ); + } + + /** + * Tests that the credentials message takes precedence, matching the order + * of the checks in WP_Automatic_Updater::should_update(). + * + * @ticket 27814 + */ + public function test_should_return_credentials_message_when_both_checks_fail() { + remove_filter( 'request_filesystem_credentials', '__return_true' ); + add_filter( 'request_filesystem_credentials', '__return_false' ); + add_filter( 'automatic_updates_is_vcs_checkout', '__return_true' ); + + $this->assertStringStartsWith( 'Automatic update will not run because WordPress needs FTP credentials', wp_get_auto_update_message( 'plugin' ) ); + } + + /** + * Tests that the directory checked matches the one the automatic updater checks. + * + * @ticket 27814 + * + * @dataProvider data_update_types + * + * @param string $type Type of update. + */ + public function test_should_check_the_directory_for_the_type( $type ) { + $credentials_contexts = array(); + $vcs_contexts = array(); + + add_filter( + 'request_filesystem_credentials', + static function ( $credentials, $form_post, $method, $error, $context ) use ( &$credentials_contexts ) { + $credentials_contexts[] = $context; + return true; + }, + 20, + 5 + ); + + add_filter( + 'automatic_updates_is_vcs_checkout', + static function ( $checkout, $context ) use ( &$vcs_contexts ) { + $vcs_contexts[] = $context; + return false; + }, + 10, + 2 + ); + + wp_get_auto_update_message( $type ); + + $expected = array( + '' => ABSPATH, + 'plugin' => WP_PLUGIN_DIR, + 'theme' => get_theme_root(), + ); + + $this->assertSame( array( $expected[ $type ] ), $credentials_contexts, 'The wrong directory was checked for credentials.' ); + $this->assertSame( array( $expected[ $type ] ), $vcs_contexts, 'The wrong directory was checked for version control.' ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_update_types() { + return array( + 'no type' => array( '' ), + 'plugin' => array( 'plugin' ), + 'theme' => array( 'theme' ), + ); + } + + /** + * Tests that users who can view Site Health are linked to it. + * + * @ticket 27814 + */ + public function test_should_link_to_site_health_for_users_who_can_view_it() { + $user_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + if ( is_multisite() ) { + grant_super_admin( $user_id ); + } + wp_set_current_user( $user_id ); + + add_filter( 'automatic_updates_is_vcs_checkout', '__return_true' ); + + $this->assertStringContainsString( 'href="' . admin_url( 'site-health.php' ) . '"', wp_get_auto_update_message( 'plugin' ) ); + } + + /** + * Tests that users who cannot view Site Health are not linked to it. + * + * @ticket 27814 + */ + public function test_should_not_link_to_site_health_for_users_who_cannot_view_it() { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'editor' ) ) ); + + add_filter( 'automatic_updates_is_vcs_checkout', '__return_true' ); + + $this->assertStringNotContainsString( 'site-health.php', wp_get_auto_update_message( 'plugin' ) ); + } +} diff --git a/tests/phpunit/tests/admin/includes/update/wpPluginUpdateRow.php b/tests/phpunit/tests/admin/includes/update/wpPluginUpdateRow.php new file mode 100644 index 0000000000000..6eae6a3a02bd0 --- /dev/null +++ b/tests/phpunit/tests/admin/includes/update/wpPluginUpdateRow.php @@ -0,0 +1,464 @@ +user->create( array( 'role' => 'administrator' ) ); + self::$subscriber_id = $factory->user->create( array( 'role' => 'subscriber' ) ); + + if ( is_multisite() ) { + grant_super_admin( self::$admin_id ); + } + } + + /** + * Revokes the super admin grant before the fixture users are deleted. + */ + public static function wpTearDownAfterClass() { + if ( is_multisite() ) { + revoke_super_admin( self::$admin_id ); + } + } + + /** + * Switches to the Plugins screen and resets the update data. + */ + public function set_up() { + parent::set_up(); + + $this->original_screen = $GLOBALS['current_screen'] ?? null; + set_current_screen( is_multisite() ? 'plugins-network' : 'plugins' ); + + // Keeps the column count fixed; auto-updates add a column. + add_filter( 'plugins_auto_update_enabled', '__return_false' ); + + delete_site_transient( 'update_plugins' ); + update_option( 'active_plugins', array() ); + update_site_option( 'active_sitewide_plugins', array() ); + } + + /** + * Restores the current screen and the update data. + */ + public function tear_down() { + $GLOBALS['current_screen'] = $this->original_screen; + delete_site_transient( 'update_plugins' ); + + parent::tear_down(); + } + + /** + * Tests that nothing is output for a plugin without an update. + * + * @ticket 65819 + */ + public function test_should_return_false_without_update() { + wp_set_current_user( self::$admin_id ); + + $this->set_update( 'other-plugin/other-plugin.php', array() ); + + $this->assertSame( array( false, '' ), $this->render_row() ); + } + + /** + * Tests the update notice with an update link. + * + * @ticket 65819 + */ + public function test_should_output_update_link() { + wp_set_current_user( self::$admin_id ); + + $this->set_update( self::FILE, array() ); + + $this->assertSame( + array( + null, + $this->get_row_start( '', 'notice-warning' ) + . 'There is a new version of My Plugin available. ' . $this->get_details_link( 'My Plugin' ) + . ' or update now.' + . '

', + ), + $this->render_row() + ); + } + + /** + * Tests that the row is marked active for an active plugin. + * + * @ticket 65819 + */ + public function test_should_mark_active_plugin() { + wp_set_current_user( self::$admin_id ); + + $this->set_update( self::FILE, array() ); + + if ( is_multisite() ) { + update_site_option( 'active_sitewide_plugins', array( self::FILE => time() ) ); + } else { + update_option( 'active_plugins', array( self::FILE ) ); + } + + list( , $output ) = $this->render_row(); + + $this->assertStringStartsWith( 'set_update( self::FILE, array() ); + + list( , $output ) = $this->render_row(); + + $this->assertSame( + $this->get_row_start( '', 'notice-warning' ) + . 'There is a new version of My Plugin available. ' . $this->get_details_link( 'My Plugin' ) . '.' + . '

', + $output + ); + } + + /** + * Tests the notice for an update without a package. + * + * @ticket 65819 + */ + public function test_should_output_unavailable_notice_without_package() { + wp_set_current_user( self::$admin_id ); + + $this->set_update( self::FILE, array( 'package' => '' ) ); + + list( , $output ) = $this->render_row(); + + $this->assertSame( + $this->get_row_start( '', 'notice-warning' ) + . 'There is a new version of My Plugin available. ' . $this->get_details_link( 'My Plugin' ) + . '. Automatic update is unavailable for this plugin.' + . '

', + $output + ); + } + + /** + * Tests the notice for an update that requires a newer PHP version. + * + * @ticket 65819 + */ + public function test_should_output_php_notice_for_incompatible_update() { + wp_set_current_user( self::$admin_id ); + + $this->set_update( self::FILE, array( 'requires_php' => '99.0' ) ); + + list( , $output ) = $this->render_row(); + + $this->assertSame( + $this->get_row_start( '', 'notice-error' ) + . 'There is a new version of My Plugin available, but it does not work with your version of PHP. ' . $this->get_details_link( 'My Plugin' ) + . ' or learn more about updating PHP.' + . '

', + $output + ); + } + + /** + * Tests that the plugin name is sanitized. + * + * @ticket 65819 + */ + public function test_should_sanitize_plugin_name() { + wp_set_current_user( self::$admin_id ); + + $this->set_update( self::FILE, array() ); + + list( , $output ) = $this->render_row( array( 'Name' => 'My Plugin & Co' ) ); + + $this->assertStringContainsString( + 'There is a new version of My Plugin & Co available.', + $output, + 'The name should keep allowed tags only.' + ); + $this->assertStringContainsString( + 'aria-label="Update My <strong>Plugin</strong> & Co now"', + $output, + 'The name should be escaped in the update link label.' + ); + } + + /** + * Tests where the details link points to without a plugin slug. + * + * @ticket 65819 + * + * @dataProvider data_details_urls + * + * @param array $response The update response fields. + * @param string $slug The expected plugin slug. + * @param string $details_url The expected escaped details URL. + */ + public function test_should_choose_details_url( $response, $slug, $details_url ) { + wp_set_current_user( self::$admin_id ); + + $this->set_update( self::FILE, $response, false ); + + list( , $output ) = $this->render_row( array( 'PluginURI' => 'https://example.org/plugin-uri/' ) ); + + $this->assertStringStartsWith( + '', + $output, + 'The row should use the expected slug.' + ); + $this->assertStringContainsString( ' array( + array( + 'id' => 'example.org/my-plugin', + 'url' => 'https://example.org/my-plugin/', + ), + 'example.org/my-plugin', + 'https://example.org/my-plugin/?TB_iframe=true&width=600&height=800', + ), + 'plugin URI' => array( + array( 'id' => 'example.org/my-plugin' ), + 'example.org/my-plugin', + 'https://example.org/plugin-uri/?TB_iframe=true&width=600&height=800', + ), + ); + } + + /** + * Tests that the update message action fires with the plugin data and the update response. + * + * @ticket 65819 + */ + public function test_should_fire_update_message_action() { + wp_set_current_user( self::$admin_id ); + + $this->set_update( self::FILE, array() ); + + add_action( + 'in_plugin_update_message-' . self::FILE, + static function ( $plugin_data, $response ) { + echo '[' . $plugin_data['Name'] . ' ' . $response->new_version . ']'; + }, + 10, + 2 + ); + + list( , $output ) = $this->render_row(); + + $this->assertStringEndsWith( 'update now.[My Plugin 2.0.0]

', $output ); + } + + /** + * Tests that nothing is output on a site's Plugins screen in a network. + * + * @ticket 65819 + * + * @group ms-required + */ + public function test_should_not_output_row_on_site_screen_in_network() { + wp_set_current_user( self::$admin_id ); + set_current_screen( 'plugins' ); + + $this->set_update( self::FILE, array() ); + + $this->assertSame( array( null, '' ), $this->render_row() ); + } + + /** + * Tests that a row is hooked for each plugin with an update. + * + * @ticket 65819 + */ + public function test_update_rows_should_hook_row_for_each_update() { + wp_set_current_user( self::$admin_id ); + + $this->set_update( self::FILE, array() ); + $this->set_update( 'other-plugin.php', array() ); + + wp_plugin_update_rows(); + + $this->assertSame( 10, has_action( 'after_plugin_row_' . self::FILE, 'wp_plugin_update_row' ), 'The row should be hooked for the first plugin.' ); + $this->assertSame( 10, has_action( 'after_plugin_row_other-plugin.php', 'wp_plugin_update_row' ), 'The row should be hooked for the second plugin.' ); + } + + /** + * Tests that no row is hooked for a user who cannot update plugins or without updates. + * + * @ticket 65819 + * + * @dataProvider data_no_update_rows + * + * @param string $user The user, 'admin' or 'subscriber'. + * @param mixed $response The response of the update transient, or false for no transient. + */ + public function test_update_rows_should_not_hook_rows( $user, $response ) { + wp_set_current_user( 'admin' === $user ? self::$admin_id : self::$subscriber_id ); + + if ( false !== $response ) { + set_site_transient( 'update_plugins', (object) array( 'response' => $response ) ); + } + + wp_plugin_update_rows(); + + $this->assertFalse( has_action( 'after_plugin_row_' . self::FILE ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_no_update_rows() { + return array( + 'user who cannot update plugins' => array( 'subscriber', array( self::FILE => (object) array( 'slug' => 'my-plugin' ) ) ), + 'no update transient' => array( 'admin', false ), + 'response is not an array' => array( 'admin', 'not an array' ), + ); + } + + /** + * Returns the expected start of the update row. + * + * @param string $active_class The active class. + * @param string $notice_type The notice type class. + * @return string The markup. + */ + private function get_row_start( $active_class, $notice_type ) { + return '' + . '' + . '

'; + } + + /** + * Returns the expected details link. + * + * @param string $label_name The plugin name in the link label. + * @return string The markup. + */ + private function get_details_link( $label_name ) { + $url = 'http://' . WP_TESTS_DOMAIN . '/wp-admin/' . ( is_multisite() ? 'network/' : '' ) + . 'plugin-install.php?tab=plugin-information&plugin=my-plugin&section=changelog&TB_iframe=true&width=600&height=800'; + + return 'View version 2.0.0 details'; + } + + /** + * Returns the expected update URL. + * + * @return string The URL. + */ + private function get_update_url() { + return 'http://' . WP_TESTS_DOMAIN . '/wp-admin/' . ( is_multisite() ? 'network/' : '' ) + . 'update.php?action=upgrade-plugin&plugin=my-plugin%2Fmy-plugin.php&_wpnonce=' . wp_create_nonce( 'upgrade-plugin_' . self::FILE ); + } + + /** + * Adds an update for a plugin to the update transient. + * + * @param string $file The plugin file. + * @param array $fields The response fields to add or replace. + * @param bool $defaults Whether to start from the default response fields. + */ + private function set_update( $file, $fields, $defaults = true ) { + $current = get_site_transient( 'update_plugins' ); + if ( ! is_object( $current ) ) { + $current = (object) array( 'response' => array() ); + } + + $response = $defaults + ? array( + 'id' => 'w.org/plugins/my-plugin', + 'slug' => 'my-plugin', + 'new_version' => '2.0.0', + 'package' => 'https://downloads.example.org/my-plugin.zip', + ) + : array( 'new_version' => '2.0.0' ); + + $current->response[ $file ] = (object) array_merge( $response, $fields ); + + set_site_transient( 'update_plugins', $current ); + } + + /** + * Renders the update row of the test plugin. + * + * @param array $plugin_data The plugin data to add or replace. + * @return array The return value and the output. + */ + private function render_row( $plugin_data = array() ) { + $plugin_data = array_merge( + array( + 'Name' => 'My Plugin', + 'PluginURI' => '', + ), + $plugin_data + ); + + ob_start(); + $result = wp_plugin_update_row( self::FILE, $plugin_data ); + $output = ob_get_clean(); + + return array( $result, $output ); + } +} diff --git a/tests/phpunit/tests/admin/includes/user/WpIsAuthorizeApplicationPasswordRequestValid_Test.php b/tests/phpunit/tests/admin/includes/user/WpIsAuthorizeApplicationPasswordRequestValid_Test.php index a2e1320b2a150..31325411ddd4b 100644 --- a/tests/phpunit/tests/admin/includes/user/WpIsAuthorizeApplicationPasswordRequestValid_Test.php +++ b/tests/phpunit/tests/admin/includes/user/WpIsAuthorizeApplicationPasswordRequestValid_Test.php @@ -35,7 +35,7 @@ public function test_is_authorize_application_password_request_valid( $request, } } - public function data_is_authorize_application_password_request_valid() { + public static function data_is_authorize_application_password_request_valid() { $environment_types = array( 'local', 'development', 'staging', 'production' ); $datasets = array(); diff --git a/tests/phpunit/tests/admin/includes/user/WpIsAuthorizeApplicationRedirectUrlValid_Test.php b/tests/phpunit/tests/admin/includes/user/WpIsAuthorizeApplicationRedirectUrlValid_Test.php index 3ba7b9045ac28..bcb84bc4df593 100644 --- a/tests/phpunit/tests/admin/includes/user/WpIsAuthorizeApplicationRedirectUrlValid_Test.php +++ b/tests/phpunit/tests/admin/includes/user/WpIsAuthorizeApplicationRedirectUrlValid_Test.php @@ -39,7 +39,7 @@ public function test_wp_is_authorize_application_redirect_url_valid( $url, $expe * * @return array[] */ - public function data_wp_is_authorize_application_redirect_url_valid() { + public static function data_wp_is_authorize_application_redirect_url_valid() { $environment_types = array( 'local', 'development', 'staging', 'production' ); $datasets = array(); diff --git a/tests/phpunit/tests/admin/includesFile.php b/tests/phpunit/tests/admin/includesFile.php index 6eddd1d6c529e..29e41d4867de4 100644 --- a/tests/phpunit/tests/admin/includesFile.php +++ b/tests/phpunit/tests/admin/includesFile.php @@ -110,7 +110,7 @@ public function test_download_url_should_respect_filename_from_content_dispositi * * @return array */ - public function data_download_url_should_respect_filename_from_content_disposition_header() { + public static function data_download_url_should_respect_filename_from_content_disposition_header() { return array( 'valid parameters' => array( 'filter_content_disposition_header_with_filename' ), 'path traversal' => array( 'filter_content_disposition_header_with_filename_with_path_traversal' ), @@ -143,7 +143,7 @@ public function test_save_to_temp_directory_when_getting_filename_from_content_d * * @return array */ - public function data_save_to_temp_directory_when_getting_filename_from_content_disposition_header() { + public static function data_save_to_temp_directory_when_getting_filename_from_content_disposition_header() { return array( 'valid parameters' => array( 'filter_content_disposition_header_with_filename' ), ); @@ -228,7 +228,7 @@ public function test_download_url_should_reject_filename_from_invalid_content_di * * @return array */ - public function data_download_url_should_reject_filename_from_invalid_content_disposition_header() { + public static function data_download_url_should_reject_filename_from_invalid_content_disposition_header() { return array( 'no context' => array( 'filter_content_disposition_header_with_filename_without_context' ), 'inline context' => array( 'filter_content_disposition_header_with_filename_with_inline_context' ), @@ -310,7 +310,7 @@ public function test_download_url_empty_url( $url ) { * * @return array */ - public function data_download_url_empty_url() { + public static function data_download_url_empty_url() { return array( 'null' => array( null ), 'false' => array( false ), @@ -420,7 +420,7 @@ public function test_download_url_should_use_the_content_type_header_to_set_exte * * @return Generator */ - public function data_download_url_should_use_the_content_type_header_to_set_extension_of_a_file_if_extension_was_not_determined() { + public static function data_download_url_should_use_the_content_type_header_to_set_extension_of_a_file_if_extension_was_not_determined() { yield 'Content-Type header in the response' => array( function () { return array( diff --git a/tests/phpunit/tests/admin/includesMedia.php b/tests/phpunit/tests/admin/includesMedia.php index 2debdc226ad35..073c6ac1b04dd 100644 --- a/tests/phpunit/tests/admin/includesMedia.php +++ b/tests/phpunit/tests/admin/includesMedia.php @@ -70,7 +70,7 @@ public function test_attachment_submitbox_metadata_filesize( $filesize, ?int $ex * * @return array|null }> */ - public function data_attachment_submitbox_metadata_filesize(): array { + public static function data_attachment_submitbox_metadata_filesize(): array { return array( 'an integer' => array( 'filesize' => 12345, @@ -167,7 +167,7 @@ public function test_attachment_submitbox_metadata_filesize_falls_back_to_the_fi * * @return array */ - public function data_attachment_submitbox_metadata_filesize_falls_back_to_the_file(): array { + public static function data_attachment_submitbox_metadata_filesize_falls_back_to_the_file(): array { return array( 'a value smaller than a byte' => array( 'filesize' => 0.5 ), 'zero' => array( 'filesize' => 0 ), diff --git a/tests/phpunit/tests/admin/includesPlugin.php b/tests/phpunit/tests/admin/includesPlugin.php index 813ba3429c204..6eff4098a0b22 100644 --- a/tests/phpunit/tests/admin/includesPlugin.php +++ b/tests/phpunit/tests/admin/includesPlugin.php @@ -222,7 +222,7 @@ public function test_submenu_position( $position, $expected_position ) { // Setup a menu with some items. $parent = add_menu_page( 'Test Toplevel', 'Test Toplevel', 'manage_options', 'mt-top-level-handle', 'mt_toplevel_page' ); - foreach ( $this->submenus_to_add() as $menu_to_add ) { + foreach ( self::submenus_to_add() as $menu_to_add ) { add_submenu_page( $parent, $menu_to_add[0], $menu_to_add[1], $menu_to_add[2], $menu_to_add[3], $menu_to_add[4] ); } @@ -326,7 +326,7 @@ public function test_submenu_helpers_position( $position, $expected_position ) { foreach ( $helper_functions as $helper_function ) { // Build up demo pages on the menu root. - foreach ( $this->submenus_to_add() as $menu_to_add ) { + foreach ( self::submenus_to_add() as $menu_to_add ) { add_menu_page( $menu_to_add[0], $menu_to_add[1], $menu_to_add[2], $helper_function['menu_root'], $helper_function['menu_root'] ); } @@ -362,7 +362,7 @@ public function test_submenu_helpers_position( $position, $expected_position ) { * } * } */ - private function submenus_to_add() { + private static function submenus_to_add() { return array( array( 'Submenu Position', 'Submenu Position', 'manage_options', 'sub-page', '' ), array( 'Submenu Position 2', 'Submenu Position 2', 'manage_options', 'sub-page2', '' ), @@ -384,8 +384,8 @@ private function submenus_to_add() { * } * } */ - public function data_submenu_position() { - $menu_count = count( $this->submenus_to_add() ); + public static function data_submenu_position() { + $menu_count = count( self::submenus_to_add() ); return array( array( null, $menu_count ), // Insert at the end of the menu if null is passed. Default behavior. array( 0, 0 ), // Insert at the beginning of the menu if 0 is passed. diff --git a/tests/phpunit/tests/admin/includesSchema.php b/tests/phpunit/tests/admin/includesSchema.php index cb8cd90abf04c..f6c96169659a5 100644 --- a/tests/phpunit/tests/admin/includesSchema.php +++ b/tests/phpunit/tests/admin/includesSchema.php @@ -141,7 +141,7 @@ public function test_populate_options( $options, $expected ) { $this->assertSame( $expected, $results ); } - public function data_populate_options() { + public static function data_populate_options() { return array( array( array(), @@ -275,7 +275,7 @@ public function test_populate_site_meta( $meta, $expected ) { $this->assertSame( $expected, $results ); } - public function data_populate_site_meta() { + public static function data_populate_site_meta() { return array( array( array(), @@ -323,7 +323,7 @@ public function test_populate_network_meta( $meta, $expected ) { $this->assertSame( $expected, $results ); } - public function data_populate_network_meta() { + public static function data_populate_network_meta() { return array( array( array(), diff --git a/tests/phpunit/tests/admin/includesScreen.php b/tests/phpunit/tests/admin/includesScreen.php index 0c6ce7cbd4dea..ae1ad0755a26e 100644 --- a/tests/phpunit/tests/admin/includesScreen.php +++ b/tests/phpunit/tests/admin/includesScreen.php @@ -639,7 +639,7 @@ public function setup_block_editor_test( $hook = 'post.php' ) { /** * Data provider for testing is_block_editor. */ - public function data_is_block_editor() { + public static function data_is_block_editor() { return array( array( // Edit post: Post type supports `show_in_rest`, no filters. diff --git a/tests/phpunit/tests/admin/includesTemplate.php b/tests/phpunit/tests/admin/includesTemplate.php index fd65828a55e1b..6e8c9461c7e61 100644 --- a/tests/phpunit/tests/admin/includesTemplate.php +++ b/tests/phpunit/tests/admin/includesTemplate.php @@ -46,7 +46,7 @@ public function test_wp_terms_checklist_with_popular_cats( $term_id ) { $this->assertStringContainsString( 'class="popular-category"', $output ); } - public function data_wp_terms_checklist_with_selected_cats() { + public static function data_wp_terms_checklist_with_selected_cats() { return array( array( '1' ), array( 1 ), @@ -279,7 +279,7 @@ public function test_add_settings_section_without_any_fields( $extra_args, $expe * * @return array */ - public function data_extra_args_for_add_settings_section() { + public static function data_extra_args_for_add_settings_section() { return array( 'class placeholder section_class present' => array( array( @@ -446,7 +446,7 @@ public function test_settings_errors_css_classes( $type, $expected ) { $this->assertStringNotContainsString( 'notice-notice-', $output ); } - public function data_settings_errors_css_classes() { + public static function data_settings_errors_css_classes() { return array( // The admin notices default types will generate CSS classes in this order. array( 'error', 'notice notice-error is-dismissible settings-error' ), @@ -542,7 +542,7 @@ public function test_get_submit_button_expands_type_shorthands( $type, $expected * * @return array[] */ - public function data_get_submit_button_shorthand() { + public static function data_get_submit_button_shorthand() { return array( 'primary shorthand' => array( 'primary', 'button button-primary' ), 'small shorthand' => array( 'small', 'button button-small' ), diff --git a/tests/phpunit/tests/admin/plugin-dependencies/getCircularDependencies.php b/tests/phpunit/tests/admin/plugin-dependencies/getCircularDependencies.php new file mode 100644 index 0000000000000..2fc68e25a8ec9 --- /dev/null +++ b/tests/phpunit/tests/admin/plugin-dependencies/getCircularDependencies.php @@ -0,0 +1,133 @@ +set_dependencies( array( 'a' => array( 'a' ) ) ); + $this->set_property_value( 'circular_dependencies_pairs', array( array( 'x', 'y' ) ) ); + + $this->assertSame( array( array( 'x', 'y' ) ), $this->call_method( 'get_circular_dependencies' ), 'The stored pairs should be returned.' ); + $this->assertNull( $this->get_property_value( 'circular_dependencies_slugs' ), 'The slugs should not be computed.' ); + } + + /** + * Tests that an empty array is returned before the dependencies are read. + * + * @ticket 65819 + */ + public function test_should_return_empty_array_without_dependencies() { + $this->assertSame( array(), $this->call_method( 'get_circular_dependencies' ), 'No pairs should be returned.' ); + $this->assertNull( $this->get_property_value( 'circular_dependencies_pairs' ), 'The pairs should not be stored.' ); + } + + /** + * Tests the circular dependency pairs and slugs found. + * + * @ticket 65819 + * + * @dataProvider data_dependencies + * + * @param array $dependencies The dependency slugs of each plugin, keyed by plugin slug. + * @param array $pairs The expected pairs. + * @param array $slugs The expected slugs, in any order. + */ + public function test_should_find_circular_dependencies( $dependencies, $pairs, $slugs ) { + $this->set_dependencies( $dependencies ); + + $this->assertSame( $pairs, $this->call_method( 'get_circular_dependencies' ), 'The pairs should match.' ); + $this->assertSame( $pairs, $this->get_property_value( 'circular_dependencies_pairs' ), 'The pairs should be stored.' ); + $this->assertSameSets( $slugs, array_unique( $this->get_property_value( 'circular_dependencies_slugs' ) ), 'The slugs should match.' ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_dependencies() { + return array( + 'no circular dependency' => array( + array( + 'a' => array( 'b' ), + 'b' => array( 'c' ), + ), + array(), + array(), + ), + 'dependency that is not installed' => array( + array( 'a' => array( 'missing' ) ), + array(), + array(), + ), + 'plugin that depends on itself' => array( + array( 'a' => array( 'a' ) ), + array( array( 'a', 'a' ) ), + array( 'a' ), + ), + 'self-dependency next to another one' => array( + array( + 'a' => array( 'a', 'b' ), + 'b' => array(), + ), + array( array( 'a', 'a' ) ), + array( 'a' ), + ), + 'two plugins that depend on each other' => array( + array( + 'a' => array( 'b' ), + 'b' => array( 'a' ), + ), + array( array( 'a', 'b' ), array( 'b', 'a' ) ), + array( 'a', 'b' ), + ), + 'three plugins in a loop' => array( + array( + 'a' => array( 'b' ), + 'b' => array( 'c' ), + 'c' => array( 'a' ), + ), + array( array( 'a', 'c' ), array( 'b', 'a' ), array( 'c', 'b' ) ), + array( 'a', 'b', 'c' ), + ), + ); + } + + /** + * Sets the dependencies, keyed by plugin slug, as read from the plugin headers. + * + * @param array $dependencies The dependency slugs of each plugin, keyed by plugin slug. + */ + private function set_dependencies( $dependencies ) { + $by_file = array(); + $dependents = array(); + + foreach ( $dependencies as $slug => $slugs ) { + $file = "{$slug}/{$slug}.php"; + + $by_file[ $file ] = $slugs; + $dependents[ $file ] = $slug; + } + + $this->set_property_value( 'dependencies', $by_file ); + $this->set_property_value( 'dependent_slugs', $dependents ); + } +} diff --git a/tests/phpunit/tests/admin/plugin-dependencies/getDependencyFilepath.php b/tests/phpunit/tests/admin/plugin-dependencies/getDependencyFilepath.php index 04b4948e63f72..68b0fb2801ca1 100644 --- a/tests/phpunit/tests/admin/plugin-dependencies/getDependencyFilepath.php +++ b/tests/phpunit/tests/admin/plugin-dependencies/getDependencyFilepath.php @@ -69,7 +69,7 @@ public function test_should_return_filepaths_for_installed_dependencies( $depend * * @return array[] */ - public function data_get_dependency_filepath() { + public static function data_get_dependency_filepath() { return array( 'no plugins' => array( 'dependency_slug' => 'dependency', diff --git a/tests/phpunit/tests/admin/plugin-dependencies/getDependentFilepath.php b/tests/phpunit/tests/admin/plugin-dependencies/getDependentFilepath.php index 1cfdb30b137bf..fb82323847d64 100644 --- a/tests/phpunit/tests/admin/plugin-dependencies/getDependentFilepath.php +++ b/tests/phpunit/tests/admin/plugin-dependencies/getDependentFilepath.php @@ -43,7 +43,7 @@ public function test_should_return_filepaths_for_installed_dependents( $dependen * * @return array[] */ - public function data_get_dependent_filepath() { + public static function data_get_dependent_filepath() { return array( 'a plugin that exists' => array( 'dependent_slug' => 'dependent', diff --git a/tests/phpunit/tests/admin/plugin-dependencies/hasCircularDependency.php b/tests/phpunit/tests/admin/plugin-dependencies/hasCircularDependency.php index 10d00d96b7637..a43a4f5025853 100644 --- a/tests/phpunit/tests/admin/plugin-dependencies/hasCircularDependency.php +++ b/tests/phpunit/tests/admin/plugin-dependencies/hasCircularDependency.php @@ -77,7 +77,7 @@ public function test_should_return_true_when_a_plugin_has_circular_dependency( $ * * @return array[] */ - public function data_circular_dependencies() { + public static function data_circular_dependencies() { return array( 'a plugin that depends on itself' => array( 'plugin_to_check' => 'dependency/dependency.php', diff --git a/tests/phpunit/tests/admin/plugin-dependencies/initialize.php b/tests/phpunit/tests/admin/plugin-dependencies/initialize.php index 375e30c4bd6e2..58e3392dcd8b0 100644 --- a/tests/phpunit/tests/admin/plugin-dependencies/initialize.php +++ b/tests/phpunit/tests/admin/plugin-dependencies/initialize.php @@ -62,7 +62,7 @@ public function test_should_only_initialize_once( $property_name ) { * * @return array[] */ - public function data_static_properties_set_during_initialization() { + public static function data_static_properties_set_during_initialization() { /* * This does not include 'dependency_api_data' as it is only set * on certain pages. This is tested later. @@ -117,7 +117,7 @@ public function test_should_set_dependency_api_data_on_certain_screens( $screen * * @return array[] */ - public function data_screens() { + public static function data_screens() { return array( 'plugins.php' => array( 'screen' => 'plugins.php', @@ -167,7 +167,7 @@ public function test_initialize_should_load_and_sanitize_dependency_slugs_from_p * * @return array[] */ - public function data_should_sanitize_slugs() { + public static function data_should_sanitize_slugs() { return array( // Valid slugs. 'one dependency' => array( diff --git a/tests/phpunit/tests/admin/pluginInstallerSkinDoOverwrite.php b/tests/phpunit/tests/admin/pluginInstallerSkinDoOverwrite.php new file mode 100644 index 0000000000000..dac1aa65bf44a --- /dev/null +++ b/tests/phpunit/tests/admin/pluginInstallerSkinDoOverwrite.php @@ -0,0 +1,454 @@ +remove_plugin(); + } + + /** + * Removes the test plugin and clears the plugins cache. + */ + public function tear_down() { + $this->remove_plugin(); + + parent::tear_down(); + } + + /** + * Tests that nothing is output unless an upload hit an existing plugin folder. + * + * @ticket 65819 + * + * @dataProvider data_not_an_overwrite + * + * @param string $type The skin type. + * @param string $error_code The result error code, or an empty string for a successful result. + */ + public function test_should_return_false_when_not_overwriting( $type, $error_code ) { + $this->create_plugin( array( 'Version' => '1.0.0' ) ); + + $result = '' === $error_code ? true : new WP_Error( $error_code, 'Error.', WP_PLUGIN_DIR . '/' . self::SLUG . '/' ); + $skin = $this->get_skin( $result, array( 'Name' => 'Overwrite Test Plugin' ), $type ); + + $this->assertSame( array( false, '' ), $this->do_overwrite( $skin ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_not_an_overwrite() { + return array( + 'web install' => array( 'web', 'folder_exists' ), + 'successful upload' => array( 'upload', '' ), + 'other upload error' => array( 'upload', 'incompatible_archive' ), + ); + } + + /** + * Tests that nothing is output when the existing folder holds no plugin. + * + * @ticket 65819 + */ + public function test_should_return_false_when_existing_folder_has_no_plugin() { + $this->create_plugin( array( 'Version' => '1.0.0' ) ); + + $skin = $this->get_skin( + new WP_Error( 'folder_exists', 'Error.', WP_PLUGIN_DIR . '/other-plugin/' ), + array( 'Name' => 'Overwrite Test Plugin' ) + ); + + $this->assertSame( array( false, '' ), $this->do_overwrite( $skin ) ); + } + + /** + * Tests that nothing is output when the uploaded plugin data is missing. + * + * @ticket 65819 + */ + public function test_should_return_false_without_uploaded_plugin_data() { + $this->create_plugin( array( 'Version' => '1.0.0' ) ); + + $skin = $this->get_skin( $this->get_folder_exists_error(), array() ); + + $this->assertSame( array( false, '' ), $this->do_overwrite( $skin ) ); + } + + /** + * Tests the comparison table, notice and actions when updating a plugin. + * + * @ticket 65819 + */ + public function test_should_output_update_comparison_and_actions() { + $this->create_plugin( + array( + 'Version' => '1.0.0', + 'Author' => 'Jane Doe', + 'RequiresPHP' => '7.4', + ) + ); + + $skin = $this->get_skin( + $this->get_folder_exists_error(), + array( + 'Name' => 'Overwrite Test Plugin', + 'Version' => '2.0.0', + 'Author' => 'Jane & Co', + 'RequiresWP' => '5.0', + 'RequiresPHP' => '', + ) + ); + + $this->assertSame( + array( + true, + '

This plugin is already installed.

' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '
CurrentUploaded
Plugin nameOverwrite Test PluginOverwrite Test Plugin
Version1.0.02.0.0
AuthorJane DoeJane & Co
Required WordPress version-5.0
Required PHP version7.4-
' + . '

You are updating a plugin. Be sure to back up your database and files first.

' + . $this->get_expected_actions( 'update-plugin' ), + ), + $this->do_overwrite( $skin ) + ); + } + + /** + * Tests the version warning, notice and overwrite URL when downgrading a plugin. + * + * @ticket 65819 + */ + public function test_should_output_downgrade_warning() { + $this->create_plugin( array( 'Version' => '2.0.0' ) ); + + $skin = $this->get_skin( + $this->get_folder_exists_error(), + array( + 'Name' => 'Overwrite Test Plugin', + 'Version' => '1.9.9', + ) + ); + + list( $overwrites, $output ) = $this->do_overwrite( $skin ); + + $this->assertTrue( $overwrites, 'The overwrite screen should be shown.' ); + $this->assertStringContainsString( + 'Version2.0.01.9.9', + $output, + 'The uploaded version should be marked as a warning.' + ); + $this->assertStringContainsString( + '

You are uploading an older version of a current plugin. You can continue to install the older version, but be sure to back up your database and files first.

', + $output, + 'The downgrade notice should be shown.' + ); + $this->assertStringEndsWith( $this->get_expected_actions( 'downgrade-plugin' ), $output, 'The overwrite URL should downgrade the plugin.' ); + } + + /** + * Tests that the plugin cannot be replaced when the uploaded plugin's requirements are not met. + * + * @ticket 65819 + * + * @dataProvider data_unmet_requirements + * + * @param array $requirements The uploaded plugin's requirements. + * @param string[] $reasons The expected reasons, as format strings for PHP_VERSION and the WordPress version. + */ + public function test_should_block_update_when_requirements_are_not_met( $requirements, $reasons ) { + $this->create_plugin( array( 'Version' => '1.0.0' ) ); + + $skin = $this->get_skin( + $this->get_folder_exists_error(), + array_merge( + array( + 'Name' => 'Overwrite Test Plugin', + 'Version' => '2.0.0', + ), + $requirements + ) + ); + + list( $overwrites, $output ) = $this->do_overwrite( $skin ); + + $items = ''; + foreach ( $reasons as $reason ) { + $items .= '
  • ' . sprintf( $reason, PHP_VERSION, wp_get_wp_version() ) . '
  • '; + } + + $this->assertTrue( $overwrites, 'The overwrite screen should be shown.' ); + $this->assertStringContainsString( + '

    The plugin cannot be updated due to the following:

      ' . $items . '
    ', + $output, + 'The reasons should be listed after the table.' + ); + $this->assertStringNotContainsString( 'update-from-upload-notice', $output, 'No backup notice should be shown.' ); + $this->assertStringEndsWith( $this->get_expected_actions( '' ), $output, 'Only the cancel action should be shown.' ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_unmet_requirements() { + $php = 'The PHP version on your server is %1$s, however the uploaded plugin requires 99.0.'; + $wp = 'Your WordPress version is %2$s, however the uploaded plugin requires 99.0.'; + + return array( + 'PHP' => array( array( 'RequiresPHP' => '99.0' ), array( $php ) ), + 'WordPress' => array( array( 'RequiresWP' => '99.0' ), array( $wp ) ), + 'PHP and WordPress' => array( + array( + 'RequiresPHP' => '99.0', + 'RequiresWP' => '99.0', + ), + array( $php, $wp ), + ), + ); + } + + /** + * Tests that the comparison table can be filtered. + * + * @ticket 65819 + */ + public function test_should_filter_comparison_table() { + $this->create_plugin( array( 'Version' => '1.0.0' ) ); + + $new_plugin_data = array( + 'Name' => 'Overwrite Test Plugin', + 'Version' => '2.0.0', + ); + + $filter = new MockAction(); + add_filter( 'install_plugin_overwrite_comparison', array( $filter, 'filter' ), 10, 3 ); + add_filter( + 'install_plugin_overwrite_comparison', + static function () { + return '
    '; + }, + 11 + ); + + list( , $output ) = $this->do_overwrite( $this->get_skin( $this->get_folder_exists_error(), $new_plugin_data ) ); + + $args = $filter->get_args(); + + $this->assertStringStartsWith( '', $args[0][0], 'The filter should receive the table.' ); + $this->assertSame( 'Overwrite Test Plugin', $args[0][1]['Name'], 'The filter should receive the current plugin data.' ); + $this->assertSame( $new_plugin_data, $args[0][2], 'The filter should receive the uploaded plugin data.' ); + $this->assertStringContainsString( '

    ', $output, 'The filtered table should be output.' ); + } + + /** + * Tests that the actions can be filtered, and none are output when they are removed. + * + * @ticket 65819 + */ + public function test_should_filter_actions() { + $this->create_plugin( array( 'Version' => '1.0.0' ) ); + + $new_plugin_data = array( + 'Name' => 'Overwrite Test Plugin', + 'Version' => '2.0.0', + ); + + $filter = new MockAction(); + add_filter( 'install_plugin_overwrite_actions', array( $filter, 'filter' ), 10, 3 ); + add_filter( 'install_plugin_overwrite_actions', '__return_empty_array', 11 ); + + $skin = $this->get_skin( $this->get_folder_exists_error(), $new_plugin_data ); + + list( $overwrites, $output ) = $this->do_overwrite( $skin ); + + $args = $filter->get_args(); + + $this->assertTrue( $overwrites, 'The overwrite screen should be shown.' ); + $this->assertSame( array( 'overwrite_plugin', 'plugins_page' ), array_keys( $args[0][0] ), 'The filter should receive both actions.' ); + $this->assertSame( $skin->api, $args[0][1], 'The filter should receive the API data.' ); + $this->assertSame( $new_plugin_data, $args[0][2], 'The filter should receive the uploaded plugin data.' ); + $this->assertStringEndsWith( 'first.

    ', $output, 'No actions should be output.' ); + } + + /** + * Tests that after() stops after the overwrite screen. + * + * @ticket 65819 + * + * @covers Plugin_Installer_Skin::after + */ + public function test_after_should_only_output_overwrite_screen() { + $this->create_plugin( array( 'Version' => '1.0.0' ) ); + + $skin = $this->get_skin( + $this->get_folder_exists_error(), + array( + 'Name' => 'Overwrite Test Plugin', + 'Version' => '2.0.0', + ) + ); + + $output = get_echo( array( $skin, 'after' ) ); + + $this->assertStringStartsWith( '

    ', $output, 'The overwrite screen should be output.' ); + $this->assertStringNotContainsString( 'Activate Plugin', $output, 'The install actions should not be output.' ); + } + + /** + * Returns the expected actions markup. + * + * @param string $overwrite The overwrite query argument, or an empty string when the plugin cannot be replaced. + * @return string The markup. + */ + private function get_expected_actions( $overwrite ) { + $actions = array(); + + if ( '' !== $overwrite ) { + $actions[] = 'Replace current with uploaded'; + } + + $actions[] = 'Cancel and go back'; + + return '' + . '

    ' . implode( ' ', $actions ) . '

    '; + } + + /** + * Returns the error the upgrader returns when the plugin folder already exists. + * + * @return WP_Error The error. + */ + private function get_folder_exists_error() { + return new WP_Error( 'folder_exists', 'Destination folder already exists.', WP_PLUGIN_DIR . '/' . self::SLUG . '/' ); + } + + /** + * Returns a skin with the given result and uploaded plugin data. + * + * @param true|WP_Error $result The install result. + * @param array $new_plugin_data The uploaded plugin data. + * @param string $type The skin type. + * @return Plugin_Installer_Skin The skin. + */ + private function get_skin( $result, $new_plugin_data, $type = 'upload' ) { + $skin = new Plugin_Installer_Skin( + array( + 'type' => $type, + 'url' => self::URL, + ) + ); + + $upgrader = new Plugin_Upgrader( $skin ); + $upgrader->new_plugin_data = $new_plugin_data; + + $skin->upgrader = $upgrader; + $skin->result = $result; + + return $skin; + } + + /** + * Calls the private do_overwrite() method. + * + * @param Plugin_Installer_Skin $skin The skin. + * @return array The return value and the output. + */ + private function do_overwrite( $skin ) { + $method = new ReflectionMethod( $skin, 'do_overwrite' ); + if ( PHP_VERSION_ID < 80100 ) { + $method->setAccessible( true ); + } + + ob_start(); + $result = $method->invoke( $skin ); + $output = ob_get_clean(); + + return array( $result, $output ); + } + + /** + * Creates the installed test plugin. + * + * @param array $headers The Version, Author, RequiresWP and RequiresPHP headers. + */ + private function create_plugin( $headers ) { + $names = array( + 'Version' => 'Version', + 'Author' => 'Author', + 'RequiresWP' => 'Requires at least', + 'RequiresPHP' => 'Requires PHP', + ); + + $contents = " $value ) { + $contents .= " * {$names[ $field ]}: {$value}\n"; + } + $contents .= " */\n"; + + $dir = WP_PLUGIN_DIR . '/' . self::SLUG; + + $this->assertTrue( mkdir( $dir ), 'The test plugin directory should be created.' ); + $this->assertNotFalse( file_put_contents( $dir . '/' . self::SLUG . '.php', $contents ), 'The test plugin file should be written.' ); + + wp_cache_delete( 'plugins', 'plugins' ); + } + + /** + * Removes the test plugin and clears the plugins cache. + */ + private function remove_plugin() { + $dir = WP_PLUGIN_DIR . '/' . self::SLUG; + + if ( is_dir( $dir ) ) { + $this->rmdir( $dir ); + rmdir( $dir ); + } + + wp_cache_delete( 'plugins', 'plugins' ); + } +} diff --git a/tests/phpunit/tests/admin/pluginUpgraderCheckPackage.php b/tests/phpunit/tests/admin/pluginUpgraderCheckPackage.php new file mode 100644 index 0000000000000..d33f436d6d340 --- /dev/null +++ b/tests/phpunit/tests/admin/pluginUpgraderCheckPackage.php @@ -0,0 +1,264 @@ +remove_package(); + + $this->had_filesystem = array_key_exists( 'wp_filesystem', $GLOBALS ); + $this->original_filesystem = $this->had_filesystem ? $GLOBALS['wp_filesystem'] : null; + + $this->set_filesystem_content_dir( trailingslashit( WP_CONTENT_DIR ) ); + + $this->upgrader = new Plugin_Upgrader( $this->createMock( 'WP_Upgrader_Skin' ) ); + $this->upgrader->generic_strings(); + } + + /** + * Restores the filesystem global and removes the package directory. + */ + public function tear_down() { + if ( $this->had_filesystem ) { + $GLOBALS['wp_filesystem'] = $this->original_filesystem; + } else { + unset( $GLOBALS['wp_filesystem'] ); + } + + $this->remove_package(); + + parent::tear_down(); + } + + /** + * Tests that an error source is returned unchanged and the uploaded plugin data is reset. + * + * @ticket 65819 + */ + public function test_should_return_error_source_and_reset_plugin_data() { + $this->upgrader->new_plugin_data = array( 'Name' => 'Previous Plugin' ); + + $source = new WP_Error( 'download_failed', 'Download failed.' ); + + $this->assertSame( $source, $this->upgrader->check_package( $source ), 'The error should be returned.' ); + $this->assertSame( array(), $this->upgrader->new_plugin_data, 'The uploaded plugin data should be reset.' ); + } + + /** + * Tests that a source that is not a local directory is returned unchanged. + * + * @ticket 65819 + */ + public function test_should_return_source_when_directory_does_not_exist() { + $source = trailingslashit( WP_CONTENT_DIR ) . self::PACKAGE . '/'; + + $this->assertSame( $source, $this->upgrader->check_package( $source ) ); + } + + /** + * Tests that a package without a valid plugin is rejected. + * + * @ticket 65819 + * + * @dataProvider data_packages_without_plugin + * + * @param array $files The package files, keyed by file name. + */ + public function test_should_reject_package_without_plugin( $files ) { + $source = $this->create_package( $files ); + + $result = $this->upgrader->check_package( $source ); + + $this->assertWPError( $result, 'The package should be rejected.' ); + $this->assertSame( 'incompatible_archive_no_plugins', $result->get_error_code(), 'The error code should match.' ); + $this->assertSame( 'The package could not be installed.', $result->get_error_message(), 'The error message should match.' ); + $this->assertSame( 'No valid plugins were found.', $result->get_error_data(), 'The error data should explain why.' ); + $this->assertSame( array(), $this->upgrader->new_plugin_data, 'No plugin data should be stored.' ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_packages_without_plugin() { + return array( + 'empty package' => array( array() ), + 'no PHP files' => array( array( 'readme.txt' => "Plugin Name: Not A PHP File\n" ) ), + 'PHP file without plugin name' => array( array( 'functions.php' => " array( array( 'includes/plugin.php' => "create_package( + array( + 'a-functions.php' => " " "assertSame( $source, $this->upgrader->check_package( $source ), 'The source should be returned.' ); + $this->assertSame( 'Check Package Plugin', $this->upgrader->new_plugin_data['Name'], 'The first plugin should be stored.' ); + $this->assertSame( '1.2.3', $this->upgrader->new_plugin_data['Version'], 'The plugin version should be stored.' ); + } + + /** + * Tests that a remote filesystem path is mapped to the local content directory. + * + * @ticket 65819 + */ + public function test_should_map_remote_path_to_local_content_directory() { + $this->create_package( array( 'plugin.php' => "set_filesystem_content_dir( '/remote/wp-content/' ); + + $source = '/remote/wp-content/' . self::PACKAGE . '/'; + + $this->assertSame( $source, $this->upgrader->check_package( $source ), 'The remote source should be returned.' ); + $this->assertSame( 'Remote Plugin', $this->upgrader->new_plugin_data['Name'], 'The plugin should be read from the local directory.' ); + } + + /** + * Tests that a plugin whose requirements are not met is rejected. + * + * @ticket 65819 + * + * @dataProvider data_unmet_requirements + * + * @param string $headers The requirement headers. + * @param string $code The expected error code. + * @param string $reason The expected reason, as a format string for PHP_VERSION and the WordPress version. + */ + public function test_should_reject_plugin_with_unmet_requirements( $headers, $code, $reason ) { + $source = $this->create_package( array( 'plugin.php' => "upgrader->check_package( $source ); + + $this->assertWPError( $result, 'The package should be rejected.' ); + $this->assertSame( $code, $result->get_error_code(), 'The error code should match.' ); + $this->assertSame( 'The package could not be installed.', $result->get_error_message(), 'The error message should match.' ); + $this->assertSame( sprintf( $reason, PHP_VERSION, wp_get_wp_version() ), $result->get_error_data(), 'The error data should explain why.' ); + $this->assertSame( 'Demanding Plugin', $this->upgrader->new_plugin_data['Name'], 'The plugin data should still be stored.' ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_unmet_requirements() { + $php = 'The PHP version on your server is %1$s, however the uploaded plugin requires 99.0.'; + $wp = 'Your WordPress version is %2$s, however the uploaded plugin requires 99.0.'; + + return array( + 'PHP' => array( " * Requires PHP: 99.0\n", 'incompatible_php_required_version', $php ), + 'WordPress' => array( " * Requires at least: 99.0\n", 'incompatible_wp_required_version', $wp ), + 'PHP and WordPress, PHP is reported' => array( " * Requires PHP: 99.0\n * Requires at least: 99.0\n", 'incompatible_php_required_version', $php ), + ); + } + + /** + * Creates the package directory with the given files. + * + * @param array $files The file contents, keyed by file name relative to the package. + * @return string The package directory, with a trailing slash. + */ + private function create_package( $files ) { + $dir = trailingslashit( WP_CONTENT_DIR ) . self::PACKAGE . '/'; + + $this->assertTrue( mkdir( $dir ), 'The package directory should be created.' ); + + foreach ( $files as $name => $contents ) { + $file = $dir . $name; + + if ( ! is_dir( dirname( $file ) ) ) { + $this->assertTrue( mkdir( dirname( $file ), 0777, true ), 'The package subdirectory should be created.' ); + } + + $this->assertNotFalse( file_put_contents( $file, $contents ), 'The package file should be written.' ); + } + + return $dir; + } + + /** + * Removes the package directory. + */ + private function remove_package() { + $dir = trailingslashit( WP_CONTENT_DIR ) . self::PACKAGE; + + if ( is_dir( $dir ) ) { + $this->rmdir( $dir ); + $this->delete_folders( $dir ); + } + } + + /** + * Sets a filesystem whose content directory is the given path. + * + * @param string $content_dir The content directory path of the filesystem. + */ + private function set_filesystem_content_dir( $content_dir ) { + $filesystem = $this->createMock( 'WP_Filesystem_Base' ); + $filesystem->method( 'wp_content_dir' )->willReturn( $content_dir ); + + $GLOBALS['wp_filesystem'] = $filesystem; + } +} diff --git a/tests/phpunit/tests/admin/wpAutomaticUpdater.php b/tests/phpunit/tests/admin/wpAutomaticUpdater.php index 2aca85616cb5f..eb7d982f2a0f6 100644 --- a/tests/phpunit/tests/admin/wpAutomaticUpdater.php +++ b/tests/phpunit/tests/admin/wpAutomaticUpdater.php @@ -112,7 +112,7 @@ function ( $args ) use ( $urls ) { * * @return array */ - public function data_send_plugin_theme_email_should_append_plugin_urls() { + public static function data_send_plugin_theme_email_should_append_plugin_urls() { return array( 'successful updates, the current version and the plugin url' => array( 'urls' => array( 'http://example.org/successful-plugin' ), @@ -380,7 +380,7 @@ function ( $args ) use ( $urls ) { * * @return array */ - public function data_send_plugin_theme_email_should_not_append_plugin_urls() { + public static function data_send_plugin_theme_email_should_not_append_plugin_urls() { return array( 'successful updates, the current version, but no plugin url' => array( 'urls' => array( 'http://example.org/successful-plugin' ), @@ -704,7 +704,7 @@ public function test_is_allowed_dir_should_throw_doing_it_wrong_with_invalid_dir * * @return array[] */ - public function data_is_allowed_dir_should_throw_doing_it_wrong_with_invalid_dir() { + public static function data_is_allowed_dir_should_throw_doing_it_wrong_with_invalid_dir() { return array( // Type checks and boolean comparisons. 'null' => array( 'dir' => null ), @@ -756,4 +756,156 @@ public function test_is_vcs_checkout_should_return_false_when_no_directories_are $this->assertFalse( $updater_mock->is_vcs_checkout( get_temp_dir() ) ); } + + /** + * Tests that {@see WP_Automatic_Updater::send_plugin_theme_email()} lists the details of a + * fatal error under the plugin whose update was rolled back. + * + * @ticket 64155 + * + * @covers WP_Automatic_Updater::send_plugin_theme_email + */ + public function test_send_plugin_theme_email_should_include_fatal_error_details(): void { + $filter = new MockAction(); + add_filter( 'wp_mail', array( $filter, 'filter' ) ); + + $fatal_error = 'PHP Fatal error: Uncaught Error: Call to undefined function failed_plugin_init() in wp-content/plugins/failed-plugin/failed-plugin.php on line 12'; + + $result = new WP_Error( 'plugin_update_fatal_error_rollback_successful', "The update for 'failed-plugin' contained a fatal error. The previously installed version has been restored." ); + $result->add( 'plugin_update_fatal_error', $fatal_error ); + + $failed = array( + (object) array( + 'name' => 'Failed Plugin', + 'result' => $result, + 'item' => (object) array( + 'current_version' => '1.0.0', + 'new_version' => '2.0.0', + 'plugin' => 'failed-plugin/failed-plugin.php', + ), + ), + ); + + self::$send_plugin_theme_email->invokeArgs( self::$updater, array( 'fail', array(), array( 'plugin' => $failed ) ) ); + + $this->assertSame( 1, $filter->get_call_count(), 'An email should have been sent.' ); + + $mail_args = $filter->get_args()[0][0]; + $this->assertIsArray( $mail_args ); + $this->assertArrayHasKey( 'message', $mail_args ); + $this->assertIsString( $mail_args['message'] ); + $this->assertStringContainsString( + "- Failed Plugin (from version 1.0.0 to 2.0.0)\n {$fatal_error}\n", + $mail_args['message'], + 'The fatal error should be listed under the plugin that failed to update.' + ); + } + + /** + * Tests that {@see WP_Automatic_Updater::has_fatal_error()} records the details of the fatal + * error found by the loopback request, without any stack trace. + * + * @ticket 64155 + * + * @covers WP_Automatic_Updater::has_fatal_error + * + * @dataProvider data_has_fatal_error_should_record_fatal_error_details + * + * @param array|true|WP_Error $scrape_result The result of the loopback request: the error + * found by the scrape, `true` for none, or a + * `WP_Error` for a failed request. + * @param bool $expected_return The expected return value. + * @param string|null $expected_message The expected details of the fatal error. + */ + public function test_has_fatal_error_should_record_fatal_error_details( $scrape_result, bool $expected_return, ?string $expected_message ): void { + $upgrading = 1700000000; + + $maintenance_file = ABSPATH . '.maintenance'; + $this->assertFileDoesNotExist( $maintenance_file, 'The test requires that no maintenance file exists.' ); + file_put_contents( $maintenance_file, " array(), + 'body' => "\n###### wp_scraping_result_start:{$scrape_key} ######\n" . wp_json_encode( $scrape_result ) . "\n###### wp_scraping_result_end:{$scrape_key} ######\n", + 'response' => array( + 'code' => 200, + 'message' => 'OK', + ), + 'cookies' => array(), + 'filename' => null, + ); + } + ); + + $has_fatal_error = new ReflectionMethod( self::$updater, 'has_fatal_error' ); + $message = new ReflectionProperty( self::$updater, 'fatal_error_message' ); + if ( PHP_VERSION_ID < 80100 ) { + $has_fatal_error->setAccessible( true ); + $message->setAccessible( true ); + } + + try { + $this->assertSame( $expected_return, $has_fatal_error->invoke( self::$updater ), 'The return value should indicate whether a fatal error was detected.' ); + } finally { + unlink( $maintenance_file ); + } + + $this->assertSame( $expected_message, $message->getValue( self::$updater ), 'The recorded details of the fatal error do not match.' ); + } + + /** + * Data provider. + * + * The scrape results mirror those of {@see wp_finalize_scraping_edited_file_errors()}, which + * passes the error through `str_replace()`, so that every value is a string. + * + * @return array|true|WP_Error, + * expected_return: bool, + * expected_message: string|null, + * }> + */ + public function data_has_fatal_error_should_record_fatal_error_details(): array { + return array( + 'fatal error with a stack trace' => array( + 'scrape_result' => array( + 'type' => '1', + 'message' => "Uncaught Exception: Could not connect to the database in wp-content/plugins/failed-plugin/failed-plugin.php:3\nStack trace:\n#0 wp-content/plugins/failed-plugin/failed-plugin.php(7): failed_plugin_connect('db_user', 'secret_password')\n#1 {main}\n thrown", + 'file' => 'wp-content/plugins/failed-plugin/failed-plugin.php', + 'line' => '3', + ), + 'expected_return' => true, + 'expected_message' => 'PHP Fatal error: Uncaught Exception: Could not connect to the database in wp-content/plugins/failed-plugin/failed-plugin.php:3 in wp-content/plugins/failed-plugin/failed-plugin.php on line 3', + ), + 'fatal error without a stack trace' => array( + 'scrape_result' => array( + 'type' => '64', + 'message' => 'Cannot redeclare failed_plugin_init()', + 'file' => 'wp-content/plugins/failed-plugin/failed-plugin.php', + 'line' => '12', + ), + 'expected_return' => true, + 'expected_message' => 'PHP Fatal error: Cannot redeclare failed_plugin_init() in wp-content/plugins/failed-plugin/failed-plugin.php on line 12', + ), + 'no fatal error' => array( + 'scrape_result' => true, + 'expected_return' => false, + 'expected_message' => null, + ), + 'failed loopback request' => array( + 'scrape_result' => new WP_Error( 'http_request_failed', 'cURL error 28: Operation timed out' ), + 'expected_return' => true, + 'expected_message' => 'The loopback request to check for fatal errors failed: cURL error 28: Operation timed out', + ), + ); + } } diff --git a/tests/phpunit/tests/admin/wpAutomaticUpdaterShouldUpdate.php b/tests/phpunit/tests/admin/wpAutomaticUpdaterShouldUpdate.php new file mode 100644 index 0000000000000..06d78555f0010 --- /dev/null +++ b/tests/phpunit/tests/admin/wpAutomaticUpdaterShouldUpdate.php @@ -0,0 +1,466 @@ +credential_requests[] = array( $context, $allow_relaxed_file_ownership ); + + return $this->credentials_available; + } + + /** + * Gets an updater that records whether a core update notification email would be sent. + * + * @param int $email_count Expected number of core update notification emails. + * @return WP_Automatic_Updater&PHPUnit\Framework\MockObject\MockObject Updater. + */ + private function get_updater( $email_count = 0 ) { + $updater = $this->getMockBuilder( WP_Automatic_Updater::class ) + // Note: setMethods() is deprecated in PHPUnit 9, but still supported. + ->setMethods( array( 'send_core_update_notification_email' ) ) + ->getMock(); + + $updater->expects( $this->exactly( $email_count ) )->method( 'send_core_update_notification_email' ); + + return $updater; + } + + /** + * Gets a plugin update offer. + * + * @param array $fields Fields to override. + * @return object Update offer. + */ + private static function plugin_offer( array $fields = array() ) { + return (object) array_merge( + array( + 'plugin' => 'my-plugin/my-plugin.php', + 'new_version' => '2.0', + ), + $fields + ); + } + + /** + * Gets a core update offer that is compatible with the current environment. + * + * @param array $fields Fields to override. + * @return object Update offer. + */ + private static function core_offer( array $fields = array() ) { + return (object) array_merge( + array( + 'current' => '99.0', + 'php_version' => '5.6', + 'mysql_version' => '5.0', + 'new_files' => true, + ), + $fields + ); + } + + /** + * Tests that nothing is updated while background updates are disabled. + * + * @ticket 65819 + */ + public function test_returns_false_when_updates_are_disabled() { + add_filter( 'automatic_updater_disabled', '__return_true', 20 ); + + $this->assertFalse( $this->get_updater()->should_update( 'plugin', self::plugin_offer( array( 'autoupdate' => true ) ), WP_PLUGIN_DIR ) ); + $this->assertSame( array(), $this->credential_requests, 'The filesystem should not be checked.' ); + } + + /** + * Tests that nothing is updated when filesystem credentials are not available. + * + * @ticket 65819 + */ + public function test_returns_false_without_filesystem_credentials() { + $this->credentials_available = false; + + $this->assertFalse( $this->get_updater()->should_update( 'plugin', self::plugin_offer( array( 'autoupdate' => true ) ), WP_PLUGIN_DIR ) ); + $this->assertSame( array( array( WP_PLUGIN_DIR, false ) ), $this->credential_requests, 'The filesystem should be checked for the context.' ); + } + + /** + * Tests that nothing is updated in a version-controlled checkout. + * + * @ticket 65819 + */ + public function test_returns_false_for_vcs_checkout() { + add_filter( 'automatic_updates_is_vcs_checkout', '__return_true', 20 ); + + $this->assertFalse( $this->get_updater()->should_update( 'plugin', self::plugin_offer( array( 'autoupdate' => true ) ), WP_PLUGIN_DIR ) ); + } + + /** + * Tests that a core update notification email is sent when core cannot be updated in the background. + * + * @ticket 65819 + * + * @dataProvider data_core_updates_that_cannot_run + * + * @param bool $credentials_available Whether filesystem credentials are available. + * @param bool $is_vcs_checkout Whether the site is a version-controlled checkout. + */ + public function test_sends_core_notification_when_core_cannot_be_updated( $credentials_available, $is_vcs_checkout ) { + $this->credentials_available = $credentials_available; + + if ( $is_vcs_checkout ) { + add_filter( 'automatic_updates_is_vcs_checkout', '__return_true', 20 ); + } + + $this->assertFalse( $this->get_updater( 1 )->should_update( 'core', self::core_offer(), ABSPATH ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_core_updates_that_cannot_run() { + return array( + 'no filesystem credentials' => array( false, false ), + 'version-controlled site' => array( true, true ), + ); + } + + /** + * Tests that relaxed file ownership is only allowed for core updates without new files. + * + * @ticket 65819 + * + * @dataProvider data_relaxed_file_ownership + * + * @param string $type Update type. + * @param object $item Update offer. + * @param bool $expected Whether relaxed file ownership should be allowed. + */ + public function test_allows_relaxed_file_ownership_only_for_core_without_new_files( $type, $item, $expected ) { + add_filter( "auto_update_{$type}", '__return_false' ); + + $this->get_updater( 'core' === $type ? 1 : 0 )->should_update( $type, $item, ABSPATH ); + + $this->assertSame( array( array( ABSPATH, $expected ) ), $this->credential_requests ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_relaxed_file_ownership() { + return array( + 'core without new files' => array( 'core', self::core_offer( array( 'new_files' => false ) ), true ), + 'core with new files' => array( 'core', self::core_offer( array( 'new_files' => true ) ), false ), + 'core without the new files flag' => array( 'core', (object) array( 'current' => '99.0' ), false ), + 'plugin without new files' => array( 'plugin', self::plugin_offer( array( 'new_files' => false ) ), false ), + ); + } + + /** + * Tests whether plugins and themes are updated, from their offer and the site's auto-update settings. + * + * @ticket 65819 + * + * @dataProvider data_plugin_and_theme_updates + * + * @param string $type Update type, 'plugin' or 'theme'. + * @param array $fields Fields of the update offer. + * @param string[] $auto_updates Items with auto-updates enabled by the site admin. + * @param bool $type_enabled Whether auto-updates are enabled for the type. + * @param bool $expected Whether the item should be updated. + */ + public function test_plugin_and_theme_updates( $type, $fields, $auto_updates, $type_enabled, $expected ) { + update_site_option( "auto_update_{$type}s", $auto_updates ); + add_filter( "{$type}s_auto_update_enabled", $type_enabled ? '__return_true' : '__return_false' ); + + $item = (object) array_merge( array( $type => "my-{$type}" ), $fields ); + + $this->assertSame( $expected, $this->get_updater()->should_update( $type, $item, WP_CONTENT_DIR ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_plugin_and_theme_updates() { + return array( + 'plugin with the autoupdate flag' => array( 'plugin', array( 'autoupdate' => true ), array(), false, true ), + 'plugin without the autoupdate flag' => array( 'plugin', array(), array(), true, false ), + 'plugin with a falsy autoupdate flag' => array( 'plugin', array( 'autoupdate' => 0 ), array(), true, false ), + 'plugin enabled by the site admin' => array( 'plugin', array(), array( 'my-plugin' ), true, true ), + 'plugin enabled while auto-updates are off' => array( 'plugin', array(), array( 'my-plugin' ), false, false ), + 'other plugin enabled by the site admin' => array( 'plugin', array(), array( 'other-plugin' ), true, false ), + 'theme enabled by the site admin' => array( 'theme', array(), array( 'my-theme' ), true, true ), + 'theme with the autoupdate flag' => array( 'theme', array( 'autoupdate' => true ), array(), false, true ), + 'theme without the autoupdate flag' => array( 'theme', array(), array(), true, false ), + ); + } + + /** + * Tests that translations are updated when the offer allows it. + * + * @ticket 65819 + * + * @dataProvider data_translation_updates + * + * @param array $fields Fields of the update offer. + * @param bool $expected Whether the translation should be updated. + */ + public function test_translation_updates( $fields, $expected ) { + $item = (object) array_merge( array( 'language' => 'de_DE' ), $fields ); + + $this->assertSame( $expected, $this->get_updater()->should_update( 'translation', $item, WP_LANG_DIR ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_translation_updates() { + return array( + 'with the autoupdate flag' => array( array( 'autoupdate' => true ), true ), + 'without the autoupdate flag' => array( array(), false ), + 'with a falsy autoupdate flag' => array( array( 'autoupdate' => false ), false ), + ); + } + + /** + * Tests that the disable_autoupdate flag overrides the user's choice, but not the filter. + * + * @ticket 65819 + */ + public function test_disable_autoupdate_flag_overrides_choice_but_not_filter() { + $item = self::plugin_offer( + array( + 'autoupdate' => true, + 'disable_autoupdate' => true, + ) + ); + + $this->assertFalse( $this->get_updater()->should_update( 'plugin', $item, WP_PLUGIN_DIR ), 'The flag should prevent the update.' ); + + add_filter( 'auto_update_plugin', '__return_true' ); + + $this->assertTrue( $this->get_updater()->should_update( 'plugin', $item, WP_PLUGIN_DIR ), 'The filter should still allow the update.' ); + } + + /** + * Tests that the auto_update_{$type} filter receives the decision and the offer, and overrides it. + * + * @ticket 65819 + * + * @dataProvider data_filter_values + * + * @param mixed $filtered Value returned by the filter. + * @param bool $expected Whether the item should be updated. + */ + public function test_auto_update_filter( $filtered, $expected ) { + $item = self::plugin_offer( array( 'autoupdate' => true ) ); + + $filter = new MockAction(); + add_filter( 'auto_update_plugin', array( $filter, 'filter' ), 10, 2 ); + add_filter( + 'auto_update_plugin', + static function () use ( $filtered ) { + return $filtered; + }, + 20 + ); + + $this->assertSame( $expected, $this->get_updater()->should_update( 'plugin', $item, WP_PLUGIN_DIR ), 'The filtered decision should be used.' ); + $this->assertSame( array( array( true, $item ) ), $filter->get_args(), 'The filter should receive the decision and the offer.' ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_filter_values() { + return array( + 'true' => array( true, true ), + 'false' => array( false, false ), + 'null' => array( null, false ), + 'zero' => array( 0, false ), + 'empty string' => array( '', false ), + 'truthy value' => array( 1, true ), + ); + } + + /** + * Tests that a core update notification email is sent when the filter declines the core update. + * + * @ticket 65819 + */ + public function test_sends_core_notification_when_core_update_is_declined() { + add_filter( 'auto_update_core', '__return_false' ); + + $this->assertFalse( $this->get_updater( 1 )->should_update( 'core', self::core_offer(), ABSPATH ) ); + } + + /** + * Tests that the core update decision comes from the core update policy when no auto_update_core filter is added. + * + * @ticket 65819 + * + * @dataProvider data_core_update_policy + * + * @param bool $allow_major Whether the core update policy allows major updates. + * @param bool $expected Whether core should be updated. + */ + public function test_core_update_follows_core_update_policy( $allow_major, $expected ) { + add_filter( 'allow_dev_auto_core_updates', '__return_true' ); + add_filter( 'allow_major_auto_core_updates', $allow_major ? '__return_true' : '__return_false' ); + + $this->assertSame( $expected, $this->get_updater( $expected ? 0 : 1 )->should_update( 'core', self::core_offer(), ABSPATH ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_core_update_policy() { + return array( + 'policy allows the update' => array( true, true ), + 'policy denies the update' => array( false, false ), + ); + } + + /** + * Tests the PHP and MySQL requirements of core updates. + * + * @ticket 65819 + * + * @dataProvider data_core_requirements + * + * @param array $fields Fields of the update offer. + * @param bool $expected Whether core should be updated. + */ + public function test_core_update_requirements( $fields, $expected ) { + add_filter( 'auto_update_core', '__return_true' ); + + $this->assertSame( $expected, $this->get_updater()->should_update( 'core', self::core_offer( $fields ), ABSPATH ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_core_requirements() { + return array( + 'requirements met' => array( array(), true ), + 'PHP version too old' => array( array( 'php_version' => '99.0' ), false ), + 'MySQL version too old' => array( array( 'mysql_version' => '99.0' ), false ), + ); + } + + /** + * Tests the PHP requirement of plugin and theme updates. + * + * @ticket 65819 + * + * @dataProvider data_plugin_and_theme_php_requirements + * + * @param string $type Update type, 'plugin' or 'theme'. + * @param mixed $requires_php Required PHP version of the update. + * @param bool $expected Whether the item should be updated. + */ + public function test_plugin_and_theme_php_requirements( $type, $requires_php, $expected ) { + $item = (object) array( + $type => "my-{$type}", + 'autoupdate' => true, + 'requires_php' => $requires_php, + ); + + $this->assertSame( $expected, $this->get_updater()->should_update( $type, $item, WP_CONTENT_DIR ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_plugin_and_theme_php_requirements() { + return array( + 'plugin, requirement met' => array( 'plugin', '5.6', true ), + 'plugin, PHP too old' => array( 'plugin', '99.0', false ), + 'plugin, no requirement' => array( 'plugin', '', true ), + 'plugin, null requirement' => array( 'plugin', null, true ), + 'theme, PHP too old' => array( 'theme', '99.0', false ), + 'theme, requirement met' => array( 'theme', '5.6', true ), + ); + } + + /** + * Tests that the PHP requirement does not apply to translations. + * + * @ticket 65819 + */ + public function test_php_requirement_does_not_apply_to_translations() { + $item = (object) array( + 'language' => 'de_DE', + 'autoupdate' => true, + 'requires_php' => '99.0', + ); + + $this->assertTrue( $this->get_updater()->should_update( 'translation', $item, WP_LANG_DIR ) ); + } +} diff --git a/tests/phpunit/tests/admin/wpColorSchemeMenuIconStyles.php b/tests/phpunit/tests/admin/wpColorSchemeMenuIconStyles.php new file mode 100644 index 0000000000000..85daae297deed --- /dev/null +++ b/tests/phpunit/tests/admin/wpColorSchemeMenuIconStyles.php @@ -0,0 +1,112 @@ +original_admin_css_colors = $GLOBALS['_wp_admin_css_colors'] ?? null; + $GLOBALS['wp_styles'] = null; + + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + } + + public function tear_down() { + $GLOBALS['_wp_admin_css_colors'] = $this->original_admin_css_colors; + $GLOBALS['wp_styles'] = null; + + parent::tear_down(); + } + + /** + * Registers a color scheme and makes it the scheme of the current user. + * + * @param string $url URL of the stylesheet of the scheme. + * @param array $icons Icon colors of the scheme. + */ + private function use_color_scheme( $url, $icons ): void { + wp_admin_css_color( 'test-scheme', 'Test scheme', $url, array( '#111111', '#222222' ), $icons ); + update_user_option( get_current_user_id(), 'admin_color', 'test-scheme', true ); + } + + /** + * A third-party scheme gets its icon colors on SVG menu icons. + * + * @ticket 65089 + */ + public function test_adds_icon_colors_of_third_party_scheme(): void { + $this->use_color_scheme( + 'https://example.org/scheme.css', + array( + 'base' => '#aaaaaa', + 'focus' => '#bbbbbb', + 'current' => '#cccccc', + ) + ); + + wp_color_scheme_menu_icon_styles(); + + $styles = implode( '', (array) wp_styles()->get_data( 'admin-menu', 'after' ) ); + + $this->assertStringContainsString( 'color:#aaaaaa', $styles ); + $this->assertStringContainsString( 'color:#bbbbbb', $styles ); + $this->assertStringContainsString( 'color:#cccccc', $styles ); + } + + /** + * @ticket 65089 + * + * @dataProvider data_schemes_without_icon_styles + * + * @param string $url URL of the stylesheet of the scheme. + * @param array $icons Icon colors of the scheme. + */ + public function test_adds_nothing_for_other_schemes( $url, $icons ): void { + $this->use_color_scheme( $url, $icons ); + + wp_color_scheme_menu_icon_styles(); + + $this->assertEmpty( wp_styles()->get_data( 'admin-menu', 'after' ) ); + } + + /** + * Data provider. + * + * @return array }> + */ + public static function data_schemes_without_icon_styles(): array { + $icons = array( + 'base' => '#aaaaaa', + 'focus' => '#bbbbbb', + 'current' => '#cccccc', + ); + + return array( + 'core scheme' => array( admin_url( 'css/colors/light/colors.css' ), $icons ), + 'no stylesheet' => array( '', $icons ), + ); + } +} diff --git a/tests/phpunit/tests/admin/wpCommentsListTable.php b/tests/phpunit/tests/admin/wpCommentsListTable.php index 47b9bec57f140..b1bbce346328a 100644 --- a/tests/phpunit/tests/admin/wpCommentsListTable.php +++ b/tests/phpunit/tests/admin/wpCommentsListTable.php @@ -215,27 +215,30 @@ public function test_get_views_should_return_views_by_default() { } /** - * Verify that the comments table never shows the note comment_type. + * Verify that the comments table never shows internal comment types. * * @ticket 64198 * @ticket 64474 + * @ticket 64638 * * @dataProvider data_comment_type * * @param string $comment_type The comment_type parameter value to test. */ - public function test_comments_list_table_does_not_show_note_comment_type( string $comment_type ) { + public function test_comments_list_table_does_not_show_internal_comment_types( string $comment_type ) { $post_id = self::factory()->post->create(); - self::factory()->comment->create( - array( - 'comment_post_ID' => $post_id, - 'comment_content' => 'This is a note.', - 'comment_type' => 'note', - 'comment_approved' => '1', - 'comment_date' => '2024-01-01 10:00:00', - 'comment_date_gmt' => '2024-01-01 10:00:00', - ) - ); + foreach ( array( 'note', 'reaction' ) as $internal_type ) { + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_content' => 'This is a ' . $internal_type . '.', + 'comment_type' => $internal_type, + 'comment_approved' => '1', + 'comment_date' => '2024-01-01 10:00:00', + 'comment_date_gmt' => '2024-01-01 10:00:00', + ) + ); + } $regular_comment_id = self::factory()->comment->create( array( 'comment_post_ID' => $post_id, @@ -265,14 +268,15 @@ public function test_comments_list_table_does_not_show_note_comment_type( string } /** - * Data provider for test_comments_list_table_does_not_show_note_comment_type(). + * Data provider for test_comments_list_table_does_not_show_internal_comment_types(). * * @return array */ - public function data_comment_type(): array { + public static function data_comment_type(): array { return array( - 'note type explicitly requested' => array( 'note' ), - 'all type requested' => array( 'all' ), + 'note type explicitly requested' => array( 'note' ), + 'reaction type explicitly requested' => array( 'reaction' ), + 'all type requested' => array( 'all' ), ); } } diff --git a/tests/phpunit/tests/admin/wpCommunityEvents.php b/tests/phpunit/tests/admin/wpCommunityEvents.php index fd44a86e272a4..a38351b5fbece 100644 --- a/tests/phpunit/tests/admin/wpCommunityEvents.php +++ b/tests/phpunit/tests/admin/wpCommunityEvents.php @@ -573,7 +573,7 @@ public function test_get_unsafe_client_ip( $raw_ip, $expected_result ) { * * @return array */ - public function data_get_unsafe_client_ip() { + public static function data_get_unsafe_client_ip() { return array( // Handle '::' returned from `wp_privacy_anonymize_ip()`. array( diff --git a/tests/phpunit/tests/admin/wpDashboardSiteActivity.php b/tests/phpunit/tests/admin/wpDashboardSiteActivity.php new file mode 100644 index 0000000000000..9d5f673afc7d6 --- /dev/null +++ b/tests/phpunit/tests/admin/wpDashboardSiteActivity.php @@ -0,0 +1,413 @@ +user->create( + array( + 'display_name' => 'Current Writer', + 'role' => 'author', + ) + ); + self::$other_user_id = $factory->user->create( + array( + 'display_name' => 'Guest Writer', + 'role' => 'author', + ) + ); + } + + public static function wpTearDownAfterClass() { + self::delete_user( self::$user_id ); + self::delete_user( self::$other_user_id ); + } + + public function set_up() { + parent::set_up(); + + set_current_screen( 'dashboard' ); + wp_set_current_user( self::$user_id ); + $_SERVER['REQUEST_METHOD'] = 'GET'; + } + + public function tear_down() { + delete_option( 'dashboard_recent_comments_widget_registered' ); + unset( $GLOBALS['wp_meta_boxes']['dashboard'] ); + + parent::tear_down(); + } + + /** + * Creates a published post on today's calendar day in a previous year. + * + * @param int $author_id Author ID. + * @param string $title Post title. + * @param int $years_ago Number of years before today. + * @param array $post_args Additional post arguments. + * @return int Post ID. + */ + private function create_matching_post( $author_id, $title = 'A memory from last year', $years_ago = 1, $post_args = array() ) { + $today = current_datetime(); + + if ( '02-29' === $today->format( 'm-d' ) ) { + $years_ago *= 4; + } + + $post_date = ( (int) $today->format( 'Y' ) - $years_ago ) . '-' . $today->format( 'm-d' ) . ' 12:00:00'; + + return self::factory()->post->create( + array_merge( + array( + 'post_author' => $author_id, + 'post_date' => $post_date, + 'post_date_gmt' => get_gmt_from_date( $post_date ), + 'post_status' => 'publish', + 'post_title' => $title, + ), + $post_args + ) + ); + } + + /** + * @ticket 65116 + * + * @covers ::_wp_dashboard_on_this_day_date_query_clause + */ + public function test_date_query_includes_february_29_on_february_28_in_non_leap_year() { + $date = new DateTimeImmutable( '2023-02-28 12:00:00', wp_timezone() ); + + $this->assertSame( + array( + 'relation' => 'OR', + array( + 'month' => 2, + 'day' => 28, + ), + array( + 'month' => 2, + 'day' => 29, + ), + ), + _wp_dashboard_on_this_day_date_query_clause( $date ) + ); + } + + /** + * @ticket 65116 + * + * @covers ::wp_dashboard_on_this_day_get_posts + */ + public function test_query_returns_only_published_posts_from_this_day_in_previous_years() { + $matching_id = $this->create_matching_post( self::$user_id ); + $this->create_matching_post( self::$user_id, 'A draft memory', 2, array( 'post_status' => 'draft' ) ); + + $today = current_datetime(); + $current_day = $today->format( 'Y-m-d' ) . ' 12:00:00'; + self::factory()->post->create( + array( + 'post_author' => self::$user_id, + 'post_date' => $current_day, + 'post_status' => 'publish', + 'post_title' => 'A memory from this year', + ) + ); + + $nearby_date = $today->modify( '-1 year +1 day' )->format( 'Y-m-d' ) . ' 12:00:00'; + self::factory()->post->create( + array( + 'post_author' => self::$user_id, + 'post_date' => $nearby_date, + 'post_status' => 'publish', + 'post_title' => 'A nearby memory', + ) + ); + + $posts = wp_dashboard_on_this_day_get_posts(); + + $this->assertSame( array( $matching_id ), wp_list_pluck( $posts, 'ID' ) ); + } + + /** + * @ticket 65116 + * + * @covers ::wp_dashboard_on_this_day_get_posts + */ + public function test_query_defaults_to_ten_posts_and_limit_is_filterable() { + for ( $years_ago = 1; $years_ago <= 11; $years_ago++ ) { + $this->create_matching_post( self::$user_id, 'Anniversary post ' . $years_ago, $years_ago ); + } + + $this->assertCount( 10, wp_dashboard_on_this_day_get_posts() ); + + add_filter( 'wp_dashboard_on_this_day_query_args', array( $this, 'filter_on_this_day_query_limit' ) ); + try { + $this->assertCount( 3, wp_dashboard_on_this_day_get_posts() ); + } finally { + remove_filter( 'wp_dashboard_on_this_day_query_args', array( $this, 'filter_on_this_day_query_limit' ) ); + } + } + + /** + * @ticket 65116 + * + * @covers ::wp_dashboard_on_this_day + */ + public function test_section_renders_posts_from_all_authors_and_untitled_excerpts() { + $this->create_matching_post( self::$user_id, 'A note from me' ); + $this->create_matching_post( self::$other_user_id, 'A note from someone else', 2 ); + $this->create_matching_post( + self::$user_id, + '', + 3, + array( 'post_excerpt' => 'word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 word11 word12 word13 word14 word15 word16' ) + ); + + ob_start(); + $result = wp_dashboard_on_this_day(); + $output = ob_get_clean(); + + $this->assertTrue( $result ); + $this->assertStringContainsString( 'Published On This Day', $output ); + $this->assertStringContainsString( 'A note from me', $output ); + $this->assertStringNotContainsString( 'by Current Writer', $output ); + $this->assertStringContainsString( 'A note from someone else', $output ); + $this->assertStringContainsString( 'by Guest Writer', $output ); + $this->assertStringContainsString( '(no title)', $output ); + $this->assertStringContainsString( 'word15', $output ); + $this->assertStringNotContainsString( 'word16', $output ); + } + + /** + * @ticket 65116 + * + * @covers ::wp_dashboard_on_this_day + */ + public function test_section_hides_untitled_excerpt_for_unreadable_posts() { + $this->create_matching_post( + self::$other_user_id, + '', + 1, + array( + 'post_excerpt' => 'Unreadable private anniversary memory.', + 'post_status' => 'private', + ) + ); + + add_filter( 'wp_dashboard_on_this_day_query_args', array( $this, 'filter_on_this_day_query_private_posts' ) ); + try { + ob_start(); + wp_dashboard_on_this_day(); + $output = ob_get_clean(); + } finally { + remove_filter( 'wp_dashboard_on_this_day_query_args', array( $this, 'filter_on_this_day_query_private_posts' ) ); + } + + $this->assertStringContainsString( '(no title)', $output ); + $this->assertStringNotContainsString( 'Unreadable private anniversary memory.', $output ); + } + + /** + * @ticket 65116 + * + * @covers ::wp_dashboard_on_this_day + */ + public function test_section_hides_untitled_excerpt_for_password_protected_posts() { + $this->create_matching_post( + self::$user_id, + '', + 1, + array( + 'post_excerpt' => 'Password-protected anniversary memory.', + 'post_password' => 'secret', + ) + ); + + ob_start(); + wp_dashboard_on_this_day(); + $output = ob_get_clean(); + + $this->assertStringContainsString( '(no title)', $output ); + $this->assertStringNotContainsString( 'Password-protected anniversary memory.', $output ); + } + + /** + * @ticket 65116 + * + * @covers ::wp_dashboard_site_activity + */ + public function test_activity_renders_no_activity_message_without_posts() { + ob_start(); + wp_dashboard_site_activity(); + $output = ob_get_clean(); + + $this->assertStringContainsString( 'No activity yet!', $output ); + $this->assertStringNotContainsString( 'Published On This Day', $output ); + } + + /** + * @ticket 65116 + * + * @covers ::wp_dashboard_site_activity + * @covers ::wp_dashboard_recent_posts + */ + public function test_activity_does_not_repeat_on_this_day_posts_in_recently_published() { + $this->create_matching_post( self::$user_id, 'One anniversary mention' ); + self::factory()->post->create( + array( + 'post_author' => self::$user_id, + 'post_status' => 'publish', + 'post_title' => 'A recent post', + ) + ); + + ob_start(); + wp_dashboard_site_activity(); + $output = ob_get_clean(); + + $this->assertStringContainsString( 'Published On This Day', $output ); + $this->assertSame( 1, preg_match( '/
    /s', $output, $recent_posts ) ); + $this->assertStringNotContainsString( 'One anniversary mention', $recent_posts[0] ); + } + + /** + * @ticket 65116 + * + * @covers ::wp_dashboard_site_activity + * @covers ::wp_dashboard_recent_comments_widget + * @covers ::wp_dashboard_recent_comments + */ + public function test_recent_comments_are_rendered_only_in_their_dedicated_widget() { + $post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_id, + 'post_status' => 'draft', + ) + ); + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_content' => 'A comment in its own widget.', + ) + ); + + ob_start(); + wp_dashboard_site_activity(); + $activity_output = ob_get_clean(); + + ob_start(); + wp_dashboard_recent_comments_widget(); + $comments_output = ob_get_clean(); + + ob_start(); + wp_dashboard_recent_comments(); + $direct_output = ob_get_clean(); + + $this->assertStringNotContainsString( 'A comment in its own widget.', $activity_output ); + $this->assertStringContainsString( 'No activity yet!', $activity_output ); + $this->assertStringContainsString( 'A comment in its own widget.', $comments_output ); + $this->assertStringNotContainsString( '

    Recent Comments

    ', $comments_output ); + $this->assertStringContainsString( '

    Recent Comments

    ', $direct_output ); + } + + /** + * @ticket 65116 + * + * @covers ::wp_dashboard_recent_comments_widget + */ + public function test_recent_comments_widget_renders_empty_state_when_user_has_no_visible_comments() { + $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_approved' => '0', + ) + ); + wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) ); + + ob_start(); + wp_dashboard_recent_comments_widget(); + $output = ob_get_clean(); + + $this->assertStringContainsString( 'No comments to show.', $output ); + } + + /** + * @ticket 65116 + * + * @covers ::_wp_dashboard_has_received_comments + * @covers ::wp_dashboard_setup + */ + public function test_recent_comments_widget_remains_registered_after_comments_are_deleted() { + add_filter( 'pre_http_request', array( $this, 'mock_http_request_failure' ) ); + + try { + wp_dashboard_setup(); + $this->assertArrayNotHasKey( 'dashboard_recent_comments', $GLOBALS['wp_meta_boxes']['dashboard']['normal']['core'] ); + + unset( $GLOBALS['wp_meta_boxes']['dashboard'] ); + + $post_id = self::factory()->post->create(); + $comment_id = self::factory()->comment->create( array( 'comment_post_ID' => $post_id ) ); + + wp_dashboard_setup(); + $this->assertArrayHasKey( 'dashboard_recent_comments', $GLOBALS['wp_meta_boxes']['dashboard']['normal']['core'] ); + $this->assertSame( + 'wp_dashboard_recent_comments_widget', + $GLOBALS['wp_meta_boxes']['dashboard']['normal']['core']['dashboard_recent_comments']['callback'] + ); + $this->assertSame( 1, get_option( 'dashboard_recent_comments_widget_registered' ) ); + + wp_delete_comment( $comment_id, true ); + unset( $GLOBALS['wp_meta_boxes']['dashboard'] ); + + wp_dashboard_setup(); + $this->assertArrayHasKey( 'dashboard_recent_comments', $GLOBALS['wp_meta_boxes']['dashboard']['normal']['core'] ); + } finally { + remove_filter( 'pre_http_request', array( $this, 'mock_http_request_failure' ) ); + } + } + + /** + * Mocks an HTTP request failure during dashboard setup. + * + * @return WP_Error Mock request error. + */ + public function mock_http_request_failure() { + return new WP_Error( 'http_request_failed' ); + } + + /** + * Sets the On This Day query limit to three. + * + * @param array $args Query arguments. + * @return array Filtered query arguments. + */ + public function filter_on_this_day_query_limit( $args ) { + $args['posts_per_page'] = 3; + + return $args; + } + + /** + * Filters the On This Day query to include private posts. + * + * @param array $args Query arguments. + * @return array Filtered query arguments. + */ + public function filter_on_this_day_query_private_posts( $args ) { + $args['post_status'] = array( 'private' ); + + return $args; + } +} diff --git a/tests/phpunit/tests/admin/wpDebugDataPlugins.php b/tests/phpunit/tests/admin/wpDebugDataPlugins.php new file mode 100644 index 0000000000000..d4a9e848473da --- /dev/null +++ b/tests/phpunit/tests/admin/wpDebugDataPlugins.php @@ -0,0 +1,446 @@ +created_files as $file ) { + unlink( $file ); + } + $this->created_files = array(); + + wp_cache_delete( 'plugins', 'plugins' ); + delete_site_transient( 'update_plugins' ); + + parent::tear_down(); + } + + /** + * Tests the must-use plugins section without must-use plugins. + * + * @ticket 65819 + */ + public function test_mu_plugins_should_return_empty_section() { + $this->assertSame( + array( + 'label' => 'Must Use Plugins', + 'show_count' => true, + 'fields' => array(), + ), + $this->call( 'get_wp_mu_plugins' ) + ); + } + + /** + * Tests the version and author strings of a must-use plugin. + * + * @ticket 65819 + * + * @dataProvider data_version_strings + * + * @param string $version The Version header. + * @param string $author The Author header. + * @param string $value The expected value. + * @param string $debug The expected debug value. + */ + public function test_mu_plugins_should_describe_version_and_author( $version, $author, $value, $debug ) { + $headers = " * Plugin Name: My MU Plugin\n"; + if ( '' !== $version ) { + $headers .= " * Version: {$version}\n"; + } + if ( '' !== $author ) { + $headers .= " * Author: {$author}\n"; + } + + $this->create_mu_plugin( 'my-mu-plugin.php', "assertSame( + array( + 'My MU Plugin' => array( + 'label' => 'My MU Plugin', + 'value' => $value, + 'debug' => $debug, + ), + ), + $this->call( 'get_wp_mu_plugins' )['fields'] + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_version_strings() { + return array( + 'version and author' => array( '1.0', 'Jane', 'Version 1.0 by Jane', 'version: 1.0, author: Jane' ), + 'author only' => array( '', 'Jane', 'By Jane', 'author: Jane, version: (undefined)' ), + 'version only' => array( '1.0', '', 'Version 1.0', 'author: (undefined), version: 1.0' ), + 'neither' => array( '', '', 'No version or author information is available.', 'author: (undefined), version: (undefined)' ), + ); + } + + /** + * Tests the version and author strings of a plugin, and its split into active and inactive. + * + * @ticket 65819 + * + * @dataProvider data_version_strings + * + * @param string $version The Version header. + * @param string $author The Author header. + * @param string $value The expected value. + * @param string $debug The expected debug value. + */ + public function test_plugins_should_describe_version_and_author( $version, $author, $value, $debug ) { + $this->set_plugins( + array( + 'active/active.php' => array( 'Active Plugin', $version, $author ), + 'inactive/inactive.php' => array( 'Inactive Plugin', $version, $author ), + ), + array( 'active/active.php' ) + ); + + $this->assertSame( + array( + 'wp-plugins-active' => array( + 'Active Plugin' => array( + 'label' => 'Active Plugin', + 'value' => $value, + 'debug' => $debug, + ), + ), + 'wp-plugins-inactive' => array( + 'Inactive Plugin' => array( + 'label' => 'Inactive Plugin', + 'value' => $value, + 'debug' => $debug, + ), + ), + ), + $this->call( 'get_wp_plugins_raw_data' ) + ); + } + + /** + * Tests that an available update is mentioned. + * + * @ticket 65819 + */ + public function test_plugins_should_mention_latest_version() { + $this->set_plugins( array( 'my-plugin/my-plugin.php' => array( 'My Plugin', '1.0', 'Jane' ) ) ); + $this->set_update( 'response', 'my-plugin/my-plugin.php', '2.0' ); + + $this->assertSame( + array( + 'label' => 'My Plugin', + 'value' => 'Version 1.0 by Jane (Latest version: 2.0)', + 'debug' => 'version: 1.0, author: Jane (latest version: 2.0)', + ), + $this->call( 'get_wp_plugins_raw_data' )['wp-plugins-inactive']['My Plugin'] + ); + } + + /** + * Tests the auto-update status when plugin auto-updates are enabled. + * + * @ticket 65819 + * + * @dataProvider data_auto_update_states + * + * @param bool $in_setting Whether the plugin is in the auto_update_plugins setting. + * @param bool|null $forced The value of the auto_update_plugin filter, or null for no filter. + * @param string $status The expected status string. + */ + public function test_plugins_should_add_auto_update_status( $in_setting, $forced, $status ) { + add_filter( 'plugins_auto_update_enabled', '__return_true', 11 ); + + $this->set_plugins( array( 'my-plugin/my-plugin.php' => array( 'My Plugin', '1.0', 'Jane' ) ) ); + + if ( $in_setting ) { + update_site_option( 'auto_update_plugins', array( 'my-plugin/my-plugin.php' ) ); + } + + if ( null !== $forced ) { + add_filter( 'auto_update_plugin', $forced ? '__return_true' : '__return_false' ); + } + + $this->assertSame( + array( + 'label' => 'My Plugin', + 'value' => 'Version 1.0 by Jane | ' . $status, + 'debug' => 'version: 1.0, author: Jane, ' . $status, + ), + $this->call( 'get_wp_plugins_raw_data' )['wp-plugins-inactive']['My Plugin'] + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_auto_update_states() { + return array( + 'in setting' => array( true, null, 'Auto-updates enabled' ), + 'not in setting' => array( false, null, 'Auto-updates disabled' ), + 'forced on, not in setting' => array( false, true, 'Auto-updates enabled' ), + 'forced off, in setting' => array( true, false, 'Auto-updates disabled' ), + ); + } + + /** + * Tests that the auto-update setting is ignored when plugin auto-updates are disabled. + * + * @ticket 65819 + */ + public function test_plugins_should_ignore_auto_update_setting_when_disabled() { + $this->set_plugins( array( 'my-plugin/my-plugin.php' => array( 'My Plugin', '1.0', 'Jane' ) ) ); + update_site_option( 'auto_update_plugins', array( 'my-plugin/my-plugin.php' ) ); + + $filter = new MockAction(); + add_filter( 'auto_update_plugin', array( $filter, 'filter' ) ); + + $this->assertSame( 'Version 1.0 by Jane', $this->call( 'get_wp_plugins_raw_data' )['wp-plugins-inactive']['My Plugin']['value'], 'No auto-update status should be added.' ); + $this->assertSame( 0, $filter->get_call_count(), 'The auto-update filter should not run.' ); + } + + /** + * Tests the update item passed to the auto-update filter. + * + * @ticket 65819 + * + * @dataProvider data_update_items + * + * @param string $transient_list The transient list holding the plugin, or an empty string for none. + * @param string $new_version The expected new version of the item. + * @param string $name The expected plugin name in the item, or an empty string for none. + */ + public function test_plugins_should_pass_update_item_to_auto_update_filter( $transient_list, $new_version, $name ) { + add_filter( 'plugins_auto_update_enabled', '__return_true', 11 ); + + $this->set_plugins( array( 'my-plugin/my-plugin.php' => array( 'My Plugin', '1.0', 'Jane' ) ) ); + + if ( '' !== $transient_list ) { + $this->set_update( $transient_list, 'my-plugin/my-plugin.php', '2.0' ); + } + + $filter = new MockAction(); + add_filter( 'auto_update_plugin', array( $filter, 'filter' ), 10, 2 ); + + $this->call( 'get_wp_plugins_raw_data' ); + + $args = $filter->get_args(); + $item = $args[0][1]; + + $this->assertNull( $args[0][0], 'The filter should receive null.' ); + $this->assertSame( 'my-plugin/my-plugin.php', $item->plugin, 'The item should be for the plugin.' ); + $this->assertSame( $new_version, $item->new_version, 'The item should hold the expected version.' ); + $this->assertSame( $name, $item->Name ?? '', 'The item should only include the plugin data when built from it.' ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_update_items() { + return array( + 'available update' => array( 'response', '2.0', '' ), + 'no update' => array( 'no_update', '2.0', '' ), + 'not checked yet' => array( '', '', 'My Plugin' ), + ); + } + + /** + * Tests that the auto-update status string can be filtered. + * + * @ticket 65819 + */ + public function test_plugins_should_filter_auto_update_string() { + add_filter( 'plugins_auto_update_enabled', '__return_true', 11 ); + + $this->set_plugins( array( 'my-plugin/my-plugin.php' => array( 'My Plugin', '1.0', 'Jane' ) ) ); + update_site_option( 'auto_update_plugins', array( 'my-plugin/my-plugin.php' ) ); + + $filter = new MockAction(); + add_filter( 'plugin_auto_update_debug_string', array( $filter, 'filter' ), 10, 4 ); + add_filter( + 'plugin_auto_update_debug_string', + static function () { + return 'Custom'; + }, + 11 + ); + + $field = $this->call( 'get_wp_plugins_raw_data' )['wp-plugins-inactive']['My Plugin']; + $args = $filter->get_args(); + + $this->assertSame( 'Version 1.0 by Jane | Custom', $field['value'], 'The filtered string should be used.' ); + $this->assertSame( 'Auto-updates enabled', $args[0][0], 'The filter should receive the status string.' ); + $this->assertSame( 'my-plugin/my-plugin.php', $args[0][1], 'The filter should receive the plugin file.' ); + $this->assertSame( 'My Plugin', $args[0][2]['Name'], 'The filter should receive the plugin data.' ); + $this->assertTrue( $args[0][3], 'The filter should receive whether auto-updates are enabled.' ); + } + + /** + * Calls a private static method of WP_Debug_Data. + * + * @param string $method The method name. + * @return array The method's return value. + */ + private function call( $method ) { + $reflection = new ReflectionMethod( 'WP_Debug_Data', $method ); + if ( PHP_VERSION_ID < 80100 ) { + $reflection->setAccessible( true ); + } + + return $reflection->invoke( null ); + } + + /** + * Creates a must-use plugin file. + * + * @param string $name The file name. + * @param string $contents The file contents. + */ + private function create_mu_plugin( $name, $contents ) { + $file = WPMU_PLUGIN_DIR . '/' . $name; + + if ( ! is_dir( WPMU_PLUGIN_DIR ) ) { + $this->assertTrue( mkdir( WPMU_PLUGIN_DIR ), 'The must-use plugins directory should be created.' ); + } + + $this->assertFileDoesNotExist( $file, 'The must-use plugin should not exist yet.' ); + $this->assertNotFalse( file_put_contents( $file, $contents ), 'The must-use plugin should be written.' ); + + $this->created_files[] = $file; + } + + /** + * Primes the plugins cache with test plugins. + * + * @param array $plugins Name, Version and Author of each plugin, keyed by plugin file. + * @param string[] $active The active plugin files. + */ + private function set_plugins( $plugins, $active = array() ) { + $data = array(); + foreach ( $plugins as $file => $headers ) { + list( $name, $version, $author ) = $headers; + + $data[ $file ] = array( + 'Name' => $name, + 'Version' => $version, + 'Author' => $author, + ); + } + + wp_cache_set( 'plugins', array( '' => $data ), 'plugins' ); + + if ( is_multisite() ) { + update_site_option( 'active_sitewide_plugins', array_fill_keys( $active, time() ) ); + } else { + update_option( 'active_plugins', $active ); + } + } + + /** + * Adds a plugin to a list of the update transient. + * + * @param string $transient_list The transient list, 'response' or 'no_update'. + * @param string $file The plugin file. + * @param string $new_version The new version. + */ + private function set_update( $transient_list, $file, $new_version ) { + $transient = get_site_transient( 'update_plugins' ); + if ( ! is_object( $transient ) ) { + $transient = (object) array( + 'response' => array(), + 'no_update' => array(), + ); + } + + $transient->{$transient_list}[ $file ] = (object) array( + 'plugin' => $file, + 'slug' => dirname( $file ), + 'new_version' => $new_version, + ); + + set_site_transient( 'update_plugins', $transient ); + } + + /** + * Moves existing must-use plugins out of the way. + */ + private static function _back_up_mu_plugins() { + if ( is_dir( WPMU_PLUGIN_DIR ) ) { + rename( WPMU_PLUGIN_DIR, WP_CONTENT_DIR . '/mu-plugin-backup-debug-data' ); + } + } + + /** + * Restores the backed-up must-use plugins. + */ + private static function _restore_mu_plugins() { + $backup = WP_CONTENT_DIR . '/mu-plugin-backup-debug-data'; + + if ( is_dir( WPMU_PLUGIN_DIR ) ) { + rmdir( WPMU_PLUGIN_DIR ); + } + + if ( is_dir( $backup ) ) { + rename( $backup, WPMU_PLUGIN_DIR ); + } + } +} diff --git a/tests/phpunit/tests/admin/wpImporter.php b/tests/phpunit/tests/admin/wpImporter.php new file mode 100644 index 0000000000000..f5620f01d6d29 --- /dev/null +++ b/tests/phpunit/tests/admin/wpImporter.php @@ -0,0 +1,533 @@ +had_argv = array_key_exists( 'argv', $_SERVER ); + $this->orig_argv = $this->had_argv ? $_SERVER['argv'] : null; + } + + public function tear_down() { + if ( $this->had_argv ) { + $_SERVER['argv'] = $this->orig_argv; + } else { + unset( $_SERVER['argv'] ); + } + + parent::tear_down(); + } + + /** + * Tests that imported permalinks are mapped to their post IDs for the given importer and blog. + * + * @ticket 65819 + * + * @covers WP_Importer::get_imported_posts + */ + public function test_get_imported_posts_maps_permalinks_to_post_ids() { + $first = self::factory()->post->create(); + $second = self::factory()->post->create(); + + add_post_meta( $first, 'blogger_7_permalink', '/2026/01/first.html' ); + add_post_meta( $second, 'blogger_7_permalink', '/2026/02/second.html' ); + add_post_meta( $second, 'blogger_8_permalink', '/other-blog.html' ); + add_post_meta( $second, 'other_7_permalink', '/other-importer.html' ); + + $this->assertSame( + array( + '/2026/01/first.html' => $first, + '/2026/02/second.html' => $second, + ), + ( new WP_Importer() )->get_imported_posts( 'blogger', 7 ) + ); + } + + /** + * Tests that imported permalinks are read in chunks of 100 until none are left. + * + * @ticket 65819 + * + * @covers WP_Importer::get_imported_posts + */ + public function test_get_imported_posts_reads_all_chunks() { + global $wpdb; + + $post_id = self::factory()->post->create(); + + for ( $i = 1; $i <= 101; $i++ ) { + $wpdb->insert( + $wpdb->postmeta, + array( + 'post_id' => $post_id, + 'meta_key' => 'blogger_7_permalink', + 'meta_value' => "/post-{$i}.html", + ) + ); + } + + $imported = ( new WP_Importer() )->get_imported_posts( 'blogger', 7 ); + + $this->assertCount( 101, $imported, 'Permalinks after the first chunk should be included.' ); + $this->assertSame( $post_id, $imported['/post-101.html'] ); + } + + /** + * Tests that no imported posts gives an empty array. + * + * @ticket 65819 + * + * @covers WP_Importer::get_imported_posts + */ + public function test_get_imported_posts_returns_empty_array_without_imports() { + $this->assertSame( array(), ( new WP_Importer() )->get_imported_posts( 'blogger', 7 ) ); + } + + /** + * Tests the number of imported permalinks for the given importer and blog. + * + * @ticket 65819 + * + * @covers WP_Importer::count_imported_posts + */ + public function test_count_imported_posts() { + $post_id = self::factory()->post->create(); + + add_post_meta( $post_id, 'blogger_7_permalink', '/first.html' ); + add_post_meta( $post_id, 'blogger_7_permalink', '/second.html' ); + add_post_meta( $post_id, 'blogger_8_permalink', '/other-blog.html' ); + + $importer = new WP_Importer(); + + $this->assertSame( 2, $importer->count_imported_posts( 'blogger', 7 ), 'Permalinks for the blog should be counted.' ); + $this->assertSame( 0, $importer->count_imported_posts( 'blogger', 9 ), 'A blog without imports should have none.' ); + } + + /** + * Tests that imported comments are mapped from their source comment ID for the given blog. + * + * @ticket 65819 + * + * @covers WP_Importer::get_imported_comments + */ + public function test_get_imported_comments_maps_source_ids_for_blog() { + $post_id = self::factory()->post->create(); + + $first = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_agent' => '7-15', + ) + ); + $second = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_agent' => '7-16', + ) + ); + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_agent' => '8-17', + ) + ); + + $this->assertSame( + array( + 15 => $first, + 16 => $second, + ), + ( new WP_Importer() )->get_imported_comments( '7' ) + ); + } + + /** + * Tests that a numeric blog ID is returned as an integer. + * + * @ticket 65819 + * + * @covers WP_Importer::set_blog + * + * @group ms-excluded + */ + public function test_set_blog_returns_numeric_blog_id() { + $this->assertSame( 1, ( new WP_Importer() )->set_blog( '1' ) ); + } + + /** + * Tests that set_blog() switches to the blog on multisite, resolving it by ID or URL. + * + * @ticket 65819 + * + * @covers WP_Importer::set_blog + * + * @group ms-required + * + * @dataProvider data_set_blog_on_multisite + * + * @param string $blog Blog ID placeholder or URL passed to set_blog(). + */ + public function test_set_blog_switches_to_blog_on_multisite( $blog ) { + $blog_id = self::factory()->blog->create( + array( + 'domain' => 'importer.example.org', + 'path' => '/target/', + ) + ); + + $blog = str_replace( '%id%', $blog_id, $blog ); + + $result = ( new WP_Importer() )->set_blog( $blog ); + + $current = get_current_blog_id(); + restore_current_blog(); + + $this->assertSame( $blog_id, $result, 'The blog ID should be returned.' ); + $this->assertSame( $blog_id, $current, 'The blog should be switched to.' ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_set_blog_on_multisite() { + return array( + 'numeric ID' => array( '%id%' ), + 'URL with scheme' => array( 'https://importer.example.org/target/' ), + 'URL without scheme' => array( 'importer.example.org/target/' ), + ); + } + + /** + * Tests that set_user() sets the current user by ID or login. + * + * @ticket 65819 + * + * @covers WP_Importer::set_user + * + * @dataProvider data_set_user + * + * @param bool $by_login Whether to pass the user login instead of the ID. + */ + public function test_set_user( $by_login ) { + $user_id = self::factory()->user->create( array( 'user_login' => 'importer_user' ) ); + + $this->assertSame( $user_id, ( new WP_Importer() )->set_user( $by_login ? 'importer_user' : (string) $user_id ) ); + $this->assertSame( $user_id, get_current_user_id(), 'The user should be set as the current user.' ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_set_user() { + return array( + 'user ID' => array( false ), + 'user login' => array( true ), + ); + } + + /** + * Tests that cmpr_strlen() sorts the longest string first. + * + * @ticket 65819 + * + * @covers WP_Importer::cmpr_strlen + */ + public function test_cmpr_strlen_sorts_longest_first() { + $importer = new WP_Importer(); + $strings = array( 'bb', 'a', 'dddd', 'ccc' ); + + usort( $strings, array( $importer, 'cmpr_strlen' ) ); + + $this->assertSame( array( 'dddd', 'ccc', 'bb', 'a' ), $strings, 'Strings should be sorted by length, longest first.' ); + $this->assertSame( 0, $importer->cmpr_strlen( 'ab', 'cd' ), 'Strings of the same length should compare equal.' ); + $this->assertSame( 2, $importer->cmpr_strlen( 'a', 'abc' ), 'A shorter first string should sort after the longer one.' ); + } + + /** + * Tests the request made by get_page(). + * + * @ticket 65819 + * + * @covers WP_Importer::get_page + * @covers WP_Importer::bump_request_timeout + * + * @dataProvider data_get_page_requests + * + * @param string $username Username. + * @param string $password Password. + * @param bool $head Whether to make a HEAD request. + * @param string $method Expected request method. + * @param string|null $authorization Expected Authorization header, or null for none. + */ + public function test_get_page_request( $username, $password, $head, $method, $authorization ) { + $requests = array(); + + add_filter( + 'pre_http_request', + static function ( $response, $args, $url ) use ( &$requests ) { + $requests[] = array( $url, $args ); + + return array( + 'headers' => array(), + 'body' => 'page body', + 'response' => array( + 'code' => 200, + 'message' => 'OK', + ), + 'cookies' => array(), + ); + }, + 10, + 3 + ); + + $response = ( new WP_Importer() )->get_page( 'https://example.org/feed', $username, $password, $head ); + + $this->assertSame( 'page body', wp_remote_retrieve_body( $response ), 'The response should be returned.' ); + $this->assertCount( 1, $requests, 'One request should be made.' ); + + list( $url, $args ) = $requests[0]; + + $this->assertSame( 'https://example.org/feed', $url, 'The URL did not match.' ); + $this->assertSame( $method, $args['method'], 'The request method did not match.' ); + $this->assertSame( 60, $args['timeout'], 'The request timeout should be bumped.' ); + $this->assertTrue( $args['reject_unsafe_urls'], 'The request should be a safe request.' ); + + if ( null === $authorization ) { + $this->assertArrayNotHasKey( 'Authorization', $args['headers'], 'No Authorization header should be sent.' ); + } else { + $this->assertSame( $authorization, $args['headers']['Authorization'], 'The Authorization header did not match.' ); + } + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_get_page_requests() { + return array( + 'GET without credentials' => array( '', '', false, 'GET', null ), + 'HEAD request' => array( '', '', true, 'HEAD', null ), + 'truthy head is not HEAD' => array( '', '', 1, 'GET', null ), + 'with credentials' => array( 'admin', 'secret', false, 'GET', 'Basic YWRtaW46c2VjcmV0' ), + 'username only' => array( 'admin', '', false, 'GET', null ), + 'password only' => array( '', 'secret', false, 'GET', null ), + ); + } + + /** + * Tests that bump_request_timeout() always returns 60 seconds. + * + * @ticket 65819 + * + * @covers WP_Importer::bump_request_timeout + */ + public function test_bump_request_timeout() { + $this->assertSame( 60, ( new WP_Importer() )->bump_request_timeout( 5 ) ); + } + + /** + * Tests that the user is never over quota on a single site. + * + * @ticket 65819 + * + * @covers WP_Importer::is_user_over_quota + * + * @group ms-excluded + */ + public function test_is_user_over_quota_on_single_site() { + $this->assertFalse( ( new WP_Importer() )->is_user_over_quota() ); + } + + /** + * Tests the quota check on multisite. + * + * @ticket 65819 + * + * @covers WP_Importer::is_user_over_quota + * + * @group ms-required + * + * @dataProvider data_quota_usage + * + * @param int $space_used Space used, in megabytes. + * @param bool $expected Whether the user should be over quota. + * @param string $output Expected output. + */ + public function test_is_user_over_quota_on_multisite( $space_used, $expected, $output ) { + require_once ABSPATH . 'wp-admin/includes/ms.php'; + + update_site_option( 'upload_space_check_disabled', 0 ); + add_filter( + 'get_space_allowed', + static function () { + return 10; + } + ); + add_filter( + 'pre_get_space_used', + static function () use ( $space_used ) { + return $space_used; + } + ); + + $this->expectOutputString( $output ); + $this->assertSame( $expected, ( new WP_Importer() )->is_user_over_quota() ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_quota_usage() { + return array( + 'under quota' => array( 5, false, '' ), + 'over quota' => array( 20, true, 'Sorry, you have used your space allocation of 10 MB. Please delete some files to upload more files.' ), + ); + } + + /** + * Tests that runs of whitespace are replaced with a single space. + * + * @ticket 65819 + * + * @covers WP_Importer::min_whitespace + * + * @dataProvider data_min_whitespace + * + * @param string $text Text. + * @param string $expected Expected text. + */ + public function test_min_whitespace( $text, $expected ) { + $this->assertSame( $expected, ( new WP_Importer() )->min_whitespace( $text ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_min_whitespace() { + return array( + 'mixed whitespace' => array( "one \r\n\ttwo three", 'one two three' ), + 'single spaces' => array( 'one two', 'one two' ), + 'leading and trailing' => array( "\n one \t", ' one ' ), + 'no whitespace' => array( 'one', 'one' ), + 'empty string' => array( '', '' ), + 'zero string' => array( '0', '0' ), + ); + } + + /** + * Tests that stop_the_insanity() resets the query log and the action counts. + * + * @ticket 65819 + * + * @covers WP_Importer::stop_the_insanity + */ + public function test_stop_the_insanity_resets_queries_and_actions() { + global $wpdb, $wp_actions; + + $orig_queries = $wpdb->queries; + $orig_wp_actions = $wp_actions; + + try { + $wpdb->queries = array( array( 'SELECT 1', 0.1, 'caller' ) ); + do_action( 'test_stop_the_insanity' ); + + ( new WP_Importer() )->stop_the_insanity(); + + $this->assertSame( array(), $wpdb->queries, 'The query log should be emptied.' ); + $this->assertSame( array(), $wp_actions, 'The action counts should be emptied.' ); + } finally { + $wpdb->queries = $orig_queries; + $wp_actions = $orig_wp_actions; + } + } + + /** + * Tests the command line arguments returned by get_cli_args(). + * + * @ticket 65819 + * + * @covers ::get_cli_args + * + * @dataProvider data_get_cli_args + * + * @param array $argv Command line arguments, including the script name. + * @param string $param Parameter name. + * @param string|true|null $expected Expected value. + */ + public function test_get_cli_args( $argv, $param, $expected ) { + $_SERVER['argv'] = $argv; + + $this->assertSame( $expected, get_cli_args( $param ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_get_cli_args() { + return array( + 'long option with a value' => array( array( 'import.php', '--blog=7' ), 'blog', '7' ), + 'long option without a value' => array( array( 'import.php', '--verbose' ), 'verbose', true ), + 'value after a long option' => array( array( 'import.php', '--user', 'admin' ), 'user', 'admin' ), + 'short options' => array( array( 'import.php', '-vq' ), 'q', true ), + 'value after short options' => array( array( 'import.php', '-vq', 'quiet' ), 'q', 'quiet' ), + 'later option overrides' => array( array( 'import.php', '--blog=1', '--blog=2' ), 'blog', '2' ), + 'script name is ignored' => array( array( '--blog=7' ), 'blog', null ), + 'value without an option' => array( array( 'import.php', 'orphan' ), 'orphan', null ), + 'missing optional parameter' => array( array( 'import.php', '--blog=7' ), 'user', null ), + 'empty value' => array( array( 'import.php', '--blog=' ), 'blog', '' ), + 'zero value' => array( array( 'import.php', '--blog=0' ), 'blog', '0' ), + ); + } + + /** + * Tests that get_cli_args() returns null when `$_SERVER['argv']` is not an array. + * + * @ticket 65819 + * + * @covers ::get_cli_args + */ + public function test_get_cli_args_without_argv_array() { + $_SERVER['argv'] = 'not an array'; + + $this->assertNull( get_cli_args( 'blog' ) ); + } +} diff --git a/tests/phpunit/tests/admin/wpListTable.php b/tests/phpunit/tests/admin/wpListTable.php index ee6cca5cf53de..909b232a2020c 100644 --- a/tests/phpunit/tests/admin/wpListTable.php +++ b/tests/phpunit/tests/admin/wpListTable.php @@ -98,7 +98,7 @@ public function test_should_only_add_primary_column_when_needed( $list_class, $h * * @return array */ - public function data_should_only_add_primary_column_when_needed() { + public static function data_should_only_add_primary_column_when_needed() { /* * `WP_Post_Comments_List_Table` overrides `get_column_info()` rather than * use the default `WP_List_Table::get_column_info()`. Therefore it is @@ -187,7 +187,7 @@ public function test_get_views_links( $link_data, $expected ) { * * @return array */ - public function data_get_views_links() { + public static function data_get_views_links() { return array( 'one "current" link' => array( 'link_data' => array( @@ -301,7 +301,7 @@ public function test_get_views_links_doing_it_wrong( $link_data ) { * * @return array */ - public function data_get_views_links_doing_it_wrong() { + public static function data_get_views_links_doing_it_wrong() { return array( 'non-array $link_data' => array( 'link_data' => 'https://example.org, All, class="current" aria-current="page"', @@ -509,7 +509,7 @@ public function test_should_throw_deprecation_when_unset_of_dynamic_property() { * * @return array */ - public function data_compat_fields() { + public static function data_compat_fields() { return array( '_args' => array( 'property_name' => '_args', diff --git a/tests/phpunit/tests/admin/wpMediaListTable.php b/tests/phpunit/tests/admin/wpMediaListTable.php index 31d2bfa7cbb9d..05dea63c8fbc2 100644 --- a/tests/phpunit/tests/admin/wpMediaListTable.php +++ b/tests/phpunit/tests/admin/wpMediaListTable.php @@ -206,7 +206,7 @@ public function test_get_row_actions_should_include_action( $action, $role, $tra * * @return array[] */ - public function data_get_row_actions_should_include_action() { + public static function data_get_row_actions_should_include_action() { return array( '"edit" while not on "trash"' => array( 'action' => 'edit', @@ -291,7 +291,7 @@ public function test_get_row_actions_should_not_include_action( $action, $role, * * @return array[] */ - public function data_get_row_actions_should_not_include_action() { + public static function data_get_row_actions_should_not_include_action() { return array( '"edit" while on "trash"' => array( 'action' => 'edit', diff --git a/tests/phpunit/tests/admin/wpMenuOutput.php b/tests/phpunit/tests/admin/wpMenuOutput.php new file mode 100644 index 0000000000000..00734e775b731 --- /dev/null +++ b/tests/phpunit/tests/admin/wpMenuOutput.php @@ -0,0 +1,177 @@ +user->create( array( 'role' => 'administrator' ) ) ); + } + + /** + * Dashicons back-compat: the Dashicons of the core menu items are kept in the menu data and rendered as SVG icons. + * + * @ticket 65089 + * + * @covers ::_wp_menu_output + * + * @dataProvider data_replaced_dashicons + * + * @param string $dashicon Dashicon class name. + */ + public function test_core_dashicon_is_rendered_as_svg_icon( $dashicon ): void { + // 0 = menu_title, 1 = capability, 2 = menu_slug, 3 = page_title, 4 = classes, 5 = hookname, 6 = icon_url. + $menu = array( + array( 'Item', 'read', 'my-plugin', '', 'menu-top', 'toplevel_page_my-plugin', $dashicon ), + ); + $html = get_echo( '_wp_menu_output', array( $menu, array() ) ); + + $this->assertMatchesRegularExpression( "##s", $html ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_replaced_dashicons(): array { + return array( + 'dashboard' => array( 'dashicons-dashboard' ), + 'posts' => array( 'dashicons-admin-post' ), + 'media' => array( 'dashicons-admin-media' ), + 'links' => array( 'dashicons-admin-links' ), + 'pages' => array( 'dashicons-admin-page' ), + 'comments' => array( 'dashicons-admin-comments' ), + 'appearance' => array( 'dashicons-admin-appearance' ), + 'plugins' => array( 'dashicons-admin-plugins' ), + 'users' => array( 'dashicons-admin-users' ), + 'tools' => array( 'dashicons-admin-tools' ), + 'settings' => array( 'dashicons-admin-settings' ), + 'sites' => array( 'dashicons-admin-multisite' ), + ); + } + + /** + * Dashicons back-compat: any other icon value is rendered as before. + * + * @ticket 65089 + * + * @covers ::_wp_menu_output + * + * @dataProvider data_unchanged_icons + * + * @param string $icon The icon of the menu item. + * @param string $expected The expected HTML of the icon element. + */ + public function test_other_icon_is_rendered_as_before( $icon, $expected ): void { + // 0 = menu_title, 1 = capability, 2 = menu_slug, 3 = page_title, 4 = classes, 5 = hookname, 6 = icon_url. + $menu = array( + array( 'Item', 'read', 'my-plugin', '', 'menu-top', 'toplevel_page_my-plugin', $icon ), + ); + $html = get_echo( '_wp_menu_output', array( $menu, array() ) ); + + $this->assertStringContainsString( $expected, $html ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_unchanged_icons(): array { + return array( + 'other Dashicon' => array( + 'dashicons-heart', + "", + ), + 'image URL' => array( + 'https://example.org/icon.png', + "", + ), + 'none' => array( + 'none', + "", + ), + 'div' => array( + 'div', + "", + ), + 'base64 SVG' => array( + 'data:image/svg+xml;base64,PHN2Zy8+', + "", + ), + ); + } + + /** + * @ticket 65089 + * + * @covers ::_wp_menu_output + */ + public function test_icon_name_is_rendered_as_svg_icon(): void { + // 0 = menu_title, 1 = capability, 2 = menu_slug, 3 = page_title, 4 = classes, 5 = hookname, 6 = icon_url. + $menu = array( + array( 'Item', 'read', 'my-plugin', '', 'menu-top', 'toplevel_page_my-plugin', 'core-admin/pin' ), + ); + $html = get_echo( '_wp_menu_output', array( $menu, array() ) ); + + $this->assertStringContainsString( "', $html ); + } + + /** + * @ticket 65089 + * + * @covers ::_wp_menu_output + * + * @expectedIncorrectUsage _wp_menu_output + */ + public function test_unregistered_icon_name_leaves_icon_element_empty(): void { + // 0 = menu_title, 1 = capability, 2 = menu_slug, 3 = page_title, 4 = classes, 5 = hookname, 6 = icon_url. + $menu = array( + array( 'Item', 'read', 'my-plugin', '', 'menu-top', 'toplevel_page_my-plugin', 'my-plugin/not-registered' ), + ); + $html = get_echo( '_wp_menu_output', array( $menu, array() ) ); + + $this->assertStringContainsString( "", $html ); + } +} diff --git a/tests/phpunit/tests/admin/wpPluginInstallListTableGetDependenciesNotice.php b/tests/phpunit/tests/admin/wpPluginInstallListTableGetDependenciesNotice.php new file mode 100644 index 0000000000000..59f09c991a7f4 --- /dev/null +++ b/tests/phpunit/tests/admin/wpPluginInstallListTableGetDependenciesNotice.php @@ -0,0 +1,368 @@ +

    Additional plugins are required

    '; + + /** + * The list table. + * + * @var WP_Plugin_Install_List_Table + */ + private $table; + + /** + * The original values of the globals and properties the tests change. + * + * @var array + */ + private $original = array(); + + /** + * Slugs requested from the plugins API. + * + * @var string[] + */ + private $api_requests = array(); + + /** + * Responses of the mocked plugins API, keyed by slug. + * + * @var array + */ + private $api_responses = array(); + + /** + * Loads plugins_api(), which the Add Plugins screen loads. + */ + public static function set_up_before_class() { + parent::set_up_before_class(); + + require_once ABSPATH . 'wp-admin/includes/plugin-install.php'; + } + + /** + * Sets up the list table, the Add Plugins screen and the plugins API mock. + */ + public function set_up() { + parent::set_up(); + + $this->original = array( + 'pagenow' => $GLOBALS['pagenow'] ?? null, + 'current_screen' => $GLOBALS['current_screen'] ?? null, + 'dependency_api_data' => $this->get_dependency_api_data(), + ); + + $this->table = _get_list_table( 'WP_Plugin_Install_List_Table', array( 'screen' => 'plugin-install' ) ); + + $GLOBALS['pagenow'] = 'plugin-install.php'; + set_current_screen( 'plugin-install' ); + $this->set_dependency_api_data( array() ); + + add_filter( 'plugins_api', array( $this, 'mock_plugins_api' ), 10, 3 ); + } + + /** + * Restores the globals and properties. + */ + public function tear_down() { + $GLOBALS['pagenow'] = $this->original['pagenow']; + $GLOBALS['current_screen'] = $this->original['current_screen']; + $this->set_dependency_api_data( $this->original['dependency_api_data'] ); + + parent::tear_down(); + } + + /** + * Tests that no notice is returned for a plugin without dependencies. + * + * @ticket 65819 + * + * @dataProvider data_no_dependencies + * + * @param array $plugin_data The plugin data. + */ + public function test_should_return_empty_string_without_dependencies( $plugin_data ) { + $this->assertSame( '', $this->get_dependencies_notice( $plugin_data ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_no_dependencies() { + return array( + 'no requires_plugins key' => array( array( 'slug' => 'my-plugin' ) ), + 'empty array' => array( array( 'requires_plugins' => array() ) ), + 'empty string' => array( array( 'requires_plugins' => '' ) ), + ); + } + + /** + * Tests that a dependency is named from the stored dependency data. + * + * @ticket 65819 + */ + public function test_should_name_dependency_from_dependency_data() { + $this->set_dependency_api_data( + array( + 'dep-one' => array( + 'name' => 'Dep & "One"', + 'slug' => 'dep-one', + 'version' => '1.0', + ), + ) + ); + + $this->assertSame( + self::NOTICE_START . $this->get_named_dependency( 'Dep <b>&</b> "One"', 'dep-one' ) . '
    ', + $this->get_dependencies_notice( array( 'requires_plugins' => array( 'dep-one' ) ) ), + 'The dependency should be named from the stored data.' + ); + $this->assertSame( array(), $this->api_requests, 'The plugins API should not be requested.' ); + } + + /** + * Tests that the details link uses the slug of the stored dependency data. + * + * @ticket 65819 + */ + public function test_should_use_slug_from_dependency_data() { + $this->set_dependency_api_data( + array( + 'dep-one' => array( + 'name' => 'Dep One', + 'slug' => 'dep-one-renamed', + 'version' => '1.0', + ), + ) + ); + + $this->assertSame( + self::NOTICE_START . $this->get_named_dependency( 'Dep One', 'dep-one-renamed' ) . '

    ', + $this->get_dependencies_notice( array( 'requires_plugins' => array( 'dep-one' ) ) ) + ); + } + + /** + * Tests that incomplete dependency data falls back to the plugins API. + * + * @ticket 65819 + * + * @dataProvider data_incomplete_dependency_data + * + * @param array|false $dependency_data The stored dependency data, or false for none. + */ + public function test_should_fall_back_to_plugins_api_for_incomplete_dependency_data( $dependency_data ) { + if ( false !== $dependency_data ) { + $this->set_dependency_api_data( array( 'dep-one' => $dependency_data ) ); + } + + $this->api_responses['dep-one'] = (object) array( + 'name' => 'Dep One From API', + 'slug' => 'dep-one', + ); + + $this->assertSame( + self::NOTICE_START . $this->get_named_dependency( 'Dep One From API', 'dep-one' ) . '', + $this->get_dependencies_notice( array( 'requires_plugins' => array( 'dep-one' ) ) ), + 'The dependency should be named from the plugins API.' + ); + $this->assertSame( array( 'dep-one' ), $this->api_requests, 'The plugins API should be requested once.' ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_incomplete_dependency_data() { + $complete = array( + 'name' => 'Dep One', + 'slug' => 'dep-one', + 'version' => '1.0', + ); + + return array( + 'no dependency data' => array( false ), + 'empty name' => array( array_merge( $complete, array( 'name' => '' ) ) ), + 'empty slug' => array( array_merge( $complete, array( 'slug' => '' ) ) ), + 'empty version' => array( array_merge( $complete, array( 'version' => '' ) ) ), + 'no version' => array( array_diff_key( $complete, array( 'version' => true ) ) ), + ); + } + + /** + * Tests that the details link uses the slug returned by the plugins API. + * + * @ticket 65819 + */ + public function test_should_use_slug_from_plugins_api() { + $this->api_responses['dep-one'] = (object) array( + 'name' => 'Dep One', + 'slug' => 'renamed-dep', + ); + + $this->assertSame( + self::NOTICE_START . $this->get_named_dependency( 'Dep <i>One</i>', 'renamed-dep' ) . '', + $this->get_dependencies_notice( array( 'requires_plugins' => array( 'dep-one' ) ) ) + ); + } + + /** + * Tests that a dependency without a name is listed by its slug. + * + * @ticket 65819 + * + * @dataProvider data_unnamed_api_responses + * + * @param string $response The plugins API response type. + */ + public function test_should_list_dependency_by_slug_without_name( $response ) { + $this->api_responses['dep'] = 'error' === $response + ? new WP_Error( 'plugins_api_failed', 'An error.' ) + : (object) array( + 'name' => '', + 'slug' => 'dep-one', + ); + + $this->assertSame( + self::NOTICE_START . '
    dep<one>
    ', + $this->get_dependencies_notice( array( 'requires_plugins' => array( 'dep' ) ) ) + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_unnamed_api_responses() { + return array( + 'API error' => array( 'error' ), + 'API data, no name' => array( 'no name' ), + ); + } + + /** + * Tests that every dependency is listed, in order. + * + * @ticket 65819 + */ + public function test_should_list_all_dependencies_in_order() { + $this->set_dependency_api_data( + array( + 'dep-one' => array( + 'name' => 'Dep One', + 'slug' => 'dep-one', + 'version' => '1.0', + ), + ) + ); + + $this->api_responses['dep-two'] = (object) array( + 'name' => 'Dep Two', + 'slug' => 'dep-two', + ); + $this->api_responses['dep-three'] = new WP_Error( 'plugins_api_failed', 'An error.' ); + + $this->assertSame( + self::NOTICE_START + . $this->get_named_dependency( 'Dep One', 'dep-one' ) + . $this->get_named_dependency( 'Dep Two', 'dep-two' ) + . '
    dep-three
    ' + . '', + $this->get_dependencies_notice( array( 'requires_plugins' => array( 'dep-one', 'dep-two', 'dep-three' ) ) ), + 'Each dependency should be listed in order.' + ); + $this->assertSame( array( 'dep-two', 'dep-three' ), $this->api_requests, 'Only dependencies without stored data should be requested.' ); + } + + /** + * Returns the expected markup of a named dependency. + * + * @param string $escaped_name The escaped plugin name. + * @param string $slug The plugin slug. + * @return string The markup. + */ + private function get_named_dependency( $escaped_name, $slug ) { + $url = 'http://' . WP_TESTS_DOMAIN . '/wp-admin/' . ( is_multisite() ? 'network/' : '' ) + . 'plugin-install.php?tab=plugin-information&plugin=' . $slug . '&TB_iframe=true&width=600&height=550'; + + return '
    ' . $escaped_name . ' ' + . 'More Details' + . '
    '; + } + + /** + * Calls the protected get_dependencies_notice() method. + * + * @param array $plugin_data The plugin data. + * @return string The notice. + */ + private function get_dependencies_notice( $plugin_data ) { + $method = new ReflectionMethod( $this->table, 'get_dependencies_notice' ); + if ( PHP_VERSION_ID < 80100 ) { + $method->setAccessible( true ); + } + + return $method->invoke( $this->table, $plugin_data ); + } + + /** + * Gets the stored dependency data of WP_Plugin_Dependencies. + * + * @return array|null The dependency data. + */ + private function get_dependency_api_data() { + $property = new ReflectionProperty( 'WP_Plugin_Dependencies', 'dependency_api_data' ); + if ( PHP_VERSION_ID < 80100 ) { + $property->setAccessible( true ); + } + + return $property->getValue(); + } + + /** + * Sets the stored dependency data of WP_Plugin_Dependencies. + * + * @param array|null $value The dependency data. + */ + private function set_dependency_api_data( $value ) { + $property = new ReflectionProperty( 'WP_Plugin_Dependencies', 'dependency_api_data' ); + if ( PHP_VERSION_ID < 80100 ) { + $property->setAccessible( true ); + } + + $property->setValue( null, $value ); + } + + /** + * Returns the mocked plugins API response for the requested slug. + * + * @param false|object|array $result The result object or array. + * @param string $action The type of information being requested. + * @param object $args Plugin API arguments. + * @return object|WP_Error The mocked response. + */ + public function mock_plugins_api( $result, $action, $args ) { + $this->api_requests[] = $args->slug; + + return $this->api_responses[ $args->slug ] ?? new WP_Error( 'plugins_api_failed', 'No mocked response.' ); + } +} diff --git a/tests/phpunit/tests/admin/wpPluginsListTable.php b/tests/phpunit/tests/admin/wpPluginsListTable.php index daa54750fdf9e..b6b8fd3d6002d 100644 --- a/tests/phpunit/tests/admin/wpPluginsListTable.php +++ b/tests/phpunit/tests/admin/wpPluginsListTable.php @@ -286,12 +286,85 @@ public function test_single_row_should_not_add_the_autoupdates_column_for_mustus $this->assertStringNotContainsString( 'column-auto-updates', $actual, 'The auto-updates column was output.' ); } + /** + * Tests that WP_Plugins_List_Table::single_row() determines the auto-update + * message once, rather than for each plugin with an update. + * + * The 'ms-excluded' group is added as the auto-updates column is only shown in the network admin. + * + * @ticket 27814 + * + * @group ms-excluded + * + * @covers WP_Plugins_List_Table::single_row + */ + public function test_single_row_should_determine_the_auto_update_message_once() { + add_filter( 'plugins_auto_update_enabled', '__return_true' ); + add_filter( 'request_filesystem_credentials', '__return_true' ); + + $vcs_checks = 0; + add_filter( + 'automatic_updates_is_vcs_checkout', + static function () use ( &$vcs_checks ) { + ++$vcs_checks; + return true; + } + ); + + wp_set_current_user( self::$admin_id ); + + $list_table_mock = $this->getMockBuilder( 'WP_Plugins_List_Table' ) + ->setMethods( array( 'get_column_info' ) ) + ->getMock(); + + $list_table_mock->method( 'get_column_info' )->willReturn( + array( + array( + 'name' => 'Plugin', + 'auto-updates' => 'Auto-updates', + ), + array(), + array(), + 'name', + ) + ); + + $output = ''; + foreach ( array( 'plugin-one', 'plugin-two' ) as $slug ) { + ob_start(); + $list_table_mock->single_row( + array( + "$slug/$slug.php", + array( + 'Name' => $slug, + 'slug' => $slug, + 'Description' => 'A plugin with an update available.', + 'Author' => 'A plugin author', + 'Version' => '1.0.0', + 'Author URI' => 'http://example.org', + 'Text Domain' => $slug, + 'update' => true, + 'update-supported' => true, + ), + ) + ); + $output .= ob_get_clean(); + } + + $this->assertSame( 1, $vcs_checks, 'Version control was not checked exactly once.' ); + $this->assertSame( + 2, + substr_count( $output, 'appears to be under version control' ), + 'The auto-update message was not output for both plugins.' + ); + } + /** * Data provider. * * @return array[] */ - public function data_status_mustuse_and_dropins() { + public static function data_status_mustuse_and_dropins() { return array( 'Must-Use' => array( 'mustuse' ), 'Drop-ins' => array( 'dropins' ), diff --git a/tests/phpunit/tests/admin/wpPluginsListTableGetBulkActions.php b/tests/phpunit/tests/admin/wpPluginsListTableGetBulkActions.php new file mode 100644 index 0000000000000..30bdb4a38f3c6 --- /dev/null +++ b/tests/phpunit/tests/admin/wpPluginsListTableGetBulkActions.php @@ -0,0 +1,301 @@ +user->create( array( 'role' => 'administrator' ) ); + + if ( is_multisite() ) { + grant_super_admin( self::$admin_id ); + } + + self::$activator_id = $factory->user->create( array( 'role' => 'subscriber' ) ); + get_userdata( self::$activator_id )->add_cap( 'activate_plugins' ); + } + + /** + * Revokes the super admin grant before the fixture users are deleted. + */ + public static function wpTearDownAfterClass() { + if ( is_multisite() ) { + revoke_super_admin( self::$admin_id ); + } + } + + /** + * Saves the globals the list table sets and enables plugin auto-updates. + */ + public function set_up() { + parent::set_up(); + + // Auto-updates are disabled in the test environment. + add_filter( 'plugins_auto_update_enabled', '__return_true' ); + + $this->original_globals = array( + 'status' => $GLOBALS['status'] ?? null, + 'page' => $GLOBALS['page'] ?? null, + ); + } + + /** + * Restores the globals the list table sets. + */ + public function tear_down() { + foreach ( $this->original_globals as $name => $value ) { + $GLOBALS[ $name ] = $value; + } + + unset( $_REQUEST['plugin_status'] ); + + parent::tear_down(); + } + + /** + * Tests the bulk actions for each plugin status on the Plugins screen of a single site. + * + * @ticket 65819 + * + * @group ms-excluded + * + * @dataProvider data_statuses + * + * @param string $status The plugin status. + * @param string[] $expected The expected bulk action names. + */ + public function test_should_return_bulk_actions_for_status( $status, $expected ) { + wp_set_current_user( self::$admin_id ); + + $this->assertSame( $expected, array_keys( $this->get_bulk_actions( 'plugins', $status ) ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_statuses() { + $all = array( + 'activate-selected', + 'deactivate-selected', + 'update-selected', + 'delete-selected', + 'enable-auto-update-selected', + 'disable-auto-update-selected', + ); + + return array( + 'all' => array( 'all', $all ), + 'upgrade' => array( 'upgrade', $all ), + 'active' => array( + 'active', + array( + 'deactivate-selected', + 'update-selected', + 'enable-auto-update-selected', + 'disable-auto-update-selected', + ), + ), + 'inactive' => array( + 'inactive', + array( + 'activate-selected', + 'update-selected', + 'delete-selected', + 'enable-auto-update-selected', + 'disable-auto-update-selected', + ), + ), + // The Recently Active view; get_bulk_actions() checks for 'recent', so Deactivate is offered. + 'recently_activated' => array( 'recently_activated', $all ), + 'recent' => array( + 'recent', + array( + 'activate-selected', + 'update-selected', + 'delete-selected', + 'enable-auto-update-selected', + 'disable-auto-update-selected', + ), + ), + 'auto-update-enabled' => array( + 'auto-update-enabled', + array( + 'activate-selected', + 'deactivate-selected', + 'update-selected', + 'delete-selected', + 'disable-auto-update-selected', + ), + ), + 'auto-update-disabled' => array( + 'auto-update-disabled', + array( + 'activate-selected', + 'deactivate-selected', + 'update-selected', + 'delete-selected', + 'enable-auto-update-selected', + ), + ), + ); + } + + /** + * Tests the labels of the bulk actions on the Plugins screen of a single site. + * + * @ticket 65819 + * + * @group ms-excluded + */ + public function test_should_return_bulk_action_labels() { + wp_set_current_user( self::$admin_id ); + + $this->assertSame( + array( + 'activate-selected' => 'Activate', + 'deactivate-selected' => 'Deactivate', + 'update-selected' => 'Update', + 'delete-selected' => 'Delete', + 'enable-auto-update-selected' => 'Enable Auto-updates', + 'disable-auto-update-selected' => 'Disable Auto-updates', + ), + $this->get_bulk_actions( 'plugins', 'all' ) + ); + } + + /** + * Tests that the auto-update actions are left out when plugin auto-updates are disabled. + * + * @ticket 65819 + * + * @group ms-excluded + */ + public function test_should_not_return_auto_update_actions_when_auto_updates_are_disabled() { + wp_set_current_user( self::$admin_id ); + + add_filter( 'plugins_auto_update_enabled', '__return_false', 11 ); + + $this->assertSame( + array( + 'activate-selected', + 'deactivate-selected', + 'update-selected', + 'delete-selected', + ), + array_keys( $this->get_bulk_actions( 'plugins', 'all' ) ) + ); + } + + /** + * Tests that a user who can only activate plugins gets only the activation actions. + * + * @ticket 65819 + */ + public function test_should_return_only_activation_actions_for_user_who_cannot_update_or_delete() { + wp_set_current_user( self::$activator_id ); + + $this->assertSame( + array( + 'activate-selected' => 'Activate', + 'deactivate-selected' => 'Deactivate', + ), + $this->get_bulk_actions( 'plugins', 'all' ) + ); + } + + /** + * Tests that the Plugins screen of a site in a network only offers the activation actions. + * + * @ticket 65819 + * + * @group ms-required + */ + public function test_should_return_only_activation_actions_on_site_screen_in_network() { + wp_set_current_user( self::$admin_id ); + + $this->assertSame( + array( + 'activate-selected' => 'Activate', + 'deactivate-selected' => 'Deactivate', + ), + $this->get_bulk_actions( 'plugins', 'all' ) + ); + } + + /** + * Tests the bulk actions on the Plugins screen of the network admin. + * + * @ticket 65819 + * + * @group ms-required + */ + public function test_should_return_network_bulk_actions_on_network_screen() { + wp_set_current_user( self::$admin_id ); + + $this->assertSame( + array( + 'activate-selected' => 'Network Activate', + 'deactivate-selected' => 'Network Deactivate', + 'update-selected' => 'Update', + 'delete-selected' => 'Delete', + 'enable-auto-update-selected' => 'Enable Auto-updates', + 'disable-auto-update-selected' => 'Disable Auto-updates', + ), + $this->get_bulk_actions( 'plugins-network', 'all' ) + ); + } + + /** + * Returns the bulk actions of a Plugins list table. + * + * @param string $screen The screen ID. + * @param string $status The plugin status, as requested. + * @return string[] The bulk actions. + */ + private function get_bulk_actions( $screen, $status ) { + $_REQUEST['plugin_status'] = $status; + + $table = _get_list_table( 'WP_Plugins_List_Table', array( 'screen' => $screen ) ); + + $method = new ReflectionMethod( $table, 'get_bulk_actions' ); + if ( PHP_VERSION_ID < 80100 ) { + $method->setAccessible( true ); + } + + return $method->invoke( $table ); + } +} diff --git a/tests/phpunit/tests/admin/wpPluginsListTableNoItems.php b/tests/phpunit/tests/admin/wpPluginsListTableNoItems.php new file mode 100644 index 0000000000000..7135207da8c6f --- /dev/null +++ b/tests/phpunit/tests/admin/wpPluginsListTableNoItems.php @@ -0,0 +1,216 @@ +user->create( array( 'role' => 'administrator' ) ); + self::$subscriber_id = $factory->user->create( array( 'role' => 'subscriber' ) ); + } + + /** + * Saves the plugins global. + */ + public function set_up() { + parent::set_up(); + + $this->original_plugins = $GLOBALS['plugins'] ?? null; + $this->original_request_uri = $_SERVER['REQUEST_URI'] ?? null; + $GLOBALS['plugins'] = array( 'all' => array() ); + } + + /** + * Restores the plugins global and the search request. + */ + public function tear_down() { + $GLOBALS['plugins'] = $this->original_plugins; + + if ( null === $this->original_request_uri ) { + unset( $_SERVER['REQUEST_URI'] ); + } else { + $_SERVER['REQUEST_URI'] = $this->original_request_uri; + } + + unset( $_REQUEST['s'] ); + + parent::tear_down(); + } + + /** + * Tests the message when no plugins are installed. + * + * @ticket 65819 + */ + public function test_should_report_no_plugins_available() { + wp_set_current_user( self::$admin_id ); + + $this->assertSame( 'No plugins are currently available.', $this->get_no_items() ); + } + + /** + * Tests the message when plugins are installed but none match the view. + * + * @ticket 65819 + */ + public function test_should_report_no_plugins_found() { + wp_set_current_user( self::$admin_id ); + + $GLOBALS['plugins']['all'] = array( 'hello.php' => array( 'Name' => 'Hello Dolly' ) ); + + $this->assertSame( 'No plugins found.', $this->get_no_items() ); + } + + /** + * Tests that an empty search term is not treated as a search. + * + * @ticket 65819 + * + * @dataProvider data_empty_search_terms + * + * @param string $search The search term. + */ + public function test_should_ignore_empty_search_term( $search ) { + wp_set_current_user( self::$admin_id ); + + $_REQUEST['s'] = $search; + + $this->assertSame( 'No plugins are currently available.', $this->get_no_items() ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_empty_search_terms() { + return array( + 'empty string' => array( '' ), + 'zero string' => array( '0' ), + ); + } + + /** + * Tests the message and directory link for a search without results. + * + * @ticket 65819 + * + * @group ms-excluded + */ + public function test_should_report_search_without_results_with_directory_link() { + wp_set_current_user( self::$admin_id ); + + $_REQUEST['s'] = 'contact form'; + + $this->assertSame( + 'No plugins found for: contact form.' + . ' Search for plugins in the WordPress Plugin Directory.', + $this->get_no_items() + ); + } + + /** + * Tests that the search term is unslashed, decoded and escaped. + * + * @ticket 65819 + * + * @group ms-excluded + */ + public function test_should_escape_search_term() { + wp_set_current_user( self::$admin_id ); + + $_REQUEST['s'] = 'it\\\'s %3Cb%3E & "x"'; + + $this->assertSame( + 'No plugins found for: it's <b> & "x".' + . ' Search for plugins in the WordPress Plugin Directory.', + $this->get_no_items() + ); + } + + /** + * Tests that no directory link is shown to a user who cannot install plugins. + * + * @ticket 65819 + */ + public function test_should_not_link_directory_for_user_without_capability() { + wp_set_current_user( self::$subscriber_id ); + + $_REQUEST['s'] = 'contact form'; + + $this->assertSame( 'No plugins found for: contact form.', $this->get_no_items() ); + } + + /** + * Tests that no directory link is shown on multisite. + * + * @ticket 65819 + * + * @group ms-required + */ + public function test_should_not_link_directory_on_multisite() { + grant_super_admin( self::$admin_id ); + wp_set_current_user( self::$admin_id ); + + $_REQUEST['s'] = 'contact form'; + + try { + $output = $this->get_no_items(); + } finally { + revoke_super_admin( self::$admin_id ); + } + + $this->assertSame( 'No plugins found for: contact form.', $output ); + } + + /** + * Returns the output of no_items(). + * + * @return string The output. + */ + private function get_no_items() { + $table = _get_list_table( 'WP_Plugins_List_Table', array( 'screen' => 'plugins' ) ); + + return get_echo( array( $table, 'no_items' ) ); + } +} diff --git a/tests/phpunit/tests/admin/wpPostsListTable.php b/tests/phpunit/tests/admin/wpPostsListTable.php index eb00894bc4b2c..50fd032c86265 100644 --- a/tests/phpunit/tests/admin/wpPostsListTable.php +++ b/tests/phpunit/tests/admin/wpPostsListTable.php @@ -524,7 +524,7 @@ public function test_column_date_should_show_scheduled_post_status( $offset, $ex /** * @return array[] */ - public function data_scheduled_post_date_column_status() { + public static function data_scheduled_post_date_column_status() { return array( 'future date' => array( DAY_IN_SECONDS, 'Scheduled' ), 'past due date' => array( -DAY_IN_SECONDS, 'Missed schedule' ), @@ -591,7 +591,7 @@ public function test_no_title_excerpt_visibility( $mode_value, $post_args, $shou * * @return array[] */ - public function data_no_title_excerpt_visibility() { + public static function data_no_title_excerpt_visibility() { return array( 'compact view, untitled' => array( 'list', array(), true ), 'extended view, untitled' => array( 'excerpt', array(), false ), diff --git a/tests/phpunit/tests/admin/wpPrivacyRequestsTable.php b/tests/phpunit/tests/admin/wpPrivacyRequestsTable.php index 66e3e02501cfb..2ea649bc74cb8 100644 --- a/tests/phpunit/tests/admin/wpPrivacyRequestsTable.php +++ b/tests/phpunit/tests/admin/wpPrivacyRequestsTable.php @@ -129,7 +129,7 @@ public function filter_posts_request( $request ) { * } * } */ - public function data_columns_should_be_sortable() { + public static function data_columns_should_be_sortable() { return array( // Default order (ID) DESC. array( diff --git a/tests/phpunit/tests/admin/wpSiteHealth.php b/tests/phpunit/tests/admin/wpSiteHealth.php index 6080b477f54c3..2ad2052369ba1 100644 --- a/tests/phpunit/tests/admin/wpSiteHealth.php +++ b/tests/phpunit/tests/admin/wpSiteHealth.php @@ -126,7 +126,7 @@ public function test_cron_health_checks( $times, $expected_status, $expected_lab * 4. bool Expected outcome has_late_cron() * 5. bool Expected outcome has_missed_cron() */ - public function data_cron_health_checks() { + public static function data_cron_health_checks() { return array( array( '+5 minutes', @@ -269,7 +269,7 @@ function ( $response, $parsed_args ) use ( &$responses, &$is_unauthorized, $has_ * delay_the_response?: bool, * }> */ - public function data_get_page_cache(): array { + public static function data_get_page_cache(): array { $recommended_label = 'Page cache is not detected but the server response time is OK'; $good_label = 'Page cache is detected and the server response time is good'; $critical_label = 'Page cache is not detected and the server response time is slow'; @@ -561,7 +561,7 @@ static function ( $thresholds ) use ( $threshold, $count ) { * * @ticket 56040 */ - public function data_object_cache_thresholds() { + public static function data_object_cache_thresholds() { return array( array( 'comments_count', 0 ), array( 'posts_count', 0 ), diff --git a/tests/phpunit/tests/admin/wpSiteHealthDetectPluginThemeAutoUpdateIssues.php b/tests/phpunit/tests/admin/wpSiteHealthDetectPluginThemeAutoUpdateIssues.php new file mode 100644 index 0000000000000..391de1ae0e331 --- /dev/null +++ b/tests/phpunit/tests/admin/wpSiteHealthDetectPluginThemeAutoUpdateIssues.php @@ -0,0 +1,141 @@ +add_value_filter( 'auto_update_plugin', $plugin_updates ); + $this->add_value_filter( 'auto_update_theme', $theme_updates ); + $this->add_value_filter( 'plugins_auto_update_enabled', $plugins_ui ); + $this->add_value_filter( 'themes_auto_update_enabled', $themes_ui ); + + $site_health = new WP_Site_Health(); + + $result = $site_health->detect_plugin_theme_auto_update_issues(); + + $this->assertIsObject( $result, 'The result should be an object.' ); + $this->assertSame( + array( + 'status' => $status, + 'message' => $message, + ), + (array) $result, + 'The status and message should match.' + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_auto_update_states() { + $critical = 'Auto-updates for plugins and/or themes appear to be disabled, but settings are still set to be displayed. This could cause auto-updates to not work as expected.'; + $both = 'Auto-updates for plugins and themes appear to be disabled. This will prevent your site from receiving new versions automatically when available.'; + $plugins = 'Auto-updates for plugins appear to be disabled. This will prevent your site from receiving new versions automatically when available.'; + $themes = 'Auto-updates for themes appear to be disabled. This will prevent your site from receiving new versions automatically when available.'; + $good = 'There appear to be no issues with plugin and theme auto-updates.'; + + return array( + 'plugin updates off, plugins UI on' => array( false, null, true, false, 'critical', $critical ), + 'theme updates off, themes UI on' => array( null, false, false, true, 'critical', $critical ), + 'both off, both UIs on' => array( false, false, true, true, 'critical', $critical ), + 'both off, UIs off' => array( false, false, false, false, 'recommended', $both ), + 'plugin updates off, UIs off' => array( false, null, false, false, 'recommended', $plugins ), + 'plugin updates off, theme updates on' => array( false, true, false, false, 'recommended', $plugins ), + 'theme updates off, UIs off' => array( null, false, false, false, 'recommended', $themes ), + 'theme updates off, plugin updates on' => array( true, false, false, false, 'recommended', $themes ), + 'no filters, UIs off' => array( null, null, false, false, 'good', $good ), + 'no filters, UIs on' => array( null, null, true, true, 'good', $good ), + 'both forced on, UIs on' => array( true, true, true, true, 'good', $good ), + 'plugin updates off, UIs on for themes' => array( false, null, false, true, 'recommended', $plugins ), + ); + } + + /** + * Tests that the auto-update filters receive an available update for a fake plugin and theme. + * + * @ticket 65819 + */ + public function test_should_pass_mock_items_to_auto_update_filters() { + $plugin_filter = new MockAction(); + $theme_filter = new MockAction(); + add_filter( 'auto_update_plugin', array( $plugin_filter, 'filter' ), 10, 2 ); + add_filter( 'auto_update_theme', array( $theme_filter, 'filter' ), 10, 2 ); + + $site_health = new WP_Site_Health(); + $site_health->detect_plugin_theme_auto_update_issues(); + + $plugin_args = $plugin_filter->get_args(); + $theme_args = $theme_filter->get_args(); + + $this->assertTrue( $plugin_args[0][0], 'The plugin filter should receive true.' ); + $this->assertSame( 'a-fake-plugin/a-fake-plugin.php', $plugin_args[0][1]->plugin, 'The plugin filter should receive the fake plugin.' ); + $this->assertTrue( $theme_args[0][0], 'The theme filter should receive true.' ); + $this->assertSame( 'a-fake-theme', $theme_args[0][1]->theme, 'The theme filter should receive the fake theme.' ); + } + + /** + * Adds a filter that returns the given value. + * + * @param string $hook The filter name. + * @param bool|null $value The value to return, or null for no filter. + */ + private function add_value_filter( $hook, $value ) { + if ( null === $value ) { + return; + } + + add_filter( $hook, $value ? '__return_true' : '__return_false' ); + } +} diff --git a/tests/phpunit/tests/admin/wpSiteHealthGetTestPluginVersion.php b/tests/phpunit/tests/admin/wpSiteHealthGetTestPluginVersion.php new file mode 100644 index 0000000000000..3aad5118d1bf5 --- /dev/null +++ b/tests/phpunit/tests/admin/wpSiteHealthGetTestPluginVersion.php @@ -0,0 +1,275 @@ +user->create( array( 'role' => 'administrator' ) ); + + if ( is_multisite() ) { + grant_super_admin( self::$admin_id ); + } + } + + /** + * Revokes the super admin grant before the fixture users are deleted. + */ + public static function wpTearDownAfterClass() { + if ( is_multisite() ) { + revoke_super_admin( self::$admin_id ); + } + } + + /** + * Resets the plugins cache, the active plugins and the update transient. + */ + public function set_up() { + parent::set_up(); + + wp_set_current_user( self::$admin_id ); + + update_option( 'active_plugins', array() ); + update_site_option( 'active_sitewide_plugins', array() ); + delete_site_transient( 'update_plugins' ); + } + + /** + * Clears the plugins cache and the update transient. + */ + public function tear_down() { + wp_cache_delete( 'plugins', 'plugins' ); + delete_site_transient( 'update_plugins' ); + + parent::tear_down(); + } + + /** + * Tests the result when every installed plugin is active and up to date. + * + * @ticket 65819 + * + * @dataProvider data_active_plugins + * + * @param int $count The number of installed, active plugins. + * @param string $sentence The expected sentence. + */ + public function test_should_report_active_plugins_up_to_date( $count, $sentence ) { + $this->set_plugins( $count, $count ); + + $this->assertSame( + array( + 'label' => 'Your plugins are all up to date', + 'status' => 'good', + 'badge' => array( + 'label' => 'Security', + 'color' => 'blue', + ), + 'description' => '

    Plugins extend your site’s functionality with things like contact forms, ecommerce and much more. That means they have deep access to your site, so it’s vital to keep them up to date.

    ' . $sentence . '

    ', + 'actions' => '

    Manage your plugins

    ', + 'test' => 'plugin_version', + ), + $this->get_result() + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_active_plugins() { + return array( + 'one plugin' => array( 1, 'Your site has 1 active plugin, and it is up to date.' ), + 'two plugins' => array( 2, 'Your site has 2 active plugins, and they are all up to date.' ), + ); + } + + /** + * Tests the result when active plugins have updates waiting. + * + * @ticket 65819 + * + * @dataProvider data_outdated_plugins + * + * @param int $outdated The number of plugins with an update. + * @param string $sentence The expected sentence. + */ + public function test_should_report_outdated_plugins( $outdated, $sentence ) { + $this->set_plugins( 2, 2, $outdated ); + + $result = $this->get_result(); + + $this->assertSame( 'critical', $result['status'], 'The status should be critical.' ); + $this->assertSame( 'You have plugins waiting to be updated', $result['label'], 'The label should match.' ); + $this->assertStringEndsWith( '

    ' . $sentence . '

    ', $result['description'], 'The description should count the outdated plugins.' ); + $this->assertSame( + '

    Manage your plugins

    ' + . '

    Update your plugins

    ', + $result['actions'], + 'The actions should link to the plugin updates.' + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_outdated_plugins() { + return array( + 'one outdated plugin' => array( 1, 'Your site has 1 plugin waiting to be updated.' ), + 'two outdated plugins' => array( 2, 'Your site has 2 plugins waiting to be updated.' ), + ); + } + + /** + * Tests that an update for a plugin that is not installed is ignored. + * + * @ticket 65819 + */ + public function test_should_ignore_updates_for_plugins_not_installed() { + $this->set_plugins( 1, 1 ); + + $update_plugins = get_site_transient( 'update_plugins' ); + $update_plugins->response['missing/missing.php'] = (object) array( + 'slug' => 'missing', + 'new_version' => '2.0', + ); + set_site_transient( 'update_plugins', $update_plugins ); + + $this->assertSame( 'good', $this->get_result()['status'] ); + } + + /** + * Tests the result when some installed plugins are inactive. + * + * @ticket 65819 + * + * @group ms-excluded + * + * @dataProvider data_inactive_plugins + * + * @param int $total The number of installed plugins. + * @param int $active The number of active plugins. + * @param string $sentence The expected sentence about inactive plugins. + */ + public function test_should_recommend_removing_inactive_plugins( $total, $active, $sentence ) { + $this->set_plugins( $total, $active ); + + $result = $this->get_result(); + + $this->assertSame( 'recommended', $result['status'], 'The status should be recommended.' ); + $this->assertSame( 'You should remove inactive plugins', $result['label'], 'The label should match.' ); + $this->assertStringEndsWith( + '

    ' . $sentence . ' Inactive plugins are tempting targets for attackers. If you are not going to use a plugin, you should consider removing it.

    ', + $result['description'], + 'The description should count the inactive plugins.' + ); + $this->assertStringEndsWith( + '

    Manage inactive plugins

    ', + $result['actions'], + 'The actions should link to the inactive plugins.' + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_inactive_plugins() { + return array( + 'one inactive plugin' => array( 2, 1, 'Your site has 1 inactive plugin.' ), + 'two inactive plugins' => array( 3, 1, 'Your site has 2 inactive plugins.' ), + ); + } + + /** + * Tests that inactive plugins are not reported on multisite. + * + * @ticket 65819 + * + * @group ms-required + */ + public function test_should_not_report_inactive_plugins_on_multisite() { + $this->set_plugins( 2, 1 ); + + $result = $this->get_result(); + + $this->assertSame( 'good', $result['status'], 'The status should stay good.' ); + $this->assertStringEndsWith( '

    Your site has 1 active plugin, and it is up to date.

    ', $result['description'], 'Only the active plugin should be counted, and inactive plugins should not be mentioned.' ); + } + + /** + * Primes the plugins cache with test plugins, activates some and adds updates. + * + * @param int $total The number of installed plugins. + * @param int $active The number of active plugins, from the first. + * @param int $outdated The number of plugins with an update, from the first. + */ + private function set_plugins( $total, $active, $outdated = 0 ) { + $plugins = array(); + $response = array(); + + for ( $i = 1; $i <= $total; $i++ ) { + $file = "plugin-{$i}/plugin-{$i}.php"; + + $plugins[ $file ] = array( + 'Name' => "Plugin {$i}", + 'Version' => '1.0', + ); + + if ( $i <= $outdated ) { + $response[ $file ] = (object) array( + 'slug' => "plugin-{$i}", + 'new_version' => '2.0', + ); + } + } + + wp_cache_set( 'plugins', array( '' => $plugins ), 'plugins' ); + + $active_plugins = array_slice( array_keys( $plugins ), 0, $active ); + if ( is_multisite() ) { + update_site_option( 'active_sitewide_plugins', array_fill_keys( $active_plugins, time() ) ); + } else { + update_option( 'active_plugins', $active_plugins ); + } + + set_site_transient( 'update_plugins', (object) array( 'response' => $response ) ); + } + + /** + * Runs the test. + * + * @return array The test result. + */ + private function get_result() { + $site_health = new WP_Site_Health(); + + return $site_health->get_test_plugin_version(); + } +} diff --git a/tests/phpunit/tests/admin/wpUpgrader.php b/tests/phpunit/tests/admin/wpUpgrader.php index 59930164a238a..78744f774d35b 100644 --- a/tests/phpunit/tests/admin/wpUpgrader.php +++ b/tests/phpunit/tests/admin/wpUpgrader.php @@ -126,7 +126,7 @@ public function test_init_should_initialize_strings( $key ) { * * @return array[] */ - public function data_init_should_initialize_strings() { + public static function data_init_should_initialize_strings() { return self::text_array_to_dataprovider( array( 'bad_request', @@ -186,7 +186,7 @@ public function test_flatten_dirlist_should_flatten_the_provided_directory_list( * * @return array[] */ - public function data_should_flatten_dirlist() { + public static function data_should_flatten_dirlist() { return array( 'empty array, default path' => array( 'expected' => array(), @@ -780,7 +780,7 @@ public function test_install_package_should_return_wp_error_with_invalid_destina * * @return array[] */ - public function data_install_package_invalid_paths() { + public static function data_install_package_invalid_paths() { return array( 'empty string' => array( 'path' => '' ), @@ -1272,7 +1272,7 @@ function ( $removed, $local_destination, $remote_destination ) use ( $expected ) * * @return array[] */ - public function data_install_package_should_make_remote_destination_safe_when_set_to_a_protected_directory() { + public static function data_install_package_should_make_remote_destination_safe_when_set_to_a_protected_directory() { return array( 'ABSPATH' => array( 'protected_directory' => ABSPATH, diff --git a/tests/phpunit/tests/admin/wpUpgraderSkin.php b/tests/phpunit/tests/admin/wpUpgraderSkin.php index 945cd8b8742fd..a4746e78a7375 100644 --- a/tests/phpunit/tests/admin/wpUpgraderSkin.php +++ b/tests/phpunit/tests/admin/wpUpgraderSkin.php @@ -58,7 +58,7 @@ public function test_decrement_update_count_should_print_nothing_without_a_usabl * * @return array */ - public function data_results_that_should_print_nothing(): array { + public static function data_results_that_should_print_nothing(): array { return array( 'a false result' => array( 'result' => false ), 'a WP_Error result' => array( 'result' => new WP_Error( 'test_error' ) ), @@ -113,7 +113,7 @@ static function ( array $attributes ) use ( $nonce ): array { * * @return array */ - public function data_update_types(): array { + public static function data_update_types(): array { return array( 'a plugin type' => array( 'type' => 'plugin' ), 'a theme type' => array( 'type' => 'theme' ), diff --git a/tests/phpunit/tests/adminbar.php b/tests/phpunit/tests/adminbar.php index 7aa8b7bce1745..95c479ceb8bdc 100644 --- a/tests/phpunit/tests/adminbar.php +++ b/tests/phpunit/tests/adminbar.php @@ -935,6 +935,7 @@ public function test_my_account_has_greeting_in_user_info_only() { $this->assertSame( 'John Doe', $node_my_account->meta['menu_title'] ); $this->assertSame( 'Howdy, John Doe' . $current_user->user_login . 'Edit Profile', wp_strip_all_tags( $node_user_info->title ) ); + $this->assertStringContainsString( "", $node_user_info->title ); } /** @@ -958,7 +959,8 @@ public function test_my_account_falls_back_to_user_login_when_display_name_is_em $this->assertSame( $current_user->user_login, wp_strip_all_tags( $node_my_account->title ) ); $this->assertSame( $current_user->user_login, $node_my_account->meta['menu_title'] ); - $this->assertSame( 'Howdy, ' . $current_user->user_login . 'Edit Profile', wp_strip_all_tags( $node_user_info->title ) ); + $this->assertSame( 'Howdy, ' . $current_user->user_login . $current_user->user_login . 'Edit Profile', wp_strip_all_tags( $node_user_info->title ) ); + $this->assertStringContainsString( "