diff --git a/.github/actionlint.yml b/.github/actionlint.yml index 42af4216713638..73cb40792c8cc8 100644 --- a/.github/actionlint.yml +++ b/.github/actionlint.yml @@ -7,6 +7,10 @@ paths: ignore: # [SC2129](https://www.shellcheck.net/wiki/SC2129) is ignored because it is a stylistic issue. - 'shellcheck reported issue in this script: SC2129:.+' + # `queue` is a real concurrency key, but actionlint does not know it + # yet, and its last release predates the feature. Tracked upstream in + # https://github.com/rhysd/actionlint/issues/654. + - 'unexpected key "queue" for "concurrency" section.+' .github/workflows/end2end-test.yml: ignore: # This file gets created in the step prior. diff --git a/.github/workflows/bundle-size.yml b/.github/workflows/bundle-size.yml index 42a4b2965643b0..dc5646e344047b 100644 --- a/.github/workflows/bundle-size.yml +++ b/.github/workflows/bundle-size.yml @@ -46,7 +46,10 @@ jobs: runs-on: 'ubuntu-24.04' permissions: contents: read + # Only used on a fork, where the action falls back to commenting and + # its own failure handling takes over. See `use-check` below. pull-requests: write + checks: write steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -63,8 +66,72 @@ jobs: cache: npm - uses: preactjs/compressed-size-action@f322c295dde06a1cb7ccaef105732dd8a726d1d9 # v2.10.0 + id: size with: omit-unchanged: true repo-token: '${{ secrets.GITHUB_TOKEN }}' pattern: '{build/scripts/**/*.min.js,build/styles/**/*.css,build/modules/**/*.min.js}' clean-script: 'distclean' + # Report a check rather than a comment, leaving the size report + # to the unified comment. Not on a fork: its token cannot create + # a check, and the check path, unlike the comment path, does not + # catch that failure. + use-check: ${{ github.event.pull_request.head.repo.full_name == github.repository }} + + - name: Save the size report + if: ${{ !cancelled() && steps.size.outputs.comment-body }} + env: + # Through the environment, never interpolated into the script. + BODY: ${{ steps.size.outputs.comment-body }} + run: | + mkdir -p "${RUNNER_TEMP}/pr-meta" + # Drop the action's own footer, which the unified comment replaces. + printf '%s' "$BODY" | sed '/compressed-size-action<\/sub>/d' > "${RUNNER_TEMP}/pr-meta/body.md" + + - name: Upload the size report + if: ${{ !cancelled() && steps.size.outputs.comment-body }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: pr-meta--bundle-size + path: ${{ runner.temp }}/pr-meta + if-no-files-found: ignore + + pr-meta: + name: Update PR meta comment + needs: build + # Runs even when the build failed, so a stale size report is cleared. + # Skipped on a fork, whose token cannot write a comment. + if: ${{ !cancelled() && github.event.pull_request.head.repo.full_name == github.repository }} + runs-on: 'ubuntu-24.04' + permissions: + contents: read + pull-requests: write + timeout-minutes: 5 + # Every writer of this comment shares one lock, across workflows. The + # default `queue: single` would cancel a pending writer once a third + # arrived, silently dropping its section. + concurrency: + group: pr-meta-${{ github.event.number }} + cancel-in-progress: false + queue: max + + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + sparse-checkout: tools/pr-meta + show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} + persist-credentials: false + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + continue-on-error: true + with: + name: pr-meta--bundle-size + path: pr-meta + + - uses: ./tools/pr-meta + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + section: bundle-size + body-path: pr-meta/body.md + commit-sha: ${{ github.event.pull_request.head.sha }} + run-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} diff --git a/.github/workflows/end2end-test.yml b/.github/workflows/end2end-test.yml index b17bfd2d952708..e65e347d0f23ca 100644 --- a/.github/workflows/end2end-test.yml +++ b/.github/workflows/end2end-test.yml @@ -139,8 +139,6 @@ jobs: runs-on: 'ubuntu-24.04' permissions: contents: read - outputs: - has-flaky-test-report: ${{ !!steps.merge-flaky-tests-reports.outputs.artifact-id }} steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -186,40 +184,69 @@ jobs: path: playwright-report if-no-files-found: ignore - report-to-issues: - name: Report to GitHub - needs: [merge-artifacts] - if: ${{ needs.merge-artifacts.outputs.has-flaky-test-report == 'true' }} + # Flaky tests are only reported as a comment on the pull request. The + # merge step above only sets `artifact-id` when a report was produced, + # so both steps are skipped on runs without any flaky test. + - name: Download merged flaky tests report + id: download-flaky-tests-report + if: ${{ github.event_name == 'pull_request' && steps.merge-flaky-tests-reports.outputs.artifact-id }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: flaky-tests-report + path: flaky-tests + + - name: Render the flaky tests report + if: ${{ steps.download-flaky-tests-report.outcome == 'success' }} + uses: ./packages/report-flaky-tests + with: + artifact-path: flaky-tests + output-path: pr-meta/body.md + + - name: Upload the flaky tests report + if: ${{ steps.download-flaky-tests-report.outcome == 'success' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: pr-meta--flaky-tests + path: pr-meta + if-no-files-found: ignore + + pr-meta: + name: Update PR meta comment + needs: merge-artifacts + # Runs on every pull request, including the clean ones: a run with no + # flaky test uploads no report, and the empty section clears a stale one. + # Only when the job above succeeded, though, since a missing report then + # means the run was clean rather than that rendering it failed. + # Skipped on a fork, whose token cannot write a comment. + if: ${{ needs.merge-artifacts.result == 'success' && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }} runs-on: 'ubuntu-24.04' permissions: contents: read - issues: write pull-requests: write + timeout-minutes: 5 + # Shared with every other writer of this comment. See bundle-size.yml. + concurrency: + group: pr-meta-${{ github.event.number }} + cancel-in-progress: false + queue: max + steps: - # Checkout defaults to using the branch which triggered the event, which - # isn't necessarily `trunk` (e.g. in the case of a merge). - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: - ref: trunk + sparse-checkout: tools/pr-meta show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} persist-credentials: false - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - # Don't fail the job if there isn't any flaky tests report. continue-on-error: true with: - name: flaky-tests-report - path: flaky-tests - - - name: Setup Node.js and install dependencies - uses: ./.github/setup-node - - - name: Npm build - run: npm run build -- --skip-types + name: pr-meta--flaky-tests + path: pr-meta - - name: Report flaky tests - uses: ./packages/report-flaky-tests + - uses: ./tools/pr-meta with: - repo-token: '${{ secrets.GITHUB_TOKEN }}' - label: '[Type] Flaky Test' - artifact-path: flaky-tests + repo-token: ${{ secrets.GITHUB_TOKEN }} + section: flaky-tests + body-path: pr-meta/body.md + commit-sha: ${{ github.event.pull_request.head.sha }} + run-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} diff --git a/.github/workflows/enforce-pr-labels.yml b/.github/workflows/enforce-pr-labels.yml index e92c9cf3e5e04b..4976979b29b020 100644 --- a/.github/workflows/enforce-pr-labels.yml +++ b/.github/workflows/enforce-pr-labels.yml @@ -1,7 +1,15 @@ name: Enforce labels on Pull Request on: pull_request_target: - types: [labeled, unlabeled, ready_for_review, review_requested] + types: + [ + opened, + reopened, + labeled, + unlabeled, + ready_for_review, + review_requested, + ] # Disable permissions for all available scopes by default. # Any needed permissions should be configured at the job level. @@ -9,17 +17,73 @@ permissions: {} jobs: type-related-labels: + name: Check the type label runs-on: 'ubuntu-24.04' if: ${{ github.event.pull_request.state == 'open' }} permissions: + contents: read pull-requests: write + timeout-minutes: 5 + # Shared with every other writer of this comment. See bundle-size.yml. + concurrency: + group: pr-meta-${{ github.event.number }} + cancel-in-progress: false + queue: max + steps: - - uses: mheap/github-action-required-labels@0ac283b4e65c1fb28ce6079dea5546ceca98ccbe # v5.5.2 + # A `pull_request_target` job has a writable token, so it checks out + # the base branch it was triggered on, never the pull request's head. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - mode: exactly - count: 1 - labels: '^\[Type\]' - use_regex: true - add_comment: true - message: "**Warning: Type of PR label mismatch**\n\n To merge this PR, it requires {{ errorString }} {{ count }} label indicating the type of PR. Other labels are optional and not being checked here. \n- **Required label**: Any label starting with `[Type]`.\n- **Labels found**: {{ applied }}.\n\nRead more about [Type labels in Gutenberg](https://github.com/WordPress/gutenberg/labels?q=type). Don't worry if you don't have the required permissions to add labels; the PR reviewer should be able to help with the task." - exit_type: failure + sparse-checkout: tools/pr-meta + show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} + persist-credentials: false + + - name: Check for a type label + id: check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.number }} + REPO: ${{ github.repository }} + run: | + mkdir -p pr-meta + + # Read the labels now rather than from the event payload. Runs + # queue behind each other on the shared lock, so an older one + # can arrive after a newer one already fixed the labels. + type_labels="$(gh api "repos/${REPO}/issues/${PR_NUMBER}/labels" \ + --jq '[ .[].name | select( startswith( "[Type]" ) ) ]')" + count="$(printf '%s' "$type_labels" | jq 'length')" + + if [ "$count" -eq 1 ]; then + echo "ok=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "ok=false" >> "$GITHUB_OUTPUT" + applied="$(printf '%s' "$type_labels" | jq -r 'if length == 0 then "none" else join( ", " ) end')" + + cat > pr-meta/body.md <get_registered( $block_name ); if ( ! $block_type ) { return $block_content; } $supported_pseudo_states = WP_Theme_JSON_Gutenberg::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] ?? array(); - $style = gutenberg_resolve_style_state_aliases( - $block['attrs']['style'] ?? array(), - $block_name - ); $css_rules = array(); $viewport_settings = gutenberg_get_global_settings( array( 'viewport' ) ); $responsive_media_queries = WP_Theme_JSON_Gutenberg::get_viewport_media_queries( $viewport_settings ); diff --git a/lib/class-wp-theme-json-gutenberg.php b/lib/class-wp-theme-json-gutenberg.php index a3fd81a9a7689d..f74f6f0a645456 100644 --- a/lib/class-wp-theme-json-gutenberg.php +++ b/lib/class-wp-theme-json-gutenberg.php @@ -3863,8 +3863,18 @@ public function get_styles_for_block( $block_metadata ) { // Only store if the variation has blockGap defined. if ( isset( $style_variation_node['spacing']['blockGap'] ) ) { // Append block selector to the variation selector for proper targeting. - $variation_metadata_with_selector = $style_variation; - $variation_metadata_with_selector['selector'] = $style_variation['selector'] . $block_metadata['css']; + $variation_metadata_with_selector = $style_variation; + $variation_metadata_with_selector['selector'] = $style_variation['selector'] . $block_metadata['css']; + + /* + * `get_layout_styles()` reads `name` as a block name, to check that the block + * supports layout at all. A variation node's `name` is the variation slug, + * which is never a registered block, so the check fails and every variation + * gap rule is discarded. Pass the block the variation belongs to, so the + * support check answers the question it is actually asking. + */ + $variation_metadata_with_selector['name'] = $block_name; + $style_variation_layout_metadata[ $style_variation['selector'] ] = array( 'metadata' => $variation_metadata_with_selector, 'node' => $style_variation_node, @@ -3920,7 +3930,11 @@ public function get_styles_for_block( $block_metadata ) { if ( isset( $breakpoint_node['spacing']['blockGap'] ) ) { $variation_layout_metadata = $style_variation; $variation_layout_metadata['selector'] = $style_variation['selector'] . $block_metadata['css']; - $variation_responsive_css .= $this->get_layout_styles( + + // The variation slug is not a block name here either. See above. + $variation_layout_metadata['name'] = $block_name; + + $variation_responsive_css .= $this->get_layout_styles( $variation_layout_metadata, array( 'node' => $breakpoint_node, diff --git a/lib/media/class-gutenberg-rest-attachments-controller.php b/lib/media/class-gutenberg-rest-attachments-controller.php index 1c270c23d0af3a..b5f465a796d769 100644 --- a/lib/media/class-gutenberg-rest-attachments-controller.php +++ b/lib/media/class-gutenberg-rest-attachments-controller.php @@ -880,7 +880,22 @@ public function finalize_item( WP_REST_Request $request ) { $response_request['_fields'] = $request['_fields']; } - return $this->prepare_item_for_response( get_post( $attachment_id ), $response_request ); + /* + * Re-read the post. The 'wp_generate_attachment_metadata' filter above + * runs long after $post was fetched, and a callback that rewrites the + * post row - an optimizer changing post_mime_type once it has + * converted the file, say - would otherwise be missing from this + * response. The editor stores the response as its copy of the record + * rather than reading the attachment again, so a stale row here is + * what it keeps. A callback that deleted the attachment instead leaves + * nothing to respond with, so that is reported as the error it is. + */ + $post = $this->get_post( $attachment_id ); + if ( is_wp_error( $post ) ) { + return $post; + } + + return $this->prepare_item_for_response( $post, $response_request ); } /** diff --git a/package-lock.json b/package-lock.json index b12368d6c61edf..8e6e30324558e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4226,15 +4226,16 @@ } }, "node_modules/@jest/console": { - "version": "29.6.2", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.6.2.tgz", - "integrity": "sha512-0N0yZof5hi44HAR2pPS+ikJ3nzKNoZdVu8FffRf3wy47I7Dm7etk/3KetMdRUqzVd16V4O2m2ISpNTbnIuqy1w==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "license": "MIT", "dependencies": { - "@jest/types": "^29.6.1", + "@jest/types": "^29.6.3", "@types/node": "*", "chalk": "^4.0.0", - "jest-message-util": "^29.6.2", - "jest-util": "^29.6.2", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", "slash": "^3.0.0" }, "engines": { @@ -4759,12 +4760,13 @@ } }, "node_modules/@jest/test-result": { - "version": "29.6.2", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.6.2.tgz", - "integrity": "sha512-3VKFXzcV42EYhMCsJQURptSqnyjqCGbtLuX5Xxb6Pm6gUf1wIRIl+mandIRGJyWKgNKYF9cnstti6Ls5ekduqw==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "license": "MIT", "dependencies": { - "@jest/console": "^29.6.2", - "@jest/types": "^29.6.1", + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", "collect-v8-coverage": "^1.0.0" }, @@ -14966,6 +14968,10 @@ "resolved": "packages/postcss-themes", "link": true }, + "node_modules/@wordpress/pr-meta-tools": { + "resolved": "tools/pr-meta", + "link": true + }, "node_modules/@wordpress/preferences": { "resolved": "packages/preferences", "link": true @@ -18726,9 +18732,10 @@ "integrity": "sha512-YfQ1tAUZm561vpYD+5eyWN8+UsceQbSrqqlc/6zDY2gtAE+uZLSdkkovhnGpmCThsvKBFakq4EdY/FF93E8XIw==" }, "node_modules/collect-v8-coverage": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz", - "integrity": "sha512-iBPtljfCNcTKNAto0KEtDfZ3qzjJvqE3aTGZsbhjSBlorqpXJlaWWtPO35D+ZImoC3KWejX64o+yPGxhWSTzfg==" + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "license": "MIT" }, "node_modules/collection-visit": { "version": "1.0.0", @@ -46988,15 +46995,13 @@ "license": "GPL-2.0-or-later", "dependencies": { "@actions/core": "^1.9.1", - "@actions/github": "^5.0.1", "@jest/test-result": "^29.6.2", - "@octokit/types": "^6.34.0", - "@octokit/webhooks-types": "^5.8.0", "@playwright/test": "^1.61.1", "jest-message-util": "^29.6.2" }, "devDependencies": { - "@types/jest": "^29.5.14" + "@types/jest": "^29.5.14", + "@types/node": "^20.19.39" }, "engines": { "node": ">=18.12.0", @@ -49076,6 +49081,19 @@ "typescript-eslint": "^8.57.1" } }, + "tools/pr-meta": { + "name": "@wordpress/pr-meta-tools", + "version": "0.0.0", + "license": "GPL-2.0-or-later", + "devDependencies": { + "@types/jest": "^29.5.14", + "@types/node": "^20.19.39" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + } + }, "tools/react-19": { "name": "@wordpress/react-19", "version": "0.0.0", diff --git a/package.json b/package.json index 039601e1fc21f5..bf1102c4a84f33 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,10 @@ "packageNamespace": "wordpress", "handlePrefix": "wp", "pages": [ - "font-library", + { + "id": "font-library", + "capability": "edit_theme_options" + }, "options-connectors", { "id": "content-types", @@ -50,6 +53,7 @@ "init": [ "@wordpress/edit-site-init" ], + "capability": "upload_files", "experimental": true }, { @@ -57,6 +61,7 @@ "init": [ "@wordpress/edit-site-init" ], + "capability": "edit_theme_options", "experimental": true } ] diff --git a/packages/block-editor/CHANGELOG.md b/packages/block-editor/CHANGELOG.md index 3ba3b3e98d1b0d..8bf37f8cb5dbc9 100644 --- a/packages/block-editor/CHANGELOG.md +++ b/packages/block-editor/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +### Bug Fixes + +- Client-side media processing: Refuse a batch of more than one file when the caller only takes one, such as a Cover block placeholder, matching what the server-side upload path already did. Every dropped file was uploaded instead, and the block kept whichever one finished last ([#82041](https://github.com/WordPress/gutenberg/issues/82041)). +- `ListView`: On keyboard activation, focus the start of the first field instead of the end of the last. A Table no longer focuses its last cell. Clicking a List View item still keeps focus in the list ([#81964](https://github.com/WordPress/gutenberg/pull/81964)). + ### Enhancements - Inspector controls in the standard block-supports panels (Typography, Dimensions, Border, Color, Background, Filters) now reflect the value a block inherits from Global Styles when no local override is set. Inherited controls show that value at rest (as a placeholder, preselected option, or resolved value) and mark the label with a dotted underline; setting a local override reveals a reset affordance that clears the override back to the inherited value ([#77894](https://github.com/WordPress/gutenberg/pull/77894)). diff --git a/packages/block-editor/src/components/list-view/block.js b/packages/block-editor/src/components/list-view/block.js index 1f7be894d9f4c9..e4900a5d52997a 100644 --- a/packages/block-editor/src/components/list-view/block.js +++ b/packages/block-editor/src/components/list-view/block.js @@ -441,11 +441,12 @@ function ListViewBlock( { const selectEditorBlock = useCallback( ( event ) => { // For keyboard activation (Enter/Space on a link), transfer focus - // to the canvas with the caret at the end of the block. - // For mouse clicks, keep focus in the list view so that subsequent - // keyboard operations (arrow navigation, copy/paste) still work. + // to the canvas at the default initial position (start of the first + // field). For mouse clicks, keep focus in the list view so that + // subsequent keyboard operations (arrow navigation, copy/paste) + // still work. const isKeyboardActivation = event?.detail === 0; - selectBlock( event, clientId, isKeyboardActivation ? -1 : null ); + selectBlock( event, clientId, isKeyboardActivation ? 0 : null ); event.preventDefault(); }, [ clientId, selectBlock ] diff --git a/packages/block-editor/src/components/provider/index.js b/packages/block-editor/src/components/provider/index.js index 410f83d5495c2f..94e5a529306cd3 100644 --- a/packages/block-editor/src/components/provider/index.js +++ b/packages/block-editor/src/components/provider/index.js @@ -4,6 +4,7 @@ import { useDispatch } from '@wordpress/data'; import { useEffect, useMemo, useRef } from '@wordpress/element'; import { SlotFillProvider } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; import { MediaUploadProvider, store as uploadStore, @@ -50,6 +51,30 @@ let isHeicCanvasEnabledCache = null; */ const HEIC_MIME_TYPES = [ 'image/heic', 'image/heif' ]; +/** + * Refuses a batch of more than one file on behalf of a caller that only takes + * one - a Cover block's placeholder, say. + * + * `uploadMedia()` in `@wordpress/media-utils` refuses such a batch with the + * same wording, but the `@wordpress/upload-media` pipeline below is a separate + * transport that never reaches that function. Without this check every dropped + * file uploads and the caller keeps whichever one lands last (gutenberg#82041). + * + * @param {Array} filesList List of files. + * @param {boolean} multiple Whether the caller accepts more than one file. + * @param {Function} onError Function called when an error happens. + * + * @return {boolean} Whether the batch was refused. + */ +function refuseExtraFiles( filesList, multiple, onError ) { + if ( ! multiple && filesList.length > 1 ) { + onError( __( 'Only one file can be used here.' ) ); + return true; + } + + return false; +} + /** * Checks if client-side media processing should be enabled. * @@ -149,6 +174,7 @@ function shouldEnableHeicCanvasProcessing() { * @param {Function} $3.onFileChange Function called each time a file or a temporary representation of the file is available. * @param {Function} $3.onSuccess Function called once a file has completely finished uploading, including thumbnails. * @param {Function} $3.onBatchSuccess Function called once all files in a group have completely finished uploading, including thumbnails. + * @param {boolean} $3.multiple Whether the caller accepts more than one file. */ function mediaUpload( registry, @@ -161,8 +187,13 @@ function mediaUpload( onFileChange, onSuccess, onBatchSuccess, + multiple = true, } ) { + if ( refuseExtraFiles( filesList, multiple, onError ) ) { + return; + } + void registry.dispatch( uploadStore ).addItems( { files: Array.from( filesList ), onChange: onFileChange, @@ -195,6 +226,7 @@ function mediaUpload( * @param {Function} $3.onFileChange Function called each time a file or a temporary representation of the file is available. * @param {Function} $3.onSuccess Function called once a file has completely finished uploading, including thumbnails. * @param {Function} $3.onBatchSuccess Function called once all files in a group have completely finished uploading, including thumbnails. + * @param {boolean} $3.multiple Whether the caller accepts more than one file. */ function heicMediaUpload( registry, @@ -207,8 +239,13 @@ function heicMediaUpload( onFileChange, onSuccess, onBatchSuccess, + multiple = true, } ) { + if ( refuseExtraFiles( filesList, multiple, onError ) ) { + return; + } + const files = Array.from( filesList ); const heicFiles = files.filter( ( file ) => HEIC_MIME_TYPES.includes( file.type ) @@ -260,6 +297,7 @@ function heicMediaUpload( onFileChange, onSuccess, onBatchSuccess: coordinatedBatchSuccess, + multiple, } ); } } diff --git a/packages/block-editor/src/hooks/states.js b/packages/block-editor/src/hooks/states.js index 5ea76492f3dfae..4c12733c2afdff 100644 --- a/packages/block-editor/src/hooks/states.js +++ b/packages/block-editor/src/hooks/states.js @@ -3,7 +3,7 @@ */ import { useMemo } from '@wordpress/element'; import { privateApis as globalStylesEnginePrivateApis } from '@wordpress/global-styles-engine'; -import { __ } from '@wordpress/i18n'; +import { __, _x } from '@wordpress/i18n'; /** * Internal dependencies @@ -17,10 +17,13 @@ import { unlock } from '../lock-unlock'; const { getViewportBreakpoints } = unlock( globalStylesEnginePrivateApis ); export const PSEUDO_STATE_LABELS = { - ':hover': __( 'Hover' ), - ':focus': __( 'Focus' ), - ':focus-visible': __( 'Focus-visible' ), - ':active': __( 'Active' ), + ':hover': _x( 'Hover', 'Name for the CSS pseudo-class selector' ), + ':focus': _x( 'Focus', 'Name for the CSS pseudo-class selector' ), + ':focus-visible': _x( + 'Focus-visible', + 'Name for the CSS pseudo-class selector' + ), + ':active': _x( 'Active', 'Name for the CSS pseudo-class selector' ), }; export const RESPONSIVE_STATE_LABELS = { diff --git a/packages/block-library/CHANGELOG.md b/packages/block-library/CHANGELOG.md index 290faf9a6cffd3..707d2a181f6153 100644 --- a/packages/block-library/CHANGELOG.md +++ b/packages/block-library/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +### Bug Fixes + +- Accordion Panel: Reset padding-block when panel is hidden ([#81782](https://github.com/WordPress/gutenberg/pull/81782)). +- Image: Keep the selected image size, and re-point a media file or attachment page link, when an edit in the media editor saves to a new attachment. Cropping, rotating or flipping left the block rendering the full-size file while the size control still reported the size the user had chosen, and left the link pointing at the pre-edit image ([#82316](https://github.com/WordPress/gutenberg/pull/82316)). +- Image: Fix cropped galleries rendering images at their natural height in the editor canvas. The baseline inline `height: auto` is no longer emitted for images inside a cropped gallery, so the gallery's own cropping CSS applies ([#82318](https://github.com/WordPress/gutenberg/pull/82318)). +- Query, Post Template: Treat a `query` attribute that omits `postType` as querying posts, matching `build_query_vars_from_query_block()` ([#82465](https://github.com/WordPress/gutenberg/pull/82465)). + ### Enhancements - Playlist Track: Use a dedicated icon for the block toolbar. ([#80959](https://github.com/WordPress/gutenberg/pull/80959)) diff --git a/packages/block-library/src/accordion-panel/style.scss b/packages/block-library/src/accordion-panel/style.scss index c5a7a38281ad17..158a01118490f0 100644 --- a/packages/block-library/src/accordion-panel/style.scss +++ b/packages/block-library/src/accordion-panel/style.scss @@ -1,6 +1,9 @@ .wp-block-accordion-panel { - // Prevent blockGap from Accordion Content block from adding extra margin between accordions. + // Prevent blockGap, borders, box-shadows, and padding from adding extra spacing or rendering when the panel is closed. &[hidden] { + border: none !important; + box-shadow: none !important; margin-block-start: 0; + padding: 0 !important; } } diff --git a/packages/block-library/src/gallery/dynamic-gallery.js b/packages/block-library/src/gallery/dynamic-gallery.js index 8172f22ed6b4fb..88f9b846b22d58 100644 --- a/packages/block-library/src/gallery/dynamic-gallery.js +++ b/packages/block-library/src/gallery/dynamic-gallery.js @@ -2,7 +2,7 @@ * WordPress dependencies */ import { __, sprintf } from '@wordpress/i18n'; -import { useState } from '@wordpress/element'; +import { useMemo, useState } from '@wordpress/element'; import { Button, Notice, @@ -26,6 +26,7 @@ import { * Internal dependencies */ import { sharedIcon } from './shared-icon'; +import { isGalleryFlexLayout } from './shared'; import { Caption } from '../utils/caption'; import { DEFAULT_ORDERBY, DEFAULT_ORDER, MAX_IMAGES } from './dynamic-source'; @@ -289,12 +290,21 @@ export function GallerySourcePanel( { * stays editable. This relies on the gallery's image styles using descendant * (not direct-child) selectors, which the box-less wrapper leaves intact. * + * The gallery's layout is passed through so the previewed images see the same + * parent layout that real inner blocks would (`useBlockPreview` provides it to + * their layout context). Without it they resolve to the default flow layout and + * behave as if they weren't in a gallery — most visibly, an image inside a + * cropped gallery would keep its baseline `height: auto` and defeat the + * gallery's cropping CSS. + * * @param {Object} props * @param {Object[]} props.imageBlocks Non-persisted `core/image` blocks to preview. + * @param {Object} props.layout The gallery's layout, for the preview's layout context. */ -function GalleryImagesPreview( { imageBlocks } ) { +function GalleryImagesPreview( { imageBlocks, layout } ) { const { children, ref, className } = useBlockPreview( { blocks: imageBlocks, + layout, } ); return (
+ isGalleryFlexLayout( attributes.layout ) + ? { ...attributes.layout, type: 'flex' } + : attributes.layout, + [ attributes.layout ] + ); + // Empty-state copy for the preview. Framed as forward-looking ("… will appear // here") rather than as an error, since the same empty result covers both a // post with no matching images and a template with no post in context yet — @@ -403,6 +426,7 @@ export function GalleryDynamicView( { ) : ( diff --git a/packages/block-library/src/gallery/shared.js b/packages/block-library/src/gallery/shared.js index f904a9e13d0d5e..dc54ce7d278b04 100644 --- a/packages/block-library/src/gallery/shared.js +++ b/packages/block-library/src/gallery/shared.js @@ -2,6 +2,36 @@ export function defaultColumnsNumber( imageCount ) { return imageCount ? Math.min( 3, imageCount ) : 3; } +/** + * Whether a value is a plain object (and not an array). + * + * @param {*} value The value to check. + * @return {boolean} Whether the value is a plain object. + */ +export function isObject( value ) { + return !! value && typeof value === 'object' && ! Array.isArray( value ); +} + +/** + * Whether the Gallery should use its default Flex layout behavior. + * + * Gallery blocks created before layout variations existed do not have an + * explicit layout attribute. Treat missing and malformed layout data as Flex + * so existing galleries keep their current appearance. + * + * @param {*} layout The Gallery layout attribute. + * @return {boolean} Whether the Gallery uses its Flex layout. + */ +export function isGalleryFlexLayout( layout ) { + const layoutType = isObject( layout ) ? layout.type : undefined; + + return ( + typeof layoutType !== 'string' || + layoutType === '' || + layoutType === 'flex' + ); +} + export const pickRelevantMediaFiles = ( image, sizeSlug = 'large' ) => { const imageProps = Object.fromEntries( Object.entries( image ?? {} ).filter( ( [ key ] ) => diff --git a/packages/block-library/src/image/image.js b/packages/block-library/src/image/image.js index c516fd82d4ab30..1e51aa2031ccfc 100644 --- a/packages/block-library/src/image/image.js +++ b/packages/block-library/src/image/image.js @@ -327,7 +327,11 @@ export default function Image( { setOffsetTop( imageElement?.offsetTop ?? 0 ); }, [ imageElement ] ); const setRefs = useMergeRefs( [ setImageElement, setResizeObserved ] ); - const { allowResize = true } = context; + const { allowResize = true, imageCrop = false } = context; + // Only a cropped gallery (flex layout) controls the image height via its + // own CSS. Grid galleries and standalone images keep the baseline + // `height: auto` so a theme can't squish them. + const isCroppedGalleryImage = imageCrop && parentLayoutType === 'flex'; const { image, attachmentResolutionError } = useSelect( ( select ) => { @@ -1233,17 +1237,23 @@ export default function Image( { ? `${ width }px` : width; } - if ( - height === 'auto' || - height === undefined || - height === null - ) { + if ( height === 'auto' ) { style.height = 'auto'; - } else { + } else if ( + height !== undefined && + height !== null + ) { style.height = typeof height === 'number' ? `${ height }px` : height; + } else if ( ! isCroppedGalleryImage ) { + // Default to `height: auto` so a + // theme that sets an explicit height + // on images can't squish them. Inside + // a cropped gallery the gallery's own + // CSS controls the height instead. + style.height = 'auto'; } return style; } )() ), diff --git a/packages/block-library/src/image/test/use-open-image-media-editor-modal.js b/packages/block-library/src/image/test/use-open-image-media-editor-modal.js index a594d486abd4c2..4c9fadaf39ae1e 100644 --- a/packages/block-library/src/image/test/use-open-image-media-editor-modal.js +++ b/packages/block-library/src/image/test/use-open-image-media-editor-modal.js @@ -13,6 +13,7 @@ import { useRegistry, useSelect } from '@wordpress/data'; */ import { getImageBlockMetadataFromAttachment, + getNewAttachmentImageBlockAttributes, getSyncedImageBlockAttributes, useOpenImageMediaEditorModal, } from '../use-open-image-media-editor-modal'; @@ -105,6 +106,60 @@ async function runModalUpdate( { return { setAttributes, registry, openMediaEditorModal }; } +// The attachment as it was before the edit. Its metadata matches the blocks +// used below, so the metadata sync never contributes attributes and the +// assertions stay about the ones derived from the edited attachment. +const ORIGINAL_ATTACHMENT = { + id: 1, + alt_text: '', + caption: { raw: '' }, +}; + +// Editing media saves to a new attachment, with its own sub-sizes, file and +// attachment page. +const CROPPED_ATTACHMENT = { + id: 2, + alt_text: '', + caption: { raw: '' }, + source_url: 'cropped.jpg', + link: 'https://example.com/cropped/', + media_details: { + sizes: { + medium: { source_url: 'cropped-300x200.jpg' }, + full: { source_url: 'cropped.jpg' }, + }, + }, +}; + +// What the media editor reports after a crop, rotate or flip: it saved to a +// new attachment, and reports that attachment's full-size file. +const CROP_UPDATE = { + id: CROPPED_ATTACHMENT.id, + url: CROPPED_ATTACHMENT.source_url, +}; + +/** + * Registry options for an edit that saved to a new attachment: the pre-edit + * record is cached, and the edited one is only reachable by resolving it. + * + * @param {Object} options Options. + * @param {boolean} options.hasOriginal Whether the pre-edit attachment is + * known at all; without it there is no + * metadata baseline. + */ +function croppedAttachmentRecords( { hasOriginal = true } = {} ) { + return { + getEditedEntityRecord: ( kind, name, attachmentId ) => + hasOriginal && attachmentId === ORIGINAL_ATTACHMENT.id + ? ORIGINAL_ATTACHMENT + : undefined, + resolveGetEntityRecord: ( kind, name, attachmentId ) => + attachmentId === CROPPED_ATTACHMENT.id + ? CROPPED_ATTACHMENT + : undefined, + }; +} + describe( 'useOpenImageMediaEditorModal', () => { beforeEach( () => { jest.clearAllMocks(); @@ -403,6 +458,121 @@ describe( 'useOpenImageMediaEditorModal', () => { } ); } ); + it( 'keeps the selected image size when the edit created a new attachment', async () => { + const onUrlChange = jest.fn(); + const { setAttributes } = await runModalUpdate( { + attributes: { + id: 1, + url: 'original-300x200.jpg', + alt: '', + caption: '', + sizeSlug: 'medium', + }, + registryOptions: croppedAttachmentRecords(), + updatePayload: CROP_UPDATE, + hookOptions: { onUrlChange }, + } ); + + // Everything lands in one update, so the block never renders against + // half-updated settings. + expect( setAttributes ).toHaveBeenCalledTimes( 1 ); + expect( setAttributes ).toHaveBeenCalledWith( { + id: 2, + url: 'cropped-300x200.jpg', + } ); + // The loading state settles against the file the block will render, + // not the full-size file the media editor reported. + expect( onUrlChange ).toHaveBeenLastCalledWith( 'cropped-300x200.jpg' ); + } ); + + it( 'falls back to the saved record when the refetch fails', async () => { + const { setAttributes } = await runModalUpdate( { + attributes: { + id: 1, + url: 'original-300x200.jpg', + alt: '', + caption: '', + sizeSlug: 'medium', + href: 'original.jpg', + linkDestination: 'media', + }, + registryOptions: { + // The media editor put the saved record in the store, so it + // is cached even though refetching it fails. + getEditedEntityRecord: ( kind, name, attachmentId ) => + attachmentId === ORIGINAL_ATTACHMENT.id + ? ORIGINAL_ATTACHMENT + : CROPPED_ATTACHMENT, + resolveGetEntityRecord: ( kind, name, attachmentId ) => { + if ( attachmentId === CROPPED_ATTACHMENT.id ) { + throw new Error( 'Network error' ); + } + return undefined; + }, + }, + updatePayload: CROP_UPDATE, + } ); + + // The size and link still follow the edited image, rather than the + // block half-updating to the new file at its old settings. + expect( setAttributes ).toHaveBeenCalledTimes( 1 ); + expect( setAttributes ).toHaveBeenCalledWith( { + id: 2, + url: 'cropped-300x200.jpg', + href: 'cropped.jpg', + } ); + } ); + + it( 'derives the size of the edited image without a metadata baseline', async () => { + const { setAttributes, registry } = await runModalUpdate( { + // Custom metadata leaves the block with no fallback baseline, and + // the original attachment is unknown, so no metadata is synced. + attributes: { + id: 1, + url: 'original-300x200.jpg', + alt: 'Custom alt', + caption: 'Custom caption', + sizeSlug: 'medium', + }, + registryOptions: croppedAttachmentRecords( { hasOriginal: false } ), + updatePayload: CROP_UPDATE, + } ); + + expect( setAttributes ).toHaveBeenCalledTimes( 1 ); + expect( setAttributes ).toHaveBeenCalledWith( { + id: 2, + url: 'cropped-300x200.jpg', + } ); + // The size is derived from a freshly resolved record for the edited + // attachment, not a stale cached one. + expect( registry.actions.invalidateResolution ).toHaveBeenCalledWith( + 'getEntityRecord', + [ 'postType', 'attachment', 2 ] + ); + } ); + + it( 'points a media file link at the edited image', async () => { + const { setAttributes } = await runModalUpdate( { + attributes: { + id: 1, + url: 'original.jpg', + alt: '', + caption: '', + href: 'original.jpg', + linkDestination: 'media', + }, + registryOptions: croppedAttachmentRecords(), + updatePayload: CROP_UPDATE, + } ); + + expect( setAttributes ).toHaveBeenCalledTimes( 1 ); + expect( setAttributes ).toHaveBeenCalledWith( { + id: 2, + url: 'cropped.jpg', + href: 'cropped.jpg', + } ); + } ); + it( 'updates back to the previous attachment from the original modal callback', async () => { const originalAttachment = { id: 1, @@ -935,3 +1105,147 @@ describe( 'getSyncedImageBlockAttributes', () => { ).toEqual( {} ); } ); } ); + +describe( 'getNewAttachmentImageBlockAttributes', () => { + const croppedAttachment = { + id: 2, + source_url: 'cropped.jpg', + link: 'https://example.com/cropped/', + media_details: { + sizes: { + medium: { source_url: 'cropped-300x200.jpg' }, + full: { source_url: 'cropped.jpg' }, + }, + }, + }; + + it( 'points the URL at the selected size on the new attachment', () => { + expect( + getNewAttachmentImageBlockAttributes( + { sizeSlug: 'medium' }, + croppedAttachment + ) + ).toEqual( { url: 'cropped-300x200.jpg' } ); + } ); + + it( 'falls back to full when the new attachment lacks the selected size', () => { + expect( + getNewAttachmentImageBlockAttributes( + { sizeSlug: 'large' }, + croppedAttachment + ) + ).toEqual( { url: 'cropped.jpg', sizeSlug: 'full' } ); + } ); + + it( 'falls back to full when the edited image is too small for any sub-size', () => { + // Cropping below the smallest registered size leaves + // `media_details.sizes` empty, so the block has to fall back to the + // file itself and stop reporting a size it no longer has. + expect( + getNewAttachmentImageBlockAttributes( + { sizeSlug: 'medium' }, + { + id: 2, + source_url: 'cropped-103x45.jpg', + media_details: { width: 103, height: 45, sizes: {} }, + } + ) + ).toEqual( { + url: 'cropped-103x45.jpg', + sizeSlug: 'full', + } ); + } ); + + it( 'makes no change when no full-size URL is available', () => { + expect( + getNewAttachmentImageBlockAttributes( + { sizeSlug: 'large' }, + { id: 2, media_details: { sizes: { medium: {} } } } + ) + ).toEqual( {} ); + } ); + + it( 'makes no change for the full size or an unset size', () => { + expect( + getNewAttachmentImageBlockAttributes( + { sizeSlug: 'full' }, + croppedAttachment + ) + ).toEqual( {} ); + expect( + getNewAttachmentImageBlockAttributes( {}, croppedAttachment ) + ).toEqual( {} ); + } ); + + it( 'makes no change when the attachment sizes are unknown', () => { + expect( + getNewAttachmentImageBlockAttributes( + { sizeSlug: 'medium' }, + { id: 2 } + ) + ).toEqual( {} ); + } ); + + it( 'makes no change when the attachment record is unknown', () => { + expect( + getNewAttachmentImageBlockAttributes( + { sizeSlug: 'medium' }, + undefined + ) + ).toBeUndefined(); + } ); + + it( 'points a media file link at the new attachment file', () => { + expect( + getNewAttachmentImageBlockAttributes( + { sizeSlug: 'medium', linkDestination: 'media' }, + croppedAttachment + ) + ).toEqual( { + url: 'cropped-300x200.jpg', + href: 'cropped.jpg', + } ); + } ); + + it( 'points an attachment page link at the new attachment page', () => { + expect( + getNewAttachmentImageBlockAttributes( + { linkDestination: 'attachment' }, + croppedAttachment + ) + ).toEqual( { href: 'https://example.com/cropped/' } ); + } ); + + it( 'leaves custom and absent links alone', () => { + expect( + getNewAttachmentImageBlockAttributes( + { linkDestination: 'custom' }, + croppedAttachment + ) + ).toEqual( {} ); + expect( + getNewAttachmentImageBlockAttributes( + { linkDestination: 'none' }, + croppedAttachment + ) + ).toEqual( {} ); + expect( + getNewAttachmentImageBlockAttributes( {}, croppedAttachment ) + ).toEqual( {} ); + } ); + + it( 'keeps the existing link when the new attachment record lacks it', () => { + expect( + getNewAttachmentImageBlockAttributes( + { linkDestination: 'media' }, + { id: 2 } + ) + ).toEqual( {} ); + expect( + getNewAttachmentImageBlockAttributes( + { linkDestination: 'attachment' }, + { id: 2 } + ) + ).toEqual( {} ); + } ); +} ); diff --git a/packages/block-library/src/image/use-open-image-media-editor-modal.js b/packages/block-library/src/image/use-open-image-media-editor-modal.js index 85218424839b7e..23380b0e8bf29a 100644 --- a/packages/block-library/src/image/use-open-image-media-editor-modal.js +++ b/packages/block-library/src/image/use-open-image-media-editor-modal.js @@ -14,6 +14,11 @@ import { useCallback, useEffect, useRef } from '@wordpress/element'; * Internal dependencies */ import { unlock } from '../lock-unlock'; +import { + DEFAULT_MEDIA_SIZE_SLUG, + LINK_DESTINATION_ATTACHMENT, + LINK_DESTINATION_MEDIA, +} from './constants'; function normalizeImageBlockCaption( caption ) { if ( typeof caption !== 'string' ) { @@ -103,6 +108,107 @@ export function getSyncedImageBlockAttributes( return syncedAttributes; } +/** + * Resolves the attributes needed to keep the selected image size on a new + * attachment. + * + * WordPress skips generating a sub-size larger than the file itself, so the + * selected size may not exist on the edited image; fall back to full when it + * doesn't. + * + * @param {string|undefined} sizeSlug The block's currently selected size. + * @param {Object} attachment The new attachment record. + * + * @return {Object|undefined} Attributes to apply, if any. + */ +function getNewAttachmentSizeAttributes( sizeSlug, attachment ) { + const sizes = attachment.media_details?.sizes; + + if ( ! sizeSlug || sizeSlug === DEFAULT_MEDIA_SIZE_SLUG || ! sizes ) { + return undefined; + } + + const sizeUrl = sizes[ sizeSlug ]?.source_url; + + if ( sizeUrl ) { + return { url: sizeUrl }; + } + + const fullUrl = attachment.source_url ?? sizes.full?.source_url; + + return fullUrl + ? { url: fullUrl, sizeSlug: DEFAULT_MEDIA_SIZE_SLUG } + : undefined; +} + +/** + * Resolves the link attributes needed to keep the block's link pointing at the + * new attachment. + * + * Only the destinations derived from the attachment follow the edited image. A + * `custom` link is the user's own URL and a `none` link has nothing to point + * at, so both are left alone. When the record doesn't carry the field, the + * existing link is kept rather than cleared. + * + * @param {string|undefined} linkDestination The block's link destination. + * @param {Object} attachment The new attachment record. + * + * @return {Object|undefined} Attributes to apply, if any. + */ +function getNewAttachmentLinkAttributes( linkDestination, attachment ) { + // Media file links point at the full-size file, independently of the size + // the block renders. + if ( linkDestination === LINK_DESTINATION_MEDIA ) { + return attachment.source_url + ? { href: attachment.source_url } + : undefined; + } + + if ( linkDestination === LINK_DESTINATION_ATTACHMENT ) { + return attachment.link ? { href: attachment.link } : undefined; + } + + return undefined; +} + +/** + * Resolves the block attributes that are derived from the attachment, for an + * edit that saved to a different one. + * + * Editing media (crop, rotate, flip) creates a new attachment with its own + * generated sub-sizes and its own attachment page, and the media editor + * reports the full-size file. Without re-deriving these, the size control + * keeps reporting e.g. "Medium" while the block renders the full-size image, + * and a "Media file" or "Attachment page" link still points at the image as it + * was before the edit. Everything else the user set on the block — dimensions, + * lightbox, title, custom links — is left untouched. + * + * @param {Object} blockAttributes The block's current attributes. + * @param {Object|undefined} attachment The new attachment record. + * + * @return {Object|undefined} Attributes to apply, or undefined when the + * attachment record isn't known yet. + */ +export function getNewAttachmentImageBlockAttributes( + blockAttributes, + attachment +) { + if ( ! attachment ) { + return undefined; + } + + return { + ...getNewAttachmentSizeAttributes( + blockAttributes.sizeSlug, + attachment + ), + ...getNewAttachmentLinkAttributes( + blockAttributes.linkDestination, + attachment + ), + }; +} + const { openMediaEditorModalKey } = unlock( blockEditorPrivateApis ); function getAttachmentFallbackForEmptyBlockMetadata( { alt, caption } ) { @@ -141,8 +247,10 @@ export function useOpenImageMediaEditorModal( { } ) { // Keep this hook private to the Image block and pass the block attributes // object so the callsite stays compact. Destructure only the attributes - // currently used for metadata sync; add more here if the sync policy grows. - const { id, url, alt, caption } = attributes; + // the update needs to read — those synced from the attachment's metadata, + // and those derived from which attachment the block points at; add more + // here if the sync policy grows. + const { id, url, alt, caption, sizeSlug, linkDestination } = attributes; const registry = useRegistry(); const openMediaEditorModal = useSelect( ( select ) => @@ -158,6 +266,8 @@ export function useOpenImageMediaEditorModal( { url, alt, caption: caption?.toString(), + sizeSlug, + linkDestination, } ); // Snapshot of the attachment's metadata taken just before the modal opens, // used as the baseline for detecting what changed during the editing session. @@ -172,8 +282,10 @@ export function useOpenImageMediaEditorModal( { url, alt, caption: caption?.toString(), + sizeSlug, + linkDestination, }; - }, [ alt, caption, id, url ] ); + }, [ alt, caption, id, linkDestination, sizeSlug, url ] ); // Reads the cached attachment record. The `attachment` postType entity // fetches with `context: 'edit'` by default, so `getEditedEntityRecord` @@ -243,14 +355,19 @@ export function useOpenImageMediaEditorModal( { const nextAttributes = {}; const currentBlockAttributes = blockAttributesRef.current; + const isNewAttachment = newId !== currentBlockAttributes.id; - if ( newId !== currentBlockAttributes.id ) { + if ( isNewAttachment ) { nextAttributes.id = newId; nextAttributes.url = newUrl ?? currentBlockAttributes.url; if ( nextAttributes.url !== currentBlockAttributes.url ) { // The block is about to point at a freshly generated file // the browser hasn't loaded yet; let the caller show a - // loading state until its fires load/error. + // loading state until its fires load/error. Raising + // it here, before anything is awaited, keeps the rest of + // this update behind that loading state: the attributes + // land in a single `setAttributes` at the end, so the + // image never renders against half-updated settings. onUrlChange?.( nextAttributes.url ); } blockAttributesRef.current = { @@ -260,10 +377,9 @@ export function useOpenImageMediaEditorModal( { }; } - if ( originalAttachment ) { - // Fetch fresh server state so the comparison reflects what - // the media editor actually saved, not a potentially stale - // cache. + if ( originalAttachment || isNewAttachment ) { + // Fetch fresh server state so this reflects what the media + // editor actually saved, not a potentially stale cache. const resolvedAttachment = await resolveFreshAttachmentRecord( newId ); @@ -275,21 +391,63 @@ export function useOpenImageMediaEditorModal( { return; } + // A failed refetch returns nothing, which would leave the + // block pointing at the new file while its size and link + // still describe the old one. The record the media editor + // received when it saved is already in the store, so fall + // back to that rather than half-updating the block. + const attachmentRecord = + resolvedAttachment ?? getCachedAttachmentRecord( newId ); + + const latestBlockAttributes = blockAttributesRef.current; + // Sync alt text and caption back to the block only when // they were changed in the media editor. Fields the user // has independently customised on the block (i.e. values // that don't match the pre-session attachment metadata) - // are left untouched. - const latestBlockAttributes = blockAttributesRef.current; - const resolvedMetadataAttributes = - getSyncedImageBlockAttributes( - latestBlockAttributes, - originalAttachment, - resolvedAttachment - ); - - if ( Object.keys( resolvedMetadataAttributes ).length ) { - Object.assign( nextAttributes, resolvedMetadataAttributes ); + // are left untouched. Without a baseline there's no way to + // tell the two apart, so nothing is synced. + if ( originalAttachment ) { + const resolvedMetadataAttributes = + getSyncedImageBlockAttributes( + latestBlockAttributes, + originalAttachment, + attachmentRecord + ); + + if ( Object.keys( resolvedMetadataAttributes ).length ) { + Object.assign( + nextAttributes, + resolvedMetadataAttributes + ); + } + } + + if ( isNewAttachment ) { + const derivedAttributes = + getNewAttachmentImageBlockAttributes( + latestBlockAttributes, + attachmentRecord + ); + + if ( derivedAttributes ) { + Object.assign( nextAttributes, derivedAttributes ); + + // The URL announced before the resolve was the + // full-size file the media editor reported, and + // deriving the selected size can change it. + // Re-announce so the pending swap names the file + // that actually lands: an update that is undone or + // superseded never changes the src, and the + // block then clears its loading state by matching + // the pending URL against the rendered one. + if ( + derivedAttributes.url && + derivedAttributes.url !== latestBlockAttributes.url + ) { + onUrlChange?.( derivedAttributes.url ); + } + } } } @@ -301,7 +459,12 @@ export function useOpenImageMediaEditorModal( { setAttributes( nextAttributes ); } }, - [ onUrlChange, resolveFreshAttachmentRecord, setAttributes ] + [ + getCachedAttachmentRecord, + onUrlChange, + resolveFreshAttachmentRecord, + setAttributes, + ] ); const openImageMediaEditorModal = useCallback( async () => { diff --git a/packages/block-library/src/list-item/hooks/use-outdent-list-item.js b/packages/block-library/src/list-item/hooks/use-outdent-list-item.js index 48bccc8f2cd4c1..5e620a0d86dd61 100644 --- a/packages/block-library/src/list-item/hooks/use-outdent-list-item.js +++ b/packages/block-library/src/list-item/hooks/use-outdent-list-item.js @@ -8,12 +8,8 @@ import { cloneBlock } from '@wordpress/blocks'; export default function useOutdentListItem() { const registry = useRegistry(); - const { - moveBlocksToPosition, - removeBlock, - insertBlock, - updateBlockListSettings, - } = useDispatch( blockEditorStore ); + const { moveBlocksToPosition, removeBlock, removeBlocks, insertBlock } = + useDispatch( blockEditorStore ); const { getBlockRootClientId, getBlockName, @@ -21,7 +17,6 @@ export default function useOutdentListItem() { getBlockIndex, getSelectedBlockClientIds, getBlock, - getBlockListSettings, } = useSelect( blockEditorStore ); function getParentListItemId( id ) { @@ -68,29 +63,28 @@ export default function useOutdentListItem() { registry.batch( () => { if ( followingListItems.length ) { - let nestedListId = getBlockOrder( firstClientId )[ 0 ]; + const nestedListId = getBlockOrder( firstClientId )[ 0 ]; - if ( ! nestedListId ) { + if ( nestedListId ) { + moveBlocksToPosition( + followingListItems, + parentListId, + nestedListId + ); + } else { + // Insert the list with the items already inside: an + // empty list would be scaffolded with the list block + // type's template at insertion. Removing the items + // first frees them to be reinserted with their client + // IDs kept. const nestedListBlock = cloneBlock( getBlock( parentListId ), {}, - [] + followingListItems.map( ( id ) => getBlock( id ) ) ); - nestedListId = nestedListBlock.clientId; + removeBlocks( followingListItems, false ); insertBlock( nestedListBlock, 0, firstClientId, false ); - // Immediately update the block list settings, otherwise - // blocks can't be moved here due to canInsert checks. - updateBlockListSettings( - nestedListId, - getBlockListSettings( parentListId ) - ); } - - moveBlocksToPosition( - followingListItems, - parentListId, - nestedListId - ); } moveBlocksToPosition( clientIds, diff --git a/packages/block-library/src/post-template/edit.js b/packages/block-library/src/post-template/edit.js index 7fed75db07c9a6..91929100800062 100644 --- a/packages/block-library/src/post-template/edit.js +++ b/packages/block-library/src/post-template/edit.js @@ -91,7 +91,9 @@ export default function PostTemplateEdit( { query: { perPage, offset = 0, - postType, + // Match `build_query_vars_from_query_block()`, which queries posts when + // `query` has no post type. + postType = 'post', order, orderBy, author, diff --git a/packages/block-library/src/query/edit/inspector-controls/index.js b/packages/block-library/src/query/edit/inspector-controls/index.js index b665c0d862abba..cf7ba31243c628 100644 --- a/packages/block-library/src/query/edit/inspector-controls/index.js +++ b/packages/block-library/src/query/edit/inspector-controls/index.js @@ -49,7 +49,9 @@ export default function QueryInspectorControls( props ) { orderBy, author: authorIds, pages, - postType, + // Match `build_query_vars_from_query_block()`, which queries posts when + // `query` has no post type. + postType = 'post', perPage, offset, sticky, @@ -179,7 +181,7 @@ export default function QueryInspectorControls( props ) { isControlAllowed( allowedControls, 'excludeCurrent' ); const postTypeSingularName = useSelect( ( select ) => - select( coreStore ).getPostType( postType )?.labels.singular_name, + select( coreStore ).getPostType( postType )?.labels?.singular_name, [ postType ] ); diff --git a/packages/e2e-tests/plugins/image-size-duplicates.php b/packages/e2e-tests/plugins/image-size-duplicates.php new file mode 100644 index 00000000000000..e3cb1072326c49 --- /dev/null +++ b/packages/e2e-tests/plugins/image-size-duplicates.php @@ -0,0 +1,19 @@ + select( uploadStore ).getItems(), - [] - ); + const { items, csmFailureCount } = useSelect( ( select ) => { + const { getItems } = select( uploadStore ); + const { getFailureCount } = unlock( select( uploadStore ) ); + return { + items: getItems(), + csmFailureCount: getFailureCount(), + }; + }, [] ); const tracker = useTracker(); // CSM path: originals in the upload-media queue (subsizes excluded). Memoized @@ -117,6 +125,11 @@ export default function UploadProgressSnackbar() { const dismissedRef = useRef( false ); const wasUploadingRef = useRef( false ); + // Both failure tallies only ever grow, so the failures belonging to the + // current batch are whatever has accrued since it started. Recorded when + // the batch begins, read back when the queue drains. + const failuresAtStartRef = useRef( 0 ); + // Timeout that removes the completion-state (checkmark) notice after a // brief display. Held so a new upload can cancel it. const completionTimeoutRef = useRef( null ); @@ -130,9 +143,11 @@ export default function UploadProgressSnackbar() { useEffect( () => { const isUploading = remaining > 0; + const failures = csmFailureCount + getTrackedFailureCount(); if ( isUploading && ! wasUploadingRef.current ) { dismissedRef.current = false; + failuresAtStartRef.current = failures; speak( __( 'Media upload started' ), 'polite' ); // A new batch started during the completion display: cancel the // pending dismissal so the snackbar transitions straight back @@ -144,28 +159,61 @@ export default function UploadProgressSnackbar() { peakRef.current = 0; } } else if ( ! isUploading && wasUploadingRef.current ) { - speak( __( 'Media upload complete' ), 'polite' ); - - if ( ! dismissedRef.current ) { - createNotice( 'info', __( 'Upload complete' ), { - id: NOTICE_ID, - type: 'snackbar', - isDismissible: false, - explicitDismiss: false, - speak: false, - icon: UPLOAD_DONE, - onDismiss: () => { - dismissedRef.current = true; - }, - } ); - - completionTimeoutRef.current = setTimeout( () => { - removeNotice( NOTICE_ID ); - completionTimeoutRef.current = null; - peakRef.current = 0; - }, COMPLETION_DISPLAY_MS ); - } else { + // The queue drains the same way whether an item uploaded or + // failed, so the batch is only "complete" for the files that + // actually made it (see gutenberg#81132). + const total = peakRef.current; + const failed = Math.min( + total, + failures - failuresAtStartRef.current + ); + const uploaded = total - failed; + + if ( uploaded === 0 ) { + // Nothing uploaded: the per-file errors already say so, and a + // completion snackbar next to them would contradict them. Take + // down the progress notice rather than replacing it. + speak( __( 'Media upload failed' ), 'polite' ); + removeNotice( NOTICE_ID ); peakRef.current = 0; + } else { + const isFullSuccess = failed === 0; + const content = isFullSuccess + ? __( 'Upload complete' ) + : sprintf( + /* translators: 1: number of files uploaded, 2: number of files in the batch. */ + __( 'Uploaded %1$d of %2$d' ), + uploaded, + total + ); + + speak( + isFullSuccess ? __( 'Media upload complete' ) : content, + 'polite' + ); + + if ( ! dismissedRef.current ) { + createNotice( 'info', content, { + id: NOTICE_ID, + type: 'snackbar', + isDismissible: false, + explicitDismiss: false, + speak: false, + // No checkmark on a partial batch — some of it failed. + icon: isFullSuccess ? UPLOAD_DONE : undefined, + onDismiss: () => { + dismissedRef.current = true; + }, + } ); + + completionTimeoutRef.current = setTimeout( () => { + removeNotice( NOTICE_ID ); + completionTimeoutRef.current = null; + peakRef.current = 0; + }, COMPLETION_DISPLAY_MS ); + } else { + peakRef.current = 0; + } } } @@ -220,6 +268,7 @@ export default function UploadProgressSnackbar() { remaining, sessionTotal, csmOriginals, + csmFailureCount, tracker, createNotice, removeNotice, diff --git a/packages/editor/src/components/upload-progress-snackbar/test/index.js b/packages/editor/src/components/upload-progress-snackbar/test/index.js index 8cf9f752e1fa15..e824010f0a8436 100644 --- a/packages/editor/src/components/upload-progress-snackbar/test/index.js +++ b/packages/editor/src/components/upload-progress-snackbar/test/index.js @@ -7,12 +7,14 @@ import { render, act } from '@testing-library/react'; * WordPress dependencies */ import { useSelect } from '@wordpress/data'; +import { speak } from '@wordpress/a11y'; /** * Internal dependencies */ import UploadProgressSnackbar from '../'; -import { addFiles, advance, reset } from '../tracker'; +import { lock } from '../../../lock-unlock'; +import { addFiles, advance, advanceFailed, reset } from '../tracker'; jest.mock( '@wordpress/data/src/components/use-select', () => { const mock = jest.fn(); @@ -36,12 +38,16 @@ jest.mock( '@wordpress/a11y', () => ( { speak: jest.fn(), } ) ); -function mockQueue( items ) { +function mockQueue( items, failureCount = 0 ) { + const storeSelect = { + getItems: () => items, + isUploading: () => items.length > 0, + }; + // `getFailureCount` is a private selector, so the component reads it + // through `unlock`. + lock( storeSelect, { getFailureCount: () => failureCount } ); useSelect.mockImplementation( ( mapSelect ) => - mapSelect( () => ( { - getItems: () => items, - isUploading: () => items.length > 0, - } ) ) + mapSelect( () => storeSelect ) ); } @@ -164,6 +170,113 @@ describe( 'UploadProgressSnackbar', () => { } } ); + it( 'announces completion when every upload succeeded', () => { + mockQueue( [] ); + act( () => { + addFiles( [ 'a.jpg' ] ); + } ); + render( ); + speak.mockClear(); + + act( () => { + advance( 1 ); + } ); + + expect( speak ).toHaveBeenCalledWith( + 'Media upload complete', + 'polite' + ); + } ); + + it( 'does not show a completion notice when every tracked upload failed', () => { + mockQueue( [] ); + act( () => { + addFiles( [ 'broken.heic' ] ); + } ); + render( ); + mockCreateNotice.mockClear(); + + act( () => { + advanceFailed( 1 ); + } ); + + expect( mockCreateNotice ).not.toHaveBeenCalled(); + // The in-progress notice has to come down, since nothing replaces it. + expect( mockRemoveNotice ).toHaveBeenCalledWith( 'upload-progress' ); + // Claiming completion here is the bug this fixes, for screen readers too. + expect( speak ).toHaveBeenCalledWith( 'Media upload failed', 'polite' ); + expect( speak ).not.toHaveBeenCalledWith( + 'Media upload complete', + 'polite' + ); + } ); + + it( 'reports how many files uploaded when only some of them failed', () => { + mockQueue( [] ); + act( () => { + addFiles( [ 'a.jpg', 'broken.heic', 'c.jpg' ] ); + } ); + render( ); + mockCreateNotice.mockClear(); + + act( () => { + advance( 1 ); + } ); + act( () => { + advanceFailed( 1 ); + } ); + mockCreateNotice.mockClear(); + act( () => { + advance( 1 ); + } ); + + expect( mockCreateNotice ).toHaveBeenCalledWith( + 'info', + 'Uploaded 2 of 3', + expect.objectContaining( { + id: 'upload-progress', + // A checkmark would overstate a batch that partly failed. + icon: undefined, + } ) + ); + // The announcement has to say the same thing the notice says. + expect( speak ).toHaveBeenCalledWith( 'Uploaded 2 of 3', 'polite' ); + expect( speak ).not.toHaveBeenCalledWith( + 'Media upload complete', + 'polite' + ); + } ); + + it( 'does not show a completion notice when every CSM upload failed', () => { + mockQueue( [ makeItem( '1', 'broken.heic' ) ] ); + const { rerender } = render( ); + mockCreateNotice.mockClear(); + + // The queue empties on failure exactly as it does on success — the + // failure tally is the only thing that tells them apart. + mockQueue( [], 1 ); + rerender( ); + + expect( mockCreateNotice ).not.toHaveBeenCalled(); + expect( mockRemoveNotice ).toHaveBeenCalledWith( 'upload-progress' ); + } ); + + it( 'ignores failures from earlier batches', () => { + // A previous batch already failed once, so the tally starts above zero. + mockQueue( [ makeItem( '1', 'photo.jpg' ) ], 1 ); + const { rerender } = render( ); + mockCreateNotice.mockClear(); + + mockQueue( [], 1 ); + rerender( ); + + expect( mockCreateNotice ).toHaveBeenCalledWith( + 'info', + 'Upload complete', + expect.objectContaining( { id: 'upload-progress' } ) + ); + } ); + it( 'middle-truncates a long filename while keeping the extension', () => { const longName = 'a-really-long-uuid-like-filename-1234567890-abcdefghij.jpg'; diff --git a/packages/editor/src/components/upload-progress-snackbar/tracker.js b/packages/editor/src/components/upload-progress-snackbar/tracker.js index ad90619c0c900b..9f261566fa8269 100644 --- a/packages/editor/src/components/upload-progress-snackbar/tracker.js +++ b/packages/editor/src/components/upload-progress-snackbar/tracker.js @@ -16,9 +16,24 @@ import { useSyncExternalStore } from '@wordpress/element'; * * The tracker holds at most one "session" at a time — if a new batch starts * while one is in progress, its files are appended to the existing session. + * + * Files that failed are tallied separately from the session state, which is + * discarded once the session ends. See `getFailureCount`. */ let state = null; + +/** + * Running tally of files that finished by failing, counted since the editor + * loaded rather than per session. Deliberately not a sibling of `total`, + * `completed` and `pending` inside `state`: `state` is cleared the moment a + * session finishes, which is exactly when a consumer needs to know whether + * anything actually uploaded. It only ever grows; see `getFailureCount`. + * + * @type {number} + */ +let cumulativeFailures = 0; + const listeners = new Set(); function notify() { @@ -46,14 +61,38 @@ export function addFiles( filenames ) { } /** - * Advances the tracker by a number of finished files (success or error). + * Advances the tracker by a number of successfully uploaded files. * * @param {number} count Number of files that finished since the last call. */ export function advance( count ) { + finish( count, 0 ); +} + +/** + * Advances the tracker by a number of files that finished by failing. + * + * Failed files still count as finished - they leave the queue like any other - + * but they are also tallied so consumers can tell an empty queue that uploaded + * everything from one that uploaded nothing. + * + * @param {number} [count] Number of files that failed since the last call. + */ +export function advanceFailed( count = 1 ) { + finish( count, count ); +} + +/** + * Advances the tracker by a number of finished files. + * + * @param {number} count Number of files that finished since the last call. + * @param {number} failed How many of those files failed. + */ +function finish( count, failed ) { if ( ! state || count <= 0 ) { return; } + cumulativeFailures += failed; const completed = Math.min( state.total, state.completed + count ); const pending = state.pending.slice( count ); if ( completed >= state.total ) { @@ -72,6 +111,7 @@ export function advance( count ) { * `advance` clears the state on its own once every file in a batch finishes. */ export function reset() { + cumulativeFailures = 0; if ( state === null ) { return; } @@ -79,6 +119,20 @@ export function reset() { notify(); } +/** + * Returns how many tracked files have failed. + * + * Read imperatively rather than through `useTracker`, because the state the + * hook exposes is gone by the time a session ends. The tally only ever grows: + * to learn how many files failed within one session, read it when the session + * starts and subtract that from its later value. + * + * @return {number} Number of failed uploads since the editor loaded. + */ +export function getFailureCount() { + return cumulativeFailures; +} + /** * Returns the current tracker state, or `null` when idle. * diff --git a/packages/editor/src/utils/media-finalize/index.js b/packages/editor/src/utils/media-finalize/index.js index 38afe9af8e321e..cd2c24334991d7 100644 --- a/packages/editor/src/utils/media-finalize/index.js +++ b/packages/editor/src/utils/media-finalize/index.js @@ -3,6 +3,7 @@ */ import apiFetch from '@wordpress/api-fetch'; import { transformAttachment } from '@wordpress/media-utils'; +import { receiveFinalizedAttachment } from '../media-upload/finalized-attachments'; export default async function mediaFinalize( id, subSizes = [] ) { const response = await apiFetch( { @@ -11,7 +12,19 @@ export default async function mediaFinalize( id, subSizes = [] ) { data: { sub_sizes: subSizes }, } ); + if ( ! response ) { + return undefined; + } + + // The finalize response is the attachment as prepared after its sub-size + // metadata was written, so it is the record the editor should be holding. + // Storing it here means the upload does not have to fetch the attachment + // again to pick the sizes up - a refetch that can be served the pre-finalize + // record either by an out-of-order response or by a host cache keyed on the + // request URL. See https://github.com/WordPress/gutenberg/issues/81844. + receiveFinalizedAttachment( response ); + // Returning the post-finalize attachment lets callers refresh the block // URL (via onChange) so it points at the scaled file and srcset matches. - return response ? transformAttachment( response ) : undefined; + return transformAttachment( response ); } diff --git a/packages/editor/src/utils/media-finalize/test/index.js b/packages/editor/src/utils/media-finalize/test/index.js index 8ed539e352735e..8ef62fa0de6a33 100644 --- a/packages/editor/src/utils/media-finalize/test/index.js +++ b/packages/editor/src/utils/media-finalize/test/index.js @@ -7,9 +7,14 @@ import apiFetch from '@wordpress/api-fetch'; * Internal dependencies */ import mediaFinalize from '..'; +import { receiveFinalizedAttachment } from '../../media-upload/finalized-attachments'; jest.mock( '@wordpress/api-fetch', () => jest.fn() ); +jest.mock( '../../media-upload/finalized-attachments', () => ( { + receiveFinalizedAttachment: jest.fn(), +} ) ); + const mockRestAttachment = { id: 123, alt_text: '', @@ -85,4 +90,30 @@ describe( 'mediaFinalize', () => { await expect( mediaFinalize( 456 ) ).rejects.toThrow( 'Network error' ); } ); + + it( 'should store the finalize response as the attachment record', async () => { + // The finalize response is the attachment as prepared after its + // sub-size metadata was written, so it is what the editor should hold. + // Receiving it here is what removes the need to fetch the attachment + // again just to pick the generated sizes up. + const finalized = { + ...mockRestAttachment, + media_details: { sizes: { thumbnail: {} } }, + }; + apiFetch.mockResolvedValue( finalized ); + + await mediaFinalize( 123 ); + + // The untransformed record: core-data holds REST records, and + // transformAttachment renames fields for block consumers. + expect( receiveFinalizedAttachment ).toHaveBeenCalledWith( finalized ); + } ); + + it( 'should store nothing when the response is empty', async () => { + apiFetch.mockResolvedValue( undefined ); + + await mediaFinalize( 123 ); + + expect( receiveFinalizedAttachment ).not.toHaveBeenCalled(); + } ); } ); diff --git a/packages/editor/src/utils/media-upload/finalized-attachments.ts b/packages/editor/src/utils/media-upload/finalized-attachments.ts new file mode 100644 index 00000000000000..a42b8027d884e8 --- /dev/null +++ b/packages/editor/src/utils/media-upload/finalized-attachments.ts @@ -0,0 +1,70 @@ +import { dispatch } from '@wordpress/data'; +import { store as coreDataStore } from '@wordpress/core-data'; +import type { Attachment } from '@wordpress/core-data'; + +/** + * The queries the editor resolves an attachment record with. + * + * Blocks read the record either as `getEntityRecord( 'postType', 'attachment', + * id, { context: 'view' } )` or without a query at all, and resolution state is + * keyed on the exact arguments, so both have to be addressed. + */ +const ATTACHMENT_QUERIES = [ { context: 'view' }, undefined ]; + +/** + * IDs of attachments whose finalized record has already been received. + * + * An entry is consumed by `mediaUploadOnSuccess`, which then has nothing left + * to refetch for that attachment. + */ +const finalized = new Set< number >(); + +/** + * Stores the attachment record returned by the `finalize` endpoint. + * + * `finalize` is the server's commit point for a client-side upload: it writes + * the sub-size metadata collected from the sideloads and returns the attachment + * as prepared *after* that write, so its `media_details.sizes` is the finished + * set of generated sizes. Storing that response is what makes the editor's copy + * of the record current, so `mediaUploadOnSuccess` has nothing left to + * invalidate for it. + * + * The record is received under both queries the editor resolves attachments + * with. It is prepared in the `edit` context, which for an attachment carries + * every field the `view` context does plus a few edit-only ones, so it also + * satisfies a consumer reading the `view` record. + * + * @param record Attachment record as returned by the `finalize` endpoint. + */ +export function receiveFinalizedAttachment( + record: Partial< Attachment > +): void { + if ( ! record?.id ) { + return; + } + + const { receiveEntityRecords } = dispatch( coreDataStore ); + + for ( const query of ATTACHMENT_QUERIES ) { + /* + * Passing the record on its own rather than in an array keeps this a + * single-item receive, which leaves list queries (the media library, + * the inserter's Media tab) untouched. + */ + receiveEntityRecords( 'postType', 'attachment', record, query ); + } + + finalized.add( record.id ); +} + +/** + * Reports whether a finalized record has been received for an attachment, + * forgetting it in the process. + * + * @param id Attachment ID. + * + * @return Whether the editor already holds the finalized record. + */ +export function consumeFinalizedAttachment( id: number ): boolean { + return finalized.delete( id ); +} diff --git a/packages/editor/src/utils/media-upload/index.js b/packages/editor/src/utils/media-upload/index.js index 1ab0a69be0d2f7..c315b859e16b35 100644 --- a/packages/editor/src/utils/media-upload/index.js +++ b/packages/editor/src/utils/media-upload/index.js @@ -9,6 +9,7 @@ import { v4 as uuid } from 'uuid'; import { select, dispatch } from '@wordpress/data'; import { store as coreDataStore } from '@wordpress/core-data'; import { uploadMedia } from '@wordpress/media-utils'; +import { __ } from '@wordpress/i18n'; /** * Internal dependencies @@ -17,6 +18,7 @@ import { store as editorStore } from '../../store'; import { addFiles as trackStart, advance as trackAdvance, + advanceFailed as trackFailure, } from '../../components/upload-progress-snackbar/tracker'; const noop = () => {}; @@ -50,6 +52,17 @@ export default function mediaUpload( { multiple = true, isTransportOnly = false, } ) { + // A caller that takes a single file refuses a multi-file batch outright. + // `uploadMedia()` makes the same check, but reports the refusal as one + // error for the whole batch, which would leave every other file in it + // counted as still in flight by the progress tracker below - and the + // tracker folds every later upload into that stuck session + // (see gutenberg#82041). Refusing here keeps the batch out of it. + if ( ! multiple && filesList.length > 1 ) { + onError( __( 'Only one file can be used here.' ) ); + return; + } + const { receiveEntityRecords } = dispatch( coreDataStore ); const { getCurrentPost, getEditorSettings } = select( editorStore ); const { @@ -140,8 +153,10 @@ export default function mediaUpload( { onError: ( { message } ) => { if ( ! isTransportOnly ) { clearSaveLock(); - // Failed files still count as "done" for the snackbar. - trackAdvance( 1 ); + // Failed files still count as "done" for the snackbar, but are + // tallied so it doesn't report a batch that failed outright as + // an upload that completed. + trackFailure( 1 ); } onError( message ); }, diff --git a/packages/editor/src/utils/media-upload/on-success.js b/packages/editor/src/utils/media-upload/on-success.js index 4ba9677e7dfc7a..b5549a718220c7 100644 --- a/packages/editor/src/utils/media-upload/on-success.js +++ b/packages/editor/src/utils/media-upload/on-success.js @@ -3,32 +3,40 @@ */ import { dispatch } from '@wordpress/data'; import { store as coreDataStore } from '@wordpress/core-data'; +import { consumeFinalizedAttachment } from './finalized-attachments'; /** - * Invalidates core-data entity records for uploaded attachments so that - * blocks re-fetch updated data (e.g. `media_details.sizes` after - * thumbnails have been generated via client-side media processing). + * Brings core-data's copy of an uploaded attachment up to date, so that blocks + * see the data the server produced after the upload itself (e.g. + * `media_details.sizes`, once the sub-sizes have been generated). + * + * A client-side upload ends with a `finalize` request whose response already + * carries the finished record, and that response has been stored by then, so + * there is nothing left to fetch. An upload the server processed on its own + * never goes through `finalize`, so its record is invalidated and refetched. * * @param {Object[]} attachments Array of attachment objects from the upload queue. */ export default function mediaUploadOnSuccess( attachments ) { const { invalidateResolution } = dispatch( coreDataStore ); for ( const attachment of attachments ) { - if ( attachment.id ) { - // Invalidate with and without the query argument, since - // resolution keys must exactly match the args used by - // each consumer's getEntityRecord() call. - invalidateResolution( 'getEntityRecord', [ - 'postType', - 'attachment', - attachment.id, - { context: 'view' }, - ] ); - invalidateResolution( 'getEntityRecord', [ - 'postType', - 'attachment', - attachment.id, - ] ); + if ( ! attachment.id || consumeFinalizedAttachment( attachment.id ) ) { + continue; } + + // Invalidate with and without the query argument, since + // resolution keys must exactly match the args used by + // each consumer's getEntityRecord() call. + invalidateResolution( 'getEntityRecord', [ + 'postType', + 'attachment', + attachment.id, + { context: 'view' }, + ] ); + invalidateResolution( 'getEntityRecord', [ + 'postType', + 'attachment', + attachment.id, + ] ); } } diff --git a/packages/editor/src/utils/media-upload/test/finalized-attachments.ts b/packages/editor/src/utils/media-upload/test/finalized-attachments.ts new file mode 100644 index 00000000000000..59abcbaffc3c1b --- /dev/null +++ b/packages/editor/src/utils/media-upload/test/finalized-attachments.ts @@ -0,0 +1,108 @@ +import type { Attachment } from '@wordpress/core-data'; +import { dispatch } from '@wordpress/data'; +import { + receiveFinalizedAttachment, + consumeFinalizedAttachment, +} from '../finalized-attachments'; + +jest.mock( '@wordpress/data', () => ( { + dispatch: jest.fn(), +} ) ); + +jest.mock( '@wordpress/core-data', () => ( { + store: 'core', +} ) ); + +const receiveEntityRecords = jest.fn(); + +describe( 'receiveFinalizedAttachment', () => { + beforeEach( () => { + jest.clearAllMocks(); + ( dispatch as unknown as jest.Mock ).mockReturnValue( { + receiveEntityRecords, + } ); + } ); + + afterEach( () => { + // Leave no entry behind for the next test. + consumeFinalizedAttachment( 123 ); + } ); + + it( 'receives the record under both queries the editor resolves attachments with', () => { + const record: Partial< Attachment > = { + id: 123, + media_details: { + sizes: { + full: { + file: 'image.jpg', + width: 1024, + height: 768, + mime_type: 'image/jpeg', + source_url: 'https://example.com/image.jpg', + }, + }, + }, + }; + + receiveFinalizedAttachment( record ); + + expect( receiveEntityRecords ).toHaveBeenCalledTimes( 2 ); + expect( receiveEntityRecords ).toHaveBeenCalledWith( + 'postType', + 'attachment', + record, + { context: 'view' } + ); + expect( receiveEntityRecords ).toHaveBeenCalledWith( + 'postType', + 'attachment', + record, + undefined + ); + } ); + + it( 'receives the record on its own, so list queries are left alone', () => { + // core-data only merges an item list into a query's results when the + // received items are an array; a lone record must stay a lone record. + receiveFinalizedAttachment( { id: 123 } ); + + for ( const call of receiveEntityRecords.mock.calls ) { + expect( Array.isArray( call[ 2 ] ) ).toBe( false ); + } + } ); + + it( 'ignores a response without an ID', () => { + receiveFinalizedAttachment( {} ); + receiveFinalizedAttachment( + undefined as unknown as Partial< Attachment > + ); + + expect( receiveEntityRecords ).not.toHaveBeenCalled(); + } ); +} ); + +describe( 'consumeFinalizedAttachment', () => { + beforeEach( () => { + jest.clearAllMocks(); + ( dispatch as unknown as jest.Mock ).mockReturnValue( { + receiveEntityRecords, + } ); + } ); + + it( 'reports an attachment whose finalized record was received', () => { + receiveFinalizedAttachment( { id: 456 } ); + + expect( consumeFinalizedAttachment( 456 ) ).toBe( true ); + } ); + + it( 'reports an attachment that was never finalized', () => { + expect( consumeFinalizedAttachment( 789 ) ).toBe( false ); + } ); + + it( 'forgets the attachment once consumed, so a later upload is not skipped', () => { + receiveFinalizedAttachment( { id: 456 } ); + + expect( consumeFinalizedAttachment( 456 ) ).toBe( true ); + expect( consumeFinalizedAttachment( 456 ) ).toBe( false ); + } ); +} ); diff --git a/packages/editor/src/utils/media-upload/test/index.js b/packages/editor/src/utils/media-upload/test/index.js new file mode 100644 index 00000000000000..9f60a61bed3052 --- /dev/null +++ b/packages/editor/src/utils/media-upload/test/index.js @@ -0,0 +1,103 @@ +import { select, dispatch } from '@wordpress/data'; +import { uploadMedia } from '@wordpress/media-utils'; +import mediaUpload from '../'; +import { + addFiles, + getState, + reset, +} from '../../../components/upload-progress-snackbar/tracker'; + +jest.mock( '@wordpress/media-utils', () => ( { + uploadMedia: jest.fn(), +} ) ); + +// The module under test only reaches the stores through `select`/`dispatch`, +// so they stand in for the whole data layer here - importing the real editor +// store pulls in most of the editor. +jest.mock( '@wordpress/data', () => ( { + select: jest.fn(), + dispatch: jest.fn(), +} ) ); +jest.mock( '@wordpress/core-data', () => ( { store: 'core' } ) ); +jest.mock( '../../../store', () => ( { store: 'core/editor' } ) ); + +function file( name ) { + return new window.File( [ 'x' ], name, { type: 'image/png' } ); +} + +describe( 'mediaUpload', () => { + beforeEach( () => { + reset(); + uploadMedia.mockClear(); + select.mockReturnValue( { + getCurrentPost: () => ( { id: 1 } ), + getEditorSettings: () => ( {} ), + } ); + dispatch.mockReturnValue( { + receiveEntityRecords: jest.fn(), + lockPostSaving: jest.fn(), + unlockPostSaving: jest.fn(), + lockPostAutosaving: jest.fn(), + unlockPostAutosaving: jest.fn(), + } ); + } ); + + it( 'uploads a batch when the caller takes more than one file', () => { + mediaUpload( { + filesList: [ file( 'a.png' ), file( 'b.png' ) ], + multiple: true, + } ); + + expect( uploadMedia ).toHaveBeenCalled(); + expect( getState() ).toEqual( + expect.objectContaining( { total: 2, completed: 0 } ) + ); + } ); + + it( 'uploads a single file for a caller that only takes one', () => { + mediaUpload( { + filesList: [ file( 'a.png' ) ], + multiple: false, + } ); + + expect( uploadMedia ).toHaveBeenCalled(); + expect( getState() ).toEqual( + expect.objectContaining( { total: 1, completed: 0 } ) + ); + } ); + + it( 'refuses a batch of more than one file for a caller that only takes one', () => { + const onError = jest.fn(); + + mediaUpload( { + filesList: [ file( 'a.png' ), file( 'b.png' ), file( 'c.png' ) ], + multiple: false, + onError, + } ); + + expect( onError ).toHaveBeenCalledTimes( 1 ); + expect( onError ).toHaveBeenCalledWith( + 'Only one file can be used here.' + ); + expect( uploadMedia ).not.toHaveBeenCalled(); + } ); + + it( 'keeps a refused batch out of the upload progress tracker', () => { + // `uploadMedia()` reports the refusal as a single error, so registering + // the batch here would strand the rest of it as "uploading" forever - + // and the tracker folds every later upload into that stuck session + // (see gutenberg#82041). + mediaUpload( { + filesList: [ file( 'a.png' ), file( 'b.png' ), file( 'c.png' ) ], + multiple: false, + } ); + + expect( getState() ).toBeNull(); + + // The next upload therefore starts a session of its own. + addFiles( [ 'later.png' ] ); + expect( getState() ).toEqual( + expect.objectContaining( { total: 1, completed: 0 } ) + ); + } ); +} ); diff --git a/packages/editor/src/utils/media-upload/test/on-success.ts b/packages/editor/src/utils/media-upload/test/on-success.ts new file mode 100644 index 00000000000000..44b8da6405cdfd --- /dev/null +++ b/packages/editor/src/utils/media-upload/test/on-success.ts @@ -0,0 +1,66 @@ +import { dispatch } from '@wordpress/data'; +import mediaUploadOnSuccess from '../on-success'; +import { receiveFinalizedAttachment } from '../finalized-attachments'; + +jest.mock( '@wordpress/data', () => ( { + dispatch: jest.fn(), +} ) ); + +jest.mock( '@wordpress/core-data', () => ( { + store: 'core', +} ) ); + +const invalidateResolution = jest.fn(); +const receiveEntityRecords = jest.fn(); + +describe( 'mediaUploadOnSuccess', () => { + beforeEach( () => { + jest.clearAllMocks(); + ( dispatch as unknown as jest.Mock ).mockReturnValue( { + invalidateResolution, + receiveEntityRecords, + } ); + } ); + + it( 'invalidates both attachment resolutions for a server-processed upload', () => { + mediaUploadOnSuccess( [ { id: 42 } ] ); + + expect( invalidateResolution ).toHaveBeenCalledTimes( 2 ); + expect( invalidateResolution ).toHaveBeenCalledWith( + 'getEntityRecord', + [ 'postType', 'attachment', 42, { context: 'view' } ] + ); + expect( invalidateResolution ).toHaveBeenCalledWith( + 'getEntityRecord', + [ 'postType', 'attachment', 42 ] + ); + } ); + + it( 'does not refetch an attachment whose finalized record was already received', () => { + // A client-side upload ends with `finalize`, whose response is stored + // as the attachment record. Refetching it would ask the server (or a + // cache in front of it) for data the editor already has. + receiveFinalizedAttachment( { id: 42 } ); + + mediaUploadOnSuccess( [ { id: 42 } ] ); + + expect( invalidateResolution ).not.toHaveBeenCalled(); + } ); + + it( 'refetches only the attachments of a batch that did not finalize', () => { + receiveFinalizedAttachment( { id: 42 } ); + + mediaUploadOnSuccess( [ { id: 42 }, { id: 43 } ] ); + + expect( invalidateResolution ).toHaveBeenCalledTimes( 2 ); + for ( const call of invalidateResolution.mock.calls ) { + expect( call[ 1 ][ 2 ] ).toBe( 43 ); + } + } ); + + it( 'ignores attachments without an ID', () => { + mediaUploadOnSuccess( [ {} ] ); + + expect( invalidateResolution ).not.toHaveBeenCalled(); + } ); +} ); diff --git a/packages/env/CHANGELOG.md b/packages/env/CHANGELOG.md index 5708491cc1b22c..cddab59a901184 100644 --- a/packages/env/CHANGELOG.md +++ b/packages/env/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Bug Fixes + +- Point the apt sources of the bullseye-based WordPress images (PHP 7.4 and 8.0) at `archive.debian.org`, so building them no longer fails now that Debian 11 has reached end-of-life and left the regular mirrors ([#82478](https://github.com/WordPress/gutenberg/pull/82478)). + ## 11.11.0 (2026-07-14) ## 11.10.0 (2026-07-01) diff --git a/packages/env/lib/runtime/docker/docker-config.js b/packages/env/lib/runtime/docker/docker-config.js index 0d9e28d4453ead..383b27568a36e4 100644 --- a/packages/env/lib/runtime/docker/docker-config.js +++ b/packages/env/lib/runtime/docker/docker-config.js @@ -102,6 +102,13 @@ RUN sed -i 's|deb.debian.org/debian buster|archive.debian.org/debian buster|g' / RUN sed -i 's|security.debian.org/debian-security buster/updates|archive.debian.org/debian-security buster/updates|g' /etc/apt/sources.list RUN sed -i '/buster-updates/d' /etc/apt/sources.list +# bullseye (https://www.debian.org/News/2026/20260831) +# The security suite is not on archive.debian.org yet, so it is dropped rather +# than rewritten. +RUN sed -i 's|deb.debian.org/debian bullseye|archive.debian.org/debian bullseye|g' /etc/apt/sources.list +RUN sed -i '/bullseye-security/d' /etc/apt/sources.list +RUN sed -i '/bullseye-updates/d' /etc/apt/sources.list + # Create the host's user so that we can match ownership in the container. ARG HOST_USERNAME ARG HOST_UID diff --git a/packages/media-utils/CHANGELOG.md b/packages/media-utils/CHANGELOG.md index 4f02a7a4d92375..e6a3da0687d8b6 100644 --- a/packages/media-utils/CHANGELOG.md +++ b/packages/media-utils/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Bug Fixes + +- Preserve array-valued fields in multipart form data so grouped image-size sideload requests reach the REST API as arrays ([#82353](https://github.com/WordPress/gutenberg/pull/82353)). + ### Internal - Remove the `window.__heicUploadSupport` type declaration, following the removal of the redundant flag ([#80452](https://github.com/WordPress/gutenberg/pull/80452)). diff --git a/packages/media-utils/src/utils/flatten-form-data.ts b/packages/media-utils/src/utils/flatten-form-data.ts index 594f172ef7eb2c..ed5cfd814f53e4 100644 --- a/packages/media-utils/src/utils/flatten-form-data.ts +++ b/packages/media-utils/src/utils/flatten-form-data.ts @@ -14,16 +14,16 @@ function isPlainObject( data: unknown ): data is Record< string, unknown > { /** * Recursively flatten data passed to form data, to allow using multi-level objects. * - * @param {FormData} formData Form data object. - * @param {string} key Key to amend to form data object - * @param {string|Object} data Data to be amended to form data. + * @param formData Form data object. + * @param key Key to amend to form data object. + * @param data Data to be amended to form data. */ export function flattenFormData( formData: FormData, key: string, - data: string | undefined | Record< string, string > + data: unknown ) { - if ( isPlainObject( data ) ) { + if ( Array.isArray( data ) || isPlainObject( data ) ) { for ( const [ name, value ] of Object.entries( data ) ) { flattenFormData( formData, `${ key }[${ name }]`, value ); } diff --git a/packages/media-utils/src/utils/sideload-to-server.ts b/packages/media-utils/src/utils/sideload-to-server.ts index 6baffde280b1d2..a15e66cee589ef 100644 --- a/packages/media-utils/src/utils/sideload-to-server.ts +++ b/packages/media-utils/src/utils/sideload-to-server.ts @@ -33,11 +33,7 @@ export async function sideloadToServer( const data = new FormData(); data.append( 'file', file, file.name || file.type.replace( '/', '.' ) ); for ( const [ key, value ] of Object.entries( additionalData ) ) { - flattenFormData( - data, - key, - value as string | Record< string, string > | undefined - ); + flattenFormData( data, key, value ); } return apiFetch< SubSizeData >( { diff --git a/packages/media-utils/src/utils/test/flatten-form-data.ts b/packages/media-utils/src/utils/test/flatten-form-data.ts index 458dfd26c8f6ed..5671fad0ba3c93 100644 --- a/packages/media-utils/src/utils/test/flatten-form-data.ts +++ b/packages/media-utils/src/utils/test/flatten-form-data.ts @@ -4,6 +4,20 @@ import { flattenFormData } from '../flatten-form-data'; describe( 'flattenFormData', () => { + it( 'should flatten arrays using indexed keys', () => { + const data = new FormData(); + + flattenFormData( data, 'image_size', [ + 'post-thumbnail', + 'gform-image-choice-lg', + ] ); + + expect( Array.from( data.entries() ) ).toStrictEqual( [ + [ 'image_size[0]', 'post-thumbnail' ], + [ 'image_size[1]', 'gform-image-choice-lg' ], + ] ); + } ); + it( 'should flatten nested data structure', () => { const data = new FormData(); @@ -28,11 +42,7 @@ describe( 'flattenFormData', () => { }; for ( const [ key, value ] of Object.entries( additionalData ) ) { - flattenFormData( - data, - key, - value as Parameters< typeof flattenFormData >[ 2 ] - ); + flattenFormData( data, key, value ); } const actual = Object.fromEntries( data.entries() ); diff --git a/packages/media-utils/src/utils/types.ts b/packages/media-utils/src/utils/types.ts index 1243bce329eb97..4a38b5368e6669 100644 --- a/packages/media-utils/src/utils/types.ts +++ b/packages/media-utils/src/utils/types.ts @@ -206,7 +206,7 @@ export type CreateRestAttachment = Partial< RestAttachment >; export type AdditionalData = BetterOmit< CreateRestAttachment, 'meta' >; export interface CreateSideloadFile { - image_size?: string; + image_size?: string | string[]; upload_request?: string; generate_sub_sizes?: boolean; convert_format?: boolean; @@ -214,7 +214,7 @@ export interface CreateSideloadFile { export interface SideloadAdditionalData { post: RestAttachment[ 'id' ]; - image_size?: string; + image_size?: string | string[]; } /** @@ -224,7 +224,7 @@ export interface SideloadAdditionalData { * The client accumulates these and sends them all to the finalize endpoint. */ export interface SubSizeData { - image_size: string; + image_size: string | string[]; width?: number; height?: number; file: string; diff --git a/packages/media-utils/src/utils/upload-to-server.ts b/packages/media-utils/src/utils/upload-to-server.ts index 7aa4243d5ccfd4..499e77ba7d2a20 100644 --- a/packages/media-utils/src/utils/upload-to-server.ts +++ b/packages/media-utils/src/utils/upload-to-server.ts @@ -19,11 +19,7 @@ export async function uploadToServer( const data = new FormData(); data.append( 'file', file, file.name || file.type.replace( '/', '.' ) ); for ( const [ key, value ] of Object.entries( additionalData ) ) { - flattenFormData( - data, - key, - value as string | Record< string, string > | undefined - ); + flattenFormData( data, key, value ); } return transformAttachment( diff --git a/packages/project-management-automation/CHANGELOG.md b/packages/project-management-automation/CHANGELOG.md index 5df5572dd0886a..d2c328ff3d3ff0 100644 --- a/packages/project-management-automation/CHANGELOG.md +++ b/packages/project-management-automation/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +### Breaking Changes + +- The first-time contributor welcome message is no longer posted as a comment. The action now exposes it as the `welcome-prompt` output ([#82249](https://github.com/WordPress/gutenberg/pull/82249)). +- The first-time contributor account link prompt is no longer posted as a comment. The action now exposes it as the `first-time-contributor-prompt` output, alongside `first-time-contributor-pr-number` for the pull request it belongs to, so the prompt can join the single automation comment maintained by `tools/pr-meta` ([#82249](https://github.com/WordPress/gutenberg/pull/82249)). + ## 2.51.0 (2026-07-14) ### Bug Fixes diff --git a/packages/project-management-automation/README.md b/packages/project-management-automation/README.md index 3f2b4383c3a56e..af62f184a59127 100644 --- a/packages/project-management-automation/README.md +++ b/packages/project-management-automation/README.md @@ -29,7 +29,11 @@ jobs: ## Outputs -_None._ +Each is empty unless the event it belongs to produced it, so a consumer should check before using one. + +- `welcome-prompt`: Message welcoming a first-time contributor. Set when a pull request is opened by someone whose first it is. +- `first-time-contributor-prompt`: Message asking a first-time contributor to link their WordPress.org profile. Set on a push that merged their first pull request, and only when no profile was found for them. +- `first-time-contributor-pr-number`: Pull request the account link prompt belongs to. Set alongside `first-time-contributor-prompt`, since a push payload carries no pull request number of its own. ## Contributing to this package diff --git a/packages/project-management-automation/action.yml b/packages/project-management-automation/action.yml index cf7f23a2174f28..aa490910f425b6 100644 --- a/packages/project-management-automation/action.yml +++ b/packages/project-management-automation/action.yml @@ -5,6 +5,20 @@ inputs: github_token: description: Secret GitHub API token to use for making API requests. required: true +outputs: + welcome-prompt: + description: > + Message welcoming a first-time contributor. Empty unless the pull + request is their first. + first-time-contributor-prompt: + description: > + Message asking a first-time contributor to link their WordPress.org + profile. Empty unless the push merged their first pull request and + no profile was found. + first-time-contributor-pr-number: + description: > + Pull request the prompt belongs to. A push payload carries no pull + request number, so a consumer needs this one. runs: using: node20 main: lib/index.js diff --git a/packages/project-management-automation/lib/tasks/first-time-contributor-account-link/index.js b/packages/project-management-automation/lib/tasks/first-time-contributor-account-link/index.js index 1a35964705b76c..06ade3dbc00f8a 100644 --- a/packages/project-management-automation/lib/tasks/first-time-contributor-account-link/index.js +++ b/packages/project-management-automation/lib/tasks/first-time-contributor-account-link/index.js @@ -1,6 +1,4 @@ -/** - * Internal dependencies - */ +const { setOutput } = require( '@actions/core' ); const debug = require( '../../debug' ); const getAssociatedPullRequest = require( '../../get-associated-pull-request' ); const hasWordPressProfile = require( '../../has-wordpress-profile' ); @@ -112,15 +110,19 @@ async function firstTimeContributorAccountLink( payload, octokit ) { } debug( - 'first-time-contributor-account-link: User not known. Adding comment to prompt for account link.' + 'first-time-contributor-account-link: User not known. Prompting for account link.' ); - await octokit.rest.issues.createComment( { - owner, - repo, - issue_number: pullRequest, - body: getPromptMessageText( author ), - } ); + /* + * The workflow posts this, so the prompt joins the single automation + * comment rather than adding one of its own. A push payload carries no + * pull request number, so the writer needs this one. + */ + setOutput( + 'first-time-contributor-prompt', + getPromptMessageText( author ) + ); + setOutput( 'first-time-contributor-pr-number', pullRequest ); } module.exports = firstTimeContributorAccountLink; diff --git a/packages/project-management-automation/lib/tasks/first-time-contributor-account-link/test/index.js b/packages/project-management-automation/lib/tasks/first-time-contributor-account-link/test/index.js index 7212c818ed836f..f338e9cb7706e2 100644 --- a/packages/project-management-automation/lib/tasks/first-time-contributor-account-link/test/index.js +++ b/packages/project-management-automation/lib/tasks/first-time-contributor-account-link/test/index.js @@ -1,10 +1,9 @@ -/** - * Internal dependencies - */ +import { setOutput } from '@actions/core'; import firstTimeContributorAccountLink from '../'; import hasWordPressProfile from '../../../has-wordpress-profile'; jest.mock( '../../../has-wordpress-profile', () => jest.fn() ); +jest.mock( '@actions/core', () => ( { setOutput: jest.fn() } ) ); const botUser = { data: { @@ -25,6 +24,7 @@ const humanUser = { describe( 'firstTimeContributorAccountLink', () => { beforeEach( () => { + setOutput.mockReset(); hasWordPressProfile.mockReset(); } ); @@ -145,9 +145,6 @@ describe( 'firstTimeContributorAccountLink', () => { users: { getByUsername: jest.fn( () => humanUser ), }, - issues: { - createComment: jest.fn(), - }, }, }; @@ -161,7 +158,7 @@ describe( 'firstTimeContributorAccountLink', () => { repo: 'gutenberg', author: 'ghost', } ); - expect( octokit.rest.issues.createComment ).not.toHaveBeenCalled(); + expect( setOutput ).not.toHaveBeenCalled(); } ); it( 'aborts if the request to retrieve WordPress.org user profile fails', async () => { @@ -181,9 +178,6 @@ describe( 'firstTimeContributorAccountLink', () => { users: { getByUsername: jest.fn( () => humanUser ), }, - issues: { - createComment: jest.fn(), - }, }, }; @@ -201,7 +195,7 @@ describe( 'firstTimeContributorAccountLink', () => { repo: 'gutenberg', author: 'ghost', } ); - expect( octokit.rest.issues.createComment ).not.toHaveBeenCalled(); + expect( setOutput ).not.toHaveBeenCalled(); } ); it( 'prompts the user to link their GitHub account to their WordPress.org profile', async () => { @@ -221,9 +215,6 @@ describe( 'firstTimeContributorAccountLink', () => { users: { getByUsername: jest.fn( () => humanUser ), }, - issues: { - createComment: jest.fn(), - }, }, }; @@ -239,11 +230,13 @@ describe( 'firstTimeContributorAccountLink', () => { repo: 'gutenberg', author: 'ghost', } ); - expect( octokit.rest.issues.createComment ).toHaveBeenCalledWith( { - owner: 'WordPress', - repo: 'gutenberg', - issue_number: 123, - body: expect.stringMatching( /^Congratulations/ ), - } ); + expect( setOutput ).toHaveBeenCalledWith( + 'first-time-contributor-prompt', + expect.stringMatching( /^Congratulations/ ) + ); + expect( setOutput ).toHaveBeenCalledWith( + 'first-time-contributor-pr-number', + 123 + ); } ); } ); diff --git a/packages/project-management-automation/lib/tasks/first-time-contributor-label/index.js b/packages/project-management-automation/lib/tasks/first-time-contributor-label/index.js index 09575a2efb238c..4aa12ceb2d10ae 100644 --- a/packages/project-management-automation/lib/tasks/first-time-contributor-label/index.js +++ b/packages/project-management-automation/lib/tasks/first-time-contributor-label/index.js @@ -1,6 +1,4 @@ -/** - * Internal dependencies - */ +const { setOutput } = require( '@actions/core' ); const debug = require( '../../debug' ); /** @typedef {ReturnType} GitHub */ @@ -69,20 +67,17 @@ async function firstTimeContributorLabel( payload, octokit ) { labels: [ 'First-time Contributor' ], } ); - /** - * Adds a welcome comment to the first time PR + /* + * The workflow posts this, so the welcome joins the single automation + * comment rather than adding one of its own. */ - - await octokit.rest.issues.createComment( { - owner, - repo, - issue_number: payload.pull_request.number, - body: - ':wave: Thanks for your first Pull Request and for helping build the future of Gutenberg and WordPress, @' + + setOutput( + 'welcome-prompt', + ':wave: Thanks for your first Pull Request and for helping build the future of Gutenberg and WordPress, @' + author + "! In case you missed it, we'd love to have you join us in our [Slack community](https://make.wordpress.org/chat/).\n\n" + - 'If you want to learn more about WordPress development in general, check out the [Core Handbook](https://make.wordpress.org/core/handbook/) full of helpful information.', - } ); + 'If you want to learn more about WordPress development in general, check out the [Core Handbook](https://make.wordpress.org/core/handbook/) full of helpful information.' + ); } module.exports = firstTimeContributorLabel; diff --git a/packages/project-management-automation/lib/tasks/first-time-contributor-label/test/index.js b/packages/project-management-automation/lib/tasks/first-time-contributor-label/test/index.js index 25d7873ee7b929..b97881803bf67a 100644 --- a/packages/project-management-automation/lib/tasks/first-time-contributor-label/test/index.js +++ b/packages/project-management-automation/lib/tasks/first-time-contributor-label/test/index.js @@ -1,9 +1,13 @@ -/** - * Internal dependencies - */ +import { setOutput } from '@actions/core'; import firstTimeContributorLabel from '../'; +jest.mock( '@actions/core', () => ( { setOutput: jest.fn() } ) ); + describe( 'firstTimeContributorLabel', () => { + beforeEach( () => { + setOutput.mockReset(); + } ); + const payload = { repository: { owner: { @@ -67,7 +71,6 @@ describe( 'firstTimeContributorLabel', () => { }, issues: { addLabels: jest.fn(), - createComment: jest.fn(), }, }, }; @@ -81,7 +84,7 @@ describe( 'firstTimeContributorLabel', () => { } ); expect( octokit.rest.search.commits ).not.toHaveBeenCalled(); expect( octokit.rest.issues.addLabels ).not.toHaveBeenCalled(); - expect( octokit.rest.issues.createComment ).not.toHaveBeenCalled(); + expect( setOutput ).not.toHaveBeenCalled(); } ); it( 'does nothing if the search fallback finds a previous commit', async () => { @@ -108,7 +111,6 @@ describe( 'firstTimeContributorLabel', () => { }, issues: { addLabels: jest.fn(), - createComment: jest.fn(), }, }, }; @@ -120,7 +122,7 @@ describe( 'firstTimeContributorLabel', () => { per_page: 1, } ); expect( octokit.rest.issues.addLabels ).not.toHaveBeenCalled(); - expect( octokit.rest.issues.createComment ).not.toHaveBeenCalled(); + expect( setOutput ).not.toHaveBeenCalled(); } ); it( 'adds the First Time Contributor label if neither finds a commit', async () => { @@ -143,7 +145,6 @@ describe( 'firstTimeContributorLabel', () => { }, issues: { addLabels: jest.fn(), - createComment: jest.fn(), }, }, }; @@ -161,11 +162,9 @@ describe( 'firstTimeContributorLabel', () => { issue_number: 123, labels: [ 'First-time Contributor' ], } ); - expect( octokit.rest.issues.createComment ).toHaveBeenCalledWith( { - owner: 'WordPress', - repo: 'gutenberg', - issue_number: 123, - body: expectedComment, - } ); + expect( setOutput ).toHaveBeenCalledWith( + 'welcome-prompt', + expectedComment + ); } ); } ); diff --git a/packages/report-flaky-tests/CHANGELOG.md b/packages/report-flaky-tests/CHANGELOG.md index 34c83ae9061ba8..e2eff64035773b 100644 --- a/packages/report-flaky-tests/CHANGELOG.md +++ b/packages/report-flaky-tests/CHANGELOG.md @@ -2,4 +2,14 @@ ## Unreleased +### Breaking Changes + +- Flaky tests are no longer reported to GitHub issues, and are no longer reported on pushes. The action renders the detected tests and their errors as markdown to the required `output-path` input, and `tools/pr-meta` posts it as one section of the single automation comment on the pull request. The `label` and `repo-token` inputs have been removed as a result ([#82249](https://github.com/WordPress/gutenberg/pull/82249)). +- The action now runs straight from its TypeScript sources on Node's type stripping, instead of from a bundle. Relative imports must carry their `.ts` extension, and the syntax must be erasable — no enums, namespaces, parameter properties or decorators. + +### Internal + +- Remove the unused `@actions/github` and `@octokit/webhooks-types` dependencies ([#82249](https://github.com/WordPress/gutenberg/pull/82249)). +- Split tsconfig into a build project and a default dev project so dev files are type checked without publishing their declarations. ([#81517](https://github.com/WordPress/gutenberg/pull/81517)) + -- Initial version of the package. diff --git a/packages/report-flaky-tests/README.md b/packages/report-flaky-tests/README.md index 12b5b06192147c..390dfb60362b78 100644 --- a/packages/report-flaky-tests/README.md +++ b/packages/report-flaky-tests/README.md @@ -1,6 +1,10 @@ # Report Flaky Tests -A GitHub action to report flaky E2E tests to GitHub issues. +A GitHub action to render a report of flaky E2E tests as markdown. + +It writes the report to the file named by its `output-path` input, and writes nothing when no test was flaky. Posting is the caller's job: in this repository `tools/pr-meta` puts it in the single automation comment on the pull request. + +It runs straight from the TypeScript sources in `src/`: the `node24` runtime strips the types on the fly, so there is no build step. Two constraints come with that — relative imports must carry their `.ts` extension, and the syntax must be erasable, which rules out enums, namespaces, parameter properties and decorators. **This package is still experimental and breaking changes could be introduced in future minor versions (`v0.x`). Use it at your own risks.** diff --git a/packages/report-flaky-tests/action.yml b/packages/report-flaky-tests/action.yml index 8685760a28dfe2..e8c1fae0caa690 100644 --- a/packages/report-flaky-tests/action.yml +++ b/packages/report-flaky-tests/action.yml @@ -1,17 +1,17 @@ name: 'Report flaky tests' -description: 'Report flaky tests to GitHub issues' +description: 'Renders a report of flaky E2E tests as markdown' inputs: - repo-token: - description: 'GitHub token' - required: true - label: - description: 'The flaky-test label name' - required: true - default: 'flaky-test' artifact-path: description: 'The path of the downloaded artifact' required: true default: 'flaky-tests' + output-path: + description: 'File to write the rendered markdown to. Nothing is written when no test was flaky.' + required: true runs: + # Node 24 strips the types on the fly, so the action runs straight from its + # TypeScript sources without a build step. This requires every relative + # import to carry its `.ts` extension, and the syntax to be erasable, which + # rules out enums, namespaces, parameter properties and decorators. using: 'node24' - main: 'build/index.cjs' + main: 'src/index.ts' diff --git a/packages/report-flaky-tests/package.json b/packages/report-flaky-tests/package.json index 974cd8fa0735ed..fea63374634f70 100644 --- a/packages/report-flaky-tests/package.json +++ b/packages/report-flaky-tests/package.json @@ -2,7 +2,7 @@ "name": "@wordpress/report-flaky-tests", "version": "0.0.0", "private": true, - "description": "A GitHub action to report flaky E2E tests to GitHub issues.", + "description": "A GitHub action to render a report of flaky E2E tests as markdown.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", "keywords": [ @@ -23,26 +23,25 @@ "node": ">=18.12.0", "npm": ">=8.19.2" }, - "main": "build/index.mjs", + "type": "module", + "main": "src/index.ts", "exports": { ".": { "types": "./build-types/index.d.ts", - "require": "./build/index.mjs" + "import": "./src/index.ts" }, "./package.json": "./package.json" }, "types": "build-types", "dependencies": { "@actions/core": "^1.9.1", - "@actions/github": "^5.0.1", "@jest/test-result": "^29.6.2", - "@octokit/types": "^6.34.0", - "@octokit/webhooks-types": "^5.8.0", "@playwright/test": "^1.61.1", "jest-message-util": "^29.6.2" }, "devDependencies": { - "@types/jest": "^29.5.14" + "@types/jest": "^29.5.14", + "@types/node": "^20.19.39" }, "publishConfig": { "access": "public" diff --git a/packages/report-flaky-tests/src/__tests__/__snapshots__/markdown.test.ts.snap b/packages/report-flaky-tests/src/__tests__/__snapshots__/markdown.test.ts.snap index c7c4e329955e11..509504bf315538 100644 --- a/packages/report-flaky-tests/src/__tests__/__snapshots__/markdown.test.ts.snap +++ b/packages/report-flaky-tests/src/__tests__/__snapshots__/markdown.test.ts.snap @@ -68,36 +68,3 @@ exports[`formatTestErrorMessage should format test error message for jest-circus at runMicrotasks () " `; - -exports[`parseIssueBody should parse issue body 1`] = ` -" -**Flaky test detected. This is an auto-generated issue by GitHub Actions. Please do NOT edit this manually.** - -## Test title -Test title - -## Test path -\`test/e2e/specs/test-title.spec.js\` - -## Errors - - Test passed after 1 failed attempt on trunk. -
- - Test passed after 2 failed attempts on try/some-branch. - - -\`\`\` -Error: - Oops... - Oops again -\`\`\` -
- -Some broken log. -Oops. - - - -" -`; diff --git a/packages/report-flaky-tests/src/__tests__/__snapshots__/run.test.ts.snap b/packages/report-flaky-tests/src/__tests__/__snapshots__/run.test.ts.snap index 8920f757c0efc6..02a586483eca7f 100644 --- a/packages/report-flaky-tests/src/__tests__/__snapshots__/run.test.ts.snap +++ b/packages/report-flaky-tests/src/__tests__/__snapshots__/run.test.ts.snap @@ -1,93 +1,10 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`Report flaky tests should report flaky tests to issue on pull request: Updated existing flaky issue 1`] = ` -" -**Flaky test detected. This is an auto-generated issue by GitHub Actions. Please do NOT edit this manually.** +exports[`Report flaky tests should write the report 1`] = ` +"Some tests passed with failed attempts. The failures may not be related to this commit but are still reported for visibility. See [the documentation](https://github.com/WordPress/gutenberg/blob/HEAD/docs/contributors/code/testing-overview.md#flaky-tests) for more information. -## Test title -should copy only partial selection of text blocks - -## Test path -\`/test/e2e/specs/editor/various/copy-cut-paste.spec.js\` - -## Errors - - Test passed after 1 failed attempt on trunk. -
- Test passed after 1 failed attempt on update/post-featured-image-component. -
- Test passed after 1 failed attempt on add/wp-env/wp-env-core-env. -
- Test passed after 1 failed attempt on fix/e2e-test. -
- Test passed after 1 failed attempt on trunk. -
- Test passed after 1 failed attempt on fix/duotone-site-editor. -
- Test passed after 1 failed attempt on fix/playwright-snapshots. -
- Test passed after 1 failed attempt on trunk. -
- Test passed after 1 failed attempt on trunk. -
- Test passed after 1 failed attempt on rnmobile/feature/drag-and-drop. -
- Test passed after 1 failed attempt on rnmobile/feature/drag-and-drop-prevent-text-focus-on-long-press. -
- Test passed after 1 failed attempt on add/section-concept. -
- Test passed after 1 failed attempt on trunk. -
- Test passed after 1 failed attempt on rnmobile/feature/drag-and-drop-refactor-draggable. -
- Test passed after 1 failed attempt on rnmobile/feature/drag-and-drop-update-chip-animation. -
- Test passed after 1 failed attempt on fix/comment-reply-link-alignment. -
- Test passed after 1 failed attempt on rnmobile/feature/drag-and-drop-haptic-feedback. -
- Test passed after 1 failed attempt on trunk. -
- Test passed after 1 failed attempt on fix/embed-block-preview-cut-off. -
- Test passed after 1 failed attempt on trunk. -
- Test passed after 1 failed attempt on fix/copy-from-non-text-inputs. -
- Test passed after 1 failed attempt on remove/block-styles-remove-role. -
- Test passed after 1 failed attempt on docgen/replace-fixtures-with-code. -
- Test passed after 1 failed attempt on try/use-css-var-for-user-presets. -
- Test passed after 1 failed attempt on fix/flaky-test-reporter. -
- Test passed after 1 failed attempt on wp/6.0. -
- Test passed after 1 failed attempt on wp/6.0. -
- Test passed after 1 failed attempt on wp/6.0. -
- Test passed after 1 failed attempt on wp/6.0. -
- Test passed after 1 failed attempt on wp/6.0. -
- Test passed after 1 failed attempt on fix/input-field-reset-behavior-moar. -
- Test passed after 1 failed attempt on refactor/range-control-to-typescript. -
- Test passed after 1 failed attempt on refactor/range-control-to-typescript. -
- Test passed after 1 failed attempt on wp/6.0. -
- Test passed after 1 failed attempt on wp/6.0. -
- Test passed after 1 failed attempt on wp/6.0. -
-
- - Test passed after 1 failed attempt on headBranch. - +
+should copy only partial selection of text blocks in /test/e2e/specs/editor/various/copy-cut-paste.spec.js, passed after 1 failed attempt. \`\`\` Error: Snapshot comparison failed: @@ -116,27 +33,10 @@ Expected: /home/runner/work/gutenberg/gutenberg/artifacts/test-results/editor-va Received: /home/runner/work/gutenberg/gutenberg/artifacts/test-results/editor-various-copy-cut-paste-Copy-cut-paste-should-copy-only-partial-selection-of-text-blocks-chromium/Copy-cut-paste-should-copy-only-partial-selection-of-text-blocks-2-actual.txt at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/various/copy-cut-paste.spec.js:253:52 \`\`\` -
- -" -`; - -exports[`Report flaky tests should report flaky tests to issue on push: Created new flaky issue 1`] = ` -" -**Flaky test detected. This is an auto-generated issue by GitHub Actions. Please do NOT edit this manually.** - -## Test title -Should insert new template part on creation -## Test path -\`specs/site-editor/template-part.test.js\` - -## Errors - -
- - Test passed after 2 failed attempts on trunk. - +
+
+Should insert new template part on creation in specs/site-editor/template-part.test.js, passed after 2 failed attempts. \`\`\` ● Template Part › Template part block › Template part placeholder › Should insert new template part on creation @@ -178,128 +78,6 @@ Should insert new template part on creation at runMicrotasks () \`\`\` -
- -" -`; - -exports[`Report flaky tests should report flaky tests to issue on push: Updated existing flaky issue 1`] = ` -" -**Flaky test detected. This is an auto-generated issue by GitHub Actions. Please do NOT edit this manually.** - -## Test title -should copy only partial selection of text blocks - -## Test path -\`/test/e2e/specs/editor/various/copy-cut-paste.spec.js\` - -## Errors - - Test passed after 1 failed attempt on trunk. -
- Test passed after 1 failed attempt on update/post-featured-image-component. -
- Test passed after 1 failed attempt on add/wp-env/wp-env-core-env. -
- Test passed after 1 failed attempt on fix/e2e-test. -
- Test passed after 1 failed attempt on trunk. -
- Test passed after 1 failed attempt on fix/duotone-site-editor. -
- Test passed after 1 failed attempt on fix/playwright-snapshots. -
- Test passed after 1 failed attempt on trunk. -
- Test passed after 1 failed attempt on trunk. -
- Test passed after 1 failed attempt on rnmobile/feature/drag-and-drop. -
- Test passed after 1 failed attempt on rnmobile/feature/drag-and-drop-prevent-text-focus-on-long-press. -
- Test passed after 1 failed attempt on add/section-concept. -
- Test passed after 1 failed attempt on trunk. -
- Test passed after 1 failed attempt on rnmobile/feature/drag-and-drop-refactor-draggable. -
- Test passed after 1 failed attempt on rnmobile/feature/drag-and-drop-update-chip-animation. -
- Test passed after 1 failed attempt on fix/comment-reply-link-alignment. -
- Test passed after 1 failed attempt on rnmobile/feature/drag-and-drop-haptic-feedback. -
- Test passed after 1 failed attempt on trunk. -
- Test passed after 1 failed attempt on fix/embed-block-preview-cut-off. -
- Test passed after 1 failed attempt on trunk. -
- Test passed after 1 failed attempt on fix/copy-from-non-text-inputs. -
- Test passed after 1 failed attempt on remove/block-styles-remove-role. -
- Test passed after 1 failed attempt on docgen/replace-fixtures-with-code. -
- Test passed after 1 failed attempt on try/use-css-var-for-user-presets. -
- Test passed after 1 failed attempt on fix/flaky-test-reporter. -
- Test passed after 1 failed attempt on wp/6.0. -
- Test passed after 1 failed attempt on wp/6.0. -
- Test passed after 1 failed attempt on wp/6.0. -
- Test passed after 1 failed attempt on wp/6.0. -
- Test passed after 1 failed attempt on wp/6.0. -
- Test passed after 1 failed attempt on fix/input-field-reset-behavior-moar. -
- Test passed after 1 failed attempt on refactor/range-control-to-typescript. -
- Test passed after 1 failed attempt on refactor/range-control-to-typescript. -
- Test passed after 1 failed attempt on wp/6.0. -
- Test passed after 1 failed attempt on wp/6.0. -
- Test passed after 1 failed attempt on wp/6.0. -
-
- - Test passed after 1 failed attempt on trunk. - - -\`\`\` -Error: Snapshot comparison failed: - - -

block

- - - -

B

- - - -

A block

- - -

- - - -

B block

- - -Expected: /home/runner/work/gutenberg/gutenberg/artifacts/test-results/editor-various-copy-cut-paste-Copy-cut-paste-should-copy-only-partial-selection-of-text-blocks-chromium/Copy-cut-paste-should-copy-only-partial-selection-of-text-blocks-2-expected.txt -Received: /home/runner/work/gutenberg/gutenberg/artifacts/test-results/editor-various-copy-cut-paste-Copy-cut-paste-should-copy-only-partial-selection-of-text-blocks-chromium/Copy-cut-paste-should-copy-only-partial-selection-of-text-blocks-2-actual.txt - at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/various/copy-cut-paste.spec.js:253:52 -\`\`\` -
- -" +
" `; diff --git a/packages/report-flaky-tests/src/__tests__/index.test.ts b/packages/report-flaky-tests/src/__tests__/index.test.ts new file mode 100644 index 00000000000000..769b54a7b44cb1 --- /dev/null +++ b/packages/report-flaky-tests/src/__tests__/index.test.ts @@ -0,0 +1,39 @@ +/** + * Loads the entry point against a fresh set of mocks. + * + * The module runs its work on import, so each case needs its own registry. + * + * @param result What `run` should do. + * @return The mocked core module, to assert against. + */ +async function loadEntryPoint( result: () => Promise< void > ) { + jest.resetModules(); + jest.doMock( '@actions/core', () => ( { setFailed: jest.fn() } ) ); + jest.doMock( '../run.ts', () => ( { run: result } ) ); + + const core = require( '@actions/core' ); + require( '../index.ts' ); + await new Promise( process.nextTick ); + + return core; +} + +describe( 'entry point', () => { + /* + * A green step with no report is indistinguishable from a clean run, and + * the writer downstream would clear the existing report. + */ + it( 'fails the step when the report cannot be produced', async () => { + const core = await loadEntryPoint( () => + Promise.reject( new Error( 'EACCES' ) ) + ); + + expect( core.setFailed ).toHaveBeenCalledWith( 'EACCES' ); + } ); + + it( 'leaves the step green on a clean run', async () => { + const core = await loadEntryPoint( () => Promise.resolve() ); + + expect( core.setFailed ).not.toHaveBeenCalled(); + } ); +} ); diff --git a/packages/report-flaky-tests/src/__tests__/markdown.test.ts b/packages/report-flaky-tests/src/__tests__/markdown.test.ts index 30de48de610f1f..78f308f7f3cb04 100644 --- a/packages/report-flaky-tests/src/__tests__/markdown.test.ts +++ b/packages/report-flaky-tests/src/__tests__/markdown.test.ts @@ -1,25 +1,5 @@ -/** - * External dependencies - */ -import * as core from '@actions/core'; - -/** - * Internal dependencies - */ -import { - formatTestErrorMessage, - formatTestResults, - renderIssueBody, - parseFormattedTestResults, - parseIssueBody, - renderCommitComment, - isReportComment, -} from '../markdown'; -import type { ReportedIssue } from '../types'; - -jest.useFakeTimers( 'modern' ).setSystemTime( new Date( '2020-05-10' ) ); - -jest.spyOn( core, 'error' ).mockImplementation( () => {} ); +import { formatTestErrorMessage, renderReport } from '../markdown'; +import type { ReportedFlakyTest } from '../types'; describe( 'formatTestErrorMessage', () => { it( 'should format test error message for jest-circus', async () => { @@ -43,246 +23,38 @@ describe( 'formatTestErrorMessage', () => { } ); } ); -describe( 'formatTestResults', () => { - it( 'should format flaky test results without error messages', () => { - const formatted = formatTestResults( { - date: new Date(), - failedTimes: 1, - headBranch: 'trunk', - runURL: 'https://github.com/WordPress/gutenberg/actions/runs/2282393879', - } ); - - expect( formatted ).toMatchInlineSnapshot( - `" Test passed after 1 failed attempt on trunk."` - ); - - expect( renderToDisplayText( formatted ) ).toMatchInlineSnapshot( - `"[2020-05-10T00:00:00.000Z] Test passed after 1 failed attempt on trunk."` - ); - } ); - - it( 'should format flaky test results with error messages', () => { - const formatted = formatTestResults( { - date: new Date(), - failedTimes: 1, - headBranch: 'trunk', - runURL: 'https://github.com/WordPress/gutenberg/actions/runs/2282393879', - errorMessage: 'Error:\n Oops...\n', - } ); - - expect( formatted ).toMatchInlineSnapshot( ` - "
- - Test passed after 1 failed attempt on trunk. - - - \`\`\` - Error: - Oops... - - \`\`\` -
" - ` ); - } ); -} ); - -describe( 'renderIssueBody', () => { - it( 'should render issue body', () => { - const meta = { - failedTimes: 5, - totalCommits: 100, - }; - - const view = renderIssueBody( { - meta, - testTitle: 'Test title', - testPath: 'test/e2e/specs/test-title.spec.js', - formattedTestResults: 'FORMATTED_TEST_RESULTS', - } ); - - expect( view ).toMatchInlineSnapshot( ` - " - **Flaky test detected. This is an auto-generated issue by GitHub Actions. Please do NOT edit this manually.** - - ## Test title - Test title - - ## Test path - \`test/e2e/specs/test-title.spec.js\` - - ## Errors - - FORMATTED_TEST_RESULTS - - " - ` ); - } ); -} ); - -describe( 'parseFormattedTestResults', () => { - it( 'should parse formatted test results without error messages', () => { - const testResult = { - date: new Date(), - failedTimes: 1, - headBranch: 'trunk', - runURL: 'https://github.com/WordPress/gutenberg/actions/runs/2297863316', - }; - - const formatted = formatTestResults( testResult ); - - const parsed = parseFormattedTestResults( formatted ); - - expect( parsed ).toEqual( testResult ); - } ); - - it( 'should parse formatted test results with error messages', () => { - const testResult = { - date: new Date(), - failedTimes: 1, - headBranch: 'trunk', - runURL: 'https://github.com/WordPress/gutenberg/actions/runs/2297863316', - errorMessage: 'Error:\n Oops...\n', - }; - - const formatted = formatTestResults( testResult ); - - const parsed = parseFormattedTestResults( formatted ); - - expect( parsed ).toEqual( testResult ); - } ); - - it( 'should handle GitHub converted line breaks (\r\n) in the result', () => { - const testResult = { - date: new Date(), - failedTimes: 1, - headBranch: 'trunk', - runURL: 'https://github.com/WordPress/gutenberg/actions/runs/2297863316', - errorMessage: 'Error:\n Oops...\n', - }; - - const formatted = formatTestResults( testResult ).replace( - /\n/g, - '\r\n' - ); - - const parsed = parseFormattedTestResults( formatted ); - - expect( parsed ).toEqual( testResult ); - } ); -} ); - -describe( 'parseIssueBody', () => { - it( 'should parse issue body', async () => { - const testResults = [ - formatTestResults( { - date: new Date( '2020-08-01' ), - failedTimes: 1, - headBranch: 'trunk', - runURL: 'https://github.com/WordPress/gutenberg/actions/runs/2282393879', - } ), - formatTestResults( { - date: new Date(), - failedTimes: 2, - headBranch: 'try/some-branch', - runURL: 'https://github.com/WordPress/gutenberg/actions/runs/2297863316', - errorMessage: 'Error:\n Oops...\n Oops again', - } ), - `\nSome broken log.\nOops.\n\n`, - ]; - - const meta = { - failedTimes: 5, - totalCommits: 95, - }; - - const view = renderIssueBody( { - meta, - testTitle: 'Test title', - testPath: 'test/e2e/specs/test-title.spec.js', - formattedTestResults: testResults.join( '\n' ), - } ); - - expect( view ).toMatchSnapshot(); - - const parsed = parseIssueBody( view ); - - expect( core.error ).toHaveBeenCalledTimes( 1 ); - - expect( parsed ).toEqual( { - meta, - testResults: [ - { - date: new Date( '2020-08-01' ), - failedTimes: 1, - headBranch: 'trunk', - runURL: 'https://github.com/WordPress/gutenberg/actions/runs/2282393879', - }, - { - date: new Date(), - failedTimes: 2, - headBranch: 'try/some-branch', - runURL: 'https://github.com/WordPress/gutenberg/actions/runs/2297863316', - errorMessage: 'Error:\n Oops...\n Oops again', - }, - ], - } ); - } ); -} ); - -describe( 'renderCommitComment', () => { - it( 'render the commit comment', () => { - const runURL = 'runURL'; - const reportedIssues: ReportedIssue[] = [ +describe( 'renderReport', () => { + it( 'render the report', () => { + const reportedTests: ReportedFlakyTest[] = [ { testTitle: 'title1', testPath: 'path1', - issueNumber: 1, - issueUrl: 'url1', + failedTimes: 1, + errorMessage: 'Error:\n Oops...\n', }, { testTitle: 'title2', testPath: 'path2', - issueNumber: 2, - issueUrl: 'url2', + failedTimes: 2, }, ]; - const commitSHA = 'commitSHA'; - const view = renderCommitComment( { - reportedIssues, - runURL, - commitSHA, - } ); + const view = renderReport( { reportedTests } ); expect( view ).toMatchInlineSnapshot( ` - " - **Flaky tests detected in commitSHA.** - Some tests passed with failed attempts. The failures may not be related to this commit but are still reported for visibility. See [the documentation](https://github.com/WordPress/gutenberg/blob/HEAD/docs/contributors/code/testing-overview.md#flaky-tests) for more information. + "Some tests passed with failed attempts. The failures may not be related to this commit but are still reported for visibility. See [the documentation](https://github.com/WordPress/gutenberg/blob/HEAD/docs/contributors/code/testing-overview.md#flaky-tests) for more information. - 🔍 Workflow run URL: runURL - 📝 Reported issues: - - #1 in \`path1\` - - #2 in \`path2\`" - ` ); - } ); -} ); +
+ title1 in path1, passed after 1 failed attempt. -describe( 'isReportComment', () => { - it( 'matches the report comment', () => { - const view = renderCommitComment( { - reportedIssues: [], - runURL: '', - commitSHA: 'commitSHA', - } ); + \`\`\` + Error: + Oops... - expect( isReportComment( view ) ).toBe( true ); + \`\`\` - expect( isReportComment( 'random string' ) ).toBe( false ); +
+

title2 in path2, passed after 2 failed attempts.

" + ` ); } ); } ); - -function renderToDisplayText( html: string ) { - const container = document.createElement( 'div' ); - container.innerHTML = html; - return container.textContent!.replace( /\s+/g, ' ' ); -} diff --git a/packages/report-flaky-tests/src/__tests__/run.test.ts b/packages/report-flaky-tests/src/__tests__/run.test.ts index f194d59c04fee6..1426c174301bc4 100644 --- a/packages/report-flaky-tests/src/__tests__/run.test.ts +++ b/packages/report-flaky-tests/src/__tests__/run.test.ts @@ -1,330 +1,121 @@ -/** - * External dependencies - */ import * as core from '@actions/core'; - -/** - * Internal dependencies - */ import { run } from '../run'; -jest.useFakeTimers( 'modern' ).setSystemTime( new Date( '2020-05-10' ) ); - -const mockPushEventContext = { - runId: 100, - repo: { - owner: 'WordPress', - repo: 'gutenberg', - }, - ref: 'refs/heads/trunk', - sha: 'commitSHA', - eventName: 'push', -}; -const mockPullRequestEventContext = { - runId: 100, - repo: { - owner: 'WordPress', - repo: 'gutenberg', - }, - ref: 'refs/pull/10/merge', - sha: 'mergeSHA', - eventName: 'pull_request', - payload: { - number: 10, - pull_request: { - head: { - ref: 'headBranch', - sha: 'headSHA', - }, - }, - }, -}; -const mockGetContext = jest.fn( - (): typeof mockPushEventContext | typeof mockPullRequestEventContext => - mockPullRequestEventContext -); -jest.mock( '@actions/github', () => ( { - get context() { - return mockGetContext(); - }, -} ) ); - jest.mock( '@actions/core', () => ( { error: jest.fn(), info: jest.fn(), getInput: jest.fn(), } ) ); -const mockAPI = { - fetchAllIssuesLabeledFlaky: jest.fn(), - findMergeBaseCommit: jest.fn(), - updateIssue: jest.fn(), - createIssue: jest.fn(), - createCommentOnPR: jest.fn(), - createCommentOnCommit: jest.fn(), -}; -jest.mock( '../github-api', () => ( { - GitHubAPI: jest.fn( () => mockAPI ), -} ) ); - jest.mock( 'fs/promises', () => ( { readdir: jest.fn(), readFile: jest.fn(), + writeFile: jest.fn(), + mkdir: jest.fn(), } ) ); +function mockInputs() { + ( core.getInput as jest.Mock ) + // artifact-path + .mockReturnValueOnce( 'flaky-tests' ) + // output-path + .mockReturnValueOnce( 'pr-meta/body.md' ); +} + +async function mockFlakyTestsArtifact() { + const playwrightFlakyTest = await import( + '../__fixtures__/should copy only partial selection of text blocks.json' + ).then( ( json ) => json.default ); + const jestFlakyTest = await import( + '../__fixtures__/Should insert new template part on creation.json' + ).then( ( json ) => json.default ); + + mockInputs(); + + // Replacing the cwd for the test for consistent snapshot results. + playwrightFlakyTest.path = playwrightFlakyTest.path.replace( + '/home/runner/work/gutenberg/gutenberg', + process.cwd() + ); + + const mockedFs = require( 'fs/promises' ); + mockedFs.readdir.mockImplementationOnce( () => + Promise.resolve( [ + `${ playwrightFlakyTest.title }.json`, + `${ jestFlakyTest.title }.json`, + ] ) + ); + mockedFs.readFile + .mockImplementationOnce( () => + Promise.resolve( JSON.stringify( playwrightFlakyTest ) ) + ) + .mockImplementationOnce( () => + Promise.resolve( JSON.stringify( jestFlakyTest ) ) + ); +} + describe( 'Report flaky tests', () => { afterEach( () => { jest.clearAllMocks(); } ); - it( 'should report flaky tests to issue on pull request', async () => { - const existingFlakyTest = await import( - '../__fixtures__/should copy only partial selection of text blocks.json' - ).then( ( json ) => json.default ); - const newFlakyTest = await import( - '../__fixtures__/Should insert new template part on creation.json' - ).then( ( json ) => json.default ); - const flakyIssues = await import( - '../__fixtures__/flaky-issues.json' - ).then( ( json ) => json.default ); + it( 'should write the report', async () => { + await mockFlakyTestsArtifact(); - ( core.getInput as jest.Mock ) - // token - .mockReturnValueOnce( 'repo-token' ) - // artifact-path - .mockReturnValueOnce( 'flaky-tests' ) - // label - .mockReturnValueOnce( '[Type] Flaky Test' ); - - // Replacing the cwd for the test for consistent snapshot results. - existingFlakyTest.path = existingFlakyTest.path.replace( - '/home/runner/work/gutenberg/gutenberg', - process.cwd() - ); + await run(); const mockedFs = require( 'fs/promises' ); - mockedFs.readdir.mockImplementationOnce( () => - Promise.resolve( [ - `${ existingFlakyTest.title }.json`, - `${ newFlakyTest.title }.json`, - ] ) - ); - mockedFs.readFile - .mockImplementationOnce( () => - Promise.resolve( JSON.stringify( existingFlakyTest ) ) - ) - .mockImplementationOnce( () => - Promise.resolve( JSON.stringify( newFlakyTest ) ) - ); - - mockAPI.fetchAllIssuesLabeledFlaky.mockImplementationOnce( - () => flakyIssues + expect( mockedFs.writeFile ).toHaveBeenCalledTimes( 1 ); + expect( mockedFs.writeFile.mock.calls[ 0 ][ 0 ] ).toBe( + 'pr-meta/body.md' ); + expect( mockedFs.writeFile.mock.calls[ 0 ][ 1 ] ).toMatchSnapshot(); + } ); - mockAPI.updateIssue.mockImplementationOnce( () => ( { - number: 1, - html_url: 'html_url', - } ) ); - - mockAPI.createIssue.mockImplementationOnce( () => ( { - number: 2, - html_url: 'html_url', - } ) ); + it( 'should write nothing when there are no flaky tests', async () => { + mockInputs(); - mockAPI.createCommentOnPR.mockImplementationOnce( () => ( { - html_url: 'comment_html_url', - } ) ); + const mockedFs = require( 'fs/promises' ); + mockedFs.readdir.mockImplementationOnce( () => Promise.resolve( [] ) ); await run(); - const existingFlakyIssue = flakyIssues.find( - ( issue ) => - issue.title === `[Flaky Test] ${ existingFlakyTest.title }` - )!; - expect( mockAPI.updateIssue ).toHaveBeenCalledWith( - expect.objectContaining( { - issue_number: existingFlakyIssue.number, - state: 'open', - } ) - ); - expect( mockAPI.updateIssue.mock.calls[ 0 ][ 0 ].body ).toMatchSnapshot( - 'Updated existing flaky issue' - ); - - expect( mockAPI.createIssue ).not.toHaveBeenCalled(); - - expect( mockAPI.createCommentOnPR ).toHaveBeenCalledTimes( 1 ); - expect( mockAPI.createCommentOnPR.mock.calls[ 0 ][ 0 ] ).toBe( 10 ); - expect( mockAPI.createCommentOnPR.mock.calls[ 0 ][ 1 ] ) - .toMatchInlineSnapshot( ` - " - **Flaky tests detected in headSHA.** - Some tests passed with failed attempts. The failures may not be related to this commit but are still reported for visibility. See [the documentation](https://github.com/WordPress/gutenberg/blob/HEAD/docs/contributors/code/testing-overview.md#flaky-tests) for more information. - - 🔍 Workflow run URL: https://github.com/WordPress/gutenberg/actions/runs/100 - 📝 Reported issues: - - #1 in \`/test/e2e/specs/editor/various/copy-cut-paste.spec.js\`" - ` ); + expect( mockedFs.writeFile ).not.toHaveBeenCalled(); } ); - it( 'should report flaky tests to issue on push', async () => { - mockGetContext.mockImplementation( () => mockPushEventContext ); - - const existingFlakyTest = await import( - '../__fixtures__/should copy only partial selection of text blocks.json' - ).then( ( json ) => json.default ); - const newFlakyTest = await import( - '../__fixtures__/Should insert new template part on creation.json' - ).then( ( json ) => json.default ); - const flakyIssues = await import( - '../__fixtures__/flaky-issues.json' - ).then( ( json ) => json.default ); - - ( core.getInput as jest.Mock ) - // token - .mockReturnValueOnce( 'repo-token' ) - // artifact-path - .mockReturnValueOnce( 'flaky-tests' ) - // label - .mockReturnValueOnce( '[Type] Flaky Test' ); - - // Replacing the cwd for the test for consistent snapshot results. - existingFlakyTest.path = existingFlakyTest.path.replace( - '/home/runner/work/gutenberg/gutenberg', - process.cwd() - ); + /* + * A clean run produces no artifact at all. The writer treats the missing + * file as an empty section, which is what clears a stale report. + */ + it( 'should write nothing when the artifact is missing', async () => { + mockInputs(); + const missing = Object.assign( new Error( 'ENOENT' ), { + code: 'ENOENT', + } ); const mockedFs = require( 'fs/promises' ); mockedFs.readdir.mockImplementationOnce( () => - Promise.resolve( [ - `${ existingFlakyTest.title }.json`, - `${ newFlakyTest.title }.json`, - ] ) + Promise.reject( missing ) ); - mockedFs.readFile - .mockImplementationOnce( () => - Promise.resolve( JSON.stringify( existingFlakyTest ) ) - ) - .mockImplementationOnce( () => - Promise.resolve( JSON.stringify( newFlakyTest ) ) - ); - - mockAPI.fetchAllIssuesLabeledFlaky.mockImplementationOnce( - () => flakyIssues - ); - - mockAPI.updateIssue.mockImplementationOnce( () => ( { - number: 1, - html_url: 'html_url', - } ) ); - - mockAPI.createIssue.mockImplementationOnce( () => ( { - number: 2, - html_url: 'html_url', - } ) ); - - mockAPI.createCommentOnCommit.mockImplementationOnce( () => ( { - html_url: 'comment_html_url', - } ) ); await run(); - const existingFlakyIssue = flakyIssues.find( - ( issue ) => - issue.title === `[Flaky Test] ${ existingFlakyTest.title }` - )!; - expect( mockAPI.updateIssue ).toHaveBeenCalledWith( - expect.objectContaining( { - issue_number: existingFlakyIssue.number, - state: 'open', - } ) - ); - expect( mockAPI.updateIssue.mock.calls[ 0 ][ 0 ].body ).toMatchSnapshot( - 'Updated existing flaky issue' - ); - - expect( mockAPI.createIssue ).toHaveBeenCalledWith( - expect.objectContaining( { - title: `[Flaky Test] ${ newFlakyTest.title }`, - } ) - ); - expect( mockAPI.createIssue.mock.calls[ 0 ][ 0 ].body ).toMatchSnapshot( - 'Created new flaky issue' - ); - - expect( mockAPI.createCommentOnCommit ).toHaveBeenCalledTimes( 1 ); - expect( mockAPI.createCommentOnCommit.mock.calls[ 0 ][ 0 ] ).toBe( - 'commitSHA' - ); - expect( mockAPI.createCommentOnCommit.mock.calls[ 0 ][ 1 ] ) - .toMatchInlineSnapshot( ` - " - **Flaky tests detected in commitSHA.** - Some tests passed with failed attempts. The failures may not be related to this commit but are still reported for visibility. See [the documentation](https://github.com/WordPress/gutenberg/blob/HEAD/docs/contributors/code/testing-overview.md#flaky-tests) for more information. - - 🔍 Workflow run URL: https://github.com/WordPress/gutenberg/actions/runs/100 - 📝 Reported issues: - - #1 in \`/test/e2e/specs/editor/various/copy-cut-paste.spec.js\` - - #2 in \`specs/site-editor/template-part.test.js\`" - ` ); - - mockGetContext.mockImplementation( () => mockPullRequestEventContext ); + expect( mockedFs.writeFile ).not.toHaveBeenCalled(); } ); - it( 'should skip for outdated branches', async () => { - const flakyTest = await import( - '../__fixtures__/should copy only partial selection of text blocks.json' - ).then( ( json ) => json.default ); - const flakyIssues = await import( - '../__fixtures__/flaky-issues.json' - ).then( ( json ) => json.default ); - - ( core.getInput as jest.Mock ) - .mockReturnValueOnce( 'repo-token' ) - .mockReturnValueOnce( 'flaky-tests-report' ) - .mockReturnValueOnce( '[Type] Flaky Test' ); - - // Replacing the cwd for the test for consistent snapshot results. - flakyTest.path = flakyTest.path.replace( - '/home/runner/work/gutenberg/gutenberg', - process.cwd() - ); + /* Anything else would clear a report that nothing had disproved. */ + it( 'should fail rather than report clean when the artifact is unreadable', async () => { + mockInputs(); + const denied = Object.assign( new Error( 'EACCES' ), { + code: 'EACCES', + } ); const mockedFs = require( 'fs/promises' ); mockedFs.readdir.mockImplementationOnce( () => - Promise.resolve( [ `${ flakyTest.title }.json` ] ) - ); - mockedFs.readFile.mockImplementationOnce( () => - Promise.resolve( JSON.stringify( flakyTest ) ) - ); - - mockAPI.fetchAllIssuesLabeledFlaky.mockImplementationOnce( - () => flakyIssues + Promise.reject( denied ) ); - const latestAncestorDate = new Date( '2022-05-10' ); - mockAPI.findMergeBaseCommit.mockImplementationOnce( () => ( { - committer: { - date: latestAncestorDate.toISOString(), - }, - } ) ); - - // The issue is closed after the latest ancestor date, - // indicating that the code base at this point is outdated. - const flakyIssue = flakyIssues.find( - ( issue ) => issue.title === `[Flaky Test] ${ flakyTest.title }` - )!; - flakyIssue.state = 'closed'; - // @ts-expect-error: "closed_at" hasn't been typed yet. - flakyIssue.closed_at = new Date( '2022-05-15' ).toISOString(); - - await run(); - - expect( mockAPI.findMergeBaseCommit ).toHaveBeenCalledTimes( 1 ); - - expect( mockAPI.updateIssue ).not.toHaveBeenCalled(); - - expect( mockAPI.createCommentOnPR ).not.toHaveBeenCalled(); - expect( mockAPI.createCommentOnCommit ).not.toHaveBeenCalled(); + await expect( run() ).rejects.toThrow( 'EACCES' ); + expect( mockedFs.writeFile ).not.toHaveBeenCalled(); } ); } ); diff --git a/packages/report-flaky-tests/src/github-api.ts b/packages/report-flaky-tests/src/github-api.ts deleted file mode 100644 index c4e52d229c612b..00000000000000 --- a/packages/report-flaky-tests/src/github-api.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * External dependencies - */ -import { getOctokit } from '@actions/github'; -import type { GitHub } from '@actions/github/lib/utils'; -import type { Endpoints } from '@octokit/types'; - -type Octokit = InstanceType< typeof GitHub >; - -type Repo = { - owner: string; - repo: string; -}; - -class GitHubAPI { - #octokit: Octokit; - #repo: Repo; - - constructor( token: string, repo: Repo ) { - this.#octokit = getOctokit( token ); - this.#repo = repo; - } - - async fetchAllIssuesLabeledFlaky( label: string ) { - const issues = await this.#octokit.paginate( - this.#octokit.rest.issues.listForRepo, - { - ...this.#repo, - state: 'all', - labels: label, - } - ); - - return issues; - } - - async findMergeBaseCommit( baseCommit: string, headCommit: string ) { - const { data } = await this.#octokit.rest.repos.compareCommits( { - ...this.#repo, - base: baseCommit, - head: headCommit, - per_page: 1, - } ); - - return data.merge_base_commit.commit; - } - - async updateIssue( - params: Omit< - Endpoints[ 'PATCH /repos/{owner}/{repo}/issues/{issue_number}' ][ 'parameters' ], - keyof Repo - > - ) { - const { data } = await this.#octokit.rest.issues.update( { - ...this.#repo, - ...params, - } ); - - return data; - } - - async createIssue( - params: Omit< - Endpoints[ 'POST /repos/{owner}/{repo}/issues' ][ 'parameters' ], - keyof Repo - > - ) { - const { data } = await this.#octokit.rest.issues.create( { - ...this.#repo, - ...params, - } ); - - return data; - } - - async createCommentOnCommit( - sha: string, - body: string, - isReportComment: ( body: string ) => boolean - ) { - const { data: comments } = - await this.#octokit.rest.repos.listCommentsForCommit( { - ...this.#repo, - commit_sha: sha, - } ); - const reportComment = comments.find( ( comment ) => - isReportComment( comment.body ) - ); - - if ( reportComment ) { - const { data } = await this.#octokit.rest.repos.updateCommitComment( - { - ...this.#repo, - comment_id: reportComment.id, - body, - } - ); - - return data; - } - - const { data } = await this.#octokit.rest.repos.createCommitComment( { - ...this.#repo, - commit_sha: sha, - body, - } ); - - return data; - } - - async createCommentOnPR( - prNumber: number, - body: string, - isReportComment: ( body: string ) => boolean - ) { - let reportComment; - let page = 1; - - while ( ! reportComment ) { - const { data: comments } = - await this.#octokit.rest.issues.listComments( { - ...this.#repo, - issue_number: prNumber, - page, - } ); - reportComment = comments.find( - ( comment ) => comment.body && isReportComment( comment.body ) - ); - if ( comments.length > 0 ) { - page += 1; - } else { - break; - } - } - - if ( reportComment ) { - const { data } = await this.#octokit.rest.issues.updateComment( { - ...this.#repo, - comment_id: reportComment.id, - body, - } ); - - return data; - } - - const { data } = await this.#octokit.rest.issues.createComment( { - ...this.#repo, - issue_number: prNumber, - body, - } ); - - return data; - } -} - -export { GitHubAPI }; diff --git a/packages/report-flaky-tests/src/index.ts b/packages/report-flaky-tests/src/index.ts index ecfd1bb6bf8066..a25d561f3fc14d 100644 --- a/packages/report-flaky-tests/src/index.ts +++ b/packages/report-flaky-tests/src/index.ts @@ -6,8 +6,13 @@ import * as core from '@actions/core'; /** * Internal dependencies */ -import { run } from './run'; +import { run } from './run.ts'; +/* + * Fail the step rather than logging. A green step with no report is how a + * clean run looks, so swallowing an error here would clear a flaky report + * that nothing had disproved. + */ run().catch( ( error ) => { - core.error( error instanceof Error ? error : String( error ) ); + core.setFailed( error instanceof Error ? error.message : String( error ) ); } ); diff --git a/packages/report-flaky-tests/src/markdown.ts b/packages/report-flaky-tests/src/markdown.ts index b92fdaca5a799d..8b6b0f7d4e7fc0 100644 --- a/packages/report-flaky-tests/src/markdown.ts +++ b/packages/report-flaky-tests/src/markdown.ts @@ -1,86 +1,7 @@ -/** - * External dependencies - */ import path from 'path'; import { formatResultsErrors } from 'jest-message-util'; -import * as core from '@actions/core'; - -/** - * Internal dependencies - */ -import { stripAnsi } from './strip-ansi'; -import type { MetaData, FlakyTestResult, ReportedIssue } from './types'; - -type ParsedTestResult = { - date: Date; - failedTimes: number; - runURL: string; - headBranch: string; - errorMessage?: string; -}; - -const TEST_RESULTS_LIST = { - open: ``, - close: ``, -}; -const TEST_RESULT = { - open: '', - close: '', -}; - -const metaData = { - render: ( json: MetaData ) => - ``, - get: ( str: string ): MetaData | undefined => { - const matched = str.match( // ); - try { - if ( matched ) { - return JSON.parse( matched[ 1 ] ); - } - } catch { - // Ignore errors. - } - return undefined; - }, -}; - -const TEST_LOG_REGEX = new RegExp( - `\\s*Test passed after (?\\d) failed attempts? on (?.+)\\.` -); -const TEST_FULL_REGEX = new RegExp( - `(?:
\\s*\\s*)?${ TEST_LOG_REGEX.source }(?:\\s*\\s* -\`\`\` -(?[\\s\\S]*) -\`\`\` -\\s*
)?` -); - -function renderIssueBody( { - meta, - testTitle, - testPath, - formattedTestResults, -}: { - meta: MetaData; - testTitle: string; - testPath: string; - formattedTestResults: string; -} ) { - return `${ metaData.render( meta ) } -**Flaky test detected. This is an auto-generated issue by GitHub Actions. Please do NOT edit this manually.** - -## Test title -${ testTitle } - -## Test path -\`${ testPath }\` - -## Errors -${ TEST_RESULTS_LIST.open } -${ formattedTestResults } -${ TEST_RESULTS_LIST.close } -`; -} +import { stripAnsi } from './strip-ansi.ts'; +import type { FlakyTestResult, ReportedFlakyTest } from './types.ts'; function formatTestErrorMessage( flakyTestResult: FlakyTestResult ) { switch ( flakyTestResult.runner ) { @@ -113,128 +34,38 @@ function formatTestErrorMessage( flakyTestResult: FlakyTestResult ) { } } -function formatTestResults( { - date, +function renderReportedTest( { + testTitle, + testPath, failedTimes, - headBranch, - runURL, errorMessage, -}: ParsedTestResult ) { - const dateString = date.toISOString(); - - // It will look something like this without formatting: - // [2021-08-31T16:15:19.875Z] Test passed after 2 failed attempts on trunk. - const log = ` Test passed after ${ failedTimes } failed ${ +}: ReportedFlakyTest ) { + const summary = `${ testTitle } in ${ testPath }, passed after ${ failedTimes } failed ${ failedTimes === 1 ? 'attempt' : 'attempts' - } on ${ headBranch }.`; + }.`; if ( ! errorMessage ) { - return `${ TEST_RESULT.open }${ log }${ TEST_RESULT.close }`; + return `

${ summary }

`; } - return `${ TEST_RESULT.open }
- - ${ log } - + return `
+${ summary } \`\`\` ${ errorMessage } \`\`\` -
${ TEST_RESULT.close }`; -} - -function parseFormattedTestResults( - formattedTestResults: string -): ParsedTestResult { - const matches = formattedTestResults - // Unify line breaks. - .replace( /\r\n/g, '\n' ) - .match( TEST_FULL_REGEX ); - if ( ! matches ) { - throw new Error( `Unable to parse the test results: -${ formattedTestResults }` ); - } - - const { date, failedTimes, runURL, headBranch, errorMessage } = - matches.groups!; - - return { - date: new Date( date ), - failedTimes: parseInt( failedTimes, 10 ), - runURL, - headBranch, - errorMessage, - }; +
`; } -function parseIssueBody( body: string ) { - const meta = metaData.get( body ); - - if ( ! meta ) { - throw new Error( 'Unable to parse meta data from issue.' ); - } - - const testResults = Array.from( - body.matchAll( - new RegExp( - `^${ TEST_RESULT.open }[\\s\\S]+?${ TEST_RESULT.close }$`, - 'gm' - ) - ) - ) - .map( ( [ match ] ) => match ) - .map( ( testResult ) => { - try { - return parseFormattedTestResults( testResult ); - } catch ( error ) { - core.error( error as Error ); - return undefined; - } - } ) - .filter( - ( testResult ): testResult is ParsedTestResult => - testResult !== undefined - ); - - return { - meta, - testResults, - }; -} - -const FLAKY_TESTS_REPORT_COMMENT_TOKEN = `flaky-tests-report-comment`; - -function renderCommitComment( { - reportedIssues, - runURL, - commitSHA, +function renderReport( { + reportedTests, }: { - reportedIssues: ReportedIssue[]; - runURL: string; - commitSHA: string; + reportedTests: ReportedFlakyTest[]; } ) { - return ` -**Flaky tests detected in ${ commitSHA }.** -Some tests passed with failed attempts. The failures may not be related to this commit but are still reported for visibility. See [the documentation](https://github.com/WordPress/gutenberg/blob/HEAD/docs/contributors/code/testing-overview.md#flaky-tests) for more information. - -🔍 Workflow run URL: ${ runURL } -📝 Reported issues: -${ reportedIssues - .map( ( issue ) => `- #${ issue.issueNumber } in \`${ issue.testPath }\`` ) - .join( '\n' ) }`; -} + return `Some tests passed with failed attempts. The failures may not be related to this commit but are still reported for visibility. See [the documentation](https://github.com/WordPress/gutenberg/blob/HEAD/docs/contributors/code/testing-overview.md#flaky-tests) for more information. -function isReportComment( body: string ) { - return body.startsWith( `` ); +${ reportedTests.map( renderReportedTest ).join( '\n' ) }`; } -export { - renderIssueBody, - formatTestErrorMessage, - formatTestResults, - parseFormattedTestResults, - parseIssueBody, - renderCommitComment, - isReportComment, -}; +export { formatTestErrorMessage, renderReport }; diff --git a/packages/report-flaky-tests/src/run.ts b/packages/report-flaky-tests/src/run.ts index d68ced5be24037..3d462de090d86e 100644 --- a/packages/report-flaky-tests/src/run.ts +++ b/packages/report-flaky-tests/src/run.ts @@ -1,38 +1,25 @@ -/** - * External dependencies - */ -import * as github from '@actions/github'; -import * as core from '@actions/core'; import * as fs from 'fs/promises'; import * as path from 'path'; -import type { PullRequestEvent } from '@octokit/webhooks-types'; - -/** - * Internal dependencies - */ -import { GitHubAPI } from './github-api'; -import { - renderIssueBody, - formatTestErrorMessage, - formatTestResults, - parseIssueBody, - renderCommitComment, - isReportComment, -} from './markdown'; -import type { ReportedIssue } from './types'; +import * as core from '@actions/core'; +import { formatTestErrorMessage, renderReport } from './markdown.ts'; +import type { FlakyTestResult, ReportedFlakyTest } from './types.ts'; async function run() { - const token = core.getInput( 'repo-token', { required: true } ); - const artifactPath = core.getInput( 'artifact-path', { - required: true, + const artifactPath = core.getInput( 'artifact-path', { required: true } ); + + /* + * A clean run produces no artifact at all, which is the ordinary case. Any + * other failure has to surface: reporting it as clean would clear a report + * that nothing had disproved. + */ + const flakyTestsDir = await fs.readdir( artifactPath ).catch( ( error ) => { + if ( error.code === 'ENOENT' ) { + return [] as string[]; + } + throw error; } ); - const { runId: runID, repo, ref } = github.context; - const runURL = `https://github.com/${ repo.owner }/${ repo.repo }/actions/runs/${ runID }`; - const api = new GitHubAPI( token, repo ); - - const flakyTestsDir = await fs.readdir( artifactPath ); - const flakyTests = await Promise.all( + const flakyTests: FlakyTestResult[] = await Promise.all( flakyTestsDir.map( ( filename ) => fs .readFile( path.join( artifactPath, filename ), 'utf-8' ) @@ -40,162 +27,32 @@ async function run() { ) ); - if ( ! flakyTests || flakyTests.length === 0 ) { - // No flaky tests reported in this run. + if ( flakyTests.length === 0 ) { + core.info( 'No flaky tests to report.' ); return; } - const isPR = github.context.eventName === 'pull_request'; - const headBranch = isPR - ? // Cast the payload type: https://github.com/actions/toolkit/tree/main/packages/github#webhook-payload-typescript-definitions - ( github.context.payload as PullRequestEvent ).pull_request.head.ref - : ref.replace( /^refs\/(heads|tag)\//, '' ); - - const label = core.getInput( 'label', { required: true } ); - const issues = await api.fetchAllIssuesLabeledFlaky( label ); - const reportedIssues: ReportedIssue[] = []; - - for ( const flakyTest of flakyTests ) { - const { title: testTitle } = flakyTest; - const issueTitle = getIssueTitle( testTitle ); - const reportedIssue = issues.find( - ( issue ) => issue.title === issueTitle - ); - const testPath = flakyTest.path.startsWith( process.cwd() ) - ? flakyTest.path.slice( process.cwd().length ) - : flakyTest.path; - let issue; - - const currentFormattedTestResults = formatTestResults( { - date: new Date(), + const reportedTests: ReportedFlakyTest[] = flakyTests.map( + ( flakyTest ) => ( { + testTitle: flakyTest.title, + testPath: flakyTest.path.startsWith( process.cwd() ) + ? flakyTest.path.slice( process.cwd().length ) + : flakyTest.path, failedTimes: flakyTest.results.length, - headBranch, - runURL, - // Always output the latest test results' stacks. errorMessage: formatTestErrorMessage( flakyTest ), - } ); - - if ( reportedIssue ) { - const body = reportedIssue.body!; - - // The issue is closed. - if ( reportedIssue.closed_at ) { - try { - // Represent the latest base commit on trunk. - const latestAncestorCommit = await api.findMergeBaseCommit( - 'trunk', - github.context.sha - ); - const latestAncestorDate = - latestAncestorCommit.committer?.date; - - // Cannot find the commit date, skipping. - if ( ! latestAncestorDate ) { - return; - } - - // The issue is closed after the latest base commit on trunk, - // which means the branch/PR/commit is outdated and the flaky test - // has probably already been fixed. Skip reporting the outdated flaky tests. - if ( - new Date( reportedIssue.closed_at ) >= - new Date( latestAncestorDate ) - ) { - return; - } - } catch ( error ) { - // It might be a deleted commit or something else. - core.error( - error instanceof Error ? error : String( error ) - ); - return; - } - } - - const { meta, testResults: prevTestResults } = - parseIssueBody( body ); - - // Concat the test results list with the latest test results. - const formattedTestResults = [ - ...prevTestResults.map( ( testResult ) => - formatTestResults( { - ...testResult, - // Don't output previous test results' stacks. - errorMessage: undefined, - } ) - ), - currentFormattedTestResults, - ].join( '\n
\n' ); - - issue = await api.updateIssue( { - issue_number: reportedIssue.number, - state: 'open', - body: renderIssueBody( { - meta, - testTitle, - testPath, - formattedTestResults, - } ), - } ); - } else if ( - ! reportedIssue && - // Don't create a flaky test issue if the test was run inside a PR. - ! isPR - ) { - issue = await api.createIssue( { - title: issueTitle, - body: renderIssueBody( { - meta: {}, - testTitle, - testPath, - formattedTestResults: currentFormattedTestResults, - } ), - labels: [ label ], - } ); - } - - if ( issue ) { - reportedIssues.push( { - testTitle, - testPath, - issueNumber: issue.number, - issueUrl: issue.html_url, - } ); - core.info( `Reported flaky test to ${ issue.html_url }` ); - } - } - - if ( reportedIssues.length === 0 ) { - return; - } + } ) + ); - const { html_url: commentUrl } = isPR - ? await api.createCommentOnPR( - // Cast the payload type: https://github.com/actions/toolkit/tree/main/packages/github#webhook-payload-typescript-definitions - ( github.context.payload as PullRequestEvent ).number, - renderCommitComment( { - runURL, - reportedIssues, - commitSHA: ( github.context.payload as PullRequestEvent ) - .pull_request.head.sha, - } ), - isReportComment - ) - : await api.createCommentOnCommit( - github.context.sha, - renderCommitComment( { - runURL, - reportedIssues, - commitSHA: github.context.sha, - } ), - isReportComment - ); + const outputPath = core.getInput( 'output-path', { required: true } ); - core.info( `Reported the summary of the flaky tests to ${ commentUrl }` ); -} + await fs.mkdir( path.dirname( outputPath ), { recursive: true } ); + await fs.writeFile( + outputPath, + renderReport( { reportedTests } ), + 'utf-8' + ); -function getIssueTitle( testTitle: string ) { - return `[Flaky Test] ${ testTitle }`; + core.info( `Wrote a report of ${ reportedTests.length } flaky tests.` ); } export { run }; diff --git a/packages/report-flaky-tests/src/types.ts b/packages/report-flaky-tests/src/types.ts index 5de8770ef75418..0a5fdc2040cc25 100644 --- a/packages/report-flaky-tests/src/types.ts +++ b/packages/report-flaky-tests/src/types.ts @@ -1,6 +1,3 @@ -/** - * External dependencies - */ import type { TestCaseResult } from '@jest/test-result'; import type { TestResult } from '@playwright/test/reporter'; @@ -24,15 +21,9 @@ export type FlakyTestResult = | JestCircusFlakyTestResult | PlaywrightTestFlakyTestResult; -export type MetaData = { - failedTimes?: number; - totalCommits?: number; - baseCommit?: string; -}; - -export type ReportedIssue = { +export type ReportedFlakyTest = { testTitle: string; testPath: string; - issueNumber: number; - issueUrl: string; + failedTimes: number; + errorMessage?: string; }; diff --git a/packages/report-flaky-tests/tsconfig.json b/packages/report-flaky-tests/tsconfig.json index ca8ad40492623d..b96c552907cb37 100644 --- a/packages/report-flaky-tests/tsconfig.json +++ b/packages/report-flaky-tests/tsconfig.json @@ -2,6 +2,9 @@ "$schema": "https://json.schemastore.org/tsconfig.json", "extends": "../../tsconfig.base.json", "compilerOptions": { + // Sources and tests alike import by their real `.ts` extension, which is + // what Node's type stripping resolves when the action runs. + "allowImportingTsExtensions": true, "types": [ "jest" ] }, "exclude": [ "src/__tests__", "src/__fixtures__" ] diff --git a/packages/upload-media/CHANGELOG.md b/packages/upload-media/CHANGELOG.md index 9d558ace6b2c63..f59b4607c47e37 100644 --- a/packages/upload-media/CHANGELOG.md +++ b/packages/upload-media/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +### Bug Fixes + +- A HEIC file that fails to convert for a reason other than a missing decoder, such as a damaged or truncated file or a canvas that could not be created, is no longer reported as the browser being unable to read HEIC. That message names a browser that would decode HEIC instead, which is no help when the codec was never the problem. It is now kept for the one case that earns it, where no decoding strategy is available at all; everything else reports a processing error ([#81123](https://github.com/WordPress/gutenberg/issues/81123)). + +### Internal + +- Add a private `getFailureCount` selector, a running tally of top-level items cancelled because they failed. Failed items leave the queue just like successful ones, so this is the only record that an upload did not make it ([#81132](https://github.com/WordPress/gutenberg/issues/81132)). + ### Breaking Changes - `vipsResizeImage`, `vipsCompressImage`, and `vipsConvertImageFormat` now accept their optional parameters (`smartCrop`, `addSuffix`, `signal`, `scaledSuffix`, `quality`, `interlaced`, `stripMeta`, `maxBitdepth`) as a single trailing `options` object instead of positional arguments ([#80328](https://github.com/WordPress/gutenberg/issues/80328)). diff --git a/packages/upload-media/src/canvas-utils.ts b/packages/upload-media/src/canvas-utils.ts index ad2dee96e368ae..95daec6361e1bb 100644 --- a/packages/upload-media/src/canvas-utils.ts +++ b/packages/upload-media/src/canvas-utils.ts @@ -2,9 +2,19 @@ * Internal dependencies */ import { getFileBasename } from './utils'; -import { parseHeic } from './heic-parser'; +import { parseHeic, type HeicImageData } from './heic-parser'; import { getHeicUnsupportedMessage } from './heic-support'; +/** + * Raised when no decoding strategy could be used at all. + * + * Separates "nothing here can decode HEIC" from a file that is damaged: only + * the former says anything about the browser's codecs, and the caller words + * the two differently. Any other error thrown by `canvasConvertToJpeg()` + * means the file itself could not be decoded. + */ +export class HeicUnsupportedError extends Error {} + /** * Converts an image file to JPEG using the browser's native decoder and canvas. * @@ -54,8 +64,12 @@ export async function canvasConvertToJpeg( bitmap.close(); } } catch { - // createImageBitmap doesn't support HEIC in this browser. - // Fall through to strategy 2. + /* + * Either this browser cannot decode HEIC through createImageBitmap, + * or it can and the file is damaged. The rejection looks the same + * both ways, so fall through: the container parse below tells a + * damaged file apart from a missing codec. + */ } // Strategy 2: WebCodecs ImageDecoder API. @@ -100,73 +114,89 @@ export async function canvasConvertToJpeg( } } + /* + * Parse the container before trying the last strategy. Parsing needs no + * codec, so a file that fails here is damaged, truncated or not a HEIC + * at all, whatever the browser: report that rather than blaming the + * codecs. This is the only signal Safari offers, since it decodes HEIC + * through createImageBitmap alone and that rejection is opaque. + */ + let heicData: HeicImageData; + try { + heicData = parseHeic( await file.arrayBuffer() ); + } catch ( error ) { + throw new Error( 'The HEIC file could not be parsed', { + cause: error, + } ); + } + // Strategy 3: HEIC container parsing + WebCodecs VideoDecoder. // Chrome 107+ on macOS supports HEVC *video* decoding via platform codecs // (macOS VideoToolbox), even though it doesn't support HEIC through image // APIs. A HEIC file is an ISOBMFF container with HEVC-encoded tiles — // we parse the container and decode each tile via VideoDecoder. if ( typeof VideoDecoder !== 'undefined' ) { + let supported = false; try { - const heicData = parseHeic( await file.arrayBuffer() ); - const support = await VideoDecoder.isConfigSupported( { codec: heicData.codecString, } ); + supported = support.supported === true; + } catch { + // A codec string the browser cannot even parse: not supported. + } - if ( support.supported ) { - const canvas = new OffscreenCanvas( - heicData.outputWidth, - heicData.outputHeight - ); - const ctx = canvas.getContext( '2d' ); + /* + * From here on the browser has said it decodes this codec, so a + * failure is the file's, not the browser's. Let it propagate. + */ + if ( supported ) { + const canvas = new OffscreenCanvas( + heicData.outputWidth, + heicData.outputHeight + ); + const ctx = canvas.getContext( '2d' ); - if ( ! ctx ) { - throw new Error( 'Could not get canvas 2d context' ); - } + if ( ! ctx ) { + throw new Error( 'Could not get canvas 2d context' ); + } - // Decode each tile and draw it at its grid position. - for ( const tile of heicData.tiles ) { - const frame = await decodeHevcFrame( - heicData.codecString, - heicData.description, - heicData.tileWidth, - heicData.tileHeight, - tile.data - ); - try { - ctx.drawImage( frame, tile.x, tile.y ); - } finally { - frame.close(); - } + // Decode each tile and draw it at its grid position. + for ( const tile of heicData.tiles ) { + const frame = await decodeHevcFrame( + heicData.codecString, + heicData.description, + heicData.tileWidth, + heicData.tileHeight, + tile.data + ); + try { + ctx.drawImage( frame, tile.x, tile.y ); + } finally { + frame.close(); } - - // Apply orientation: the native ISOBMFF `irot` transform when - // present, otherwise the EXIF orientation tag (libheif applies - // the former but ignores the latter for HEIF-family inputs). - const outputCanvas = - heicData.rotation !== 0 - ? applyRotation( canvas, heicData.rotation ) - : applyExifOrientation( - canvas, - heicData.exifOrientation - ); - - const jpegBlob = await outputCanvas.convertToBlob( { - type: 'image/jpeg', - quality, - } ); - - return new File( [ jpegBlob ], `${ baseName }.jpg`, { - type: 'image/jpeg', - } ); } - } catch { - // VideoDecoder HEVC not available or HEIC parsing failed. - // Fall through to error. + + // Apply orientation: the native ISOBMFF `irot` transform when + // present, otherwise the EXIF orientation tag (libheif applies + // the former but ignores the latter for HEIF-family inputs). + const outputCanvas = + heicData.rotation !== 0 + ? applyRotation( canvas, heicData.rotation ) + : applyExifOrientation( canvas, heicData.exifOrientation ); + + const jpegBlob = await outputCanvas.convertToBlob( { + type: 'image/jpeg', + quality, + } ); + + return new File( [ jpegBlob ], `${ baseName }.jpg`, { + type: 'image/jpeg', + } ); } } - throw new Error( getHeicUnsupportedMessage() ); + throw new HeicUnsupportedError( getHeicUnsupportedMessage() ); } /** diff --git a/packages/upload-media/src/heic-parser.ts b/packages/upload-media/src/heic-parser.ts index 594cee56f8df35..6bc7f5d11d20c5 100644 --- a/packages/upload-media/src/heic-parser.ts +++ b/packages/upload-media/src/heic-parser.ts @@ -512,6 +512,17 @@ function readItemData( ): Uint8Array { const baseOffset = loc.constructionMethod === 1 ? idatOffset : 0; + // An extent past the end of the buffer means the file was cut short, + // usually a partial copy or download. Slicing it would silently hand the + // decoder an empty or partial bitstream. + for ( const ext of loc.extents ) { + if ( baseOffset + ext.offset + ext.length > buffer.byteLength ) { + throw new Error( + 'HEIC item data extends past the end of the file' + ); + } + } + if ( loc.extents.length === 1 ) { const ext = loc.extents[ 0 ]; const start = baseOffset + ext.offset; diff --git a/packages/upload-media/src/store/private-actions.ts b/packages/upload-media/src/store/private-actions.ts index 401305d74841ec..facf5cb76bd3eb 100644 --- a/packages/upload-media/src/store/private-actions.ts +++ b/packages/upload-media/src/store/private-actions.ts @@ -20,7 +20,7 @@ import { isAnimatedGif, renameFile, } from '../utils'; -import { canvasConvertToJpeg } from '../canvas-utils'; +import { canvasConvertToJpeg, HeicUnsupportedError } from '../canvas-utils'; import { getHeicUnsupportedMessage } from '../heic-support'; import { getUnappliedExifOrientation } from '../heic-parser'; import { @@ -894,13 +894,28 @@ export function prepareItem( id: QueueItemId ) { file, settings.imageQuality ?? DEFAULT_OUTPUT_QUALITY ); - } catch { + } catch ( error ) { + /* + * Only the dead end where nothing could decode the file is + * about codec support. A decode that was attempted and + * failed, or a canvas that could not be created, says + * nothing about the browser, and sending the user off to + * install a different one would not help. + */ + const unsupported = error instanceof HeicUnsupportedError; dispatch.cancelItem( id, new UploadError( { - code: ErrorCode.HEIC_DECODE_ERROR, - message: getHeicUnsupportedMessage(), + code: unsupported + ? ErrorCode.HEIC_DECODE_ERROR + : ErrorCode.IMAGE_TRANSCODING_ERROR, + message: unsupported + ? getHeicUnsupportedMessage() + : __( + 'This HEIC image could not be converted. Try converting it to JPEG before uploading.' + ), file, + cause: error instanceof Error ? error : undefined, } ) ); return; diff --git a/packages/upload-media/src/store/private-selectors.ts b/packages/upload-media/src/store/private-selectors.ts index 9c0ccc192710d0..e4ebe95bd5cb64 100644 --- a/packages/upload-media/src/store/private-selectors.ts +++ b/packages/upload-media/src/store/private-selectors.ts @@ -20,6 +20,22 @@ export function getAllItems( state: State ): QueueItem[] { return state.queue; } +/** + * Returns how many top-level items have been cancelled because they failed. + * + * Failed items are removed from the queue just like successful ones, so an + * empty queue on its own says nothing about whether anything was uploaded. + * This tally only ever grows: to learn how many items failed within a single + * batch, read it when the batch starts and subtract that from its later value. + * + * @param state Upload state. + * + * @return Number of failed uploads since the editor loaded. + */ +export function getFailureCount( state: State ): number { + return state.failureCount; +} + /** * Returns a specific item given its unique ID. * diff --git a/packages/upload-media/src/store/reducer.ts b/packages/upload-media/src/store/reducer.ts index a87517e15a5ac0..47bd48a556a566 100644 --- a/packages/upload-media/src/store/reducer.ts +++ b/packages/upload-media/src/store/reducer.ts @@ -43,6 +43,7 @@ const DEFAULT_STATE: State = { maxConcurrentImageProcessing: DEFAULT_MAX_CONCURRENT_IMAGE_PROCESSING, retry: { ...DEFAULT_RETRY_SETTINGS }, }, + failureCount: 0, }; type Action = @@ -118,9 +119,22 @@ function reducer( queue: [ ...state.queue, action.item ], }; - case Type.Cancel: + case Type.Cancel: { + /* + * Only top-level items count as failures. A sub-size that fails on + * its own does not stop the attachment from being uploaded, and a + * sub-size failure bad enough to sink the whole attachment cancels + * the parent too - which is the cancellation counted here. + */ + const cancelled = state.queue.find( + ( item ) => item.id === action.id + ); return { ...state, + failureCount: + cancelled && ! cancelled.parentId + ? state.failureCount + 1 + : state.failureCount, queue: state.queue.map( ( item ): QueueItem => item.id === action.id @@ -131,6 +145,7 @@ function reducer( : item ), }; + } case Type.RetryItem: return { diff --git a/packages/upload-media/src/store/test/private-actions.js b/packages/upload-media/src/store/test/private-actions.js index 3df59b485cff89..f4e81cfeba4fbd 100644 --- a/packages/upload-media/src/store/test/private-actions.js +++ b/packages/upload-media/src/store/test/private-actions.js @@ -34,6 +34,7 @@ import { convertGifToVideo, terminateVideoConversionWorker, } from '../utils/video-conversion'; +import { canvasConvertToJpeg, HeicUnsupportedError } from '../../canvas-utils'; // Mock @wordpress/blob jest.mock( '@wordpress/blob', () => ( { @@ -58,6 +59,15 @@ jest.mock( '../utils', () => { }; } ); +// Mock the HEIC conversion so prepareItem's failure branch can be driven. +// HeicUnsupportedError is kept real: prepareItem tells the two failures apart +// with instanceof, so a stubbed class would make the test pass vacuously. +jest.mock( '../../canvas-utils', () => ( { + canvasConvertToJpeg: jest.fn(), + HeicUnsupportedError: + jest.requireActual( '../../canvas-utils' ).HeicUnsupportedError, +} ) ); + // Mock the video-conversion wrapper so the dynamic worker import is never // executed. isUnsupportedConversionError is kept real so transcodeGifItem's // graceful-skip vs. hard-failure branching is genuinely exercised. @@ -721,6 +731,61 @@ describe( 'private actions', () => { } ); } ); + describe( 'prepareItem HEIC conversion failures', () => { + async function runPrepareItem() { + const file = new File( [ 'data' ], 'IMG_1982.heic', { + type: 'image/heic', + } ); + const item = { id: 'heic-id', file, additionalData: {} }; + + const dispatch = () => {}; + dispatch.cancelItem = jest.fn(); + dispatch.finishOperation = jest.fn(); + + const select = { + getItem: () => item, + getSettings: () => ( {} ), + }; + + await prepareItem( 'heic-id' )( { select, dispatch } ); + + return dispatch; + } + + it( 'reports a browser with no HEIC decoder as a decode error', async () => { + canvasConvertToJpeg.mockRejectedValue( + new HeicUnsupportedError( 'No decoder here' ) + ); + + const dispatch = await runPrepareItem(); + + expect( dispatch.cancelItem ).toHaveBeenCalledWith( + 'heic-id', + expect.objectContaining( { + code: ErrorCode.HEIC_DECODE_ERROR, + } ) + ); + } ); + + it( 'reports a failed decode as a processing error, not a missing decoder', async () => { + // A damaged file, an out-of-memory canvas and a null 2d context + // all land here. None of them says the browser cannot read HEIC, + // so none should send the user off to install another one. + const cause = new Error( 'Corrupt image data' ); + canvasConvertToJpeg.mockRejectedValue( cause ); + + const dispatch = await runPrepareItem(); + + expect( dispatch.cancelItem ).toHaveBeenCalledWith( + 'heic-id', + expect.objectContaining( { + code: ErrorCode.IMAGE_TRANSCODING_ERROR, + cause, + } ) + ); + } ); + } ); + describe( 'transcodeGifItem', () => { const gifFile = new File( [ 'gif' ], 'animation.gif', { type: 'image/gif', diff --git a/packages/upload-media/src/store/test/reducer.ts b/packages/upload-media/src/store/test/reducer.ts index 9489e3952f3c93..2b0a7a76accf95 100644 --- a/packages/upload-media/src/store/test/reducer.ts +++ b/packages/upload-media/src/store/test/reducer.ts @@ -15,6 +15,7 @@ describe( 'reducer', () => { it( 'adds an item to the queue', () => { const initialState: State = { queueStatus: 'active', + failureCount: 0, blobUrls: {}, settings: { mediaUpload: jest.fn(), @@ -36,6 +37,7 @@ describe( 'reducer', () => { expect( state ).toEqual( { queueStatus: 'active', + failureCount: 0, blobUrls: {}, settings: { mediaUpload: expect.any( Function ), @@ -58,6 +60,7 @@ describe( 'reducer', () => { it( 'removes an item from the queue', () => { const initialState: State = { queueStatus: 'active', + failureCount: 0, blobUrls: {}, settings: { mediaUpload: jest.fn(), @@ -81,6 +84,7 @@ describe( 'reducer', () => { expect( state ).toEqual( { queueStatus: 'active', + failureCount: 1, blobUrls: {}, settings: { mediaUpload: expect.any( Function ), @@ -98,12 +102,61 @@ describe( 'reducer', () => { ], } ); } ); + + it( 'does not count a sub-size failure as a failed upload', () => { + const initialState: State = { + queueStatus: 'active', + failureCount: 0, + blobUrls: {}, + settings: { + mediaUpload: jest.fn(), + }, + queue: [ + { + id: '1', + status: ItemStatus.Processing, + } as QueueItem, + { + id: '1-thumb', + parentId: '1', + status: ItemStatus.Processing, + } as QueueItem, + ], + }; + const state = reducer( initialState, { + type: Type.Cancel, + id: '1-thumb', + error: new Error(), + } ); + + expect( state.failureCount ).toBe( 0 ); + } ); + + it( 'does not count a cancellation for an item that already left the queue', () => { + const initialState: State = { + queueStatus: 'active', + failureCount: 2, + blobUrls: {}, + settings: { + mediaUpload: jest.fn(), + }, + queue: [], + }; + const state = reducer( initialState, { + type: Type.Cancel, + id: '1', + error: new Error(), + } ); + + expect( state.failureCount ).toBe( 2 ); + } ); } ); describe( `${ Type.Remove }`, () => { it( 'removes an item from the queue', () => { const initialState: State = { queueStatus: 'active', + failureCount: 0, blobUrls: {}, settings: { mediaUpload: jest.fn(), @@ -126,6 +179,7 @@ describe( 'reducer', () => { expect( state ).toEqual( { queueStatus: 'active', + failureCount: 0, blobUrls: {}, settings: { mediaUpload: expect.any( Function ), @@ -144,6 +198,7 @@ describe( 'reducer', () => { it( 'appends operations to the list', () => { const initialState: State = { queueStatus: 'active', + failureCount: 0, blobUrls: {}, settings: { mediaUpload: jest.fn(), @@ -164,6 +219,7 @@ describe( 'reducer', () => { expect( state ).toEqual( { queueStatus: 'active', + failureCount: 0, blobUrls: {}, settings: { mediaUpload: expect.any( Function ), @@ -186,6 +242,7 @@ describe( 'reducer', () => { it( 'marks an item as processing', () => { const initialState: State = { queueStatus: 'active', + failureCount: 0, blobUrls: {}, settings: { mediaUpload: jest.fn(), @@ -211,6 +268,7 @@ describe( 'reducer', () => { expect( state ).toEqual( { queueStatus: 'active', + failureCount: 0, blobUrls: {}, settings: { mediaUpload: expect.any( Function ), @@ -236,6 +294,7 @@ describe( 'reducer', () => { it( 'marks an item as processing', () => { const initialState: State = { queueStatus: 'active', + failureCount: 0, blobUrls: {}, settings: { mediaUpload: jest.fn(), @@ -259,6 +318,7 @@ describe( 'reducer', () => { expect( state ).toEqual( { queueStatus: 'active', + failureCount: 0, blobUrls: {}, settings: { mediaUpload: expect.any( Function ), @@ -281,6 +341,7 @@ describe( 'reducer', () => { it( 'pauses a specific item', () => { const initialState: State = { queueStatus: 'active', + failureCount: 0, blobUrls: {}, settings: { mediaUpload: jest.fn(), @@ -310,6 +371,7 @@ describe( 'reducer', () => { it( 'resumes a paused item', () => { const initialState: State = { queueStatus: 'active', + failureCount: 0, blobUrls: {}, settings: { mediaUpload: jest.fn(), @@ -334,6 +396,7 @@ describe( 'reducer', () => { it( 'retries a failed item', () => { const initialState: State = { queueStatus: 'active', + failureCount: 0, blobUrls: {}, settings: { mediaUpload: jest.fn(), @@ -360,6 +423,7 @@ describe( 'reducer', () => { it( 'increments retry count on subsequent retries', () => { const initialState: State = { queueStatus: 'active', + failureCount: 0, blobUrls: {}, settings: { mediaUpload: jest.fn(), @@ -386,6 +450,7 @@ describe( 'reducer', () => { oldController.abort(); const initialState: State = { queueStatus: 'active', + failureCount: 0, blobUrls: {}, settings: { mediaUpload: jest.fn(), @@ -421,6 +486,7 @@ describe( 'reducer', () => { it( 'sets item status to PendingRetry', () => { const initialState: State = { queueStatus: 'active', + failureCount: 0, blobUrls: {}, settings: { mediaUpload: jest.fn(), @@ -446,6 +512,7 @@ describe( 'reducer', () => { it( 'sets error from action', () => { const initialState: State = { queueStatus: 'active', + failureCount: 0, blobUrls: {}, settings: { mediaUpload: jest.fn(), @@ -472,6 +539,7 @@ describe( 'reducer', () => { it( 'does not modify other items in queue', () => { const initialState: State = { queueStatus: 'active', + failureCount: 0, blobUrls: {}, settings: { mediaUpload: jest.fn(), @@ -505,6 +573,7 @@ describe( 'reducer', () => { it( 'transitions queueStatus from active to paused', () => { const initialState: State = { queueStatus: 'active', + failureCount: 0, blobUrls: {}, settings: { mediaUpload: jest.fn(), @@ -521,6 +590,7 @@ describe( 'reducer', () => { it( 'is idempotent when queue is already paused', () => { const initialState: State = { queueStatus: 'paused', + failureCount: 0, blobUrls: {}, settings: { mediaUpload: jest.fn(), @@ -537,6 +607,7 @@ describe( 'reducer', () => { it( 'preserves other state fields', () => { const initialState: State = { queueStatus: 'active', + failureCount: 0, blobUrls: { '1': [ 'blob:foo' ] }, settings: { mediaUpload: jest.fn(), @@ -561,6 +632,7 @@ describe( 'reducer', () => { it( 'transitions queueStatus from paused to active', () => { const initialState: State = { queueStatus: 'paused', + failureCount: 0, blobUrls: {}, settings: { mediaUpload: jest.fn(), @@ -577,6 +649,7 @@ describe( 'reducer', () => { it( 'is idempotent when queue is already active', () => { const initialState: State = { queueStatus: 'active', + failureCount: 0, blobUrls: {}, settings: { mediaUpload: jest.fn(), @@ -593,6 +666,7 @@ describe( 'reducer', () => { it( 'preserves item statuses', () => { const initialState: State = { queueStatus: 'paused', + failureCount: 0, blobUrls: {}, settings: { mediaUpload: jest.fn(), @@ -621,6 +695,7 @@ describe( 'reducer', () => { it( 'updates the progress of an item', () => { const initialState: State = { queueStatus: 'active', + failureCount: 0, blobUrls: {}, settings: { mediaUpload: jest.fn(), diff --git a/packages/upload-media/src/store/test/selectors.ts b/packages/upload-media/src/store/test/selectors.ts index bb028af26ed769..748b99e6224e61 100644 --- a/packages/upload-media/src/store/test/selectors.ts +++ b/packages/upload-media/src/store/test/selectors.ts @@ -9,6 +9,7 @@ import { } from '../selectors'; import { getActiveUploadCount, + getFailureCount, getActiveImageProcessingCount, getActiveVideoProcessingCount, getFailedItems, @@ -26,11 +27,28 @@ import { } from '../types'; describe( 'selectors', () => { + describe( 'getFailureCount', () => { + it( 'should return the number of failed uploads', () => { + const state: State = { + queue: [], + queueStatus: 'active', + failureCount: 3, + blobUrls: {}, + settings: { + mediaUpload: jest.fn(), + }, + }; + + expect( getFailureCount( state ) ).toBe( 3 ); + } ); + } ); + describe( 'getItems', () => { it( 'should return empty array by default', () => { const state: State = { queue: [], queueStatus: 'paused', + failureCount: 0, blobUrls: {}, settings: { mediaUpload: jest.fn(), @@ -56,6 +74,7 @@ describe( 'selectors', () => { }, ] as QueueItem[], queueStatus: 'paused', + failureCount: 0, blobUrls: {}, settings: { mediaUpload: jest.fn(), @@ -81,6 +100,7 @@ describe( 'selectors', () => { }, ] as QueueItem[], queueStatus: 'paused', + failureCount: 0, blobUrls: {}, settings: { mediaUpload: jest.fn(), @@ -108,6 +128,7 @@ describe( 'selectors', () => { }, ] as QueueItem[], queueStatus: 'paused', + failureCount: 0, blobUrls: {}, settings: { mediaUpload: jest.fn(), @@ -140,6 +161,7 @@ describe( 'selectors', () => { }, ] as QueueItem[], queueStatus: 'active', + failureCount: 0, blobUrls: {}, settings: { mediaUpload: jest.fn(), @@ -176,6 +198,7 @@ describe( 'selectors', () => { }, ] as QueueItem[], queueStatus: 'active', + failureCount: 0, blobUrls: {}, settings: { mediaUpload: jest.fn(), @@ -195,6 +218,7 @@ describe( 'selectors', () => { }, ] as QueueItem[], queueStatus: 'active', + failureCount: 0, blobUrls: {}, settings: { mediaUpload: jest.fn(), @@ -223,6 +247,7 @@ describe( 'selectors', () => { }, ] as QueueItem[], queueStatus: 'active', + failureCount: 0, blobUrls: {}, settings: { mediaUpload: jest.fn(), @@ -279,6 +304,7 @@ describe( 'selectors', () => { }, ] as QueueItem[], queueStatus: 'active', + failureCount: 0, blobUrls: {}, settings: { mediaUpload: jest.fn(), @@ -303,6 +329,7 @@ describe( 'selectors', () => { }, ] as QueueItem[], queueStatus: 'active', + failureCount: 0, blobUrls: {}, settings: { mediaUpload: jest.fn(), @@ -335,6 +362,7 @@ describe( 'selectors', () => { }, ] as QueueItem[], queueStatus: 'active', + failureCount: 0, blobUrls: {}, settings: { mediaUpload: jest.fn(), @@ -364,6 +392,7 @@ describe( 'selectors', () => { }, ] as QueueItem[], queueStatus: 'active', + failureCount: 0, blobUrls: {}, settings: { mediaUpload: jest.fn(), @@ -432,6 +461,7 @@ describe( 'selectors', () => { }, ] as QueueItem[], queueStatus: 'paused', + failureCount: 0, blobUrls: {}, settings: { mediaUpload: jest.fn(), @@ -455,6 +485,7 @@ describe( 'selectors', () => { }, ] as QueueItem[], queueStatus: 'paused', + failureCount: 0, blobUrls: {}, settings: { mediaUpload: jest.fn(), diff --git a/packages/upload-media/src/store/types.ts b/packages/upload-media/src/store/types.ts index 2b03b4416322de..4bc4e935cd2fda 100644 --- a/packages/upload-media/src/store/types.ts +++ b/packages/upload-media/src/store/types.ts @@ -64,6 +64,15 @@ export interface State { queueStatus: QueueStatus; blobUrls: Record< QueueItemId, string[] >; settings: Settings; + /** + * Running tally of top-level items cancelled because they failed. + * + * Cancelled items leave the queue exactly like successful ones, so this is + * the only record that an upload did not make it. It only ever increases; + * consumers interested in a single batch compare it against its value when + * that batch started. + */ + failureCount: number; } export enum Type { diff --git a/packages/upload-media/src/test/canvas-utils.ts b/packages/upload-media/src/test/canvas-utils.ts index 2bacb9be55cf78..5c6bce88deea2c 100644 --- a/packages/upload-media/src/test/canvas-utils.ts +++ b/packages/upload-media/src/test/canvas-utils.ts @@ -1,9 +1,30 @@ +/** + * External dependencies + */ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + /** * Internal dependencies */ -import { canvasConvertToJpeg } from '../canvas-utils'; +import { canvasConvertToJpeg, HeicUnsupportedError } from '../canvas-utils'; import { getHeicUnsupportedMessage } from '../heic-support'; +/* + * A small, valid HEIC. Every decoder is stubbed out below, so what the bytes + * decide is whether the container parses, which is how a damaged file is + * told apart from a browser without a codec. + */ +const validHeic = readFileSync( + join( __dirname, 'fixtures', 'exif-rotated-90cw.heic' ) +); + +function heicFile( bytes: Uint8Array ) { + return new File( [ new Uint8Array( bytes ) ], 'photo.heic', { + type: 'image/heic', + } ); +} + describe( 'canvasConvertToJpeg', () => { const originalCreateImageBitmap = global.createImageBitmap; const originalOffscreenCanvas = global.OffscreenCanvas; @@ -15,13 +36,13 @@ describe( 'canvasConvertToJpeg', () => { if ( originalCreateImageBitmap ) { global.createImageBitmap = originalCreateImageBitmap; } else { - // @ts-ignore + // @ts-expect-error The operand of `delete` must be optional. delete global.createImageBitmap; } if ( originalOffscreenCanvas ) { global.OffscreenCanvas = originalOffscreenCanvas; } else { - // @ts-ignore + // @ts-expect-error The operand of `delete` must be optional. delete global.OffscreenCanvas; } if ( originalImageDecoder ) { @@ -34,6 +55,7 @@ describe( 'canvasConvertToJpeg', () => { } else { delete ( global as any ).VideoDecoder; } + delete ( global as any ).EncodedVideoChunk; } ); describe( 'Strategy 1: createImageBitmap + OffscreenCanvas', () => { @@ -55,10 +77,14 @@ describe( 'canvasConvertToJpeg', () => { global.createImageBitmap = jest .fn() .mockResolvedValue( mockBitmap ); - global.OffscreenCanvas = jest.fn().mockImplementation( () => ( { - getContext: jest.fn().mockReturnValue( mockCtx ), - convertToBlob: jest.fn().mockResolvedValue( jpegBlob ), - } ) ); + global.OffscreenCanvas = jest + .fn() + .mockImplementation( function OffscreenCanvas() { + return { + getContext: jest.fn().mockReturnValue( mockCtx ), + convertToBlob: jest.fn().mockResolvedValue( jpegBlob ), + }; + } ); const file = new File( [ 'heic-data' ], 'photo.heic', { type: 'image/heic', @@ -83,12 +109,16 @@ describe( 'canvasConvertToJpeg', () => { global.createImageBitmap = jest .fn() .mockResolvedValue( mockBitmap ); - global.OffscreenCanvas = jest.fn().mockImplementation( () => ( { - getContext: jest - .fn() - .mockReturnValue( { drawImage: jest.fn() } ), - convertToBlob: mockConvertToBlob, - } ) ); + global.OffscreenCanvas = jest + .fn() + .mockImplementation( function OffscreenCanvas() { + return { + getContext: jest + .fn() + .mockReturnValue( { drawImage: jest.fn() } ), + convertToBlob: mockConvertToBlob, + }; + } ); const file = new File( [ 'data' ], 'photo.heic', { type: 'image/heic', @@ -110,12 +140,16 @@ describe( 'canvasConvertToJpeg', () => { global.createImageBitmap = jest .fn() .mockResolvedValue( mockBitmap ); - global.OffscreenCanvas = jest.fn().mockImplementation( () => ( { - getContext: jest - .fn() - .mockReturnValue( { drawImage: jest.fn() } ), - convertToBlob: jest.fn().mockResolvedValue( jpegBlob ), - } ) ); + global.OffscreenCanvas = jest + .fn() + .mockImplementation( function OffscreenCanvas() { + return { + getContext: jest + .fn() + .mockReturnValue( { drawImage: jest.fn() } ), + convertToBlob: jest.fn().mockResolvedValue( jpegBlob ), + }; + } ); const file = new File( [ 'data' ], 'my-photo.HEIC', { type: 'image/heic', @@ -130,22 +164,22 @@ describe( 'canvasConvertToJpeg', () => { global.createImageBitmap = jest .fn() .mockResolvedValue( mockBitmap ); - global.OffscreenCanvas = jest.fn().mockImplementation( () => ( { - getContext: jest.fn().mockReturnValue( null ), - convertToBlob: jest.fn(), - } ) ); + global.OffscreenCanvas = jest + .fn() + .mockImplementation( function OffscreenCanvas() { + return { + getContext: jest.fn().mockReturnValue( null ), + convertToBlob: jest.fn(), + }; + } ); // Remove other decoders so it falls through to the final error. delete ( global as any ).ImageDecoder; delete ( global as any ).VideoDecoder; - const file = new File( [ 'data' ], 'photo.heic', { - type: 'image/heic', - } ); - - await expect( canvasConvertToJpeg( file ) ).rejects.toThrow( - getHeicUnsupportedMessage() - ); + await expect( + canvasConvertToJpeg( heicFile( validHeic ) ) + ).rejects.toThrow( getHeicUnsupportedMessage() ); expect( mockBitmap.close ).toHaveBeenCalled(); } ); } ); @@ -160,12 +194,130 @@ describe( 'canvasConvertToJpeg', () => { delete ( global as any ).ImageDecoder; delete ( global as any ).VideoDecoder; + await expect( + canvasConvertToJpeg( heicFile( validHeic ) ) + ).rejects.toThrow( HeicUnsupportedError ); + } ); + + it( 'should not blame the codec for bytes that are not a HEIC', async () => { + global.createImageBitmap = jest + .fn() + .mockRejectedValue( new Error( 'Unsupported format' ) ); + delete ( global as any ).ImageDecoder; + delete ( global as any ).VideoDecoder; + + const error = await canvasConvertToJpeg( + heicFile( new TextEncoder().encode( 'not a heic file' ) ) + ).catch( ( e ) => e ); + + expect( error ).toBeInstanceOf( Error ); + expect( error ).not.toBeInstanceOf( HeicUnsupportedError ); + expect( error.cause ).toBeInstanceOf( Error ); + } ); + + it( 'should not blame the codec for a HEIC cut short of its pixel data', async () => { + global.createImageBitmap = jest + .fn() + .mockRejectedValue( new Error( 'Unsupported format' ) ); + delete ( global as any ).ImageDecoder; + delete ( global as any ).VideoDecoder; + + // Metadata intact, `mdat` missing: what a partial copy looks like. + const error = await canvasConvertToJpeg( + heicFile( validHeic.subarray( 0, 500 ) ) + ).catch( ( e ) => e ); + + expect( error ).not.toBeInstanceOf( HeicUnsupportedError ); + expect( error.cause.message ).toContain( + 'past the end of the file' + ); + } ); + + it( 'should not report a failed HEVC decode as unsupported', async () => { + global.createImageBitmap = jest + .fn() + .mockRejectedValue( new Error( 'Unsupported format' ) ); + delete ( global as any ).ImageDecoder; + + global.OffscreenCanvas = jest + .fn() + .mockImplementation( function OffscreenCanvas() { + return { + getContext: jest + .fn() + .mockReturnValue( { drawImage: jest.fn() } ), + }; + } ); + ( global as any ).EncodedVideoChunk = jest.fn(); + + // Strategy 3: the browser reports HEVC support, then the decode + // fails, which a damaged bitstream does. + ( global as any ).VideoDecoder = jest.fn( function () { + return { + state: 'configured', + configure: jest.fn(), + decode: jest.fn(), + flush: jest + .fn() + .mockRejectedValue( new Error( 'Decoding error' ) ), + close: jest.fn(), + }; + } ); + ( global as any ).VideoDecoder.isConfigSupported = jest + .fn() + .mockResolvedValue( { supported: true } ); + + const rejection = canvasConvertToJpeg( heicFile( validHeic ) ); + await expect( rejection ).rejects.toThrow( 'Decoding error' ); + await expect( rejection ).rejects.not.toBeInstanceOf( + HeicUnsupportedError + ); + } ); + + it( 'should treat a codec string the browser rejects as unsupported', async () => { + global.createImageBitmap = jest + .fn() + .mockRejectedValue( new Error( 'Unsupported format' ) ); + delete ( global as any ).ImageDecoder; + ( global as any ).VideoDecoder = { + isConfigSupported: jest + .fn() + .mockRejectedValue( new TypeError( 'Invalid codec' ) ), + }; + + await expect( + canvasConvertToJpeg( heicFile( validHeic ) ) + ).rejects.toThrow( HeicUnsupportedError ); + } ); + + it( 'should not report a failed decode as unsupported', async () => { + // Strategy 1 rejects, as it does for any HEIC in Chromium. + global.createImageBitmap = jest + .fn() + .mockRejectedValue( new Error( 'Unsupported format' ) ); + + // Strategy 2 supports the type, so the browser can decode HEIC. + // The decode itself fails, which a damaged file does. + ( global as any ).ImageDecoder = jest.fn( function () { + return { + decode: jest + .fn() + .mockRejectedValue( new Error( 'Corrupt image data' ) ), + close: jest.fn(), + }; + } ); + ( global as any ).ImageDecoder.isTypeSupported = jest + .fn() + .mockResolvedValue( true ); + const file = new File( [ 'data' ], 'photo.heic', { type: 'image/heic', } ); - await expect( canvasConvertToJpeg( file ) ).rejects.toThrow( - getHeicUnsupportedMessage() + const rejection = canvasConvertToJpeg( file ); + await expect( rejection ).rejects.toThrow( 'Corrupt image data' ); + await expect( rejection ).rejects.not.toBeInstanceOf( + HeicUnsupportedError ); } ); @@ -183,13 +335,9 @@ describe( 'canvasConvertToJpeg', () => { // No VideoDecoder. delete ( global as any ).VideoDecoder; - const file = new File( [ 'data' ], 'photo.heic', { - type: 'image/heic', - } ); - - await expect( canvasConvertToJpeg( file ) ).rejects.toThrow( - getHeicUnsupportedMessage() - ); + await expect( + canvasConvertToJpeg( heicFile( validHeic ) ) + ).rejects.toThrow( HeicUnsupportedError ); expect( ( global as any ).ImageDecoder.isTypeSupported diff --git a/packages/vips/CHANGELOG.md b/packages/vips/CHANGELOG.md index d14a81e0a130dd..6d118aeaf00792 100644 --- a/packages/vips/CHANGELOG.md +++ b/packages/vips/CHANGELOG.md @@ -4,16 +4,17 @@ ### Breaking Changes -- `resizeImage`, `compressImage`, and `convertImageFormat` now accept their optional parameters (`quality`, `interlaced`, `smartCrop`, `stripMeta`, `maxBitdepth`) as a single trailing `options` object instead of positional arguments ([#80328](https://github.com/WordPress/gutenberg/issues/80328)). +- `resizeImage`, `compressImage`, and `convertImageFormat` now accept their optional parameters (`quality`, `interlaced`, `smartCrop`, `stripMeta`, `maxBitdepth`) as a single trailing `options` object instead of positional arguments ([#80328](https://github.com/WordPress/gutenberg/issues/80328)). ### Enhancements -- Add optional `stripMeta` and `maxBitdepth` options to `resizeImage`, `compressImage`, and `convertImageFormat`, so callers can keep all image metadata and cap the output bit depth, mirroring WordPress core's `image_strip_meta` and `image_max_bit_depth` filters ([#80216](https://github.com/WordPress/gutenberg/issues/80216)). +- Add optional `stripMeta` and `maxBitdepth` options to `resizeImage`, `compressImage`, and `convertImageFormat`, so callers can keep all image metadata and cap the output bit depth, mirroring WordPress core's `image_strip_meta` and `image_max_bit_depth` filters ([#80216](https://github.com/WordPress/gutenberg/issues/80216)). ### Bug Fixes -- `convertImageFormat`: Only decode all animation frames when the output format supports animation. Converting a long animated GIF to a still format (e.g. a JPEG poster) previously decoded every frame as one vertical strip, which exceeded the JPEG encoder's 65,500 px dimension limit and produced a film-strip image instead of a single frame ([#80259](https://github.com/WordPress/gutenberg/issues/80259)). -- `resizeImage`: Generate sub-sizes of animated images from the first frame only, matching WordPress core's server-side behavior. Re-encoding a full animated GIF per sub-size took tens of seconds per size for long GIFs and could produce sub-sizes larger than the original file ([#80266](https://github.com/WordPress/gutenberg/issues/80266)). +- Keep indexed (palette) PNGs indexed when resizing, compressing, converting, or rotating them, instead of re-encoding them as truecolour, which made sub-sizes several times larger than the original. The image quality setting is not applied to the resulting palette, since PNG output stays lossless. Reported against core as https://core.trac.wordpress.org/ticket/65922 ([#81895](https://github.com/WordPress/gutenberg/issues/81895)). +- `convertImageFormat`: Only decode all animation frames when the output format supports animation. Converting a long animated GIF to a still format (e.g. a JPEG poster) previously decoded every frame as one vertical strip, which exceeded the JPEG encoder's 65,500 px dimension limit and produced a film-strip image instead of a single frame ([#80259](https://github.com/WordPress/gutenberg/issues/80259)). +- `resizeImage`: Generate sub-sizes of animated images from the first frame only, matching WordPress core's server-side behavior. Re-encoding a full animated GIF per sub-size took tens of seconds per size for long GIFs and could produce sub-sizes larger than the original file ([#80266](https://github.com/WordPress/gutenberg/issues/80266)). ## 2.4.0 (2026-07-14) diff --git a/packages/vips/src/index.ts b/packages/vips/src/index.ts index 9eeb6a2f18520b..b2226ae6436b73 100644 --- a/packages/vips/src/index.ts +++ b/packages/vips/src/index.ts @@ -210,6 +210,15 @@ export async function convertImageFormat( saveOptions.interlace = interlaced; } + // Keep indexed sources indexed. libvips decodes a palette image into + // RGB(A) pixels, so without this a compressed or converted PNG is + // written as truecolour and can grow several times larger than the + // indexed original. + // See https://core.trac.wordpress.org/ticket/65922. + if ( 'image/png' === outputType && isPaletteImage( image ) ) { + saveOptions.palette = true; + } + // See https://github.com/swissspidy/media-experiments/issues/324. if ( 'image/avif' === outputType ) { saveOptions.effort = 2; @@ -402,6 +411,25 @@ function applyResizeAndCrop< return result; } +/** + * Determines whether a decoded image came from an indexed (palette) source. + * + * libvips decodes indexed images into full RGB(A) pixels and flags the source + * encoding with a `palette` metadata field. Writing such an image back out + * without asking for quantisation produces a truecolour PNG, which for + * palette-based artwork is typically several times larger than the indexed + * original. See https://core.trac.wordpress.org/ticket/65922. + * + * libvips only attaches the field when the source was indexed, so its presence + * is the signal. `getTypeof` reports the GType of a field, or 0 when absent. + * + * @param image Decoded vips image. + * @return Whether the source image used a palette. + */ +function isPaletteImage( image: Vips.Image ): boolean { + return image.getTypeof( 'palette' ) !== 0; +} + /** * Reads the source bit depth of a decoded HEIF/AVIF image. * @@ -413,9 +441,7 @@ function applyResizeAndCrop< * @param image Decoded vips image. * @return Source bit depth (typically 8, 10, or 12). */ -function getSourceBitdepth< T extends { getInt: ( name: string ) => number } >( - image: T -): number { +function getSourceBitdepth( image: Vips.Image ): number { try { const bitdepth = image.getInt( 'heif-bitdepth' ); if ( bitdepth > 8 ) { @@ -453,19 +479,30 @@ function resolveSaveBitdepth( /** * Builds save options for writing an image to a buffer. * - * @param type Output mime type. - * @param quality Desired quality (0-1). - * @param bitdepth Save bit depth; values above 8 are preserved for AVIF. - * @param stripMeta Whether to strip metadata (except color profiles), - * from the `image_strip_meta` filter. + * @param options Save option inputs. + * @param options.type Output mime type. + * @param options.quality Desired quality (0-1). + * @param options.bitdepth Save bit depth; values above 8 are preserved for + * AVIF. + * @param options.stripMeta Whether to strip metadata (except color + * profiles), from the `image_strip_meta` filter. + * @param options.isPalette Whether the source image was indexed (palette) + * encoded. * @return Save options object. */ -function buildSaveOptions( - type: string, - quality: number, +function buildSaveOptions( { + type, + quality, bitdepth = 8, - stripMeta = true -): SaveOptions< typeof type > { + stripMeta = true, + isPalette = false, +}: { + type: string; + quality: number; + bitdepth?: number; + stripMeta?: boolean; + isPalette?: boolean; +} ): SaveOptions< string > { const saveOptions: SaveOptions< typeof type > = { // Strip metadata except ICC color profiles or gainmaps, // matching WordPress core's behavior. The `image_strip_meta` @@ -477,6 +514,14 @@ function buildSaveOptions( saveOptions.Q = quality * 100; } + // Keep indexed sources indexed. libvips decodes a palette PNG into RGB(A) + // pixels, so without this the sub-sizes are written as truecolour and can + // end up larger than the indexed original. + // See https://core.trac.wordpress.org/ticket/65922. + if ( 'image/png' === type && isPalette ) { + saveOptions.palette = true; + } + // See https://github.com/swissspidy/media-experiments/issues/324. if ( 'image/avif' === type ) { saveOptions.effort = 2; @@ -655,12 +700,13 @@ export async function resizeImage( } ); - const saveOptions = buildSaveOptions( + const saveOptions = buildSaveOptions( { type, quality, - saveBitdepth, - stripMeta - ); + bitdepth: saveBitdepth, + stripMeta, + isPalette: isPaletteImage( sourceImage ), + } ); const outBuffer = image.writeToBuffer( `.${ ext }`, saveOptions ); const result = { @@ -793,6 +839,10 @@ export async function rotateImage( } }; + // Read this off the source, before the transforms below replace + // `image` with their result. + const isPalette = isPaletteImage( image ); + // Apply transformation based on EXIF orientation. // See: https://exiftool.org/TagNames/EXIF.html#:~:text=0x0112,Orientation switch ( orientation ) { @@ -839,6 +889,15 @@ export async function rotateImage( image.remove( 'orientation' ); const saveOptions: SaveOptions< typeof type > = {}; + + // Keep indexed sources indexed, as the resize and convert paths do. + // Rotating writes the full-size file, so losing the palette here + // inflates the image the user actually uploaded. + // See https://github.com/WordPress/gutenberg/issues/81895. + if ( 'image/png' === type && isPalette ) { + saveOptions.palette = true; + } + const outBuffer = image.writeToBuffer( `.${ ext }`, saveOptions ); const result = { diff --git a/packages/vips/src/test/convert-image-format.ts b/packages/vips/src/test/convert-image-format.ts index dbfb96fda30418..b02dca5fb3edd9 100644 --- a/packages/vips/src/test/convert-image-format.ts +++ b/packages/vips/src/test/convert-image-format.ts @@ -3,6 +3,15 @@ */ import { convertImageFormat, compressImage } from '../'; +/* + * Controls whether the mocked source image reports the `palette` metadata + * field, which libvips attaches only for indexed sources. + */ +const mockState = { hasPalette: false }; + +// GType of `gint`. Only whether `getTypeof` returns non-zero matters here. +const G_TYPE_INT = 24; + const mockNewFromBuffer = jest.fn( () => new MockImage() ); const mockWriteToBuffer = jest.fn( () => ( { buffer: '', @@ -13,7 +22,23 @@ class MockImage { height = 100; pageHeight = 100; writeToBuffer = mockWriteToBuffer; - getInt = jest.fn( () => 0 ); + /* + * Mirrors libvips: reading a field the image does not carry throws + * rather than returning a falsy default. The production helpers rely + * on that, so the mock has to throw too or their fallbacks would + * never be exercised. + */ + getInt = jest.fn( ( name: string ) => { + throw new Error( `${ name }: no such field` ); + } ); + /* + * libvips only attaches `palette` when the source was indexed, so + * presence is the signal. Absent fields report GType 0 rather than + * throwing. + */ + getTypeof = jest.fn( ( name: string ) => + 'palette' === name && mockState.hasPalette ? G_TYPE_INT : 0 + ); } class MockVipsImage { @@ -32,6 +57,7 @@ jest.mock( 'wasm-vips', () => describe( 'convertImageFormat', () => { afterEach( () => { jest.clearAllMocks(); + mockState.hasPalette = false; } ); it( 'loads only the first frame when converting a GIF to JPEG', async () => { @@ -97,4 +123,70 @@ describe( 'convertImageFormat', () => { expect( mockNewFromBuffer ).toHaveBeenCalledWith( buffer, '', {} ); } ); + + describe( 'indexed (palette) PNG', () => { + /* + * Regression tests for https://core.trac.wordpress.org/ticket/65922. + * libvips decodes an indexed PNG into RGB(A) pixels, so pngsave has to + * be told to quantise back down. Otherwise compressing or converting + * to PNG rewrites the image as truecolour and inflates it. + */ + it( 'quantises a compressed indexed PNG back to a palette', async () => { + mockState.hasPalette = true; + const pngFile = new File( [ '' ], 'example.png', { + type: 'image/png', + } ); + const buffer = await pngFile.arrayBuffer(); + + await compressImage( 'itemId', buffer, 'image/png' ); + + expect( mockWriteToBuffer ).toHaveBeenCalledWith( + '.png', + expect.objectContaining( { palette: true } ) + ); + // `Q` is pngsave's quantisation quality and only applies once + // `palette` is on, so the lossy image quality must not leak in. + expect( mockWriteToBuffer ).toHaveBeenCalledWith( + '.png', + expect.not.objectContaining( { Q: expect.anything() } ) + ); + } ); + + it( 'leaves a truecolour PNG unquantised', async () => { + // libvips attaches `palette` only for an indexed source, so a + // truecolour PNG carries no such field. + mockState.hasPalette = false; + const pngFile = new File( [ '' ], 'example.png', { + type: 'image/png', + } ); + const buffer = await pngFile.arrayBuffer(); + + await compressImage( 'itemId', buffer, 'image/png' ); + + expect( mockWriteToBuffer ).toHaveBeenCalledWith( + '.png', + expect.not.objectContaining( { palette: expect.anything() } ) + ); + } ); + + it( 'does not quantise when converting an indexed source away from PNG', async () => { + mockState.hasPalette = true; + const pngFile = new File( [ '' ], 'example.png', { + type: 'image/png', + } ); + const buffer = await pngFile.arrayBuffer(); + + await convertImageFormat( + 'itemId', + buffer, + 'image/png', + 'image/webp' + ); + + expect( mockWriteToBuffer ).toHaveBeenCalledWith( + '.webp', + expect.not.objectContaining( { palette: expect.anything() } ) + ); + } ); + } ); } ); diff --git a/packages/vips/src/test/resize-image.ts b/packages/vips/src/test/resize-image.ts index 9cc7e2c6cfdfc8..86196736ff1dc9 100644 --- a/packages/vips/src/test/resize-image.ts +++ b/packages/vips/src/test/resize-image.ts @@ -14,7 +14,18 @@ const mockWriteToBuffer = jest.fn( () => ( { // Controls the `heif-bitdepth` value reported by the mocked source image so // tests can exercise both the standard (8-bit) and high-bit-depth code paths. -let mockBitdepth = 8; +/* + * Controls the metadata fields reported by the mocked source image. + * `undefined` means the field is absent, which is the common case: a JPEG + * carries neither `heif-bitdepth` nor `palette`. + */ +const mockState: { bitdepth: number | undefined; hasPalette: boolean } = { + bitdepth: undefined, + hasPalette: false, +}; + +// GType of `gint`. Only whether `getTypeof` returns non-zero matters here. +const G_TYPE_INT = 24; class MockImage { width = 100; @@ -23,8 +34,23 @@ class MockImage { crop = mockCrop; resize = mockResize; writeToBuffer = mockWriteToBuffer; - getInt = jest.fn( ( name: string ) => - 'heif-bitdepth' === name ? mockBitdepth : 0 + /* + * Mirrors libvips: reading a field the image does not carry throws rather + * than returning a falsy default. The production helpers rely on that, so + * the mock has to throw too or their fallbacks would never be exercised. + */ + getInt = jest.fn( ( name: string ) => { + if ( 'heif-bitdepth' === name && undefined !== mockState.bitdepth ) { + return mockState.bitdepth; + } + throw new Error( `${ name }: no such field` ); + } ); + /* + * libvips only attaches `palette` when the source was indexed, so presence + * is the signal. Absent fields report GType 0 rather than throwing. + */ + getTypeof = jest.fn( ( name: string ) => + 'palette' === name && mockState.hasPalette ? G_TYPE_INT : 0 ); } @@ -45,7 +71,8 @@ jest.mock( 'wasm-vips', () => describe( 'resizeImage', () => { afterEach( () => { jest.clearAllMocks(); - mockBitdepth = 8; + mockState.bitdepth = undefined; + mockState.hasPalette = false; } ); it( 'resizes without crop', async () => { @@ -262,7 +289,7 @@ describe( 'resizeImage', () => { describe( 'high-bit-depth AVIF', () => { it( 'preserves bit depth when resizing a 10-bit AVIF without crop', async () => { - mockBitdepth = 10; + mockState.bitdepth = 10; const avifFile = new File( [ '' ], 'example.avif', { type: 'image/avif', } ); @@ -285,7 +312,7 @@ describe( 'resizeImage', () => { } ); it( 'centre-crops a 12-bit AVIF while preserving bit depth', async () => { - mockBitdepth = 12; + mockState.bitdepth = 12; const avifFile = new File( [ '' ], 'example.avif', { type: 'image/avif', } ); @@ -307,7 +334,7 @@ describe( 'resizeImage', () => { } ); it( 'crops a 10-bit AVIF to a position while preserving bit depth', async () => { - mockBitdepth = 10; + mockState.bitdepth = 10; const avifFile = new File( [ '' ], 'example.avif', { type: 'image/avif', } ); @@ -331,7 +358,7 @@ describe( 'resizeImage', () => { } ); it( 'uses the standard thumbnail path for an 8-bit AVIF', async () => { - mockBitdepth = 8; + mockState.bitdepth = 8; const avifFile = new File( [ '' ], 'example.avif', { type: 'image/avif', } ); @@ -395,7 +422,7 @@ describe( 'resizeImage', () => { describe( 'image_max_bit_depth', () => { it( 'caps a 12-bit AVIF at 10-bit', async () => { - mockBitdepth = 12; + mockState.bitdepth = 12; const avifFile = new File( [ '' ], 'example.avif', { type: 'image/avif', } ); @@ -422,7 +449,7 @@ describe( 'resizeImage', () => { } ); it( 'snaps an unsupported cap down to the nearest valid depth', async () => { - mockBitdepth = 12; + mockState.bitdepth = 12; const avifFile = new File( [ '' ], 'example.avif', { type: 'image/avif', } ); @@ -446,7 +473,7 @@ describe( 'resizeImage', () => { } ); it( 'flattens a 10-bit AVIF via the thumbnail path when capped at 8-bit', async () => { - mockBitdepth = 10; + mockState.bitdepth = 10; const avifFile = new File( [ '' ], 'example.avif', { type: 'image/avif', } ); @@ -474,7 +501,7 @@ describe( 'resizeImage', () => { } ); it( 'ignores the cap for sources at or below it', async () => { - mockBitdepth = 10; + mockState.bitdepth = 10; const avifFile = new File( [ '' ], 'example.avif', { type: 'image/avif', } ); @@ -497,4 +524,74 @@ describe( 'resizeImage', () => { ); } ); } ); + describe( 'indexed (palette) PNG', () => { + /* + * Regression tests for https://core.trac.wordpress.org/ticket/65922. + * libvips decodes an indexed PNG into RGB(A) pixels, so pngsave has to + * be told to quantise back down. Otherwise every sub-size is written + * as truecolour and can be larger than the indexed original. + */ + it( 'quantises sub-sizes of an indexed PNG back to a palette', async () => { + mockState.hasPalette = true; + const pngFile = new File( [ '' ], 'example.png', { + type: 'image/png', + } ); + const buffer = await pngFile.arrayBuffer(); + + await resizeImage( 'itemId', buffer, 'image/png', { + width: 50, + height: 50, + } ); + + expect( mockWriteToBuffer ).toHaveBeenCalledWith( + '.png', + expect.objectContaining( { palette: true } ) + ); + // `Q` is pngsave's quantisation quality and only applies once + // `palette` is on, so the lossy image quality must not leak in. + expect( mockWriteToBuffer ).toHaveBeenCalledWith( + '.png', + expect.not.objectContaining( { Q: expect.anything() } ) + ); + } ); + + it( 'leaves a truecolour PNG unquantised', async () => { + // libvips attaches `palette` only for an indexed source, so a + // truecolour PNG carries no such field. + mockState.hasPalette = false; + const pngFile = new File( [ '' ], 'example.png', { + type: 'image/png', + } ); + const buffer = await pngFile.arrayBuffer(); + + await resizeImage( 'itemId', buffer, 'image/png', { + width: 50, + height: 50, + } ); + + expect( mockWriteToBuffer ).toHaveBeenCalledWith( + '.png', + expect.not.objectContaining( { palette: expect.anything() } ) + ); + } ); + + it( 'does not quantise non-PNG output from an indexed source', async () => { + // A GIF is always indexed, but only pngsave takes `palette`. + mockState.hasPalette = true; + const gifFile = new File( [ '' ], 'example.gif', { + type: 'image/gif', + } ); + const buffer = await gifFile.arrayBuffer(); + + await resizeImage( 'itemId', buffer, 'image/gif', { + width: 50, + height: 50, + } ); + + expect( mockWriteToBuffer ).toHaveBeenCalledWith( + '.gif', + expect.not.objectContaining( { palette: expect.anything() } ) + ); + } ); + } ); } ); diff --git a/packages/vips/src/test/rotate-image-integration.ts b/packages/vips/src/test/rotate-image-integration.ts index a24a685a1a5ede..ea5b3082f0c259 100644 --- a/packages/vips/src/test/rotate-image-integration.ts +++ b/packages/vips/src/test/rotate-image-integration.ts @@ -60,6 +60,18 @@ const loadFixture = ( file: string ): ArrayBuffer => { const isRed = ( [ r, , b ]: number[] ) => r > 128 && r > b; const isBlue = ( [ r, , b ]: number[] ) => b > 128 && b > r; +/** + * Colour type 3 in a PNG header means the image is palette (indexed) encoded. + */ +const PNG_COLOR_TYPE_INDEXED = 3; + +/* + * A PNG's IHDR chunk always comes first: an 8-byte signature, a 4-byte length, + * the 4-byte chunk type, then width (4), height (4), bit depth (1) and colour + * type (1). That puts the colour type at a fixed offset of 25. + */ +const pngColorType = ( bytes: Uint8Array ): number => bytes[ 25 ]; + describe( 'rotateImage EXIF orientation fixtures', () => { let vips: any; @@ -192,4 +204,35 @@ describe( 'rotateImage EXIF orientation fixtures', () => { rotated.delete(); } ); + + it( 'keeps a rotated indexed PNG indexed', async () => { + /* + * Rotating is the third path that writes a PNG, alongside resizing and + * converting. It writes the full-size file, so dropping the palette + * here inflates the image the user actually uploaded. + * See https://github.com/WordPress/gutenberg/issues/81895. + */ + const source = vips.Image.newFromBuffer( + loadFixture( 'exif-rotated-90cw.jpg' ) + ); + const indexedPng: Uint8Array = source.writeToBuffer( '.png', { + palette: true, + } ); + source.delete(); + + // Premise check: the generated source really is indexed, so a + // truecolour result below can only come from the rotation. + expect( pngColorType( indexedPng ) ).toBe( PNG_COLOR_TYPE_INDEXED ); + + const result = await rotateImage( + 'test-item', + indexedPng.buffer as ArrayBuffer, + 'image/png', + 6 + ); + + expect( pngColorType( new Uint8Array( result.buffer ) ) ).toBe( + PNG_COLOR_TYPE_INDEXED + ); + } ); } ); diff --git a/packages/vips/src/test/rotate-image.ts b/packages/vips/src/test/rotate-image.ts index 475672021e9095..63a3a29a02e92d 100644 --- a/packages/vips/src/test/rotate-image.ts +++ b/packages/vips/src/test/rotate-image.ts @@ -13,6 +13,19 @@ import { rotateImage } from '../'; let calls: string[]; let removed: string[]; +/* + * Controls whether the mocked source image reports the `palette` metadata + * field, which libvips attaches only for indexed sources. + */ +let mockHasPalette = false; + +// GType of `gint`. Only whether `getTypeof` returns non-zero matters here. +const G_TYPE_INT = 24; + +const mockWriteToBuffer = jest.fn( () => ( { + buffer: new ArrayBuffer( 0 ), +} ) ); + class MockImage { width = 100; height = 100; @@ -44,9 +57,14 @@ class MockImage { removed.push( field ); return true; } ); - writeToBuffer = jest.fn( () => ( { - buffer: new ArrayBuffer( 0 ), - } ) ); + writeToBuffer = mockWriteToBuffer; + /* + * libvips only attaches `palette` when the source was indexed, so presence + * is the signal. Absent fields report GType 0. + */ + getTypeof = jest.fn( ( name: string ) => + 'palette' === name && mockHasPalette ? G_TYPE_INT : 0 + ); } class MockVipsImage { @@ -66,6 +84,7 @@ describe( 'rotateImage', () => { beforeEach( () => { calls = []; removed = []; + mockHasPalette = false; } ); afterEach( () => { @@ -107,4 +126,61 @@ describe( 'rotateImage', () => { expect( removed ).toContain( 'orientation' ); } ); + describe( 'indexed (palette) PNG', () => { + /* + * Rotating is the third path that writes a PNG, alongside resizing and + * converting. libvips decodes an indexed PNG into RGB(A) pixels, so + * without asking pngsave to quantise back down a rotated PNG is + * rewritten as truecolour and inflates - here on the full-size file + * rather than a sub-size. + * See https://github.com/WordPress/gutenberg/issues/81895. + */ + async function rotatePng( orientation: number ) { + const file = new File( [ '' ], 'example.png', { + type: 'image/png', + } ); + const buffer = await file.arrayBuffer(); + await rotateImage( 'itemId', buffer, 'image/png', orientation ); + } + + it( 'quantises a rotated indexed PNG back to a palette', async () => { + mockHasPalette = true; + + await rotatePng( 6 ); + + expect( mockWriteToBuffer ).toHaveBeenCalledWith( + '.png', + expect.objectContaining( { palette: true } ) + ); + } ); + + it( 'leaves a rotated truecolour PNG unquantised', async () => { + // libvips attaches `palette` only for an indexed source, so a + // truecolour PNG carries no such field. + mockHasPalette = false; + + await rotatePng( 6 ); + + expect( mockWriteToBuffer ).toHaveBeenCalledWith( + '.png', + expect.not.objectContaining( { palette: expect.anything() } ) + ); + } ); + + it( 'does not quantise a rotated non-PNG', async () => { + // A GIF is indexed too, but only pngsave takes `palette`. + mockHasPalette = true; + const file = new File( [ '' ], 'example.gif', { + type: 'image/gif', + } ); + const buffer = await file.arrayBuffer(); + + await rotateImage( 'itemId', buffer, 'image/gif', 6 ); + + expect( mockWriteToBuffer ).toHaveBeenCalledWith( + '.gif', + expect.not.objectContaining( { palette: expect.anything() } ) + ); + } ); + } ); } ); diff --git a/packages/vips/src/test/ultrahdr.ts b/packages/vips/src/test/ultrahdr.ts index d8f5da2bbee543..3c803e72c9818d 100644 --- a/packages/vips/src/test/ultrahdr.ts +++ b/packages/vips/src/test/ultrahdr.ts @@ -23,6 +23,9 @@ class MockImage { crop = mockCrop.mockImplementation( () => this ); copy = jest.fn( () => this ); setImage = mockSetImage; + // These sources are never indexed: libvips reports GType 0 for a field the + // image does not carry. + getTypeof = jest.fn( () => 0 ); } class MockVipsImage { diff --git a/packages/vips/src/types.ts b/packages/vips/src/types.ts index 1a1b5ce5486c37..ec0ca08c58aaca 100644 --- a/packages/vips/src/types.ts +++ b/packages/vips/src/types.ts @@ -191,6 +191,14 @@ export interface SaveOptions< T extends string > { * flattening them to 8-bit. */ bitdepth?: number; + /** + * Quantise to an 8bpp palette. Only supported by PNG. + * Do not provide for any other type! + * + * Used to keep indexed (palette) sources indexed instead of writing them + * back out as truecolour RGB/RGBA. + */ + palette?: boolean; } export interface ThumbnailOptions { diff --git a/packages/vips/src/utils.ts b/packages/vips/src/utils.ts index 924d28110b597e..0d17fe899b875c 100644 --- a/packages/vips/src/utils.ts +++ b/packages/vips/src/utils.ts @@ -1,6 +1,12 @@ /** * Determines whether a given file type supports a quality setting, * + * PNG is deliberately absent: `Q` is pngsave's quantisation quality rather + * than a lossy compression level, libvips only reads it once `palette` is on, + * and PNG output in WordPress core is lossless. Passing the image quality + * through would start degrading the palette of indexed images. + * See https://core.trac.wordpress.org/ticket/65922. + * * @todo Make this smarter. * * @param type Mime type. @@ -8,10 +14,8 @@ */ export function supportsQuality( type: string -): type is 'image/jpeg' | 'image/png' | 'image/webp' | 'image/avif' { - return [ 'image/jpeg', 'image/png', 'image/webp', 'image/avif' ].includes( - type - ); +): type is 'image/jpeg' | 'image/webp' | 'image/avif' { + return [ 'image/jpeg', 'image/webp', 'image/avif' ].includes( type ); } /** diff --git a/packages/wp-build/CHANGELOG.md b/packages/wp-build/CHANGELOG.md index 021062779e0675..1cfeb4350c47a5 100644 --- a/packages/wp-build/CHANGELOG.md +++ b/packages/wp-build/CHANGELOG.md @@ -9,6 +9,7 @@ ### Bug Fixes +- Pages: require authentication and a configurable capability (new `capability` page setting, default `manage_options`) before rendering generated standalone pages, so `admin_init` entry points such as `admin-post.php` no longer serve them to logged-out visitors ([#82254](https://github.com/WordPress/gutenberg/pull/82254)). - Pass the current `$hook_suffix` to the `admin_footer` action in the generated single-page admin template instead of an empty string ([#75985](https://github.com/WordPress/gutenberg/pull/75985)). - Sync the page template `/?_fields=` preload path with the `_fields` list in `@wordpress/core-data` so the preload is consumed instead of the page issuing a duplicate request ([#80648](https://github.com/WordPress/gutenberg/pull/80648)). - Scope the generated page template's critical styles to `body.js` so the no-JS notice stays visible ([#80628](https://github.com/WordPress/gutenberg/pull/80628)). diff --git a/packages/wp-build/README.md b/packages/wp-build/README.md index f549ec0e8b0145..186cc8840375a9 100644 --- a/packages/wp-build/README.md +++ b/packages/wp-build/README.md @@ -287,9 +287,10 @@ Pages can be defined as simple strings or as objects with initialization modules **Page Configuration:** - **String format**: `"my-admin-page"` - Simple page with no init modules -- **Object format**: `{ "id": "page-slug", "init": ["@scope/package"], "experimental": true }` - Page with optional init modules +- **Object format**: `{ "id": "page-slug", "init": ["@scope/package"], "capability": "edit_theme_options", "experimental": true }` - Page with optional init modules - **`id`** (required): The page slug used in WordPress admin URLs - **`init`** (optional): Array of script module IDs to execute during page initialization + - **`capability`** (optional, default `manage_options`): Capability a user needs to view the page. Full-page mode enforces this itself, since it renders outside the menu page callback flow; match it to the capability used when registering the page's menu entry. - **`experimental`** (optional, default `false`): When `true`, the page is excluded from WordPress Core builds (`IS_WORDPRESS_CORE=true`), along with any route that belongs only to experimental pages. **Generated Files:** diff --git a/packages/wp-build/lib/build.mjs b/packages/wp-build/lib/build.mjs index ae4ad71d68bf23..00960bf66a4263 100755 --- a/packages/wp-build/lib/build.mjs +++ b/packages/wp-build/lib/build.mjs @@ -115,6 +115,11 @@ const HANDLE_PREFIX = WP_PLUGIN_CONFIG.handlePrefix || PACKAGE_NAMESPACE; const EXTERNAL_NAMESPACES = WP_PLUGIN_CONFIG.externalNamespaces || {}; const PAGES = WP_PLUGIN_CONFIG.pages || []; +// Capability required to view a generated page when the page config does not +// declare one. Pages rendering outside the menu page callback flow must +// enforce this themselves. +const DEFAULT_PAGE_CAPABILITY = 'manage_options'; + /** * Interprets a configuration value as a boolean, where `"true"` and `"1"` * are considered true while all other values are false. @@ -1301,6 +1306,7 @@ async function generatePagesPhp( pageData, replacements ) { '{{PREFIX}}': prefixUnderscore, '{{INIT_MODULES_PHP_ARRAY}}': initModulesPhp, '{{INIT_MODULES_JSON}}': JSON.stringify( page.initModules ), + '{{CAPABILITY}}': page.capability || DEFAULT_PAGE_CAPABILITY, }; // Generate both page.php and page-wp-admin.php @@ -2278,13 +2284,19 @@ async function buildAll( baseUrlExpression ) { // Normalize PAGES config to support both string and object formats const normalizedPages = PAGES.map( ( page ) => { if ( typeof page === 'string' ) { - return { id: page, init: [], title: undefined }; + return { + id: page, + init: [], + title: undefined, + capability: DEFAULT_PAGE_CAPABILITY, + }; } return { id: page.id, init: page.init || [], title: page.title || undefined, experimental: page.experimental || false, + capability: page.capability || DEFAULT_PAGE_CAPABILITY, }; } ); @@ -2347,6 +2359,7 @@ async function buildAll( baseUrlExpression ) { routes: pageRoutes, initModules: page.init, title: page.title, + capability: page.capability, }; } ); diff --git a/packages/wp-build/lib/test/php-generator.js b/packages/wp-build/lib/test/php-generator.js new file mode 100644 index 00000000000000..6ee9c7c76f9b93 --- /dev/null +++ b/packages/wp-build/lib/test/php-generator.js @@ -0,0 +1,27 @@ +/** + * Internal dependencies + */ +import { renderTemplateToString as generateTemplate } from '../php-generator.mjs'; + +describe( 'page.php template', () => { + it( 'guards the admin_init interceptor with authentication and the configured capability', async () => { + const generatedPage = await generateTemplate( 'page.php.template', { + '{{PAGE_SLUG}}': 'example', + '{{PAGE_SLUG_UNDERSCORE}}': 'example', + '{{PREFIX}}': 'wp', + '{{CAPABILITY}}': 'edit_theme_options', + } ); + + const interceptor = generatedPage.slice( + generatedPage.indexOf( 'function wp_example_intercept_render()' ) + ); + + expect( interceptor ).toContain( 'is_user_logged_in()' ); + expect( interceptor ).toContain( + "current_user_can( 'edit_theme_options' )" + ); + expect( interceptor.indexOf( 'current_user_can' ) ).toBeLessThan( + interceptor.indexOf( 'wp_example_render_page()' ) + ); + } ); +} ); diff --git a/packages/wp-build/templates/page.php.template b/packages/wp-build/templates/page.php.template index 93cfbb5678fdc5..37f8e2bb9400da 100644 --- a/packages/wp-build/templates/page.php.template +++ b/packages/wp-build/templates/page.php.template @@ -310,6 +310,22 @@ function {{PREFIX}}_{{PAGE_SLUG_UNDERSCORE}}_render_page() { function {{PREFIX}}_{{PAGE_SLUG_UNDERSCORE}}_intercept_render() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( isset( $_GET['page'] ) && '{{PAGE_SLUG}}' === $_GET['page'] ) { + // The page renders outside the menu page callback flow, so it must + // enforce authentication and capability checks itself. Without this, + // any admin entry point firing `admin_init` (such as admin-post.php, + // which serves logged-out requests) would render the page for + // unauthenticated visitors. + if ( ! is_user_logged_in() ) { + auth_redirect(); + } + + if ( ! current_user_can( '{{CAPABILITY}}' ) ) { + wp_die( + __( 'Sorry, you are not allowed to access this page.' ), + 403 + ); + } + {{PREFIX}}_{{PAGE_SLUG_UNDERSCORE}}_render_page(); exit; } diff --git a/phpunit/class-wp-theme-json-test.php b/phpunit/class-wp-theme-json-test.php index 0cc743e91c917b..4baea439bec8fe 100644 --- a/phpunit/class-wp-theme-json-test.php +++ b/phpunit/class-wp-theme-json-test.php @@ -7604,6 +7604,187 @@ public function test_opt_in_to_block_style_variations() { $this->assertEquals( $expected, $button_variations ); } + /** + * Tests that a block style variation declaring `spacing.blockGap` emits a layout + * gap rule scoped to the variation. + * + * The variation node carries the variation slug in its `name`, which + * `get_layout_styles()` reads as a block name. A slug is never a registered block, + * so without care the layout rules for every variation are dropped. + * + * @covers WP_Theme_JSON_Gutenberg::get_styles_for_block + */ + public function test_block_style_variation_with_block_gap_emits_layout_styles() { + $registry = WP_Block_Styles_Registry::get_instance(); + $registry->register( 'core/group', array( 'name' => 'custom-group' ) ); + + $theme_json = new WP_Theme_JSON_Gutenberg( + array( + 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, + 'settings' => array( + 'spacing' => array( + 'blockGap' => true, + ), + ), + 'styles' => array( + 'blocks' => array( + 'core/group' => array( + 'variations' => array( + 'custom-group' => array( + 'spacing' => array( + 'blockGap' => '3em', + ), + ), + ), + ), + ), + ), + ), + 'blocks' + ); + + $stylesheet = $theme_json->get_stylesheet( + array( 'styles' ), + array( 'custom' ), + array( + 'include_block_style_variations' => true, + 'skip_root_layout_styles' => true, + ) + ); + + $registry->unregister( 'core/group', 'custom-group' ); + + $this->assertStringContainsString( + ':root :where(.wp-block-group.is-style-custom-group.wp-block-group-is-layout-flex){gap: 3em;}', + $stylesheet, + 'The variation should emit a gap rule scoped to itself.' + ); + } + + /** + * Tests that a block style variation declaring `spacing.blockGap` inside a + * viewport breakpoint emits a layout gap rule within the media query. + * + * The responsive branch takes its own copy of the variation metadata, so it + * needs the variation slug removed for the same reason the base branch does. + * + * @covers WP_Theme_JSON_Gutenberg::get_styles_for_block + */ + public function test_block_style_variation_with_responsive_block_gap_emits_layout_styles() { + $registry = WP_Block_Styles_Registry::get_instance(); + $registry->register( 'core/group', array( 'name' => 'custom-group' ) ); + + $theme_json = new WP_Theme_JSON_Gutenberg( + array( + 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, + 'settings' => array( + 'spacing' => array( + 'blockGap' => true, + ), + 'viewport' => array( + 'mobile' => '599px', + ), + ), + 'styles' => array( + 'blocks' => array( + 'core/group' => array( + 'variations' => array( + 'custom-group' => array( + 'spacing' => array( + 'blockGap' => '3em', + ), + '@mobile' => array( + 'spacing' => array( + 'blockGap' => '1em', + ), + ), + ), + ), + ), + ), + ), + ), + 'blocks' + ); + + $stylesheet = $theme_json->get_stylesheet( + array( 'styles' ), + array( 'custom' ), + array( + 'include_block_style_variations' => true, + 'skip_root_layout_styles' => true, + ) + ); + + $registry->unregister( 'core/group', 'custom-group' ); + + $this->assertStringContainsString( + '@media (width <= 599px)', + $stylesheet, + 'The breakpoint media query should be emitted.' + ); + $this->assertStringContainsString( + ':root :where(.wp-block-group.is-style-custom-group.wp-block-group-is-layout-flex){gap: 1em;}', + $stylesheet, + 'The variation should emit a gap rule inside the breakpoint.' + ); + } + + /** + * Tests that the layout support check still applies to a variation's blockGap. + * + * The variation metadata carries the block it belongs to, so a block without + * layout support emits no gap rule -- the check is answered, not skipped. + * + * @covers WP_Theme_JSON_Gutenberg::get_styles_for_block + */ + public function test_block_style_variation_block_gap_respects_layout_support() { + $registry = WP_Block_Styles_Registry::get_instance(); + $registry->register( 'core/paragraph', array( 'name' => 'custom-paragraph' ) ); + + $theme_json = new WP_Theme_JSON_Gutenberg( + array( + 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, + 'settings' => array( + 'spacing' => array( + 'blockGap' => true, + ), + ), + 'styles' => array( + 'blocks' => array( + 'core/paragraph' => array( + 'variations' => array( + 'custom-paragraph' => array( + 'spacing' => array( + 'blockGap' => '3em', + ), + ), + ), + ), + ), + ), + ), + 'blocks' + ); + + $stylesheet = $theme_json->get_stylesheet( + array( 'styles' ), + array( 'custom' ), + array( + 'include_block_style_variations' => true, + 'skip_root_layout_styles' => true, + ) + ); + + $registry->unregister( 'core/paragraph', 'custom-paragraph' ); + + $this->assertStringNotContainsString( + 'is-style-custom-paragraph', + $stylesheet, + 'core/paragraph has no layout support, so its variation should emit no gap rule.' + ); + } + /** * This test covers `get_block_nodes` with the `$include_node_paths_only` option. * When `true`, `$include_node_paths_only` should return only the paths of the block nodes. diff --git a/phpunit/media/class-gutenberg-rest-attachments-controller-test.php b/phpunit/media/class-gutenberg-rest-attachments-controller-test.php index 1b657e73984ec7..5c1690d1badbcc 100644 --- a/phpunit/media/class-gutenberg-rest-attachments-controller-test.php +++ b/phpunit/media/class-gutenberg-rest-attachments-controller-test.php @@ -1538,6 +1538,201 @@ public function test_finalize_writes_regular_sub_sizes() { $this->assertSame( 15000, $metadata['sizes']['medium']['filesize'] ); } + /** + * Verifies that the finalize response carries the generated sub-sizes. + * + * The response is prepared after the sub-size metadata has been written, so + * it is the finished attachment record. The editor stores it as-is instead + * of fetching the attachment again to pick the sizes up, which is what + * keeps a client-side upload correct when a refetch would be answered with + * the pre-finalize record - either by an out-of-order response or by a host + * cache keyed on the request URL. + * + * @link https://github.com/WordPress/gutenberg/issues/81844 + * + * @covers ::finalize_item + */ + public function test_finalize_response_contains_generated_sub_sizes() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=finalize-response-test.jpg' ); + $request->set_param( 'generate_sub_sizes', false ); + $request->set_body( file_get_contents( DIR_TESTDATA . '/images/canola.jpg' ) ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $attachment_id = $data['id']; + + // Nothing has generated sub-sizes yet, which is the window in which the + // editor's first read of the attachment happens. + $this->assertEmpty( + (array) $data['media_details']['sizes'], + 'The create response should not carry sub-sizes yet.' + ); + + $request = new WP_REST_Request( 'POST', "/wp/v2/media/$attachment_id/sideload" ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=finalize-response-test-150x150.jpg' ); + $request->set_param( 'image_size', 'thumbnail' ); + $request->set_body( file_get_contents( DIR_TESTDATA . '/images/test-image.jpg' ) ); + + $response = rest_get_server()->dispatch( $request ); + $thumbnail_data = $response->get_data(); + $this->assertSame( 200, $response->get_status(), 'Sideloading the thumbnail should succeed.' ); + + $request = new WP_REST_Request( 'POST', "/wp/v2/media/$attachment_id/finalize" ); + $request->set_param( 'sub_sizes', array( $thumbnail_data ) ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'media_details', $data ); + $this->assertArrayHasKey( + 'thumbnail', + $data['media_details']['sizes'], + 'The finalize response should list the sideloaded sub-size.' + ); + + // source_url is what the Image block's Resolution control offers. + $this->assertArrayHasKey( + 'source_url', + $data['media_details']['sizes']['thumbnail'], + 'Each sub-size in the finalize response should carry its URL.' + ); + $this->assertStringEndsWith( + 'finalize-response-test-150x150.jpg', + $data['media_details']['sizes']['thumbnail']['source_url'] + ); + + // The finalize response must match what a later read would return, as + // the editor stores it in place of that read. + $request = new WP_REST_Request( 'GET', "/wp/v2/media/$attachment_id" ); + $request->set_param( 'context', 'view' ); + $fetched = rest_get_server()->dispatch( $request )->get_data(); + + $this->assertSame( + array_keys( (array) $fetched['media_details']['sizes'] ), + array_keys( (array) $data['media_details']['sizes'] ), + 'The finalize response should carry the same sizes a refetch would.' + ); + } + + /** + * Verifies that the finalize response reflects the post row as it stands + * after the metadata has been generated. + * + * finalize_item() applies the 'wp_generate_attachment_metadata' filter + * before preparing its response, and a callback is free to rewrite the + * attachment's post row - an optimizer that converts the file updates + * post_mime_type, for instance. The editor stores this response as its + * copy of the record rather than reading the attachment again, so + * anything stale here is what the block keeps for the session. + * + * @link https://github.com/WordPress/gutenberg/issues/81844 + * + * @covers ::finalize_item + */ + public function test_finalize_response_reflects_post_row_changed_by_metadata_filter() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=filtered-post-row.jpg' ); + $request->set_param( 'generate_sub_sizes', false ); + $request->set_param( 'title', 'Original title' ); + $request->set_body( file_get_contents( DIR_TESTDATA . '/images/canola.jpg' ) ); + + $attachment_id = rest_get_server()->dispatch( $request )->get_data()['id']; + + $request = new WP_REST_Request( 'POST', "/wp/v2/media/$attachment_id/sideload" ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=filtered-post-row-150x150.jpg' ); + $request->set_param( 'image_size', 'thumbnail' ); + $request->set_body( file_get_contents( DIR_TESTDATA . '/images/test-image.jpg' ) ); + + $thumbnail_data = rest_get_server()->dispatch( $request )->get_data(); + + // Stands in for a plugin that rewrites the post row as the metadata is + // generated. Added after the upload so only finalize runs it. + add_filter( + 'wp_generate_attachment_metadata', + static function ( $metadata, $id ) { + wp_update_post( + array( + 'ID' => $id, + 'post_title' => 'Rewritten while generating metadata', + ) + ); + return $metadata; + }, + 10, + 2 + ); + + $request = new WP_REST_Request( 'POST', "/wp/v2/media/$attachment_id/finalize" ); + $request->set_param( 'sub_sizes', array( $thumbnail_data ) ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 200, $response->get_status() ); + + $this->assertSame( + 'Rewritten while generating metadata', + get_post( $attachment_id )->post_title, + 'The filter should have rewritten the stored post row.' + ); + + $this->assertSame( + 'Rewritten while generating metadata', + $response->get_data()['title']['raw'], + 'The finalize response should carry the rewritten post row, not the row as it was before the metadata was generated.' + ); + } + + /** + * Verifies that finalize fails when the attachment no longer exists by the + * time its response is prepared. + * + * A 'wp_generate_attachment_metadata' callback that rejects the file and + * deletes the attachment must not be answered with a 200 built from the row + * as it was before the callback ran: the editor would store that stale + * record and report the upload as complete. + * + * @link https://github.com/WordPress/gutenberg/issues/81844 + * + * @covers ::finalize_item + */ + public function test_finalize_fails_when_metadata_filter_deletes_attachment() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=deleted-while-finalizing.jpg' ); + $request->set_param( 'generate_sub_sizes', false ); + $request->set_body( file_get_contents( DIR_TESTDATA . '/images/canola.jpg' ) ); + + $attachment_id = rest_get_server()->dispatch( $request )->get_data()['id']; + + add_filter( + 'wp_generate_attachment_metadata', + static function ( $metadata, $id ) { + wp_delete_attachment( $id, true ); + return $metadata; + }, + 10, + 2 + ); + + $request = new WP_REST_Request( 'POST', "/wp/v2/media/$attachment_id/finalize" ); + $request->set_param( 'sub_sizes', array() ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_post_invalid_id', $response, 404 ); + } + /** * Verifies that finalize writes scaled sub-size metadata correctly. * diff --git a/test/e2e/assets/2000x1200_e2e_test_image_indexed.png b/test/e2e/assets/2000x1200_e2e_test_image_indexed.png new file mode 100644 index 00000000000000..b0c116f85a7c99 Binary files /dev/null and b/test/e2e/assets/2000x1200_e2e_test_image_indexed.png differ diff --git a/test/e2e/assets/64x64_e2e_test_image.heic b/test/e2e/assets/64x64_e2e_test_image.heic new file mode 100644 index 00000000000000..868e8dc90d8e28 Binary files /dev/null and b/test/e2e/assets/64x64_e2e_test_image.heic differ diff --git a/test/e2e/specs/editor/blocks/gallery.spec.js b/test/e2e/specs/editor/blocks/gallery.spec.js index f25d59f6cffcd9..d8b9ee952eaa55 100644 --- a/test/e2e/specs/editor/blocks/gallery.spec.js +++ b/test/e2e/specs/editor/blocks/gallery.spec.js @@ -19,6 +19,7 @@ test.use( { test.describe( 'Gallery', () => { let uploadedMedia; + let landscapeMedia; test.beforeAll( async ( { requestUtils } ) => { await requestUtils.deleteAllMedia(); @@ -26,6 +27,9 @@ test.describe( 'Gallery', () => { uploadedMedia = await requestUtils.uploadMedia( './assets/10x10_e2e_test_image_z9T8jK.png' ); + landscapeMedia = await requestUtils.uploadMedia( + './assets/200x150_e2e_test_image_opaque.png' + ); } ); test.afterEach( async ( { requestUtils } ) => { @@ -282,6 +286,167 @@ test.describe( 'Gallery', () => { ); expect( numbers ).not.toEqual( imageAltTexts ); } ); + + // Regression test for https://github.com/WordPress/gutenberg/issues/82252. + test( 'crops linked images to a uniform height in the editor canvas', async ( { + admin, + editor, + } ) => { + await admin.createNewPost(); + await editor.insertBlock( { + name: 'core/gallery', + attributes: { + linkTo: 'media', + }, + innerBlocks: [ + { + name: 'core/image', + attributes: { + id: landscapeMedia.id, + url: landscapeMedia.source_url, + sizeSlug: 'full', + linkDestination: 'media', + href: landscapeMedia.source_url, + }, + }, + { + name: 'core/image', + attributes: { + id: uploadedMedia.id, + url: uploadedMedia.source_url, + sizeSlug: 'full', + linkDestination: 'media', + href: uploadedMedia.source_url, + }, + }, + ], + } ); + + const images = editor.canvas.locator( + 'role=document[name="Block: Gallery"i] >> role=img' + ); + await expect( images ).toHaveCount( 2 ); + + // With cropping enabled, both images in the row render at the same + // height. Poll so the assertion waits for both images to finish + // loading and for the layout to settle. + await expect + .poll( async () => { + const landscapeBox = await images.nth( 0 ).boundingBox(); + const squareBox = await images.nth( 1 ).boundingBox(); + if ( ! landscapeBox || ! squareBox ) { + return Number.POSITIVE_INFINITY; + } + return Math.abs( landscapeBox.height - squareBox.height ); + } ) + .toBeLessThanOrEqual( 1 ); + } ); + + test( 'does not crop images to a uniform height when cropping is disabled', async ( { + admin, + editor, + } ) => { + await admin.createNewPost(); + await editor.insertBlock( { + name: 'core/gallery', + attributes: { + imageCrop: false, + }, + innerBlocks: [ + { + name: 'core/image', + attributes: { + id: landscapeMedia.id, + url: landscapeMedia.source_url, + sizeSlug: 'full', + }, + }, + { + name: 'core/image', + attributes: { + id: uploadedMedia.id, + url: uploadedMedia.source_url, + sizeSlug: 'full', + }, + }, + ], + } ); + + const images = editor.canvas.locator( + 'role=document[name="Block: Gallery"i] >> role=img' + ); + await expect( images ).toHaveCount( 2 ); + + // With cropping disabled, each image renders at its natural aspect + // ratio, so the two images have different heights. Poll so the + // assertion waits for both images to finish loading. + await expect + .poll( async () => { + const landscapeBox = await images.nth( 0 ).boundingBox(); + const squareBox = await images.nth( 1 ).boundingBox(); + if ( ! landscapeBox || ! squareBox ) { + return 0; + } + return Math.abs( landscapeBox.height - squareBox.height ); + } ) + .toBeGreaterThan( 10 ); + } ); + + // Regression test for the dynamic gallery preview, where the gallery's + // layout must be passed to `useBlockPreview` for the crop to apply. + test( 'crops images to a uniform height in a dynamic gallery', async ( { + admin, + editor, + requestUtils, + } ) => { + // A dynamic gallery resolves the images attached to the post being + // edited, so create a post and attach two different-aspect-ratio + // images to it. + const post = await requestUtils.createPost( { + title: 'Dynamic gallery', + status: 'draft', + } ); + for ( const file of [ + './assets/200x150_e2e_test_image_opaque.png', + './assets/10x10_e2e_test_image_z9T8jK.png', + ] ) { + const media = await requestUtils.uploadMedia( file ); + await requestUtils.rest( { + method: 'POST', + path: `/wp/v2/media/${ media.id }`, + data: { post: post.id }, + } ); + } + + await admin.editPost( post.id ); + await editor.insertBlock( { + name: 'core/gallery', + attributes: { + dynamicContent: { source: 'core/attached-media' }, + linkTo: 'media', + }, + } ); + + const images = editor.canvas.locator( + '[data-type="core/gallery"] img' + ); + // Wait for the dynamic source to resolve and preview both images. + await expect( images ).toHaveCount( 2 ); + + // With cropping enabled, both previewed images render at the same + // height. Poll so the assertion waits for the images to load and the + // layout to settle. + await expect + .poll( async () => { + const firstBox = await images.nth( 0 ).boundingBox(); + const secondBox = await images.nth( 1 ).boundingBox(); + if ( ! firstBox || ! secondBox ) { + return Number.POSITIVE_INFINITY; + } + return Math.abs( firstBox.height - secondBox.height ); + } ) + .toBeLessThanOrEqual( 1 ); + } ); } ); class GalleryBlockUtils { diff --git a/test/e2e/specs/editor/blocks/image.spec.js b/test/e2e/specs/editor/blocks/image.spec.js index 15aed29b7e40ed..9bcf9fcba047b8 100644 --- a/test/e2e/specs/editor/blocks/image.spec.js +++ b/test/e2e/specs/editor/blocks/image.spec.js @@ -1066,6 +1066,109 @@ test.describe( 'Image - Site editor', () => { } ); } ); +// Regression test for https://github.com/WordPress/gutenberg/pull/70575. +test.describe( 'Image - dimensions forced by global styles', () => { + let uploadedMedia; + + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.deleteAllMedia(); + uploadedMedia = await requestUtils.uploadMedia( + './assets/200x150_e2e_test_image_opaque.png' + ); + + // Mimic a theme that forces a fixed height on image blocks. User + // global styles load after the block's own stylesheet, so this rule + // wins the equal-specificity cascade against the block's `height: auto` + // unless the block sets an inline `height: auto` (which always wins). + const stylesPostId = + await requestUtils.getCurrentThemeGlobalStylesPostId(); + await requestUtils.rest( { + method: 'POST', + path: `/wp/v2/global-styles/${ stylesPostId }`, + data: { + id: stylesPostId, + styles: { + css: '.wp-block-image img { height: 100px; }', + }, + }, + } ); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await requestUtils.resetThemeGlobalStyles(); + await requestUtils.deleteAllMedia(); + await requestUtils.deleteAllPosts(); + } ); + + test( 'preserves the aspect ratio when only the width is set', async ( { + admin, + editor, + } ) => { + await admin.createNewPost(); + await editor.insertBlock( { + name: 'core/image', + attributes: { + id: uploadedMedia.id, + url: uploadedMedia.source_url, + sizeSlug: 'full', + width: '200px', + }, + } ); + + const image = editor.canvas.locator( + 'role=document[name="Block: Image"i] >> role=img' + ); + await expect( image ).toBeVisible(); + await image.evaluate( ( img ) => img.decode() ); + + const box = await image.boundingBox(); + // The 200x150 image displayed at 200px wide keeps its 4:3 aspect + // ratio (150px tall) instead of being squished to the 100px height + // forced by global styles. + expect( box.width / box.height ).toBeCloseTo( 4 / 3, 1 ); + } ); + + test( 'preserves the aspect ratio for images in a grid gallery', async ( { + admin, + editor, + } ) => { + await admin.createNewPost(); + await editor.insertBlock( { + name: 'core/gallery', + attributes: { + layout: { type: 'grid' }, + }, + innerBlocks: [ + { + name: 'core/image', + attributes: { + id: uploadedMedia.id, + url: uploadedMedia.source_url, + sizeSlug: 'full', + }, + }, + ], + } ); + + // The grid variation relabels the block to "Gallery Grid", so select + // by data-type rather than by the block's accessible name. + const image = editor.canvas.locator( + '[data-type="core/gallery"] [data-type="core/image"] img' + ); + await expect( image ).toBeVisible(); + + // Grid galleries do not crop images, so the image keeps its baseline + // `height: auto` and is not squished by the global styles. Poll so the + // assertion waits for the image to load and the layout to settle. + await expect + .poll( async () => { + const box = await image.boundingBox(); + return box ? box.width / box.height : 0; + } ) + .toBeCloseTo( 4 / 3, 1 ); + } ); +} ); + class ImageBlockUtils { constructor( { page } ) { /** @type {Page} */ diff --git a/test/e2e/specs/editor/blocks/list.spec.js b/test/e2e/specs/editor/blocks/list.spec.js index 8e484fea5832c8..9a748ae831fc6b 100644 --- a/test/e2e/specs/editor/blocks/list.spec.js +++ b/test/e2e/specs/editor/blocks/list.spec.js @@ -1102,6 +1102,68 @@ test.describe( 'List (@firefox)', () => { // the list. ;) } ); + test( 'should outdent an empty middle item without adding an item', async ( { + editor, + page, + } ) => { + await editor.canvas + .locator( 'role=button[name="Add default block"i]' ) + .click(); + + await page.keyboard.type( '* a' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( ' b' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( 'c' ); + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.press( 'ArrowUp' ); + + await expect.poll( editor.getEditedPostContent ).toBe( + ` +
    +
  • a +
      +
    • b
    • + + + +
    • + + + +
    • c
    • +
    +
  • +
+` + ); + + await page.keyboard.press( 'Backspace' ); + // Type to also verify the caret stays in the outdented item. + await page.keyboard.type( 'x' ); + + await expect.poll( editor.getEditedPostContent ).toBe( + ` +
    +
  • a +
      +
    • b
    • +
    +
  • + + + +
  • x +
      +
    • c
    • +
    +
  • +
+` + ); + } ); + test( 'should place the caret in the right place with nested list', async ( { editor, page, diff --git a/test/e2e/specs/editor/various/client-side-media-processing.spec.js b/test/e2e/specs/editor/various/client-side-media-processing.spec.js index 53db3ff370467e..092d1e78aa365a 100644 --- a/test/e2e/specs/editor/various/client-side-media-processing.spec.js +++ b/test/e2e/specs/editor/various/client-side-media-processing.spec.js @@ -26,6 +26,9 @@ const wasmVipsEntry = pathToFileURL( * WordPress dependencies */ const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); +const { + skipIfClientSideMediaInactive, +} = require( './client-side-media-utils' ); /** * Probes a remote JPEG for an embedded UltraHDR gain map. @@ -52,6 +55,35 @@ async function probeUltraHdrUrl( url ) { }; } +/** + * Colour type 3 in a PNG header means the image is palette (indexed) encoded. + * + * @type {number} + */ +const PNG_COLOR_TYPE_INDEXED = 3; + +/** + * Downloads a PNG and reads its colour type straight out of the IHDR chunk. + * + * The IHDR chunk always comes first: an 8-byte signature, a 4-byte length, the + * 4-byte chunk type, then width (4), height (4), bit depth (1) and colour + * type (1). That puts the colour type at a fixed offset of 25. + * + * @param {string} url PNG URL to fetch. + * @return {Promise<{ colorType: number, byteLength: number }>} Probe result. + */ +async function probePngUrl( url ) { + const response = await fetch( url ); + if ( ! response.ok ) { + throw new Error( `Failed to fetch ${ url }: ${ response.status }` ); + } + const bytes = new Uint8Array( await response.arrayBuffer() ); + return { + colorType: bytes[ 25 ], + byteLength: bytes.byteLength, + }; +} + /** * @typedef {import('@playwright/test').Page} Page */ @@ -113,41 +145,12 @@ class MediaProcessingUtils { /** * Skip the test unless the client-side media processing pipeline is the - * active upload path. This mirrors the gate used in the editor's - * media-upload util: the global flag must be set AND the browser must - * meet the feature detection requirements (cross-origin isolation, - * SharedArrayBuffer, Web Workers, WebAssembly). + * active upload path. * - * @param {import('@playwright/test').TestInfo} testInstance The test object for skipping. + * @param {import('@playwright/test').TestType} testInstance The test object for skipping. */ async skipIfClientSideMediaInactive( testInstance ) { - const isActive = await this.page.evaluate( () => { - if ( ! window.__clientSideMediaProcessing ) { - return false; - } - // Prefer the package's own detection when available so the - // gate stays in sync with the editor's runtime decision. - if ( - window.wp?.uploadMedia && - typeof window.wp.uploadMedia.isClientSideMediaSupported === - 'function' - ) { - return window.wp.uploadMedia.isClientSideMediaSupported(); - } - // Fall back to the core preconditions for CSM. These are the - // signals the package's feature detection inspects first. - return ( - window.crossOriginIsolated === true && - typeof SharedArrayBuffer !== 'undefined' && - typeof WebAssembly !== 'undefined' && - typeof Worker !== 'undefined' - ); - } ); - - testInstance.skip( - ! isActive, - 'Client-side media processing is not active in this environment' - ); + await skipIfClientSideMediaInactive( this.page, testInstance ); } /** @@ -354,6 +357,74 @@ test.describe( 'Client-side media processing', () => { } } ); + test( 'keeps sub-sizes of an indexed PNG indexed', async ( { + editor, + mediaProcessingUtils, + requestUtils, + } ) => { + /* + * Regression test for https://core.trac.wordpress.org/ticket/65922. + * + * libvips decodes an indexed (palette) PNG into full RGB(A) pixels. + * Without asking pngsave to quantise back down, every sub-size was + * written as truecolour, which for palette artwork is several times + * larger than the indexed original — the "large" sub-size of this + * fixture came out at roughly twice the size of the full-size file. + */ + const media = await mediaProcessingUtils.uploadImageAndGetMedia( + editor, + requestUtils, + '2000x1200_e2e_test_image_indexed.png' + ); + + expect( media.mime_type ).toBe( 'image/png' ); + expect( media.media_details.width ).toBe( 2000 ); + expect( media.media_details.height ).toBe( 1200 ); + + // The uploaded original must still be indexed, so the sub-sizes below + // are compared against a like-for-like baseline. + const fullSize = await probePngUrl( media.source_url ); + expect( fullSize.colorType ).toBe( PNG_COLOR_TYPE_INDEXED ); + + // Every generated sub-size must stay indexed rather than being + // re-encoded as truecolour RGB/RGBA. `full` is the uploaded original, + // already checked above. + const subSizes = Object.entries( media.media_details.sizes ).filter( + ( [ sizeName ] ) => 'full' !== sizeName + ); + expect( subSizes.map( ( [ sizeName ] ) => sizeName ) ).toContain( + 'large' + ); + + let largeByteLength; + + for ( const [ sizeName, size ] of subSizes ) { + const subSize = await probePngUrl( size.source_url ); + + expect( + subSize.colorType, + `sub-size "${ sizeName }" must stay indexed` + ).toBe( PNG_COLOR_TYPE_INDEXED ); + + if ( 'large' === sizeName ) { + largeByteLength = subSize.byteLength; + } + } + + /* + * And the user-visible symptom from the ticket: the `large` sub-size + * came out bigger than the full-size image it was generated from. + * + * This is checked on `large` alone rather than every sub-size. It is + * not a general invariant: requantising a resized image dithers, and + * the dithering noise costs bytes, so a big sub-size of an already + * small indexed original can legitimately exceed it. The 1536px size + * of this fixture does, at a fraction of what it weighed before the + * fix. + */ + expect( largeByteLength ).toBeLessThan( fullSize.byteLength ); + } ); + test( 'scales oversized images and generates the standard sub-sizes', async ( { editor, mediaProcessingUtils, @@ -574,6 +645,60 @@ test.describe( 'Client-side media processing', () => { } } ); + test( 'registers one generated file under every image size sharing its dimensions', async ( { + page, + editor, + mediaProcessingUtils, + requestUtils, + } ) => { + await requestUtils.activatePlugin( + 'gutenberg-test-plugin-duplicate-image-sizes' + ); + + try { + await page.reload(); + await mediaProcessingUtils.skipIfClientSideMediaInactive( test ); + + // Sizes that share width, height and crop are generated once and + // sideloaded in a single request whose `image_size` is an array + // of names. Record how the server answers each sideload so a + // rejected request fails here with its status code rather than + // as a downstream symptom. + const sideloadStatuses = []; + page.on( 'response', ( response ) => { + if ( /\/wp\/v2\/media\/\d+\/sideload/.test( response.url() ) ) { + sideloadStatuses.push( response.status() ); + } + } ); + + const media = await mediaProcessingUtils.uploadImageAndGetMedia( + editor, + requestUtils, + '1024x768_e2e_test_image_size.jpeg' + ); + + expect( sideloadStatuses.length ).toBeGreaterThan( 0 ); + expect( + sideloadStatuses.filter( ( status ) => status >= 400 ) + ).toEqual( [] ); + + // Both registered names must be present in the attachment + // metadata and point at the same generated file. + const sizes = media.media_details.sizes; + expect( sizes[ 'duplicate-size-one' ] ).toBeDefined(); + expect( sizes[ 'duplicate-size-two' ] ).toBeDefined(); + expect( sizes[ 'duplicate-size-two' ].source_url ).toBe( + sizes[ 'duplicate-size-one' ].source_url + ); + expect( sizes[ 'duplicate-size-one' ].width ).toBe( 400 ); + expect( sizes[ 'duplicate-size-one' ].height ).toBe( 300 ); + } finally { + await requestUtils.deactivatePlugin( + 'gutenberg-test-plugin-duplicate-image-sizes' + ); + } + } ); + test( 'renders srcset on the front end after publishing a CSM-uploaded image', async ( { page, editor, @@ -933,4 +1058,104 @@ test.describe( 'Client-side media processing', () => { ); expect( media.mime_type ).toBe( 'image/jpeg' ); } ); + + test( 'shows the Resolution control when a URL-keyed cache stands in front of the REST API', async ( { + page, + editor, + mediaProcessingUtils, + } ) => { + /* + * Regression test for https://github.com/WordPress/gutenberg/issues/81844. + * + * A client-side upload creates the attachment first and writes its + * sub-size metadata only in the final `finalize` request, so an + * attachment GET issued in between returns `media_details.sizes: {}`. + * Some hosts cache REST responses keyed on the full request URL, even + * for authenticated requests WordPress marks `no-cache`, so that + * mid-upload response gets cached - and any later read of the same URL + * is answered with it, leaving the Image block without its Resolution + * control. + * + * Simulate such a cache: once a response for an exact attachment URL + * has been seen, every later request for that URL is served from it, + * never reaching the server. The editor stays correct only by not + * needing that read at all - the `finalize` response already carries + * the finished record. + */ + const urlCache = new Map(); + + await page.route( /\/wp\/v2\/media\/\d+\?/, async ( route ) => { + if ( route.request().method() !== 'GET' ) { + await route.continue(); + return; + } + const url = route.request().url(); + if ( urlCache.has( url ) ) { + await route.fulfill( urlCache.get( url ) ); + return; + } + const response = await route.fetch(); + const body = await response.body(); + urlCache.set( url, { + status: response.status(), + headers: response.headers(), + body, + } ); + await route.fulfill( { response, body } ); + } ); + + await editor.insertBlock( { name: 'core/image' } ); + + const imageBlock = editor.canvas.locator( + 'role=document[name="Block: Image"i]' + ); + await expect( imageBlock ).toBeVisible(); + + await mediaProcessingUtils.upload( + imageBlock.locator( 'data-testid=form-file-upload-input' ), + '1024x768_e2e_test_image_size.jpeg' + ); + + const image = imageBlock.getByRole( 'img', { + name: 'This image has an empty alt attribute', + } ); + await expect( image ).toHaveAttribute( 'src', /^https?:\/\//, { + timeout: 30_000, + } ); + + await mediaProcessingUtils.waitForUploadQueueEmpty(); + + const imageId = await mediaProcessingUtils.getSelectedBlockImageId(); + expect( imageId ).toBeDefined(); + + // The generated sub-sizes reach the store even though every read of + // the attachment URL is answered by the cache. + await page.waitForFunction( + ( id ) => { + const record = window.wp.data + .select( 'core' ) + .getEntityRecord( 'postType', 'attachment', id, { + context: 'view', + } ); + return ( + !! record && + Object.keys( record.media_details?.sizes || {} ).length > 0 + ); + }, + imageId, + { timeout: 30_000 } + ); + + // The Resolution control offers the generated sizes. + await editor.openDocumentSettingsSidebar(); + await page + .getByRole( 'region', { name: 'Editor settings' } ) + .getByRole( 'tab', { name: 'Settings' } ) + .click(); + await expect( + page.getByRole( 'combobox', { name: 'Resolution' } ) + ).toBeVisible(); + + await page.unroute( /\/wp\/v2\/media\/\d+\?/ ); + } ); } ); diff --git a/test/e2e/specs/editor/various/client-side-media-utils.js b/test/e2e/specs/editor/various/client-side-media-utils.js new file mode 100644 index 00000000000000..505e97cebd63e4 --- /dev/null +++ b/test/e2e/specs/editor/various/client-side-media-utils.js @@ -0,0 +1,41 @@ +/** + * Skip the test unless the client-side media processing pipeline is the + * active upload path. This mirrors the gate used in the editor's media-upload + * util: the global flag must be set AND the browser must meet the feature + * detection requirements (cross-origin isolation, SharedArrayBuffer, Web + * Workers, WebAssembly). + * + * @param {import('@playwright/test').Page} page Playwright page. + * @param {import('@playwright/test').TestType} testInstance The test object for skipping. + */ +async function skipIfClientSideMediaInactive( page, testInstance ) { + const isActive = await page.evaluate( () => { + if ( ! window.__clientSideMediaProcessing ) { + return false; + } + // Prefer the package's own detection when available so the gate stays + // in sync with the editor's runtime decision. + if ( + window.wp?.uploadMedia && + typeof window.wp.uploadMedia.isClientSideMediaSupported === + 'function' + ) { + return window.wp.uploadMedia.isClientSideMediaSupported(); + } + // Fall back to the core preconditions for CSM. These are the signals + // the package's feature detection inspects first. + return ( + window.crossOriginIsolated === true && + typeof SharedArrayBuffer !== 'undefined' && + typeof WebAssembly !== 'undefined' && + typeof Worker !== 'undefined' + ); + } ); + + testInstance.skip( + ! isActive, + 'Client-side media processing is not active in this environment' + ); +} + +module.exports = { skipIfClientSideMediaInactive }; diff --git a/test/e2e/specs/editor/various/heic-unsupported-error.spec.js b/test/e2e/specs/editor/various/heic-unsupported-error.spec.js index 39f555f1c29a06..f7f178599f69e2 100644 --- a/test/e2e/specs/editor/various/heic-unsupported-error.spec.js +++ b/test/e2e/specs/editor/various/heic-unsupported-error.spec.js @@ -1,8 +1,31 @@ +/** + * External dependencies + */ +const fs = require( 'fs' ); +const path = require( 'path' ); + /** * WordPress dependencies */ const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); +/* + * A small, valid HEIC. Chromium cannot decode it: `createImageBitmap()` + * rejects it, `ImageDecoder` is not defined, and `VideoDecoder` reports no + * HEVC support. That is the same dead end a real photo reaches in a browser + * without platform HEVC codecs, such as Firefox. + */ +const heicFixture = fs.readFileSync( + path.join( + __dirname, + '..', + '..', + '..', + 'assets', + '64x64_e2e_test_image.heic' + ) +); + test.describe( 'HEIC upload error message', () => { test.beforeEach( async ( { admin } ) => { await admin.createNewPost(); @@ -22,19 +45,12 @@ test.describe( 'HEIC upload error message', () => { 'role=document[name="Block: Image"i]' ); - /* - * Bytes that are not a valid HEIC file, so every decoding strategy - * fails: `createImageBitmap()` rejects it, `ImageDecoder` has no HEIC - * support, and the container parser finds no HEVC bitstream to hand to - * `VideoDecoder`. This is the same dead end a real HEIC file reaches in - * a browser without platform HEVC codecs, such as Firefox. - */ await imageBlock .locator( 'data-testid=form-file-upload-input' ) .setInputFiles( { name: 'IMG_1982.heic', mimeType: 'image/heic', - buffer: Buffer.from( 'not a heic file' ), + buffer: heicFixture, } ); /* @@ -66,4 +82,39 @@ test.describe( 'HEIC upload error message', () => { await dismissButton.click(); await expect( notice ).toBeHidden(); } ); + + test( 'blames the file, not the browser, when the HEIC is damaged', async ( { + editor, + page, + } ) => { + await editor.insertBlock( { name: 'core/image' } ); + + const imageBlock = editor.canvas.locator( + 'role=document[name="Block: Image"i]' + ); + + /* + * The same file cut short, the way a partial copy or download leaves + * it: the container metadata is intact but the pixel data it points + * at is missing. No browser can decode this, so the message must not + * suggest switching to one that decodes HEIC. + */ + await imageBlock + .locator( 'data-testid=form-file-upload-input' ) + .setInputFiles( { + name: 'IMG_1983.heic', + mimeType: 'image/heic', + buffer: heicFixture.subarray( 0, 500 ), + } ); + + const notice = page.locator( '.components-snackbar' ).filter( { + hasText: 'This HEIC image could not be converted', + } ); + await expect( notice ).toBeVisible( { timeout: 30_000 } ); + await expect( notice ).not.toContainText( "we couldn't convert" ); + + await expect( + imageBlock.getByRole( 'button', { name: 'Media Library' } ) + ).toBeVisible(); + } ); } ); diff --git a/test/e2e/specs/editor/various/list-view.spec.js b/test/e2e/specs/editor/various/list-view.spec.js index 34b3ebcac63ec1..d02260722d187e 100644 --- a/test/e2e/specs/editor/various/list-view.spec.js +++ b/test/e2e/specs/editor/various/list-view.spec.js @@ -1396,7 +1396,7 @@ test.describe( 'List View', () => { ).toBeVisible(); } ); - test( 'should place the caret at the end of the block when activating from List View', async ( { + test( 'should place the caret at the start of the block when activating from List View', async ( { editor, page, listViewUtils, @@ -1416,8 +1416,8 @@ test.describe( 'List View', () => { // Click the first paragraph in List View to select it, // then press Enter to activate it (transfer focus to canvas). - // Keyboard activation (Enter/Space) places the caret at the end - // of the block, while mouse click keeps focus in the list view. + // Keyboard activation places the caret at the start of the first + // field. Mouse click keeps focus in the list view. await listView .getByRole( 'gridcell', { name: 'Paragraph' } ) .first() @@ -1425,24 +1425,75 @@ test.describe( 'List View', () => { await page.keyboard.press( 'Enter' ); // Press Enter to split the block at the caret position. - // If the caret is at the end, this creates a new block after the first paragraph. - // If the caret is at the start, this creates a new block before the first paragraph. + // With the caret at the start, this creates a new block before + // the first paragraph. await page.keyboard.press( 'Enter' ); - // Verify the block order: if the caret was at the end, the new empty - // block should be after the first paragraph, not before it. await expect.poll( editor.getBlocks ).toMatchObject( [ + { name: 'core/paragraph', attributes: { content: '' } }, { name: 'core/paragraph', attributes: { content: 'First paragraph' }, }, - { name: 'core/paragraph', attributes: { content: '' } }, { name: 'core/paragraph', attributes: { content: 'Second paragraph' }, }, ] ); } ); + + test( 'should focus the first cell of a Table when activating from List View', async ( { + editor, + page, + listViewUtils, + } ) => { + await editor.insertBlock( { + name: 'core/table', + attributes: { + body: [ + { + cells: [ + { content: 'R1C1', tag: 'td' }, + { content: 'R1C2', tag: 'td' }, + ], + }, + { + cells: [ + { content: 'R2C1', tag: 'td' }, + { content: 'R2C2', tag: 'td' }, + ], + }, + ], + }, + } ); + + const listView = await listViewUtils.openListView(); + await listView.getByRole( 'gridcell', { name: 'Table' } ).click(); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( 'X' ); + + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/table', + attributes: { + body: [ + { + cells: [ + { content: 'XR1C1', tag: 'td' }, + { content: 'R1C2', tag: 'td' }, + ], + }, + { + cells: [ + { content: 'R2C1', tag: 'td' }, + { content: 'R2C2', tag: 'td' }, + ], + }, + ], + }, + }, + ] ); + } ); } ); /** @typedef {import('@playwright/test').Locator} Locator */ diff --git a/test/e2e/specs/editor/various/single-file-placeholder-drop.spec.js b/test/e2e/specs/editor/various/single-file-placeholder-drop.spec.js new file mode 100644 index 00000000000000..14f83f5b134c94 --- /dev/null +++ b/test/e2e/specs/editor/various/single-file-placeholder-drop.spec.js @@ -0,0 +1,125 @@ +const path = require( 'path' ); +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); +const { + skipIfClientSideMediaInactive, +} = require( './client-side-media-utils' ); + +const IMAGES = [ + path.join( __dirname, '../../../assets/10x10_e2e_test_image_z9T8jK.png' ), + path.join( __dirname, '../../../assets/10x10_e2e_test_image_green.png' ), +]; + +/** + * Drops both images at once on the Cover block's placeholder, which does not + * set `multiple` and so takes a single file (see gutenberg#82041). + * + * @param {Object} editor Editor utils. + * @param {Object} pageUtils Page utils. + * + * @return {Promise} Locator for the Cover block. + */ +async function dropTwoImagesOnCoverPlaceholder( editor, pageUtils ) { + await editor.insertBlock( { name: 'core/cover' } ); + + const coverBlock = editor.canvas.getByRole( 'document', { + name: 'Block: Cover', + } ); + await expect( coverBlock ).toBeVisible(); + + const dragging = await pageUtils.dragFiles( IMAGES ); + await dragging.dragOver( coverBlock ); + await dragging.drop(); + + return coverBlock; +} + +test.describe( 'Dropping multiple files on a single-file placeholder', () => { + test.beforeEach( async ( { admin } ) => { + await admin.createNewPost(); + } ); + + test.afterEach( async ( { requestUtils } ) => { + await requestUtils.deleteAllMedia(); + } ); + + test.describe( 'with client-side media processing', () => { + test.beforeEach( async ( { page } ) => { + await skipIfClientSideMediaInactive( page, test ); + } ); + + test( 'refuses the drop and uploads nothing', async ( { + editor, + page, + pageUtils, + requestUtils, + } ) => { + const coverBlock = await dropTwoImagesOnCoverPlaceholder( + editor, + pageUtils + ); + + await expect( + page + .locator( '.components-snackbar-list' ) + .getByText( 'Only one file can be used here.' ) + ).toBeVisible(); + + // The client-side path used to ignore the limit entirely: every + // dropped file was uploaded and the block kept whichever one + // finished last. + await expect( coverBlock.locator( 'img' ) ).toHaveCount( 0 ); + expect( await requestUtils.listMedia() ).toHaveLength( 0 ); + } ); + } ); + + test.describe( 'with client-side media processing disabled', () => { + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.activatePlugin( + 'gutenberg-test-plugin-disable-client-side-media-processing' + ); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await requestUtils.deactivatePlugin( + 'gutenberg-test-plugin-disable-client-side-media-processing' + ); + } ); + + test( 'refuses the drop without stranding the progress snackbar', async ( { + editor, + page, + pageUtils, + } ) => { + await dropTwoImagesOnCoverPlaceholder( editor, pageUtils ); + + const snackbarList = page.locator( '.components-snackbar-list' ); + await expect( + snackbarList.getByText( 'Only one file can be used here.' ) + ).toBeVisible(); + + // The refusal used to register every dropped file with the progress + // snackbar while only ever finishing one of them, leaving + // "Uploading …" up for the rest of the session. + await expect( snackbarList.getByText( /^Uploading/ ) ).toBeHidden(); + + // So a later single-file upload still reports completion, rather + // than being folded into the stranded session. + await editor.insertBlock( { name: 'core/image' } ); + const imageBlock = editor.canvas.locator( + 'role=document[name="Block: Image"i]' + ); + await imageBlock + .locator( 'data-testid=form-file-upload-input' ) + .setInputFiles( IMAGES[ 0 ] ); + + await expect( + imageBlock.getByRole( 'img', { + name: 'This image has an empty alt attribute', + } ) + ).toHaveAttribute( 'src', /^https?:\/\//, { timeout: 30_000 } ); + await expect( + snackbarList.getByText( 'Upload complete' ) + ).toBeVisible( { timeout: 10_000 } ); + } ); + } ); +} ); diff --git a/test/e2e/specs/editor/various/upload-progress-snackbar.spec.js b/test/e2e/specs/editor/various/upload-progress-snackbar.spec.js index f2158468e2fac8..4c2274b9edbd8f 100644 --- a/test/e2e/specs/editor/various/upload-progress-snackbar.spec.js +++ b/test/e2e/specs/editor/various/upload-progress-snackbar.spec.js @@ -3,6 +3,115 @@ */ const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); +/** + * Selects an OpenEXR file with no mime type on a file input. + * + * WordPress does not allow OpenEXR uploads, and Chrome on macOS does not map + * the extension to a mime type either, so `File.type` is empty. Both upload + * paths deliberately pass a file with no detected type to the server rather + * than guessing at it, which is why the rejection only arrives once the upload + * is already under way. + * + * The empty type is the whole point of the fixture, so the `File` is built in + * the page with `type: ''` rather than handed to `setInputFiles`: given a + * buffer Playwright supplies a mime type of its own, and given a path the type + * comes from the mime map of whichever OS the test runs on (`''` on macOS, but + * `image/aces` on Linux, which fails in the browser and never reaches the + * server). Building the file in the canvas realm is also what a real file + * picker does there. + * + * The bytes are the OpenEXR magic number, so anything that sniffs content sees + * a plausible file rather than a truncated one. + * + * @param {Object} fileInput Locator for the file input. + */ +async function selectUnsupportedFile( fileInput ) { + await fileInput.evaluate( ( input ) => { + const file = new File( + [ new Uint8Array( [ 0x76, 0x2f, 0x31, 0x01 ] ) ], + 'openEXR.exr', + { type: '' } + ); + const dataTransfer = new DataTransfer(); + dataTransfer.items.add( file ); + input.files = dataTransfer.files; + input.dispatchEvent( new Event( 'change', { bubbles: true } ) ); + } ); +} + +/** + * Uploads a file the server rejects and asserts the editor never claims the + * upload completed (regression: gutenberg#81708, gutenberg#81132). + * + * @param {Object} editor Editor utils. + * @param {Object} page Playwright page. + */ +async function expectNoCompletionForRejectedUpload( editor, page ) { + await editor.insertBlock( { name: 'core/image' } ); + + /* + * Record every notice as it is created. The completion snackbar dismisses + * itself after a few seconds, so looking for it in the DOM once the upload + * has settled would be a race — this catches it whenever it appears. + */ + await page.evaluate( () => { + window.__uploadNotices = []; + window.wp.data.subscribe( () => { + const notices = window.wp.data + .select( 'core/notices' ) + .getNotices(); + for ( const notice of notices ) { + const isRecorded = window.__uploadNotices.some( + ( recorded ) => + recorded.id === notice.id && + recorded.content === notice.content + ); + if ( ! isRecorded ) { + window.__uploadNotices.push( { + id: notice.id, + content: notice.content, + } ); + } + } + } ); + } ); + + const imageBlock = editor.canvas.locator( + 'role=document[name="Block: Image"i]' + ); + await selectUnsupportedFile( + imageBlock.locator( 'data-testid=form-file-upload-input' ) + ); + + // The rejection reaches the user. + const snackbarList = page.locator( '.components-snackbar-list' ); + await expect( + snackbarList.getByText( /not allowed to upload this file type/ ) + ).toBeVisible( { timeout: 30_000 } ); + + // Wait for the batch to end: the progress notice is gone from the store. + await page.waitForFunction( + () => + ! window.wp.data + .select( 'core/notices' ) + .getNotices() + .some( ( notice ) => notice.id === 'upload-progress' ), + undefined, + { timeout: 30_000 } + ); + + const noticeContents = ( + await page.evaluate( () => window.__uploadNotices ) + ).map( ( notice ) => notice.content ); + + // The upload has to have started for this test to mean anything: without + // the progress snackbar there is no completion snackbar to get wrong. + expect( + noticeContents.filter( ( content ) => /^Uploading/.test( content ) ) + ).not.toEqual( [] ); + expect( noticeContents ).not.toContain( 'Upload complete' ); +} + test.describe( 'Upload progress snackbar (server-side uploads)', () => { test.beforeAll( async ( { requestUtils } ) => { // Force the traditional server-side upload path, mirroring a site @@ -62,4 +171,42 @@ test.describe( 'Upload progress snackbar (server-side uploads)', () => { { timeout: 10_000 } ); } ); + + // eslint-disable-next-line playwright/expect-expect -- Asserted in the shared helper. + test( 'does not claim completion for a file the server rejects', async ( { + editor, + page, + } ) => { + await expectNoCompletionForRejectedUpload( editor, page ); + } ); +} ); + +test.describe( 'Upload progress snackbar (client-side media processing)', () => { + test.beforeEach( async ( { admin, page } ) => { + await admin.createNewPost(); + + // Client-side processing needs cross-origin isolation and friends; + // where the browser cannot provide them the editor falls back to the + // server-side path, which the describe above already covers. + const isActive = await page.evaluate( + () => window.__clientSideMediaProcessing === true + ); + // eslint-disable-next-line playwright/no-skipped-test -- Depends on runtime browser support. + test.skip( + ! isActive, + 'Client-side media processing is not active in this environment' + ); + } ); + + test.afterEach( async ( { requestUtils } ) => { + await requestUtils.deleteAllMedia(); + } ); + + // eslint-disable-next-line playwright/expect-expect -- Asserted in the shared helper. + test( 'does not claim completion for a file the server rejects', async ( { + editor, + page, + } ) => { + await expectNoCompletionForRejectedUpload( editor, page ); + } ); } ); diff --git a/tools/pr-meta/README.md b/tools/pr-meta/README.md new file mode 100644 index 00000000000000..465172c225497c --- /dev/null +++ b/tools/pr-meta/README.md @@ -0,0 +1,60 @@ +# PR meta + +A GitHub action that maintains a single automation comment on a pull request, so the repository posts one comment per pull request instead of one per workflow. + +Each producing workflow calls this action with a `section` id and a body. The action reads the existing comment, replaces only that section, and writes the whole thing back. A section registry fixes the render order, so the comment looks the same however the workflows interleave. + +## Usage + +```yaml +- uses: ./tools/pr-meta + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + section: bundle-size + body-path: pr-meta/body.md + commit-sha: ${{ github.event.pull_request.head.sha }} + run-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} +``` + +An empty body, or a `body-path` pointing at a file that does not exist, removes the section. That is how a producer with nothing to report clears a stale one. + +## Writing a section + +Every calling job needs three things, and each of them fixes a specific failure: + +**A shared concurrency group**, so writers do not overwrite each other: + +```yaml +concurrency: + group: pr-meta-${{ github.event.number }} + cancel-in-progress: false + queue: max +``` + +`queue: max` matters. The default cancels a pending run once a third arrives, which silently drops a section. On a `push`-triggered workflow `github.event.number` is empty, so the pull request number has to come from somewhere else, such as a producer job output. + +**Its own `permissions`**, which do not carry over from another job: + +```yaml +permissions: + contents: read + pull-requests: write +``` + +**`if: ${{ !cancelled() }}`**, so a failed producer still clears its section instead of leaving a stale one behind. + +The job also needs `actions/checkout` before `uses: ./tools/pr-meta`, since a local action needs the repository on disk. `sparse-checkout: tools/pr-meta` is enough. Under `pull_request_target` the checkout must stay on the base ref, never the pull request's head. + +## Adding a section + +Add it to `SECTIONS` in `src/sections.ts` with an id, heading, scope and character budget. Headings lead with an emoji, so a reader scanning a comment of seven sections can find theirs without reading any of them. The budgets must sum, with the headings and markers, to less than GitHub's 65536-character comment limit; a test covers that. + +A `summary` collapses the section behind a fold labelled with it, for content long enough that it would otherwise push the rest of the comment out of view. Leave it out to keep the section open. + +`scope` decides how staleness is handled. `commit` sections describe one commit, carry its SHA, and are rejected if they arrive from a rerun of an older one. `pr-state` sections describe the pull request as it currently is and carry no SHA. + +## No dependencies + +The sources import nothing, so a writer job needs only a checkout and not a dependency install. `src/core.ts` covers the few pieces of `@actions/core` that are needed, and `src/github-api.ts` talks to the REST API through Node's global `fetch`. + +It runs straight from the TypeScript sources: the `node24` runtime strips the types on the fly, so there is no build step. Two constraints come with that, relative imports must carry their `.ts` extension, and the syntax must be erasable, which rules out enums, namespaces, parameter properties and decorators. diff --git a/tools/pr-meta/action.yml b/tools/pr-meta/action.yml new file mode 100644 index 00000000000000..efd2f24256ebaf --- /dev/null +++ b/tools/pr-meta/action.yml @@ -0,0 +1,35 @@ +name: 'PR meta comment' +description: 'Writes one section of the unified automation comment on a pull request' +inputs: + repo-token: + description: 'GitHub token with `pull-requests: write`' + required: true + section: + description: 'Section id, which must exist in the section registry' + required: true + body: + description: 'Markdown body of the section. Empty removes the section.' + required: false + default: '' + body-path: + description: 'File holding the markdown body, for content too large or too untrusted for a job output. Takes precedence over `body`.' + required: false + pr-number: + description: 'Pull request number. Required on events whose payload has none, such as `push`.' + required: false + commit-sha: + description: 'Head SHA the content was produced from. Required for commit-scoped sections.' + required: false + run-url: + description: 'URL of the workflow run that produced the content.' + required: false +runs: + # Node 24 strips the types on the fly, so the action runs straight from its + # TypeScript sources without a build step. This requires every relative + # import to carry its `.ts` extension, and the syntax to be erasable, which + # rules out enums, namespaces, parameter properties and decorators. + # + # The sources deliberately import nothing, so a writer job needs only a + # checkout, not a full dependency install. + using: 'node24' + main: 'src/index.ts' diff --git a/tools/pr-meta/package.json b/tools/pr-meta/package.json new file mode 100644 index 00000000000000..682ea3a7d66bf6 --- /dev/null +++ b/tools/pr-meta/package.json @@ -0,0 +1,35 @@ +{ + "name": "@wordpress/pr-meta-tools", + "version": "0.0.0", + "description": "A GitHub action that maintains a single unified automation comment on a pull request.", + "private": true, + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "gutenberg", + "GitHub Actions" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/HEAD/tools/pr-meta/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "tools/pr-meta" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + }, + "type": "module", + "main": "src/index.ts", + "devDependencies": { + "@types/jest": "^29.5.14", + "@types/node": "^20.19.39" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/tools/pr-meta/src/__tests__/comment.test.ts b/tools/pr-meta/src/__tests__/comment.test.ts new file mode 100644 index 00000000000000..87177860de86bb --- /dev/null +++ b/tools/pr-meta/src/__tests__/comment.test.ts @@ -0,0 +1,564 @@ +import { + COMMENT_MARKER, + isParseable, + isPrMetaComment, + mergeSection, + parseSections, + renderComment, + sanitizeBody, +} from '../comment.ts'; +import { SECTIONS, COMMENT_LIMIT, getSection } from '../sections.ts'; + +const HEAD = 'a'.repeat( 40 ); +const OLD = 'b'.repeat( 40 ); + +function bodyOf( result: { body?: string } ) { + if ( ! result.body ) { + throw new Error( 'Expected a merged comment body.' ); + } + return result.body; +} + +describe( 'mergeSection', () => { + it( 'creates a comment for the first section', () => { + const { body } = mergeSection( undefined, { + id: 'labels', + body: 'Missing a type label.', + } ); + + expect( body ).toContain( COMMENT_MARKER ); + expect( body ).toContain( `#### ${ getSection( 'labels' )!.heading }` ); + expect( body ).toContain( 'Missing a type label.' ); + } ); + + it( 'leaves other sections untouched when adding one', () => { + const first = bodyOf( + mergeSection( undefined, { id: 'labels', body: 'Label warning.' } ) + ); + const second = bodyOf( + mergeSection( + first, + { id: 'bundle-size', body: 'Size Change: 0 B', sha: HEAD }, + HEAD + ) + ); + + expect( second ).toContain( 'Label warning.' ); + expect( second ).toContain( 'Size Change: 0 B' ); + } ); + + it( 'replaces a section rather than appending a second copy', () => { + const first = bodyOf( + mergeSection( undefined, { id: 'labels', body: 'Old warning.' } ) + ); + const second = bodyOf( + mergeSection( first, { id: 'labels', body: 'New warning.' } ) + ); + + expect( second ).not.toContain( 'Old warning.' ); + expect( parseSections( second ) ).toHaveLength( 1 ); + } ); + + it( 'renders sections in registry order regardless of write order', () => { + let comment = bodyOf( + mergeSection( + undefined, + { id: 'flaky-tests', body: 'Flaky.', sha: HEAD }, + HEAD + ) + ); + comment = bodyOf( + mergeSection( comment, { id: 'props', body: 'Props.' } ) + ); + + expect( parseSections( comment ).map( ( { id } ) => id ) ).toEqual( [ + 'props', + 'flaky-tests', + ] ); + } ); + + it( 'removes a section when the body is empty', () => { + let comment = bodyOf( + mergeSection( undefined, { id: 'props', body: 'Props.' } ) + ); + comment = bodyOf( + mergeSection( comment, { id: 'labels', body: 'Warning.' } ) + ); + + const cleared = bodyOf( + mergeSection( comment, { id: 'labels', body: '' } ) + ); + + expect( parseSections( cleared ).map( ( { id } ) => id ) ).toEqual( [ + 'props', + ] ); + } ); + + it( 'asks for the comment to be removed once the last section goes', () => { + const comment = bodyOf( + mergeSection( undefined, { id: 'props', body: 'Props.' } ) + ); + + expect( mergeSection( comment, { id: 'props', body: '' } ) ).toEqual( { + remove: true, + } ); + } ); + + it( 'writes nothing when clearing a section of a comment that does not exist', () => { + expect( mergeSection( undefined, { id: 'props', body: '' } ) ).toEqual( + { + remove: false, + } + ); + } ); + + it( 'rejects an unknown section', () => { + expect( + mergeSection( undefined, { id: 'nope', body: 'Hello.' } ).rejected + ).toMatch( /Unknown section/ ); + } ); + + it( 'preserves a section id it does not know', () => { + const legacy = `${ COMMENT_MARKER }\n### PR meta\n\n\nLater.\n\n`; + + const merged = bodyOf( + mergeSection( legacy, { id: 'props', body: 'Props.' } ) + ); + + expect( merged ).toContain( 'Later.' ); + expect( parseSections( merged ).map( ( { id } ) => id ) ).toEqual( [ + 'props', + 'from-the-future', + ] ); + } ); +} ); + +describe( 'staleness', () => { + it( 'rejects a result for a commit that is neither the head nor the one reported', () => { + const comment = bodyOf( + mergeSection( + undefined, + { id: 'bundle-size', body: 'Current.', sha: HEAD }, + HEAD + ) + ); + + const result = mergeSection( + comment, + { id: 'bundle-size', body: 'Stale rerun.', sha: OLD }, + HEAD + ); + + expect( result.body ).toBeUndefined(); + expect( result.rejected ).toContain( OLD ); + } ); + + it( 'lets a rerun refresh the commit the section already reports', () => { + const comment = bodyOf( + mergeSection( + undefined, + { id: 'bundle-size', body: 'First attempt.', sha: OLD }, + OLD + ) + ); + + const merged = bodyOf( + mergeSection( + comment, + { id: 'bundle-size', body: 'Second attempt.', sha: OLD }, + HEAD + ) + ); + + expect( merged ).toContain( 'Second attempt.' ); + } ); + + it( 'refuses to guess when the head cannot be read', () => { + const result = mergeSection( undefined, { + id: 'bundle-size', + body: 'No head known.', + sha: OLD, + } ); + + expect( result.body ).toBeUndefined(); + expect( result.rejected ).toMatch( /current head/ ); + } ); + + it( 'refuses a commit-scoped section that carries no commit', () => { + const result = mergeSection( + undefined, + { id: 'bundle-size', body: 'Which commit?' }, + HEAD + ); + + expect( result.body ).toBeUndefined(); + expect( result.rejected ).toMatch( /commit/ ); + } ); + + it( 'refuses to clear a commit-scoped section without a commit', () => { + const comment = bodyOf( + mergeSection( + undefined, + { id: 'bundle-size', body: 'Measured.', sha: HEAD }, + HEAD + ) + ); + + expect( + mergeSection( comment, { id: 'bundle-size', body: '' } ).rejected + ).toMatch( /commit/ ); + } ); + + /* + * A rerun of an older commit that happens to be clean must not wipe a + * result from a newer one. + */ + it( 'refuses to let a stale clean run clear a newer result', () => { + const comment = bodyOf( + mergeSection( + undefined, + { id: 'flaky-tests', body: 'Flaky on the head.', sha: HEAD }, + HEAD + ) + ); + + const result = mergeSection( + comment, + { id: 'flaky-tests', body: '', sha: OLD }, + HEAD + ); + + expect( result.body ).toBeUndefined(); + expect( result.rejected ).toContain( OLD ); + } ); + + it( 'lets a clean run for the head clear its own section', () => { + const comment = bodyOf( + mergeSection( + undefined, + { id: 'flaky-tests', body: 'Flaky.', sha: HEAD }, + HEAD + ) + ); + + expect( + mergeSection( + comment, + { id: 'flaky-tests', body: '', sha: HEAD }, + HEAD + ) + ).toEqual( { remove: true } ); + } ); + + it( 'marks a section that is no longer the head, without dropping it', () => { + const comment = bodyOf( + mergeSection( + undefined, + { id: 'bundle-size', body: 'Measured.', sha: OLD }, + OLD + ) + ); + + const merged = bodyOf( + mergeSection( comment, { id: 'props', body: 'Props.' }, HEAD ) + ); + + expect( merged ).toContain( 'Measured.' ); + expect( merged ).toContain( 'not the current head' ); + } ); + + it( 'ignores a SHA on a section that describes the pull request, not a commit', () => { + const merged = bodyOf( + mergeSection( + undefined, + { id: 'labels', body: 'Warning.', sha: OLD }, + HEAD + ) + ); + + expect( parseSections( merged )[ 0 ].sha ).toBeUndefined(); + } ); +} ); + +describe( 'sanitizeBody', () => { + it.each( [ + '', + '', + '', + ] )( 'neutralises %s', ( delimiter ) => { + expect( sanitizeBody( delimiter ) ).not.toMatch( + /\n\nProps by me.\n'; + + const merged = bodyOf( + mergeSection( + undefined, + { id: 'flaky-tests', body: attack, sha: HEAD }, + HEAD + ) + ); + + expect( parseSections( merged ).map( ( { id } ) => id ) ).toEqual( [ + 'flaky-tests', + ] ); + expect( merged ).not.toContain( '\n' ); + } ); +} ); + +describe( 'budgets', () => { + /* A note appended inside a fence renders as code, link and all. */ + it( 'closes a code fence the cut left open', () => { + const definition = getSection( 'flaky-tests' )!; + const trace = `
\nA flaky test\n\n\`\`\`\n${ 'Error: socket hang up\n'.repeat( + 2000 + ) }\`\`\`\n\n
`; + + const merged = bodyOf( + mergeSection( + undefined, + { + id: 'flaky-tests', + body: trace, + sha: HEAD, + runUrl: 'https://example.com/run', + }, + HEAD + ) + ); + const section = parseSections( merged )[ 0 ].body; + + expect( section.length ).toBeLessThanOrEqual( definition.budget + 200 ); + expect( ( section.match( /^```/gm ) ?? [] ).length % 2 ).toBe( 0 ); + expect( ( section.match( /
/g ) ?? [] ).length ).toBe( + ( section.match( /<\/details>/g ) ?? [] ).length + ); + // The note and its link land outside the fence, so the link works. + expect( section ).toMatch( + /<\/details>\n\nTruncated\. \[See the full report\]/ + ); + } ); + + it( 'truncates a section that overruns its budget', () => { + const definition = getSection( 'flaky-tests' )!; + const merged = bodyOf( + mergeSection( + undefined, + { + id: 'flaky-tests', + body: 'x'.repeat( definition.budget + 100 ), + sha: HEAD, + runUrl: 'https://example.com/run', + }, + HEAD + ) + ); + + expect( merged ).toContain( 'Truncated.' ); + expect( merged ).toContain( 'https://example.com/run' ); + } ); + + it( 'keeps every section at its budget within the comment limit', () => { + let comment: string | undefined; + + for ( const section of SECTIONS ) { + comment = bodyOf( + mergeSection( + comment, + { + id: section.id, + body: 'x'.repeat( section.budget ), + sha: HEAD, + runUrl: 'https://example.com/run', + }, + HEAD + ) + ); + } + + expect( comment!.length ).toBeLessThan( COMMENT_LIMIT ); + } ); +} ); + +describe( 'isPrMetaComment', () => { + it( 'matches only the unified comment', () => { + expect( isPrMetaComment( renderComment( [] ) ) ).toBe( true ); + expect( isPrMetaComment( '' ) ).toBe( + false + ); + } ); +} ); + +describe( 'isParseable', () => { + it( 'accepts a comment this action wrote', () => { + const comment = bodyOf( + mergeSection( undefined, { id: 'props', body: 'Props.' } ) + ); + + expect( isParseable( comment ) ).toBe( true ); + } ); + + it( 'rejects a comment with an unbalanced delimiter', () => { + const truncated = `${ COMMENT_MARKER }\n### PR meta\n\n\nProps.\n`; + + expect( isParseable( truncated ) ).toBe( false ); + } ); +} ); + +describe( 'unknown sections', () => { + it( 'holds a section it does not know to a budget', () => { + const legacy = `${ COMMENT_MARKER }\n### PR meta\n\n\n${ 'x'.repeat( + 10000 + ) }\n\n`; + + const merged = bodyOf( + mergeSection( legacy, { id: 'props', body: 'Props.' } ) + ); + + expect( merged ).toContain( 'Truncated.' ); + expect( merged.length ).toBeLessThan( 10000 ); + } ); +} ); + +describe( 'repeated writes', () => { + /* + * Every write re-renders every section from what was parsed back, so any + * decoration kept inside the delimiters would stack up run after run. + */ + it( 'renders a heading once however many times the comment is written', () => { + let comment = bodyOf( + mergeSection( undefined, { id: 'props', body: 'Props.' } ) + ); + comment = bodyOf( + mergeSection( comment, { id: 'labels', body: 'Labels.' } ) + ); + comment = bodyOf( + mergeSection( + comment, + { id: 'bundle-size', body: 'Size.', sha: HEAD }, + HEAD + ) + ); + + const heading = `#### ${ getSection( 'props' )!.heading }`; + expect( comment.split( heading ) ).toHaveLength( 2 ); + expect( parseSections( comment )[ 0 ].body ).toBe( 'Props.' ); + } ); + + it( 'renders a commit footer once however many times it is written', () => { + let comment = bodyOf( + mergeSection( + undefined, + { + id: 'bundle-size', + body: 'Size.', + sha: HEAD, + runUrl: 'https://example.com/run', + }, + HEAD + ) + ); + comment = bodyOf( + mergeSection( comment, { id: 'props', body: 'Props.' }, HEAD ) + ); + comment = bodyOf( + mergeSection( comment, { id: 'labels', body: 'Labels.' }, HEAD ) + ); + + expect( comment.match( //g ) ).toHaveLength( 1 ); + expect( parseSections( comment ).at( -1 )?.body ).toBe( 'Size.' ); + } ); +} ); + +describe( 'comment limit', () => { + it( 'stays under the limit even with preserved unknown sections', () => { + const unknown = Array.from( + { length: 40 }, + ( _, index ) => + `\n${ 'x'.repeat( + 4000 + ) }\n` + ).join( '\n\n' ); + const legacy = `${ COMMENT_MARKER }\n### PR meta\n\n${ unknown }\n`; + + const merged = bodyOf( + mergeSection( legacy, { id: 'props', body: 'Props.' } ) + ); + + expect( merged.length ).toBeLessThanOrEqual( COMMENT_LIMIT ); + expect( merged ).toContain( 'Props.' ); + } ); +} ); + +describe( 'collapsing', () => { + it( 'folds a section long enough to warrant it', () => { + const merged = bodyOf( + mergeSection( + undefined, + { id: 'performance', body: 'Tables.', sha: HEAD }, + HEAD + ) + ); + + expect( merged ).toContain( + `${ getSection( 'performance' )!.summary }` + ); + } ); + + it( 'leaves a short section open', () => { + const merged = bodyOf( + mergeSection( undefined, { id: 'labels', body: 'Warning.' } ) + ); + + expect( merged ).not.toContain( '
' ); + } ); + + /* The fold sits outside the delimiters, so it cannot nest into itself. */ + it( 'folds once however many times the comment is written', () => { + let comment = bodyOf( + mergeSection( + undefined, + { id: 'flaky-tests', body: 'Traces.', sha: HEAD }, + HEAD + ) + ); + comment = bodyOf( + mergeSection( comment, { id: 'props', body: 'Props.' }, HEAD ) + ); + comment = bodyOf( + mergeSection( comment, { id: 'labels', body: 'Warning.' }, HEAD ) + ); + + expect( comment.match( /
/g ) ).toHaveLength( 1 ); + expect( + parseSections( comment ).find( ( s ) => s.id === 'flaky-tests' ) + ?.body + ).toBe( 'Traces.' ); + } ); +} ); + +describe( 'rendering other sections', () => { + /* + * Every write re-renders every section, so a writer that does not know the + * head would quietly present an old result as current. + */ + it( 'keeps a stale result marked stale when another section is written', () => { + const comment = bodyOf( + mergeSection( + undefined, + { id: 'bundle-size', body: 'Measured.', sha: OLD }, + OLD + ) + ); + + const merged = bodyOf( + mergeSection( comment, { id: 'labels', body: 'Warning.' }, HEAD ) + ); + + expect( merged ).toContain( 'not the current head' ); + } ); +} ); diff --git a/tools/pr-meta/src/__tests__/run.test.ts b/tools/pr-meta/src/__tests__/run.test.ts new file mode 100644 index 00000000000000..7e798947853b85 --- /dev/null +++ b/tools/pr-meta/src/__tests__/run.test.ts @@ -0,0 +1,66 @@ +import { run } from '../run.ts'; +import { GitHubAPI } from '../github-api.ts'; + +jest.mock( '../github-api.ts', () => ( { + GitHubAPI: jest.fn(), +} ) ); + +const HEAD = 'a'.repeat( 40 ); + +const api = { + getHeadSha: jest.fn(), + findComment: jest.fn(), + createComment: jest.fn(), + updateComment: jest.fn(), + deleteComment: jest.fn(), +}; + +function withInputs( inputs: Record< string, string > ) { + for ( const [ name, value ] of Object.entries( inputs ) ) { + process.env[ `INPUT_${ name.toUpperCase() }` ] = value; + } +} + +describe( 'run', () => { + beforeEach( () => { + jest.clearAllMocks(); + ( GitHubAPI as jest.Mock ).mockImplementation( () => api ); + process.env.GITHUB_REPOSITORY = 'WordPress/gutenberg'; + api.getHeadSha.mockResolvedValue( HEAD ); + api.findComment.mockResolvedValue( undefined ); + api.createComment.mockResolvedValue( 'https://example.com/comment' ); + api.updateComment.mockResolvedValue( 'https://example.com/comment' ); + withInputs( { + 'repo-token': 'token', + section: 'labels', + body: 'Warning.', + 'body-path': '', + 'pr-number': '10', + 'commit-sha': '', + 'run-url': '', + } ); + } ); + + it( 'writes the section', async () => { + await run(); + + expect( api.createComment ).toHaveBeenCalledTimes( 1 ); + expect( api.createComment.mock.calls[ 0 ][ 1 ] ).toContain( + 'Warning.' + ); + } ); + + /* + * Rendering without the head would drop the "not the current head" footer + * from every stale section, presenting old results as current. + */ + it( 'writes nothing when the head cannot be read', async () => { + api.getHeadSha.mockRejectedValue( new Error( 'boom' ) ); + + await expect( run() ).rejects.toThrow( 'boom' ); + + expect( api.createComment ).not.toHaveBeenCalled(); + expect( api.updateComment ).not.toHaveBeenCalled(); + expect( api.deleteComment ).not.toHaveBeenCalled(); + } ); +} ); diff --git a/tools/pr-meta/src/comment.ts b/tools/pr-meta/src/comment.ts new file mode 100644 index 00000000000000..49d481743a66c4 --- /dev/null +++ b/tools/pr-meta/src/comment.ts @@ -0,0 +1,329 @@ +import { COMMENT_LIMIT, SECTIONS, getSection } from './sections.ts'; +import type { SectionDefinition } from './sections.ts'; + +export const COMMENT_MARKER = ''; +const COMMENT_HEADING = '### 🤖 PR meta 🤖'; + +export type ParsedSection = { + id: string; + body: string; + sha?: string; + runUrl?: string; +}; + +export type SectionUpdate = { + id: string; + body: string; + sha?: string; + runUrl?: string; +}; + +const SECTION_PATTERN = + /\n([\s\S]*?)\n/g; + +export function isPrMetaComment( body: string ): boolean { + return body.startsWith( COMMENT_MARKER ); +} + +/** + * Neutralises section delimiters in producer content. + * + * Some bodies carry text this repository does not control, most of all the + * flaky tests section, which interpolates errors thrown by PR-authored tests. + * An unescaped delimiter there could close its own section early and forge + * another one. + * + * @param body Untrusted markdown. + * @return The same markdown with every delimiter rendered inert. + */ +export function sanitizeBody( body: string ): string { + return body.replace( /`; + const close = ``; + const heading = definition ? `#### ${ definition.heading }\n\n` : ''; + const body = truncate( + section.body, + definition ?? UNKNOWN_SECTION, + section.runUrl + ); + + const delimited = `${ open }\n${ body }\n${ close }`; + + /* + * The heading, the fold and the footer all sit outside the delimiters. + * Everything between them is read back verbatim as the body, so a + * decoration kept inside would be re-rendered on top of itself at every + * write. The blank lines are what let GitHub render markdown inside the + * fold. + */ + const content = definition?.summary + ? `
\n${ definition.summary }\n\n${ delimited }\n\n
` + : delimited; + + return `${ heading }${ content }${ renderFooter( section, headSha ) }`; +} + +/** + * Orders sections by the registry, keeping ids it does not know at the end so + * a workflow running an older revision of this action never loses its section. + * + * @param sections Sections in no particular order. + * @return The same sections in render order. + */ +function inRegistryOrder( sections: ParsedSection[] ): ParsedSection[] { + const known = SECTIONS.map( ( { id } ) => + sections.find( ( section ) => section.id === id ) + ).filter( ( section ): section is ParsedSection => Boolean( section ) ); + const unknown = sections.filter( + ( section ) => ! getSection( section.id ) + ); + + return [ ...known, ...unknown ]; +} + +/** + * Drops trailing sections until the comment fits. + * + * Section budgets are chosen to fit together, but a section written by a newer + * revision of this action is outside that arithmetic, and GitHub rejects an + * oversized body outright. Registry order puts what a human acts on first, so + * trimming from the end sheds the least important content. + * + * @param sections Sections in render order. + * @param render Renders the whole comment from a list of sections. + * @return The sections that fit. + */ +function withinLimit( + sections: ParsedSection[], + render: ( kept: ParsedSection[] ) => string +): ParsedSection[] { + const kept = [ ...sections ]; + + while ( kept.length > 1 && render( kept ).length > COMMENT_LIMIT ) { + kept.pop(); + } + + return kept; +} + +export function renderComment( + sections: ParsedSection[], + headSha?: string +): string { + const render = ( kept: ParsedSection[] ) => + `${ COMMENT_MARKER }\n${ COMMENT_HEADING }\n\n${ kept + .map( ( section ) => + renderSection( section, getSection( section.id ), headSha ) + ) + .join( '\n\n' ) }\n`; + + return render( withinLimit( inRegistryOrder( sections ), render ) ); +} + +export type MergeResult = { + /** The full comment body to write, when there is one. */ + body?: string; + /** Whether the comment should be deleted, its last section having gone. */ + remove?: boolean; + /** Set when the update was dropped, explaining why. */ + rejected?: string; +}; + +/** + * Applies one section update to an existing unified comment. + * + * @param existing The current comment body, or `undefined` when there is none. + * @param update The section to insert, replace or, with an empty body, remove. + * @param headSha Current head SHA of the pull request, when known. + * @return The merged comment body, or the reason the update was dropped. + */ +export function mergeSection( + existing: string | undefined, + update: SectionUpdate, + headSha?: string +): MergeResult { + const definition = getSection( update.id ); + + if ( ! definition ) { + return { rejected: `Unknown section "${ update.id }".` }; + } + + const sections = existing ? parseSections( existing ) : []; + + const current = sections.find( ( section ) => section.id === update.id ); + + /* + * A rerun of an older commit, or a run cancelled mid-flight, can finish + * after a newer one. Only the current head, or a refresh of what the + * section already shows, may write it. This covers clearing a section as + * much as filling one: an old rerun that happens to be clean would + * otherwise wipe a newer result. Both SHAs are required rather than + * optional, since without either one the two cases are indistinguishable + * and guessing lets the stale run through. + */ + if ( definition.scope === 'commit' && ( update.body || current ) ) { + if ( ! update.sha || ! headSha ) { + return { + rejected: + 'A commit-scoped section needs both its own commit and the current head.', + }; + } + + if ( update.sha !== headSha && update.sha !== current?.sha ) { + return { + rejected: `Result is for ${ update.sha }, which is neither the head (${ headSha }) nor the commit already reported.`, + }; + } + } + + const body = sanitizeBody( update.body ).trim(); + const remaining = sections.filter( + ( section ) => section.id !== update.id + ); + const next = body + ? [ + ...remaining, + { + id: update.id, + body, + sha: definition.scope === 'commit' ? update.sha : undefined, + runUrl: + definition.scope === 'commit' + ? update.runUrl + : undefined, + }, + ] + : remaining; + + if ( next.length === 0 ) { + return { remove: Boolean( existing ) }; + } + + return { body: renderComment( next, headSha ) }; +} diff --git a/tools/pr-meta/src/core.ts b/tools/pr-meta/src/core.ts new file mode 100644 index 00000000000000..9400e279107d38 --- /dev/null +++ b/tools/pr-meta/src/core.ts @@ -0,0 +1,76 @@ +import { readFileSync } from 'node:fs'; + +/* + * A minimal stand-in for the parts of `@actions/core` and `@actions/github` + * this action uses. Depending on them would mean a full `npm install` in every + * writer job just to edit one comment, which costs more than the comment is + * worth. Node 24 gives us `fetch`, and the rest is environment variables. + */ + +export function getInput( name: string ): string { + /* The runner's own mangling: spaces to underscores, then uppercased. */ + const variable = `INPUT_${ name.replace( / /g, '_' ).toUpperCase() }`; + + return ( process.env[ variable ] ?? '' ).trim(); +} + +export function info( message: string ): void { + process.stdout.write( `${ message }\n` ); +} + +function escapeData( value: string ): string { + return value + .replace( /%/g, '%25' ) + .replace( /\r/g, '%0D' ) + .replace( /\n/g, '%0A' ); +} + +export function warning( message: string ): void { + process.stdout.write( `::warning::${ escapeData( message ) }\n` ); +} + +export function setFailed( message: string ): void { + process.stdout.write( `::error::${ escapeData( message ) }\n` ); + process.exitCode = 1; +} + +export type Repo = { + owner: string; + repo: string; +}; + +export function getRepo(): Repo { + const [ owner, repo ] = ( process.env.GITHUB_REPOSITORY ?? '' ).split( + '/' + ); + + if ( ! owner || ! repo ) { + throw new Error( 'GITHUB_REPOSITORY is not set.' ); + } + + return { owner, repo }; +} + +type EventPayload = { + number?: number; + issue?: { number?: number }; + pull_request?: { number?: number }; +}; + +export function getEventPayload(): EventPayload { + const path = process.env.GITHUB_EVENT_PATH; + + if ( ! path ) { + return {}; + } + + try { + return JSON.parse( readFileSync( path, 'utf8' ) ) as EventPayload; + } catch { + return {}; + } +} + +export function getApiUrl(): string { + return process.env.GITHUB_API_URL ?? 'https://api.github.com'; +} diff --git a/tools/pr-meta/src/github-api.ts b/tools/pr-meta/src/github-api.ts new file mode 100644 index 00000000000000..53370dbdd01375 --- /dev/null +++ b/tools/pr-meta/src/github-api.ts @@ -0,0 +1,149 @@ +import { getApiUrl } from './core.ts'; +import type { Repo } from './core.ts'; +import { isPrMetaComment } from './comment.ts'; + +export type ExistingComment = { + id: number; + body: string; +}; + +type ListedComment = { + id: number; + body?: string | null; + user?: { type?: string; login?: string } | null; +}; + +/* The identity `GITHUB_TOKEN` posts under, and so the only one we can edit. */ +const COMMENT_AUTHOR = 'github-actions[bot]'; + +class GitHubAPI { + #token: string; + #repo: Repo; + + constructor( token: string, repo: Repo ) { + this.#token = token; + this.#repo = repo; + } + + async #fetch( method: string, path: string, body?: unknown ) { + const response = await fetch( `${ getApiUrl() }${ path }`, { + method, + headers: { + accept: 'application/vnd.github+json', + authorization: `Bearer ${ this.#token }`, + 'content-type': 'application/json', + 'user-agent': 'wordpress-gutenberg-pr-meta', + 'x-github-api-version': '2022-11-28', + }, + body: body === undefined ? undefined : JSON.stringify( body ), + } ); + + if ( ! response.ok ) { + throw new Error( + `${ method } ${ path } responded ${ + response.status + }: ${ await response.text() }` + ); + } + + return response; + } + + async #request< T >( + method: string, + path: string, + body?: unknown + ): Promise< T > { + const response = await this.#fetch( method, path, body ); + + return ( await response.json() ) as T; + } + + get #base() { + return `/repos/${ this.#repo.owner }/${ this.#repo.repo }`; + } + + /** + * Reads the head SHA of a pull request, to tell a result for the current + * commit from one produced by a rerun of an older commit. + * + * @param prNumber Pull request number. + * @return The head SHA. + */ + async getHeadSha( prNumber: number ): Promise< string > { + const pullRequest = await this.#request< { head: { sha: string } } >( + 'GET', + `${ this.#base }/pulls/${ prNumber }` + ); + + return pullRequest.head.sha; + } + + /** + * Finds the unified comment. + * + * Both the marker and the author have to match. Anyone can post a comment + * starting with the marker, and every later write would fail trying to + * edit a comment it does not own. + * + * @param prNumber Pull request number. + * @return The comment, when this pull request has one. + */ + async findComment( + prNumber: number + ): Promise< ExistingComment | undefined > { + for ( let page = 1; ; page++ ) { + const comments = await this.#request< ListedComment[] >( + 'GET', + `${ + this.#base + }/issues/${ prNumber }/comments?per_page=100&page=${ page }` + ); + + const found = comments.find( + ( comment ) => + comment.user?.type === 'Bot' && + comment.user?.login === COMMENT_AUTHOR && + comment.body && + isPrMetaComment( comment.body ) + ); + + if ( found ) { + return { id: found.id, body: found.body! }; + } + + if ( comments.length < 100 ) { + return undefined; + } + } + } + + async createComment( prNumber: number, body: string ) { + const comment = await this.#request< { html_url: string } >( + 'POST', + `${ this.#base }/issues/${ prNumber }/comments`, + { body } + ); + + return comment.html_url; + } + + async updateComment( commentId: number, body: string ) { + const comment = await this.#request< { html_url: string } >( + 'PATCH', + `${ this.#base }/issues/comments/${ commentId }`, + { body } + ); + + return comment.html_url; + } + + async deleteComment( commentId: number ) { + await this.#fetch( + 'DELETE', + `${ this.#base }/issues/comments/${ commentId }` + ); + } +} + +export { GitHubAPI }; diff --git a/tools/pr-meta/src/index.ts b/tools/pr-meta/src/index.ts new file mode 100644 index 00000000000000..905d3a446272ac --- /dev/null +++ b/tools/pr-meta/src/index.ts @@ -0,0 +1,21 @@ +import { info, warning } from './core.ts'; +import { resolveBody, run } from './run.ts'; + +/* + * A comment is never worth failing a build over. Report the problem and log + * the section so its content is still recoverable from the run. Most sections + * arrive as a file, so read the body the same way `run` does. + */ +run().catch( ( error ) => { + warning( + `Could not update the PR meta comment: ${ + error instanceof Error ? error.message : String( error ) + }` + ); + + try { + info( resolveBody() ); + } catch { + info( 'The section body could not be read either.' ); + } +} ); diff --git a/tools/pr-meta/src/run.ts b/tools/pr-meta/src/run.ts new file mode 100644 index 00000000000000..a6d440bc59b7c6 --- /dev/null +++ b/tools/pr-meta/src/run.ts @@ -0,0 +1,127 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { getEventPayload, getInput, getRepo, info, setFailed } from './core.ts'; +import { GitHubAPI } from './github-api.ts'; +import { isParseable, mergeSection } from './comment.ts'; +import { getSection } from './sections.ts'; + +function resolvePrNumber(): number | undefined { + const input = getInput( 'pr-number' ); + + if ( input ) { + const parsed = Number.parseInt( input, 10 ); + return Number.isNaN( parsed ) ? undefined : parsed; + } + + /* + * `number` covers pull_request and pull_request_target, `issue` covers + * issue_comment. A push carries no pull request, so those callers have to + * pass `pr-number` themselves. + */ + const payload = getEventPayload(); + return payload.number ?? payload.issue?.number; +} + +export function resolveBody(): string { + const path = getInput( 'body-path' ); + + if ( ! path ) { + return getInput( 'body' ); + } + + /* + * Large or untrusted content travels as a file rather than a job output, + * which caps at 1MB and has to be escaped to survive the transfer. A + * producer with nothing to report uploads no artifact at all, so a missing + * file means "clear this section", not a mistake. + */ + if ( ! existsSync( path ) ) { + info( `No body at ${ path }, clearing the section.` ); + return ''; + } + + return readFileSync( path, 'utf8' ); +} + +async function run() { + const token = getInput( 'repo-token' ); + const section = getInput( 'section' ); + + if ( ! token || ! section ) { + setFailed( 'Both `repo-token` and `section` are required.' ); + return; + } + + const definition = getSection( section ); + if ( ! definition ) { + setFailed( `Unknown section "${ section }".` ); + return; + } + + const prNumber = resolvePrNumber(); + if ( ! prNumber ) { + setFailed( + 'No pull request to comment on. Pass `pr-number` on events without one.' + ); + return; + } + + const api = new GitHubAPI( token, getRepo() ); + const existing = await api.findComment( prNumber ); + + if ( existing && ! isParseable( existing.body ) ) { + setFailed( + 'The existing comment has an unbalanced section delimiter, leaving it untouched.' + ); + return; + } + + const body = resolveBody(); + + /* + * Every write re-renders every section, including the footers marking a + * commit-scoped result as no longer current, so the head is needed + * whatever this section's own scope is. Letting a failure through to the + * outer handler skips the write: rendering without it would present every + * stale result as current. + */ + const headSha = await api.getHeadSha( prNumber ); + + const { + body: merged, + remove, + rejected, + } = mergeSection( + existing?.body, + { + id: section, + body, + sha: getInput( 'commit-sha' ) || undefined, + runUrl: getInput( 'run-url' ) || undefined, + }, + headSha + ); + + if ( rejected ) { + info( `Skipped the "${ section }" section. ${ rejected }` ); + return; + } + + if ( remove && existing ) { + await api.deleteComment( existing.id ); + info( 'Removed the comment, its last section having gone.' ); + return; + } + + if ( ! merged ) { + info( `Nothing to report for the "${ section }" section.` ); + return; + } + + const url = existing + ? await api.updateComment( existing.id, merged ) + : await api.createComment( prNumber, merged ); + + info( `Wrote the "${ section }" section to ${ url }` ); +} + +export { run }; diff --git a/tools/pr-meta/src/sections.ts b/tools/pr-meta/src/sections.ts new file mode 100644 index 00000000000000..c05a5ad09492ee --- /dev/null +++ b/tools/pr-meta/src/sections.ts @@ -0,0 +1,82 @@ +/** + * How a section's content relates to the pull request. + * + * `commit` content is only true of the commit it was produced from, so it + * carries a SHA and goes stale. `pr-state` content describes the pull request + * as it currently is, so it is only ever current or absent. + */ +export type SectionScope = 'commit' | 'pr-state'; + +export type SectionDefinition = { + id: string; + /* Led by an emoji, so a reader can pick out their section at a glance. */ + heading: string; + scope: SectionScope; + /** Maximum characters of rendered body before the section is truncated. */ + budget: number; + /** Label for a section long enough to collapse. Omitted leaves it open. */ + summary?: string; +}; + +/* + * Render order, independent of the order the workflows finish in. The two a + * human acts on lead: the welcome greets a first-time contributor, and props + * is copied out by hand at merge time. + */ +export const SECTIONS: SectionDefinition[] = [ + { + id: 'welcome', + heading: '👋 Welcome', + scope: 'pr-state', + budget: 2000, + }, + { + id: 'props', + heading: '🎉 Props', + scope: 'pr-state', + budget: 8000, + }, + { + id: 'labels', + heading: '🏷️ Labels', + scope: 'pr-state', + budget: 2000, + }, + { + id: 'account-link', + heading: '🔗 WordPress.org profile', + scope: 'pr-state', + budget: 2000, + }, + { + id: 'bundle-size', + heading: '📦 Bundle size', + scope: 'commit', + budget: 12000, + }, + { + id: 'performance', + heading: '⚡ Performance', + scope: 'commit', + budget: 12000, + summary: 'Show the results', + }, + { + id: 'flaky-tests', + heading: '🏁 Flaky tests', + scope: 'commit', + budget: 16000, + summary: 'Show the failures', + }, +]; + +/** + * GitHub rejects a comment body longer than this. + * + * @see https://docs.github.com/en/rest/issues/comments + */ +export const COMMENT_LIMIT = 65536; + +export function getSection( id: string ): SectionDefinition | undefined { + return SECTIONS.find( ( section ) => section.id === id ); +} diff --git a/tools/pr-meta/tsconfig.json b/tools/pr-meta/tsconfig.json new file mode 100644 index 00000000000000..580a530ce8e1c8 --- /dev/null +++ b/tools/pr-meta/tsconfig.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.base.json", + "compilerOptions": { + // Sources and tests alike import by their real `.ts` extension, which is + // what Node's type stripping resolves when the action runs. + "allowImportingTsExtensions": true, + // The action talks to Node and the runner directly, with no dependencies. + "types": [ "jest", "node" ] + }, + "include": [ "src" ] +} diff --git a/tsconfig.json b/tsconfig.json index 1382c66c3fe23d..5b27e7aa91f679 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -94,6 +94,7 @@ { "path": "packages/worker-threads" }, { "path": "packages/wp-build" }, { "path": "storybook" }, + { "path": "tools/pr-meta" }, { "path": "test/e2e" }, { "path": "test/storybook-playwright" }, { "path": "test/performance" },