diff --git a/.gitignore b/.gitignore index 96f49190bf8b5..fc31278c325de 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ playground/dist # Report generated from jest-junit test/native/junit.xml + +# Local overrides +.wp-env.override.json diff --git a/.travis.yml b/.travis.yml index 122858672c93e..593dde89c7b9b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -24,7 +24,6 @@ branches: env: global: - - PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: true - WP_DEVELOP_DIR: ./wordpress - LOCAL_SCRIPT_DEBUG: false - INSTALL_COMPOSER: false @@ -162,49 +161,49 @@ jobs: - npm run test-php && npm run test-unit-php-multisite - name: E2E tests (Admin) (1/4) - env: FORCE_REDUCED_MOTION=true PUPPETEER_SKIP_CHROMIUM_DOWNLOAD= + env: FORCE_REDUCED_MOTION=true script: - $( npm bin )/wp-scripts test-e2e --config=./packages/e2e-tests/jest.config.js --listTests > ~/.jest-e2e-tests - $( npm bin )/wp-scripts test-e2e --config=./packages/e2e-tests/jest.config.js --cacheDirectory="$HOME/.jest-cache" --runTestsByPath $( awk 'NR % 4 == 0' < ~/.jest-e2e-tests ) - name: E2E tests (Admin) (2/4) - env: FORCE_REDUCED_MOTION=true PUPPETEER_SKIP_CHROMIUM_DOWNLOAD= + env: FORCE_REDUCED_MOTION=true script: - $( npm bin )/wp-scripts test-e2e --config=./packages/e2e-tests/jest.config.js --listTests > ~/.jest-e2e-tests - $( npm bin )/wp-scripts test-e2e --config=./packages/e2e-tests/jest.config.js --cacheDirectory="$HOME/.jest-cache" --runTestsByPath $( awk 'NR % 4 == 1' < ~/.jest-e2e-tests ) - name: E2E tests (Admin) (3/4) - env: FORCE_REDUCED_MOTION=true PUPPETEER_SKIP_CHROMIUM_DOWNLOAD= + env: FORCE_REDUCED_MOTION=true script: - $( npm bin )/wp-scripts test-e2e --config=./packages/e2e-tests/jest.config.js --listTests > ~/.jest-e2e-tests - $( npm bin )/wp-scripts test-e2e --config=./packages/e2e-tests/jest.config.js --cacheDirectory="$HOME/.jest-cache" --runTestsByPath $( awk 'NR % 4 == 2' < ~/.jest-e2e-tests ) - name: E2E tests (Admin) (4/4) - env: FORCE_REDUCED_MOTION=true PUPPETEER_SKIP_CHROMIUM_DOWNLOAD= + env: FORCE_REDUCED_MOTION=true script: - $( npm bin )/wp-scripts test-e2e --config=./packages/e2e-tests/jest.config.js --listTests > ~/.jest-e2e-tests - $( npm bin )/wp-scripts test-e2e --config=./packages/e2e-tests/jest.config.js --cacheDirectory="$HOME/.jest-cache" --runTestsByPath $( awk 'NR % 4 == 3' < ~/.jest-e2e-tests ) - name: E2E tests (Author) (1/4) - env: E2E_ROLE=author FORCE_REDUCED_MOTION=true PUPPETEER_SKIP_CHROMIUM_DOWNLOAD= + env: E2E_ROLE=author FORCE_REDUCED_MOTION=true script: - $( npm bin )/wp-scripts test-e2e --config=./packages/e2e-tests/jest.config.js --listTests > ~/.jest-e2e-tests - $( npm bin )/wp-scripts test-e2e --config=./packages/e2e-tests/jest.config.js --cacheDirectory="$HOME/.jest-cache" --runTestsByPath $( awk 'NR % 4 == 0' < ~/.jest-e2e-tests ) - name: E2E tests (Author) (2/4) - env: E2E_ROLE=author FORCE_REDUCED_MOTION=true PUPPETEER_SKIP_CHROMIUM_DOWNLOAD= + env: E2E_ROLE=author FORCE_REDUCED_MOTION=true script: - $( npm bin )/wp-scripts test-e2e --config=./packages/e2e-tests/jest.config.js --listTests > ~/.jest-e2e-tests - $( npm bin )/wp-scripts test-e2e --config=./packages/e2e-tests/jest.config.js --cacheDirectory="$HOME/.jest-cache" --runTestsByPath $( awk 'NR % 4 == 1' < ~/.jest-e2e-tests ) - name: E2E tests (Author) (3/4) - env: E2E_ROLE=author FORCE_REDUCED_MOTION=true PUPPETEER_SKIP_CHROMIUM_DOWNLOAD= + env: E2E_ROLE=author FORCE_REDUCED_MOTION=true script: - $( npm bin )/wp-scripts test-e2e --config=./packages/e2e-tests/jest.config.js --listTests > ~/.jest-e2e-tests - $( npm bin )/wp-scripts test-e2e --config=./packages/e2e-tests/jest.config.js --cacheDirectory="$HOME/.jest-cache" --runTestsByPath $( awk 'NR % 4 == 2' < ~/.jest-e2e-tests ) - name: E2E tests (Author) (4/4) - env: E2E_ROLE=author FORCE_REDUCED_MOTION=true PUPPETEER_SKIP_CHROMIUM_DOWNLOAD= + env: E2E_ROLE=author FORCE_REDUCED_MOTION=true script: - $( npm bin )/wp-scripts test-e2e --config=./packages/e2e-tests/jest.config.js --listTests > ~/.jest-e2e-tests - $( npm bin )/wp-scripts test-e2e --config=./packages/e2e-tests/jest.config.js --cacheDirectory="$HOME/.jest-cache" --runTestsByPath $( awk 'NR % 4 == 3' < ~/.jest-e2e-tests ) diff --git a/package-lock.json b/package-lock.json index 0ef8da4b1ea83..e0b0534df68e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10498,11 +10498,245 @@ "chalk": "^2.4.2", "copy-dir": "^1.2.0", "docker-compose": "^0.22.2", + "extract-zip": "^1.6.7", + "inquirer": "^7.0.4", "js-yaml": "^3.13.1", "nodegit": "^0.26.2", "ora": "^4.0.2", + "request": "^2.88.2", + "request-progress": "^3.0.0", + "rimraf": "^3.0.2", "terminal-link": "^2.0.0", "yargs": "^14.0.0" + }, + "dependencies": { + "ansi-escapes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.0.tgz", + "integrity": "sha512-EiYhwo0v255HUL6eDyuLrXEkTi7WwVCLAw+SeOQ7M7qdun1z1pum4DEm/nuqIVbPvi9RPPc9k9LbyBv6H0DwVg==", + "dev": true, + "requires": { + "type-fest": "^0.8.1" + } + }, + "ansi-regex": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", + "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", + "dev": true + }, + "cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "requires": { + "restore-cursor": "^3.1.0" + } + }, + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "figures": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "dev": true, + "requires": { + "escape-string-regexp": "^1.0.5" + } + }, + "glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "inquirer": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-7.0.4.tgz", + "integrity": "sha512-Bu5Td5+j11sCkqfqmUTiwv+tWisMtP0L7Q8WrqA2C/BbBhy1YTdFrvjjlrKq8oagA/tLQBski2Gcx/Sqyi2qSQ==", + "dev": true, + "requires": { + "ansi-escapes": "^4.2.1", + "chalk": "^2.4.2", + "cli-cursor": "^3.1.0", + "cli-width": "^2.0.0", + "external-editor": "^3.0.3", + "figures": "^3.0.0", + "lodash": "^4.17.15", + "mute-stream": "0.0.8", + "run-async": "^2.2.0", + "rxjs": "^6.5.3", + "string-width": "^4.1.0", + "strip-ansi": "^5.1.0", + "through": "^2.3.6" + } + }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true + }, + "mime-db": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.43.0.tgz", + "integrity": "sha512-+5dsGEEovYbT8UY9yD7eE4XTc4UwJ1jBYlgaQQF38ENsKR3wj/8q8RFZrF9WIZpB2V1ArTVFUva8sAul1NzRzQ==", + "dev": true + }, + "mime-types": { + "version": "2.1.26", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.26.tgz", + "integrity": "sha512-01paPWYgLrkqAyrlDorC1uDwl2p3qZT7yl806vW7DvDoxwXi46jsjFbg+WdwotBIk6/MbEhO/dh5aZ5sNj/dWQ==", + "dev": true, + "requires": { + "mime-db": "1.43.0" + } + }, + "mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true + }, + "mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "dev": true + }, + "onetime": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.0.tgz", + "integrity": "sha512-5NcSkPHhwTVFIQN+TUqXoS5+dlElHXdpAWu9I0HP20YOtIi+aZ0Ct82jdlILDxjLEAWwvm+qj1m6aEtsDVmm6Q==", + "dev": true, + "requires": { + "mimic-fn": "^2.1.0" + } + }, + "request": { + "version": "2.88.2", + "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", + "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", + "dev": true, + "requires": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "har-validator": "~5.1.3", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "~6.5.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~2.5.0", + "tunnel-agent": "^0.6.0", + "uuid": "^3.3.2" + } + }, + "restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "requires": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + } + }, + "rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "rxjs": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.4.tgz", + "integrity": "sha512-naMQXcgEo3csAEGvw/NydRA0fuS2nDZJiw1YUWFKU7aPPAPGZEsD4Iimit96qwCieH6y614MCLYwdkrWx7z/7Q==", + "dev": true, + "requires": { + "tslib": "^1.9.0" + } + }, + "string-width": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", + "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", + "dev": true, + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "dependencies": { + "strip-ansi": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", + "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.0" + } + } + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + } + } + }, + "tough-cookie": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", + "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "dev": true, + "requires": { + "psl": "^1.1.28", + "punycode": "^2.1.1" + } + }, + "type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true + } } }, "@wordpress/escape-html": { @@ -34789,6 +35023,15 @@ } } }, + "request-progress": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/request-progress/-/request-progress-3.0.0.tgz", + "integrity": "sha1-TKdUCBx/7GP1BeT6qCWqBs1mnb4=", + "dev": true, + "requires": { + "throttleit": "^1.0.0" + } + }, "request-promise-core": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.1.tgz", @@ -38292,6 +38535,12 @@ "integrity": "sha512-AOvyNahXQuU7NN+VVvOOX+uW6FPaWdAOdRP5HfwYxAfCzXTFKRMoIMk+n+po318+ktcChx+F1Dd91G3YHeMKyg==", "dev": true }, + "throttleit": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-1.0.0.tgz", + "integrity": "sha1-nnhYNtr0Z0MUWlmEtiaNgoUorGw=", + "dev": true + }, "through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", diff --git a/packages/block-editor/src/components/editor-skeleton/index.js b/packages/block-editor/src/components/editor-skeleton/index.js index 5b566f87221ff..ca4f3646f529c 100644 --- a/packages/block-editor/src/components/editor-skeleton/index.js +++ b/packages/block-editor/src/components/editor-skeleton/index.js @@ -13,7 +13,7 @@ import { __ } from '@wordpress/i18n'; function useHTMLClass( className ) { useEffect( () => { const element = - document && document.querySelector( `html:not(.${ className }` ); + document && document.querySelector( `html:not(.${ className })` ); if ( ! element ) { return; } diff --git a/packages/block-editor/src/components/link-control/style.scss b/packages/block-editor/src/components/link-control/style.scss index 959e1f30c1126..6c2f53643270c 100644 --- a/packages/block-editor/src/components/link-control/style.scss +++ b/packages/block-editor/src/components/link-control/style.scss @@ -135,6 +135,8 @@ $block-editor-link-control-number-of-actions: 1; .block-editor-link-control__search-item-header { display: block; margin-right: $grid-size-xlarge; + overflow: hidden; + white-space: nowrap; } .block-editor-link-control__search-item-icon { @@ -144,10 +146,10 @@ $block-editor-link-control-number-of-actions: 1; .block-editor-link-control__search-item-info, .block-editor-link-control__search-item-title { - text-overflow: ellipsis; max-width: 230px; overflow: hidden; white-space: nowrap; + text-overflow: ellipsis; } .block-editor-link-control__search-item-title { diff --git a/packages/block-editor/src/components/typewriter/index.js b/packages/block-editor/src/components/typewriter/index.js index b9b2e18ea6104..5b68cea5080c6 100644 --- a/packages/block-editor/src/components/typewriter/index.js +++ b/packages/block-editor/src/components/typewriter/index.js @@ -236,12 +236,6 @@ class Typewriter extends Component { } render() { - // There are some issues with Internet Explorer, which are probably not - // worth spending time on. Let's disable it. - if ( isIE ) { - return this.props.children; - } - // Disable reason: Wrapper itself is non-interactive, but must capture // bubbling events from children to determine focus transition intents. /* eslint-disable jsx-a11y/no-static-element-interactions */ @@ -261,12 +255,23 @@ class Typewriter extends Component { } } +/** + * The exported component. The implementation of Typewriter faced technical + * challenges in Internet Explorer, and is simply skipped, rendering the given + * props children instead. + * + * @type {WPComponent} + */ +const TypewriterOrIEBypass = isIE + ? ( props ) => props.children + : withSelect( ( select ) => { + const { getSelectedBlockClientId } = select( 'core/block-editor' ); + return { selectedBlockClientId: getSelectedBlockClientId() }; + } )( Typewriter ); + /** * Ensures that the text selection keeps the same vertical distance from the * viewport during keyboard events within this component. The vertical distance * can vary. It is the last clicked or scrolled to position. */ -export default withSelect( ( select ) => { - const { getSelectedBlockClientId } = select( 'core/block-editor' ); - return { selectedBlockClientId: getSelectedBlockClientId() }; -} )( Typewriter ); +export default TypewriterOrIEBypass; diff --git a/packages/block-library/src/buttons/editor.scss b/packages/block-library/src/buttons/editor.scss index 19b0acb884b58..2f7aaf1572892 100644 --- a/packages/block-library/src/buttons/editor.scss +++ b/packages/block-library/src/buttons/editor.scss @@ -17,7 +17,7 @@ } .block-list-appender { - display: inline-block !important; + display: inline-block; margin: 0; } diff --git a/packages/block-library/src/columns/style.scss b/packages/block-library/src/columns/style.scss index 44ec7ae38bb85..4499ac92f1a4f 100644 --- a/packages/block-library/src/columns/style.scss +++ b/packages/block-library/src/columns/style.scss @@ -31,8 +31,11 @@ overflow-wrap: break-word; // New standard. // Between mobile and large viewports, allow 2 columns. - @include break-small() { - flex-basis: calc(50% - #{$grid-size-large}); + @media (min-width: #{ ($break-small) }) and (max-width: #{ ($break-medium - 1) }) { + // As with mobile styles, this must be important since the Column + // assigns its own width as an inline style, which should take effect + // starting at `break-medium`. + flex-basis: calc(50% - #{$grid-size-large}) !important; flex-grow: 0; // Add space between the multiple columns. Themes can customize this if they wish to work differently. diff --git a/packages/block-library/src/latest-posts/index.php b/packages/block-library/src/latest-posts/index.php index 86706bd2b23a3..6e96f10461231 100644 --- a/packages/block-library/src/latest-posts/index.php +++ b/packages/block-library/src/latest-posts/index.php @@ -5,6 +5,26 @@ * @package WordPress */ +/** + * The excerpt length set by the Latest Posts core block + * set at render time and used by the block itself. + * + * @var int + */ +$block_core_latest_posts_excerpt_length = 0; + +/** + * Callback for the excerpt_length filter used by + * the Latest Posts block at render time. + * + * @return int Returns the global $block_core_latest_posts_excerpt_length variable + * to allow the excerpt_length filter respect the Latest Block setting. + */ +function block_core_latest_posts_get_excerpt_length() { + global $block_core_latest_posts_excerpt_length; + return $block_core_latest_posts_excerpt_length; +} + /** * Renders the `core/latest-posts` block on server. * @@ -13,6 +33,8 @@ * @return string Returns the post content with latest posts added. */ function render_block_core_latest_posts( $attributes ) { + global $block_core_latest_posts_excerpt_length; + $args = array( 'posts_per_page' => $attributes['postsToShow'], 'post_status' => 'publish', @@ -21,6 +43,9 @@ function render_block_core_latest_posts( $attributes ) { 'suppress_filters' => false, ); + $block_core_latest_posts_excerpt_length = $attributes['excerptLength']; + add_filter( 'excerpt_length', 'block_core_latest_posts_get_excerpt_length', 20 ); + if ( isset( $attributes['categories'] ) ) { $args['category'] = $attributes['categories']; } @@ -111,6 +136,8 @@ function render_block_core_latest_posts( $attributes ) { $list_items_markup .= "\n"; } + remove_filter( 'excerpt_length', 'block_core_latest_posts_get_excerpt_length', 20 ); + $class = 'wp-block-latest-posts wp-block-latest-posts__list'; if ( isset( $attributes['align'] ) ) { $class .= ' align' . $attributes['align']; diff --git a/packages/block-library/src/social-link/block.json b/packages/block-library/src/social-link/block.json index b30cfc9ae44d5..977f5731fd961 100644 --- a/packages/block-library/src/social-link/block.json +++ b/packages/block-library/src/social-link/block.json @@ -10,7 +10,7 @@ "type": "string" }, "label": { - "type": "number" + "type": "string" } } } diff --git a/packages/block-library/src/social-link/index.php b/packages/block-library/src/social-link/index.php index a414cb6c275ee..32111ff72db73 100644 --- a/packages/block-library/src/social-link/index.php +++ b/packages/block-library/src/social-link/index.php @@ -15,7 +15,10 @@ function render_block_core_social_link( $attributes ) { $service = ( isset( $attributes['service'] ) ) ? $attributes['service'] : 'Icon'; $url = ( isset( $attributes['url'] ) ) ? $attributes['url'] : false; - $label = ( isset( $attributes['label'] ) ) ? $attributes['label'] : __( 'Link to ' ) . block_core_social_link_get_name( $service ); + $label = ( isset( $attributes['label'] ) ) ? + $attributes['label'] : + /* translators: %s: Social Link service name */ + sprintf( __( 'Link to %s' ), block_core_social_link_get_name( $service ) ); // Don't render a link if there is no URL set. if ( ! $url ) { @@ -23,7 +26,7 @@ function render_block_core_social_link( $attributes ) { } $icon = block_core_social_link_get_icon( $service ); - return ''; + return ''; } /** @@ -90,163 +93,163 @@ function block_core_social_link_services( $service = '', $field = '' ) { $services_data = array( 'fivehundredpx' => array( 'name' => '500px', - 'icon' => '', + 'icon' => '', ), 'amazon' => array( 'name' => 'Amazon', - 'icon' => '', + 'icon' => '', ), 'bandcamp' => array( 'name' => 'Bandcamp', - 'icon' => '', + 'icon' => '', ), 'behance' => array( 'name' => 'Behance', - 'icon' => '', + 'icon' => '', ), 'chain' => array( 'name' => 'Link', - 'icon' => '', + 'icon' => '', ), 'codepen' => array( 'name' => 'CodePen', - 'icon' => '', + 'icon' => '', ), 'deviantart' => array( 'name' => 'DeviantArt', - 'icon' => '', + 'icon' => '', ), 'dribbble' => array( 'name' => 'Dribbble', - 'icon' => '', + 'icon' => '', ), 'dropbox' => array( 'name' => 'Dropbox', - 'icon' => '', + 'icon' => '', ), 'etsy' => array( 'name' => 'Etsy', - 'icon' => '', + 'icon' => '', ), 'facebook' => array( 'name' => 'Facebook', - 'icon' => '', + 'icon' => '', ), 'feed' => array( 'name' => 'RSS Feed', - 'icon' => '', + 'icon' => '', ), 'flickr' => array( 'name' => 'Flickr', - 'icon' => '', + 'icon' => '', ), 'foursquare' => array( 'name' => 'Foursquare', - 'icon' => '', + 'icon' => '', ), 'goodreads' => array( 'name' => 'Goodreads', - 'icon' => '', + 'icon' => '', ), 'google' => array( 'name' => 'Google', - 'icon' => '', + 'icon' => '', ), 'github' => array( 'name' => 'GitHub', - 'icon' => '', + 'icon' => '', ), 'instagram' => array( 'name' => 'Instagram', - 'icon' => '', + 'icon' => '', ), 'lastfm' => array( 'name' => 'Last.fm', - 'icon' => '', + 'icon' => '', ), 'linkedin' => array( 'name' => 'LinkedIn', - 'icon' => '', + 'icon' => '', ), 'mail' => array( 'name' => 'Mail', - 'icon' => '', + 'icon' => '', ), 'mastodon' => array( 'name' => 'Mastodon', - 'icon' => '', + 'icon' => '', ), 'meetup' => array( 'name' => 'Meetup', - 'icon' => '', + 'icon' => '', ), 'medium' => array( 'name' => 'Medium', - 'icon' => '', + 'icon' => '', ), 'pinterest' => array( 'name' => 'Pinterest', - 'icon' => '', + 'icon' => '', ), 'pocket' => array( 'name' => 'Pocket', - 'icon' => '', + 'icon' => '', ), 'reddit' => array( 'name' => 'Reddit', - 'icon' => '', + 'icon' => '', ), 'skype' => array( 'name' => 'Skype', - 'icon' => '', + 'icon' => '', ), 'snapchat' => array( 'name' => 'Snapchat', - 'icon' => '', + 'icon' => '', ), 'soundcloud' => array( 'name' => 'Soundcloud', - 'icon' => '', + 'icon' => '', ), 'spotify' => array( 'name' => 'Spotify', - 'icon' => '', + 'icon' => '', ), 'tumblr' => array( 'name' => 'Tumblr', - 'icon' => '', + 'icon' => '', ), 'twitch' => array( 'name' => 'Twitch', - 'icon' => '', + 'icon' => '', ), 'twitter' => array( 'name' => 'Twitter', - 'icon' => '', + 'icon' => '', ), 'vimeo' => array( 'name' => 'Vimeo', - 'icon' => '', + 'icon' => '', ), 'vk' => array( 'name' => 'VK', - 'icon' => '', + 'icon' => '', ), 'wordpress' => array( 'name' => 'WordPress', - 'icon' => '', + 'icon' => '', ), 'yelp' => array( 'name' => 'Yelp', - 'icon' => '', + 'icon' => '', ), 'youtube' => array( 'name' => 'YouTube', - 'icon' => '', + 'icon' => '', ), 'share' => array( 'name' => 'Share Icon', - 'icon' => '', + 'icon' => '', ), ); diff --git a/packages/e2e-tests/plugins/meta-attribute-block.php b/packages/e2e-tests/plugins/meta-attribute-block.php index 58eec7c04244a..4513ebafde26a 100644 --- a/packages/e2e-tests/plugins/meta-attribute-block.php +++ b/packages/e2e-tests/plugins/meta-attribute-block.php @@ -29,13 +29,23 @@ function init_test_meta_attribute_block_plugin() { */ function enqueue_test_meta_attribute_block() { wp_enqueue_script( - 'gutenberg-test-meta-attribute-block', - plugins_url( 'meta-attribute-block/index.js', __FILE__ ), + 'gutenberg-test-meta-attribute-block-early', + plugins_url( 'meta-attribute-block/early.js', __FILE__ ), array( 'wp-blocks', 'wp-element', ), - filemtime( plugin_dir_path( __FILE__ ) . 'meta-attribute-block/index.js' ), + filemtime( plugin_dir_path( __FILE__ ) . 'meta-attribute-block/early.js' ) + ); + + wp_enqueue_script( + 'gutenberg-test-meta-attribute-block-late', + plugins_url( 'meta-attribute-block/late.js', __FILE__ ), + array( + 'wp-blocks', + 'wp-element', + ), + filemtime( plugin_dir_path( __FILE__ ) . 'meta-attribute-block/late.js' ), true ); } diff --git a/packages/e2e-tests/plugins/meta-attribute-block/early.js b/packages/e2e-tests/plugins/meta-attribute-block/early.js new file mode 100644 index 0000000000000..7c2831e84e4de --- /dev/null +++ b/packages/e2e-tests/plugins/meta-attribute-block/early.js @@ -0,0 +1,32 @@ +( function() { + var registerBlockType = wp.blocks.registerBlockType; + var el = wp.element.createElement; + + registerBlockType( 'test/test-meta-attribute-block-early', { + title: 'Test Meta Attribute Block (Early Registration)', + icon: 'star', + category: 'common', + + attributes: { + content: { + type: 'string', + source: 'meta', + meta: 'my_meta', + }, + }, + + edit: function( props ) { + return el( 'input', { + className: 'my-meta-input', + value: props.attributes.content, + onChange: function( event ) { + props.setAttributes( { content: event.target.value } ); + }, + } ); + }, + + save: function() { + return null; + }, + } ); +} )(); diff --git a/packages/e2e-tests/plugins/meta-attribute-block/index.js b/packages/e2e-tests/plugins/meta-attribute-block/index.js deleted file mode 100644 index 0910e648299ae..0000000000000 --- a/packages/e2e-tests/plugins/meta-attribute-block/index.js +++ /dev/null @@ -1,35 +0,0 @@ -( function() { - var registerBlockType = wp.blocks.registerBlockType; - var el = wp.element.createElement; - - registerBlockType( 'test/test-meta-attribute-block', { - title: 'Test Meta Attribute Block', - icon: 'star', - category: 'common', - - attributes: { - content: { - type: "string", - source: "meta", - meta: "my_meta", - }, - }, - - edit: function( props ) { - return el( - 'input', - { - className: 'my-meta-input', - value: props.attributes.content, - onChange: function( event ) { - props.setAttributes( { content: event.target.value } ); - }, - } - ); - }, - - save: function() { - return null; - }, - } ); -} )(); diff --git a/packages/e2e-tests/plugins/meta-attribute-block/late.js b/packages/e2e-tests/plugins/meta-attribute-block/late.js new file mode 100644 index 0000000000000..362acab0f776a --- /dev/null +++ b/packages/e2e-tests/plugins/meta-attribute-block/late.js @@ -0,0 +1,32 @@ +( function() { + var registerBlockType = wp.blocks.registerBlockType; + var el = wp.element.createElement; + + registerBlockType( 'test/test-meta-attribute-block-late', { + title: 'Test Meta Attribute Block (Late Registration)', + icon: 'star', + category: 'common', + + attributes: { + content: { + type: 'string', + source: 'meta', + meta: 'my_meta', + }, + }, + + edit: function( props ) { + return el( 'input', { + className: 'my-meta-input', + value: props.attributes.content, + onChange: function( event ) { + props.setAttributes( { content: event.target.value } ); + }, + } ); + }, + + save: function() { + return null; + }, + } ); +} )(); diff --git a/packages/e2e-tests/specs/editor/plugins/__snapshots__/meta-attribute-block.test.js.snap b/packages/e2e-tests/specs/editor/plugins/__snapshots__/meta-attribute-block.test.js.snap index 1b5c130366402..268c8b45d059f 100644 --- a/packages/e2e-tests/specs/editor/plugins/__snapshots__/meta-attribute-block.test.js.snap +++ b/packages/e2e-tests/specs/editor/plugins/__snapshots__/meta-attribute-block.test.js.snap @@ -1,5 +1,9 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Block with a meta attribute Should persist the meta attribute properly 1`] = `""`; +exports[`Block with a meta attribute Early Registration Should persist the meta attribute properly 1`] = `""`; -exports[`Block with a meta attribute Should persist the meta attribute properly in a different post type 1`] = `""`; +exports[`Block with a meta attribute Early Registration Should persist the meta attribute properly in a different post type 1`] = `""`; + +exports[`Block with a meta attribute Late Registration Should persist the meta attribute properly 1`] = `""`; + +exports[`Block with a meta attribute Late Registration Should persist the meta attribute properly in a different post type 1`] = `""`; diff --git a/packages/e2e-tests/specs/editor/plugins/meta-attribute-block.test.js b/packages/e2e-tests/specs/editor/plugins/meta-attribute-block.test.js index 68f4de2768fbb..22569268de629 100644 --- a/packages/e2e-tests/specs/editor/plugins/meta-attribute-block.test.js +++ b/packages/e2e-tests/specs/editor/plugins/meta-attribute-block.test.js @@ -24,68 +24,75 @@ describe( 'Block with a meta attribute', () => { await deactivatePlugin( 'gutenberg-test-meta-attribute-block' ); } ); - it( 'Should persist the meta attribute properly', async () => { - await insertBlock( 'Test Meta Attribute Block' ); - await page.keyboard.type( 'Value' ); + describe.each( [ [ 'Early Registration' ], [ 'Late Registration' ] ] )( + '%s', + ( variant ) => { + it( 'Should persist the meta attribute properly', async () => { + await insertBlock( `Test Meta Attribute Block (${ variant })` ); + await page.keyboard.type( 'Value' ); - // Regression Test: Previously the caret would wrongly reset to the end - // of any input for meta-sourced attributes, due to syncing behavior of - // meta attribute updates. - // - // See: https://github.com/WordPress/gutenberg/issues/15739 - await pressKeyTimes( 'ArrowLeft', 5 ); - await page.keyboard.type( 'Meta ' ); + // Regression Test: Previously the caret would wrongly reset to the end + // of any input for meta-sourced attributes, due to syncing behavior of + // meta attribute updates. + // + // See: https://github.com/WordPress/gutenberg/issues/15739 + await pressKeyTimes( 'ArrowLeft', 5 ); + await page.keyboard.type( 'Meta ' ); - await saveDraft(); - await page.reload(); + await saveDraft(); + await page.reload(); - expect( await getEditedPostContent() ).toMatchSnapshot(); - const persistedValue = await page.evaluate( - () => document.querySelector( '.my-meta-input' ).value - ); - expect( persistedValue ).toBe( 'Meta Value' ); - } ); + expect( await getEditedPostContent() ).toMatchSnapshot(); + const persistedValue = await page.evaluate( + () => document.querySelector( '.my-meta-input' ).value + ); + expect( persistedValue ).toBe( 'Meta Value' ); + } ); - it( 'Should use the same value in all the blocks', async () => { - await insertBlock( 'Test Meta Attribute Block' ); - await insertBlock( 'Test Meta Attribute Block' ); - await insertBlock( 'Test Meta Attribute Block' ); - await page.keyboard.type( 'Meta Value' ); + it( 'Should use the same value in all the blocks', async () => { + await insertBlock( `Test Meta Attribute Block (${ variant })` ); + await insertBlock( `Test Meta Attribute Block (${ variant })` ); + await insertBlock( `Test Meta Attribute Block (${ variant })` ); + await page.keyboard.type( 'Meta Value' ); - const inputs = await page.$$( '.my-meta-input' ); - await Promise.all( - inputs.map( async ( input ) => { - // Clicking the input selects the block, - // and selecting the block enables the sync data mode - // as otherwise the asynchronous re-rendering of unselected blocks - // may cause the input to have not yet been updated for the other blocks - await input.click(); - const inputValue = await input.getProperty( 'value' ); - expect( await inputValue.jsonValue() ).toBe( 'Meta Value' ); - } ) - ); - } ); + const inputs = await page.$$( '.my-meta-input' ); + await Promise.all( + inputs.map( async ( input ) => { + // Clicking the input selects the block, + // and selecting the block enables the sync data mode + // as otherwise the asynchronous re-rendering of unselected blocks + // may cause the input to have not yet been updated for the other blocks + await input.click(); + const inputValue = await input.getProperty( 'value' ); + expect( await inputValue.jsonValue() ).toBe( + 'Meta Value' + ); + } ) + ); + } ); - it( 'Should persist the meta attribute properly in a different post type', async () => { - await createNewPost( { postType: 'page' } ); - await insertBlock( 'Test Meta Attribute Block' ); - await page.keyboard.type( 'Value' ); + it( 'Should persist the meta attribute properly in a different post type', async () => { + await createNewPost( { postType: 'page' } ); + await insertBlock( `Test Meta Attribute Block (${ variant })` ); + await page.keyboard.type( 'Value' ); - // Regression Test: Previously the caret would wrongly reset to the end - // of any input for meta-sourced attributes, due to syncing behavior of - // meta attribute updates. - // - // See: https://github.com/WordPress/gutenberg/issues/15739 - await pressKeyTimes( 'ArrowLeft', 5 ); - await page.keyboard.type( 'Meta ' ); + // Regression Test: Previously the caret would wrongly reset to the end + // of any input for meta-sourced attributes, due to syncing behavior of + // meta attribute updates. + // + // See: https://github.com/WordPress/gutenberg/issues/15739 + await pressKeyTimes( 'ArrowLeft', 5 ); + await page.keyboard.type( 'Meta ' ); - await saveDraft(); - await page.reload(); + await saveDraft(); + await page.reload(); - expect( await getEditedPostContent() ).toMatchSnapshot(); - const persistedValue = await page.evaluate( - () => document.querySelector( '.my-meta-input' ).value - ); - expect( persistedValue ).toBe( 'Meta Value' ); - } ); + expect( await getEditedPostContent() ).toMatchSnapshot(); + const persistedValue = await page.evaluate( + () => document.querySelector( '.my-meta-input' ).value + ); + expect( persistedValue ).toBe( 'Meta Value' ); + } ); + } + ); } ); diff --git a/packages/e2e-tests/specs/editor/various/a11y.test.js b/packages/e2e-tests/specs/editor/various/a11y.test.js index 8f16e6a7ac636..0ca44d7fb7114 100644 --- a/packages/e2e-tests/specs/editor/various/a11y.test.js +++ b/packages/e2e-tests/specs/editor/various/a11y.test.js @@ -23,7 +23,7 @@ describe( 'a11y', () => { ':focus', ( focusedElement ) => { return focusedElement.classList.contains( - 'editor-post-publish-button__button' + 'block-editor-inserter__toggle' ); } ); diff --git a/packages/edit-post/src/components/header/index.js b/packages/edit-post/src/components/header/index.js index a18f5a207c114..d1d434cbd861f 100644 --- a/packages/edit-post/src/components/header/index.js +++ b/packages/edit-post/src/components/header/index.js @@ -57,6 +57,10 @@ function Header() { return (
+
+ + +
{ ! isPublishSidebarOpened && ( // This button isn't completely hidden by the publish sidebar. @@ -88,10 +92,6 @@ function Header() {
-
- - -
); } diff --git a/packages/edit-post/src/components/header/style.scss b/packages/edit-post/src/components/header/style.scss index 3c06b4dc15d9f..b862dfa1388bc 100644 --- a/packages/edit-post/src/components/header/style.scss +++ b/packages/edit-post/src/components/header/style.scss @@ -4,7 +4,6 @@ background: $white; display: flex; flex-wrap: wrap; - flex-direction: row-reverse; justify-content: space-between; align-items: center; // The header should never be wider than the viewport, or buttons might be hidden. Especially relevant at high zoom levels. Related to https://core.trac.wordpress.org/ticket/47603#ticket. diff --git a/packages/editor/src/hooks/custom-sources-backwards-compatibility.js b/packages/editor/src/hooks/custom-sources-backwards-compatibility.js index 8c91cd625da62..51d5bf4ac5fda 100644 --- a/packages/editor/src/hooks/custom-sources-backwards-compatibility.js +++ b/packages/editor/src/hooks/custom-sources-backwards-compatibility.js @@ -6,7 +6,7 @@ import { pickBy, mapValues, isEmpty, mapKeys } from 'lodash'; /** * WordPress dependencies */ -import { useSelect } from '@wordpress/data'; +import { select as globalSelect, useSelect } from '@wordpress/data'; import { useEntityProp } from '@wordpress/core-data'; import { useMemo } from '@wordpress/element'; import { createHigherOrderComponent } from '@wordpress/compose'; @@ -116,3 +116,26 @@ addFilter( 'core/editor/custom-sources-backwards-compatibility/shim-attribute-source', shimAttributeSource ); + +// The above filter will only capture blocks registered after the filter was +// added. There may already be blocks registered by this point, and those must +// be updated to apply the shim. +// +// The following implementation achieves this, albeit with a couple caveats: +// - Only blocks registered on the global store will be modified. +// - The block settings are directly mutated, since there is currently no +// mechanism to update an existing block registration. This is the reason for +// `getBlockType` separate from `getBlockTypes`, since the latter returns a +// _copy_ of the block registration (i.e. the mutation would not affect the +// actual registered block settings). +// +// `getBlockTypes` or `getBlockType` implementation could change in the future +// in regards to creating settings clones, but the corresponding end-to-end +// tests for meta blocks should cover against any potential regressions. +// +// In the future, we could support updating block settings, at which point this +// implementation could use that mechanism instead. +globalSelect( 'core/blocks' ) + .getBlockTypes() + .map( ( { name } ) => globalSelect( 'core/blocks' ).getBlockType( name ) ) + .forEach( shimAttributeSource ); diff --git a/packages/env/CHANGELOG.md b/packages/env/CHANGELOG.md index 9400e9a113faa..774afe9e90411 100644 --- a/packages/env/CHANGELOG.md +++ b/packages/env/CHANGELOG.md @@ -1,18 +1,26 @@ ## Master +### New Feature + +- URLs for ZIP files are now supported as core, plugin, and theme sources. +- The `.wp-env.json` coniguration file now accepts a `config` object for setting `wp-config.php` values. +- A `.wp-env.override.json` configuration file can now be used to override fields from `.wp-env.json`. +- You may now override the directory in which `wp-env` creates generated files with the `WP_ENV_HOME` environment variable. The default directory is `~/.wp-env/` (or `~/wp-env/` on Linux). +- The `.wp-env.json` coniguration file now accepts `port` and `testsPort` options which can be used to set the ports on which the docker instance is mounted. + ## 1.0.0 (2020-02-10) ### Breaking Changes -- `wp-env start` no longer accepts a WordPress branch or tag reference as its argument. Instead, create a `.wp-env.json` file and specify a `"core"` field. -- `wp-env start` will now download WordPress into a hidden directory located in `~/.wp-env`. You may delete your `{projectName}-wordpress` and `{projectName}-tests-wordpress` directories. +- `wp-env start` no longer accepts a WordPress branch or tag reference as its argument. Instead, create a `.wp-env.json` file and specify a `"core"` field. +- `wp-env start` will now download WordPress into a hidden directory located in `~/.wp-env`. You may delete your `{projectName}-wordpress` and `{projectName}-tests-wordpress` directories. ### New Feature -- A `.wp-env.json` configuration file can now be used to specify the WordPress installation, plugins, and themes to use in the local development environment. +- A `.wp-env.json` configuration file can now be used to specify the WordPress installation, plugins, and themes to use in the local development environment. ## 0.4.0 (2020-02-04) ### Bug Fixes -- When running scripts using `wp-env run`, the output will not be formatted if not written to terminal display, resolving an issue where piped or redirected output could be unintentionally padded with newlines. +- When running scripts using `wp-env run`, the output will not be formatted if not written to terminal display, resolving an issue where piped or redirected output could be unintentionally padded with newlines. diff --git a/packages/env/README.md b/packages/env/README.md index 9e4ac9f7b4387..dadb8e406a20f 100644 --- a/packages/env/README.md +++ b/packages/env/README.md @@ -79,6 +79,8 @@ $ WP_ENV_PORT=3333 wp-env start Running `docker ps` and inspecting the `PORTS` column allows you to determine which port `wp-env` is currently using. +You may also specify the port numbers in your `.wp-env.json` file, but the environment variables take precedent. + ### 3. Restart `wp-env` Restarting `wp-env` will restart the underlying Docker containers which can fix many issues. @@ -135,6 +137,8 @@ $ wp-env start ## Command reference +`wp-env` creates generated files in the `wp-env` home directory. By default, this is `~/.wp-env`. The exception is Linux, where files are placed at `~/wp-env` [for compatibility with Snap Packages](https://github.com/WordPress/gutenberg/issues/20180#issuecomment-587046325). The `wp-env` home directory contains a subdirectory for each project named `/$md5_of_project_path`. To change the `wp-env` home directory, set the `WP_ENV_HOME` environment variable. For example, running `WP_ENV_HOME="something" wp-env start` will download the project files to the directory `./something/$md5_of_project_path` (relative to the current directory). + ### `wp-env start [ref]` ```sh @@ -175,24 +179,34 @@ Positionals: You can customize the WordPress installation, plugins and themes that the development environment will use by specifying a `.wp-env.json` file in the directory that you run `wp-env` from. -`.wp-env.json` supports three fields: +`.wp-env.json` supports five fields: + +| Field | Type | Default | Description | +| ------------- | ------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- | +| `"core"` | `string|null` | `null` | The WordPress installation to use. If `null` is specified, `wp-env` will use the latest production release of WordPress. | +| `"plugins"` | `string[]` | `[]` | A list of plugins to install and activate in the environment. | +| `"themes"` | `string[]` | `[]` | A list of themes to install in the environment. The first theme in the list will be activated. | +| `"port"` | `string` | `"8888"` | The primary port number to use for the insallation. You'll access the instance through the port: 'http://localhost:8888'. | +| `"testsPort"` | `string` | `"8889"` | The port number to use for the tests instance. | +| `"config"` | `Object` | `"{ WP_DEBUG: true, SCRIPT_DEBUG: true }"` | Mapping of wp-config.php constants to their desired values. | -| Field | Type | Default | Description | -| -- | -- | -- | -- | -| `"core"` | `string|null` | `null` | The WordPress installation to use. If `null` is specified, `wp-env` will use the latest production release of WordPress. | -| `"plugins"` | `string[]` | `[]` | A list of plugins to install and activate in the environment. | -| `"themes"` | `string[]` | `[]` | A list of themes to install in the environment. The first theme in the list will be activated. | +_Note: the port number environment variables (`WP_ENV_PORT` and `WP_ENV_TESTS_PORT`) take precedent over the .wp-env.json values._ -Several types of strings can be passed into these fields: +Several types of strings can be passed into the `core`, `plugins`, and `themes` fields: -| Type | Format | Example(s) | -| -- | -- | -- | -| Relative path | `.|~` | `"./a/directory"`, `"../a/directory"`, `"~/a/directory"` | -| Absolute path | `/|:\` | `"/a/directory"`, `"C:\\a\\directory"` | -| GitHub repository | `/[#]` | `"WordPress/WordPress"`, `"WordPress/gutenberg#master"` | +| Type | Format | Example(s) | +| ----------------- | ----------------------------- | -------------------------------------------------------- | +| Relative path | `.|~` | `"./a/directory"`, `"../a/directory"`, `"~/a/directory"` | +| Absolute path | `/|:\` | `"/a/directory"`, `"C:\\a\\directory"` | +| GitHub repository | `/[#]` | `"WordPress/WordPress"`, `"WordPress/gutenberg#master"` | +| ZIP File | `http[s]:///.zip` | `"https://wordpress.org/wordpress-5.4-beta2.zip"` | Remote sources will be downloaded into a temporary directory located in `~/.wp-env`. +## .wp-env.override.json + +Any fields here will take precedence over .wp-env.json. This file is useful, when ignored from version control, to persist local development overrides. + ### Examples #### Latest production WordPress + current directory as a plugin @@ -201,10 +215,8 @@ This is useful for plugin development. ```json { - "core": null, - "plugins": [ - "." - ] + "core": null, + "plugins": [ "." ] } ``` @@ -214,10 +226,8 @@ This is useful for plugin development when upstream Core changes need to be test ```json { - "core": "WordPress/WordPress#master", - "plugins": [ - "." - ] + "core": "WordPress/WordPress#master", + "plugins": [ "." ] } ``` @@ -227,10 +237,8 @@ This is useful for working on plugins and WordPress Core at the same time. ```json { - "core": "../wordpress-develop/build", - "plugins": [ - "." - ] + "core": "../wordpress-develop/build", + "plugins": [ "." ] } ``` @@ -240,14 +248,21 @@ This is useful for integration testing: that is, testing how old versions of Wor ```json { - "core": "WordPress/WordPress#5.2.0", - "plugins": [ - "WordPress/wp-lazy-loading", - "WordPress/classic-editor", - ], - "themes": [ - "WordPress/theme-experiments" - ] + "core": "WordPress/WordPress#5.2.0", + "plugins": [ "WordPress/wp-lazy-loading", "WordPress/classic-editor" ], + "themes": [ "WordPress/theme-experiments" ] +} +``` + +#### Custom Port Numbers + +You can tell `wp-env` to use a custom port number so that your instance does not conflict with other `wp-env` instances. + +```json +{ + "plugins": [ "." ], + "port": 4013, + "testsPort": 4012 } ``` diff --git a/packages/env/lib/build-docker-compose-config.js b/packages/env/lib/build-docker-compose-config.js index fae4a6d743e1b..36d97bace63de 100644 --- a/packages/env/lib/build-docker-compose-config.js +++ b/packages/env/lib/build-docker-compose-config.js @@ -51,6 +51,16 @@ module.exports = function buildDockerComposeConfig( config ) { ...themeMounts, ]; + // Set the default ports based on the config values. + const developmentPorts = `\${WP_ENV_PORT:-${ config.port }}:80`; + const testsPorts = `\${WP_ENV_TESTS_PORT:-${ config.testsPort }}:80`; + + // The www-data user in wordpress:cli has a different UID (82) to the + // www-data user in wordpress (33). Ensure we use the wordpress www-data + // user for CLI commands. + // https://github.com/docker-library/wordpress/issues/256 + const cliUser = '33:33'; + return { version: '3.7', services: { @@ -63,9 +73,8 @@ module.exports = function buildDockerComposeConfig( config ) { wordpress: { depends_on: [ 'mysql' ], image: 'wordpress', - ports: [ '${WP_ENV_PORT:-8888}:80' ], + ports: [ developmentPorts ], environment: { - WORDPRESS_DEBUG: '1', WORDPRESS_DB_NAME: 'wordpress', }, volumes: developmentMounts, @@ -73,9 +82,8 @@ module.exports = function buildDockerComposeConfig( config ) { 'tests-wordpress': { depends_on: [ 'mysql' ], image: 'wordpress', - ports: [ '${WP_ENV_TESTS_PORT:-8889}:80' ], + ports: [ testsPorts ], environment: { - WORDPRESS_DEBUG: '1', WORDPRESS_DB_NAME: 'tests-wordpress', }, volumes: testsMounts, @@ -84,11 +92,13 @@ module.exports = function buildDockerComposeConfig( config ) { depends_on: [ 'wordpress' ], image: 'wordpress:cli', volumes: developmentMounts, + user: cliUser, }, 'tests-cli': { depends_on: [ 'wordpress' ], image: 'wordpress:cli', volumes: testsMounts, + user: cliUser, }, composer: { image: 'composer', diff --git a/packages/env/lib/cli.js b/packages/env/lib/cli.js index b589e9cce5ecf..2ddc209e376f5 100644 --- a/packages/env/lib/cli.js +++ b/packages/env/lib/cli.js @@ -32,20 +32,47 @@ const withSpinner = ( command ) => ( ...args ) => { time[ 1 ] / 1e6 ).toFixed( 0 ) }ms)` ); + process.exit( 0 ); }, ( error ) => { - spinner.fail( error.message || error.err ); - if ( ! ( error instanceof env.ValidationError ) ) { + if ( error instanceof env.ValidationError ) { + // Error is a validation error. That means the user did something wrong. + spinner.fail( error.message ); + process.exit( 1 ); + } else if ( + 'exitCode' in error && + 'err' in error && + 'out' in error + ) { + // Error is a docker-compose error. That means something docker-related failed. + // https://github.com/PDMLab/docker-compose/blob/master/src/index.ts + spinner.fail( 'Error while running docker-compose command.' ); + if ( error.out ) { + process.stdout.write( error.out ); + } + if ( error.err ) { + process.stderr.write( error.err ); + } + process.exit( error.exitCode ); + } else { + // Error is an unknown error. That means there was a bug in our code. + spinner.fail( error.message ); + // Disable reason: Using console.error() means we get a stack trace. // eslint-disable-next-line no-console - console.error( `\n\n${ error.out || error.err }\n\n` ); + console.error( error ); + process.exit( 1 ); } - process.exit( error.exitCode || 1 ); } ); }; module.exports = function cli() { yargs.usage( wpPrimary( '$0 ' ) ); + yargs.option( 'debug', { + type: 'boolean', + describe: 'Enable debug output.', + default: false, + } ); yargs.command( 'start', diff --git a/packages/env/lib/config.js b/packages/env/lib/config.js index 817b71b9540dc..31e0d4bbb3715 100644 --- a/packages/env/lib/config.js +++ b/packages/env/lib/config.js @@ -28,13 +28,17 @@ const HOME_PATH_PREFIX = `~${ path.sep }`; * A wp-env config object. * * @typedef Config - * @property {string} name Name of the environment. - * @property {string} configDirectoryPath Path to the .wp-env.json file. - * @property {string} workDirectoryPath Path to the work directory located in ~/.wp-env. - * @property {string} dockerComposeConfigPath Path to the docker-compose.yml file. - * @property {Source|null} coreSource The WordPress installation to load in the environment. - * @property {Source[]} pluginSources Plugins to load in the environment. - * @property {Source[]} themeSources Themes to load in the environment. + * @property {string} name Name of the environment. + * @property {string} configDirectoryPath Path to the .wp-env.json file. + * @property {string} workDirectoryPath Path to the work directory located in ~/.wp-env. + * @property {string} dockerComposeConfigPath Path to the docker-compose.yml file. + * @property {Source|null} coreSource The WordPress installation to load in the environment. + * @property {Source[]} pluginSources Plugins to load in the environment. + * @property {Source[]} themeSources Themes to load in the environment. + * @property {number} port The port on which to start the development WordPress environment. + * @property {number} testsPort The port on which to start the testing WordPress environment. + * @property {Object} config Mapping of wp-config.php constants to their desired values. + * @property {boolean} debug True if debug mode is enabled. */ /** @@ -59,6 +63,7 @@ module.exports = { const configDirectoryPath = path.dirname( configPath ); let config = null; + let overrideConfig = {}; try { config = JSON.parse( await fs.readFile( configPath, 'utf8' ) ); @@ -76,6 +81,30 @@ module.exports = { } } + try { + overrideConfig = JSON.parse( + await fs.readFile( + configPath.replace( + /\.wp-env\.json$/, + '.wp-env.override.json' + ), + 'utf8' + ) + ); + } catch ( error ) { + if ( error.code === 'ENOENT' ) { + // Config override file does not exist. Do nothing - it's optional. + } else if ( error instanceof SyntaxError ) { + throw new ValidationError( + `Invalid .wp-env.override.json: ${ error.message }` + ); + } else { + throw new ValidationError( + `Could not read .wp-env.override.json: ${ error.message }` + ); + } + } + if ( config === null ) { const type = await detectDirectoryType( configDirectoryPath ); if ( type === 'core' ) { @@ -96,10 +125,18 @@ module.exports = { core: null, plugins: [], themes: [], + port: 8888, + testsPort: 8889, + config: { WP_DEBUG: true, SCRIPT_DEBUG: true }, }, - config + config, + overrideConfig ); + config.port = getNumberFromEnvVariable( 'WP_ENV_PORT' ) || config.port; + config.testsPort = + getNumberFromEnvVariable( 'WP_ENV_TESTS_PORT' ) || config.testsPort; + if ( config.core !== null && typeof config.core !== 'string' ) { throw new ValidationError( 'Invalid .wp-env.json: "core" must be null or a string.' @@ -124,9 +161,32 @@ module.exports = { ); } + if ( ! Number.isInteger( config.port ) ) { + throw new ValidationError( + 'Invalid .wp-env.json: "port" must be an integer.' + ); + } + + if ( ! Number.isInteger( config.testsPort ) ) { + throw new ValidationError( + 'Invalid .wp-env.json: "testsPort" must be an integer.' + ); + } + + if ( config.port === config.testsPort ) { + throw new ValidationError( + 'Invalid .wp-env.json: "testsPort" and "port" must be different.' + ); + } + + if ( typeof config.config !== 'object' ) { + throw new ValidationError( + 'Invalid .wp-env.json: "config" must be an object.' + ); + } + const workDirectoryPath = path.resolve( - os.homedir(), - '.wp-env', + getHomeDirectory(), md5( configPath ) ); @@ -134,6 +194,8 @@ module.exports = { name: path.basename( configDirectoryPath ), configDirectoryPath, workDirectoryPath, + port: config.port, + testsPort: config.testsPort, dockerComposeConfigPath: path.resolve( workDirectoryPath, 'docker-compose.yml' @@ -141,7 +203,8 @@ module.exports = { coreSource: includeTestsPath( parseSourceString( config.core, { workDirectoryPath, - } ) + } ), + { workDirectoryPath } ), pluginSources: config.plugins.map( ( sourceString ) => parseSourceString( sourceString, { @@ -153,6 +216,7 @@ module.exports = { workDirectoryPath, } ) ), + config: config.config, }; }, }; @@ -193,6 +257,21 @@ function parseSourceString( sourceString, { workDirectoryPath } ) { }; } + const zipFields = sourceString.match( + /^https?:\/\/([^\s$.?#].[^\s]*)\.zip$/ + ); + if ( zipFields ) { + return { + type: 'zip', + url: sourceString, + path: path.resolve( + workDirectoryPath, + encodeURIComponent( zipFields[ 1 ] ) + ), + basename: encodeURIComponent( zipFields[ 1 ] ), + }; + } + const gitHubFields = sourceString.match( /^([^\/]+)\/([^#]+)(?:#(.+))?$/ ); if ( gitHubFields ) { return { @@ -214,9 +293,11 @@ function parseSourceString( sourceString, { workDirectoryPath } ) { * property set correctly. Only the 'core' source requires a testsPath. * * @param {Source|null} source A source object. + * @param {Object} options + * @param {string} options.workDirectoryPath Path to the work directory located in ~/.wp-env. * @return {Source|null} A source object. */ -function includeTestsPath( source ) { +function includeTestsPath( source, { workDirectoryPath } ) { if ( source === null ) { return null; } @@ -224,13 +305,65 @@ function includeTestsPath( source ) { return { ...source, testsPath: path.resolve( - source.path, - '..', + workDirectoryPath, 'tests-' + path.basename( source.path ) ), }; } +/** + * Parses an environment variable which should be a number. + * + * Throws an error if the variable cannot be parsed to a number. + * Returns null if the environment variable has not been specified. + * + * @param {string} varName The environment variable to check (e.g. WP_ENV_PORT). + * @return {null|number} The number. Null if it does not exist. + */ +function getNumberFromEnvVariable( varName ) { + // Allow use of the default if it does not exist. + if ( ! process.env[ varName ] ) { + return null; + } + + const maybeNumber = parseInt( process.env[ varName ] ); + + // Throw an error if it is not parseable as a number. + if ( isNaN( maybeNumber ) ) { + throw new ValidationError( + `Invalid environment variable: ${ varName } must be a number.` + ); + } + + return maybeNumber; +} + +/** + * Gets the `wp-env` home directory in which generated files are created. + * + * By default, '~/.wp-env/'. On Linux, '~/wp-env/'. Can be overriden with the + * WP_ENV_HOME environment variable. + * + * @return {string} The absolute path to the `wp-env` home directory. + */ +function getHomeDirectory() { + // Allow user to override download location. + if ( process.env.WP_ENV_HOME ) { + return path.resolve( process.env.WP_ENV_HOME ); + } + + /** + * Installing docker with Snap Packages on Linux is common, but does not + * support hidden directories. Therefore we use a public directory on Linux. + * + * @see https://github.com/WordPress/gutenberg/issues/20180#issuecomment-587046325 + */ + return path.resolve( + os.homedir(), + os.platform() === 'linux' ? 'wp-env' : '.wp-env' + ); +} + /** * Hashes the given string using the MD5 algorithm. * diff --git a/packages/env/lib/download-source.js b/packages/env/lib/download-source.js index 507db9db5aa69..74bf5f1c4fb56 100644 --- a/packages/env/lib/download-source.js +++ b/packages/env/lib/download-source.js @@ -2,7 +2,20 @@ /** * External dependencies */ +const util = require( 'util' ); const NodeGit = require( 'nodegit' ); +const fs = require( 'fs' ); +const requestProgress = require( 'request-progress' ); +const request = require( 'request' ); +const path = require( 'path' ); + +/** + * Promisified dependencies + */ +const finished = util.promisify( require( 'stream' ).finished ); +const extractZip = util.promisify( require( 'extract-zip' ) ); +const rimraf = util.promisify( require( 'rimraf' ) ); +const copyDir = util.promisify( require( 'copy-dir' ) ); /** * @typedef {import('./config').Source} Source @@ -12,13 +25,17 @@ const NodeGit = require( 'nodegit' ); * Downloads the given source if necessary. The specific action taken depends * on the source type. * - * @param {Source} source The source to download. - * @param {Object} options + * @param {Source} source The source to download. + * @param {Object} options * @param {Function} options.onProgress A function called with download progress. Will be invoked with one argument: a number that ranges from 0 to 1 which indicates current download progress for this source. + * @param {Object} options.spinner A CLI spinner which indicates progress. + * @param {boolean} options.debug True if debug mode is enabled. */ module.exports = async function downloadSource( source, options ) { if ( source.type === 'git' ) { await downloadGitSource( source, options ); + } else if ( source.type === 'zip' ) { + await downloadZipSource( source, options ); } }; @@ -26,11 +43,19 @@ module.exports = async function downloadSource( source, options ) { * Clones the git repository at `source.url` into `source.path`. If the * repository already exists, it is updated instead. * - * @param {Source} source The source to download. - * @param {Object} options + * @param {Source} source The source to download. + * @param {Object} options * @param {Function} options.onProgress A function called with download progress. Will be invoked with one argument: a number that ranges from 0 to 1 which indicates current download progress for this source. + * @param {Object} options.spinner A CLI spinner which indicates progress. + * @param {boolean} options.debug True if debug mode is enabled. */ -async function downloadGitSource( source, { onProgress } ) { +async function downloadGitSource( source, { onProgress, spinner, debug } ) { + const log = debug + ? ( message ) => { + spinner.info( `NodeGit: ${ message }` ); + spinner.start(); + } + : () => {}; onProgress( 0 ); const gitFetchOptions = { @@ -50,20 +75,22 @@ async function downloadGitSource( source, { onProgress } ) { }, }; - // Clone or get the repo. + log( 'Cloning or getting the repo.' ); const repository = await NodeGit.Clone( source.url, source.path, gitFetchOptions - ) - // Repo already exists, get it. - .catch( () => NodeGit.Repository.open( source.path ) ); + ).catch( () => { + log( 'Repo already exists, get it.' ); + return NodeGit.Repository.open( source.path ); + } ); - // Checkout the specified ref. + log( 'Fetching the specified ref.' ); const remote = await repository.getRemote( 'origin' ); await remote.fetch( source.ref, gitFetchOptions.fetchOpts ); await remote.disconnect(); try { + log( 'Checking out the specified ref.' ); await repository.checkoutRef( await repository .getReference( 'FETCH_HEAD' ) @@ -77,9 +104,52 @@ async function downloadGitSource( source, { onProgress } ) { } ); } catch ( error ) { - // Some commit refs need to be set as detached. + log( 'Ref needs to be set as detached.' ); await repository.setHeadDetached( source.ref ); } onProgress( 1 ); } + +/** + * Downloads and extracts the zip file at `source.url` into `source.path`. + * + * @param {Source} source The source to download. + * @param {Object} options + * @param {Function} options.onProgress A function called with download progress. Will be invoked with one argument: a number that ranges from 0 to 1 which indicates current download progress for this source. + * @param {Object} options.spinner A CLI spinner which indicates progress. + * @param {boolean} options.debug True if debug mode is enabled. + */ +async function downloadZipSource( source, { onProgress, spinner, debug } ) { + const log = debug + ? ( message ) => { + spinner.info( `NodeGit: ${ message }` ); + spinner.start(); + } + : () => {}; + onProgress( 0 ); + + log( 'Downloading zip file.' ); + const zipName = `${ source.path }.zip`; + const zipFile = fs.createWriteStream( zipName ); + await finished( + requestProgress( request( source.url ) ) + .on( 'progress', ( { percent } ) => onProgress( percent ) ) + .pipe( zipFile ) + ); + + log( 'Extracting to temporary folder.' ); + const dirName = `${ source.path }.temp`; + await extractZip( zipName, { dir: dirName } ); + + log( 'Copying to mounted folder and cleaning up.' ); + await Promise.all( [ + rimraf( zipName ), + ...( await fs.promises.readdir( dirName ) ).map( ( file ) => + copyDir( path.join( dirName, file ), source.path ) + ), + ] ); + await rimraf( dirName ); + + onProgress( 1 ); +} diff --git a/packages/env/lib/env.js b/packages/env/lib/env.js index 94421544d2ff9..02070c2b07672 100644 --- a/packages/env/lib/env.js +++ b/packages/env/lib/env.js @@ -7,12 +7,14 @@ const path = require( 'path' ); const fs = require( 'fs' ).promises; const dockerCompose = require( 'docker-compose' ); const yaml = require( 'js-yaml' ); +const inquirer = require( 'inquirer' ); /** * Promisified dependencies */ const copyDir = util.promisify( require( 'copy-dir' ) ); const sleep = util.promisify( setTimeout ); +const rimraf = util.promisify( require( 'rimraf' ) ); /** * Internal dependencies @@ -29,11 +31,27 @@ module.exports = { /** * Starts the development server. * - * @param {Object} options - * @param {Object} options.spinner A CLI spinner which indicates progress. + * @param {Object} options + * @param {Object} options.spinner A CLI spinner which indicates progress. + * @param {boolean} options.debug True if debug mode is enabled. */ - async start( { spinner } ) { - const config = await initConfig(); + async start( { spinner, debug } ) { + /** + * If the Docker image is already running and the `wp-env` files have been + * deleted, the start command will not complete successfully. Stopping + * the container before continuing allows the docker entrypoint script, + * which restores the files, to run again when we start the containers. + * + * Additionally, this serves as a way to restart the container entirely + * should the need arise. + * + * @see https://github.com/WordPress/gutenberg/pull/20253#issuecomment-587228440 + */ + await module.exports.stop( { spinner, debug } ); + + await checkForLegacyInstall( spinner ); + + const config = await initConfig( { spinner, debug } ); spinner.text = 'Downloading WordPress.'; @@ -56,12 +74,15 @@ module.exports = { // Preemptively start the database while we wait for sources to download. dockerCompose.upOne( 'mysql', { config: config.dockerComposeConfigPath, + log: config.debug, } ), ( async () => { if ( config.coreSource ) { await downloadSource( config.coreSource, { onProgress: getProgressSetter( 'core' ), + spinner, + debug: config.debug, } ); await copyCoreFiles( config.coreSource.path, @@ -73,12 +94,16 @@ module.exports = { ...config.pluginSources.map( ( source ) => downloadSource( source, { onProgress: getProgressSetter( source.basename ), + spinner, + debug: config.debug, } ) ), ...config.themeSources.map( ( source ) => downloadSource( source, { onProgress: getProgressSetter( source.basename ), + spinner, + debug: config.debug, } ) ), ] ); @@ -87,8 +112,17 @@ module.exports = { await dockerCompose.upMany( [ 'wordpress', 'tests-wordpress' ], { config: config.dockerComposeConfigPath, + log: config.debug, } ); + if ( config.coreSource === null ) { + // Don't chown wp-content when it exists on the user's local filesystem. + await Promise.all( [ + makeContentDirectoriesWritable( 'development', config ), + makeContentDirectoriesWritable( 'tests', config ), + ] ); + } + try { await checkDatabaseConnection( config ); } catch ( error ) { @@ -116,15 +150,22 @@ module.exports = { /** * Stops the development server. * - * @param {Object} options - * @param {Object} options.spinner A CLI spinner which indicates progress. + * @param {Object} options + * @param {Object} options.spinner A CLI spinner which indicates progress. + * @param {boolean} options.debug True if debug mode is enabled. */ - async stop( { spinner } ) { - const { dockerComposeConfigPath } = await initConfig(); + async stop( { spinner, debug } ) { + const { dockerComposeConfigPath } = await initConfig( { + spinner, + debug, + } ); spinner.text = 'Stopping WordPress.'; - await dockerCompose.down( { config: dockerComposeConfigPath } ); + await dockerCompose.down( { + config: dockerComposeConfigPath, + log: debug, + } ); spinner.text = 'Stopped WordPress.'; }, @@ -132,12 +173,13 @@ module.exports = { /** * Wipes the development server's database, the tests server's database, or both. * - * @param {Object} options - * @param {string} options.environment The environment to clean. Either 'development', 'tests', or 'all'. - * @param {Object} options.spinner A CLI spinner which indicates progress. + * @param {Object} options + * @param {string} options.environment The environment to clean. Either 'development', 'tests', or 'all'. + * @param {Object} options.spinner A CLI spinner which indicates progress. + * @param {boolean} options.debug True if debug mode is enabled. */ - async clean( { environment, spinner } ) { - const config = await initConfig(); + async clean( { environment, spinner, debug } ) { + const config = await initConfig( { spinner, debug } ); const description = `${ environment } environment${ environment === 'all' ? 's' : '' @@ -170,13 +212,14 @@ module.exports = { /** * Runs an arbitrary command on the given Docker container. * - * @param {Object} options - * @param {Object} options.container The Docker container to run the command on. - * @param {Object} options.command The command to run. - * @param {Object} options.spinner A CLI spinner which indicates progress. + * @param {Object} options + * @param {Object} options.container The Docker container to run the command on. + * @param {Object} options.command The command to run. + * @param {Object} options.spinner A CLI spinner which indicates progress. + * @param {boolean} options.debug True if debug mode is enabled. */ - async run( { container, command, spinner } ) { - const config = await initConfig(); + async run( { container, command, spinner, debug } ) { + const config = await initConfig( { spinner, debug } ); command = command.join( ' ' ); @@ -185,6 +228,7 @@ module.exports = { const result = await dockerCompose.run( container, command, { config: config.dockerComposeConfigPath, commandOptions: [ '--rm' ], + log: config.debug, } ); if ( result.out ) { @@ -206,23 +250,90 @@ module.exports = { ValidationError, }; +/** + * Checks for legacy installs and provides + * the user the option to delete them. + * + * @param {Object} spinner A CLI spinner which indicates progress. + */ +async function checkForLegacyInstall( spinner ) { + const basename = path.basename( process.cwd() ); + const installs = [ + `../${ basename }-wordpress`, + `../${ basename }-tests-wordpress`, + ]; + await Promise.all( + installs.map( ( install ) => + fs + .access( install ) + .catch( () => + installs.splice( installs.indexOf( install ), 1 ) + ) + ) + ); + if ( ! installs.length ) { + return; + } + + spinner.info( + `It appears that you have used a previous version of this tool where installs were kept in ${ installs.join( + ' and ' + ) }. Installs are now in your home folder.\n` + ); + const { yesDelete } = await inquirer.prompt( [ + { + type: 'confirm', + name: 'yesDelete', + message: + 'Do you wish to delete these old installs to reclaim disk space?', + default: true, + }, + ] ); + if ( yesDelete ) { + await Promise.all( installs.map( ( install ) => rimraf( install ) ) ); + spinner.info( 'Old installs deleted successfully.' ); + } + spinner.start(); +} + /** * Initializes the local environment so that Docker commands can be run. Reads * ./.wp-env.json, creates ~/.wp-env, and creates ~/.wp-env/docker-compose.yml. * + * @param {Object} options + * @param {Object} options.spinner A CLI spinner which indicates progress. + * @param {boolean} options.debug True if debug mode is enabled. + * * @return {Config} The-env config object. */ -async function initConfig() { +async function initConfig( { spinner, debug } ) { const configPath = path.resolve( '.wp-env.json' ); const config = await readConfig( configPath ); + config.debug = debug; await fs.mkdir( config.workDirectoryPath, { recursive: true } ); + const dockerComposeConfig = buildDockerComposeConfig( config ); await fs.writeFile( config.dockerComposeConfigPath, - yaml.dump( buildDockerComposeConfig( config ) ) + yaml.dump( dockerComposeConfig ) ); + if ( config.debug ) { + spinner.info( + `Config:\n${ JSON.stringify( + config, + null, + 4 + ) }\n\nDocker Compose Config:\n${ JSON.stringify( + dockerComposeConfig, + null, + 4 + ) }` + ); + spinner.start(); + } + return config; } @@ -253,6 +364,35 @@ async function copyCoreFiles( fromPath, toPath ) { } ); } +/** + * Makes the WordPress content directories (wp-content, wp-content/plugins, + * wp-content/themes) owned by the www-data user. This ensures that WordPress + * can write to these directories. + * + * This is necessary when running wp-env with `"core": null` because Docker + * will automatically create these directories as the root user when binding + * volumes during `docker-compose up`, and `docker-compose up` doesn't support + * the `-u` option. + * + * See https://github.com/docker-library/wordpress/issues/436. + * + * @param {string} environment The environment to check. Either 'development' or 'tests'. + * @param {Config} config The wp-env config object. + */ +async function makeContentDirectoriesWritable( + environment, + { dockerComposeConfigPath, debug } +) { + await dockerCompose.exec( + environment === 'development' ? 'wordpress' : 'tests-wordpress', + 'chown www-data:www-data wp-content wp-content/plugins wp-content/themes', + { + config: dockerComposeConfigPath, + log: debug, + } + ); +} + /** * Performs the given action again and again until it does not throw an error. * @@ -281,10 +421,11 @@ async function retry( action, { times, delay = 5000 } ) { * * @param {Config} config The wp-env config object. */ -async function checkDatabaseConnection( { dockerComposeConfigPath } ) { +async function checkDatabaseConnection( { dockerComposeConfigPath, debug } ) { await dockerCompose.run( 'cli', 'wp db check', { config: dockerComposeConfigPath, commandOptions: [ '--rm' ], + log: debug, } ); } @@ -300,25 +441,41 @@ async function configureWordPress( environment, config ) { const options = { config: config.dockerComposeConfigPath, commandOptions: [ '--rm' ], + log: config.debug, }; + const port = environment === 'development' ? config.port : config.testsPort; + // Install WordPress. await dockerCompose.run( environment === 'development' ? 'cli' : 'tests-cli', - `wp core install - --url=localhost:${ - environment === 'development' - ? process.env.WP_ENV_PORT || '8888' - : process.env.WP_ENV_TESTS_PORT || '8889' - } - --title='${ config.name }' - --admin_user=admin - --admin_password=password - --admin_email=wordpress@example.com - --skip-email`, + [ + 'wp', + 'core', + 'install', + `--url=localhost:${ port }`, + `--title=${ config.name }`, + '--admin_user=admin', + '--admin_password=password', + '--admin_email=wordpress@example.com', + '--skip-email', + ], options ); + // Set wp-config.php values. + for ( const [ key, value ] of Object.entries( config.config ) ) { + const command = [ 'wp', 'config', 'set', key, value ]; + if ( typeof value !== 'string' ) { + command.push( '--raw' ); + } + await dockerCompose.run( + environment === 'development' ? 'cli' : 'tests-cli', + command, + options + ); + } + // Activate all plugins. for ( const pluginSource of config.pluginSources ) { await dockerCompose.run( @@ -345,10 +502,14 @@ async function configureWordPress( environment, config ) { * @param {string} environment The environment to clean. Either 'development', 'tests', or 'all'. * @param {Config} config The wp-env config object. */ -async function resetDatabase( environment, { dockerComposeConfigPath } ) { +async function resetDatabase( + environment, + { dockerComposeConfigPath, debug } +) { const options = { config: dockerComposeConfigPath, commandOptions: [ '--rm' ], + log: debug, }; const tasks = []; diff --git a/packages/env/package.json b/packages/env/package.json index 62dd6d06a2456..1cdd5d80bb412 100644 --- a/packages/env/package.json +++ b/packages/env/package.json @@ -35,9 +35,14 @@ "chalk": "^2.4.2", "copy-dir": "^1.2.0", "docker-compose": "^0.22.2", + "extract-zip": "^1.6.7", + "inquirer": "^7.0.4", "js-yaml": "^3.13.1", "nodegit": "^0.26.2", "ora": "^4.0.2", + "request": "^2.88.2", + "request-progress": "^3.0.0", + "rimraf": "^3.0.2", "terminal-link": "^2.0.0", "yargs": "^14.0.0" }, diff --git a/packages/env/test/cli.js b/packages/env/test/cli.js index ef19cc816dd0b..2e2b30aff5bdf 100644 --- a/packages/env/test/cli.js +++ b/packages/env/test/cli.js @@ -8,6 +8,7 @@ const env = require( '../lib/env' ); /** * Mocked dependencies */ +jest.spyOn( process, 'exit' ).mockImplementation( () => {} ); jest.mock( 'ora', () => () => ( { start() { return { text: '', succeed: jest.fn(), fail: jest.fn() }; @@ -84,8 +85,6 @@ describe( 'env cli', () => { /* eslint-disable no-console */ env.start.mockRejectedValueOnce( { message: 'failure message', - out: 'failure message', - exitCode: 2, } ); const consoleError = console.error; console.error = jest.fn(); @@ -98,28 +97,37 @@ describe( 'env cli', () => { expect( spinner.fail ).toHaveBeenCalledWith( 'failure message' ); expect( console.error ).toHaveBeenCalled(); - expect( process.exit ).toHaveBeenCalledWith( 2 ); + expect( process.exit ).toHaveBeenCalledWith( 1 ); console.error = consoleError; process.exit = processExit; /* eslint-enable no-console */ } ); - it( 'handles failed commands with errors.', async () => { + it( 'handles failed docker commands with errors.', async () => { /* eslint-disable no-console */ - env.start.mockRejectedValueOnce( { err: 'failure error' } ); + env.start.mockRejectedValueOnce( { + err: 'failure error', + out: 'message', + exitCode: 1, + } ); const consoleError = console.error; console.error = jest.fn(); const processExit = process.exit; process.exit = jest.fn(); + const stderr = process.stderr.write; + process.stderr.write = jest.fn(); cli().parse( [ 'start' ] ); const { spinner } = env.start.mock.calls[ 0 ][ 0 ]; await env.start.mock.results[ 0 ].value.catch( () => {} ); - expect( spinner.fail ).toHaveBeenCalledWith( 'failure error' ); - expect( console.error ).toHaveBeenCalled(); + expect( spinner.fail ).toHaveBeenCalledWith( + 'Error while running docker-compose command.' + ); + expect( process.stderr.write ).toHaveBeenCalledWith( 'failure error' ); expect( process.exit ).toHaveBeenCalledWith( 1 ); console.error = consoleError; process.exit = processExit; + process.stderr.write = stderr; /* eslint-enable no-console */ } ); } ); diff --git a/packages/env/test/config.js b/packages/env/test/config.js index ef886f078ce4c..181a5a3baec23 100644 --- a/packages/env/test/config.js +++ b/packages/env/test/config.js @@ -3,6 +3,7 @@ * External dependencies */ const { readFile } = require( 'fs' ).promises; +const os = require( 'os' ); /** * Internal dependencies @@ -217,4 +218,200 @@ describe( 'readConfig', () => { ); } } ); + + it( 'should throw a validaton error if the ports are not numbers', async () => { + expect.assertions( 10 ); + testPortNumberValidation( 'port', 'string' ); + testPortNumberValidation( 'testsPort', [] ); + testPortNumberValidation( 'port', {} ); + testPortNumberValidation( 'testsPort', false ); + testPortNumberValidation( 'port', null ); + } ); + + it( 'should throw a validaton error if the ports are the same', async () => { + expect.assertions( 2 ); + readFile.mockImplementation( () => + Promise.resolve( JSON.stringify( { port: 8888, testsPort: 8888 } ) ) + ); + try { + await readConfig( '.wp-env.json' ); + } catch ( error ) { + expect( error ).toBeInstanceOf( ValidationError ); + expect( error.message ).toContain( + 'Invalid .wp-env.json: "testsPort" and "port" must be different.' + ); + } + } ); + + it( 'should parse custom ports', async () => { + readFile.mockImplementation( () => + Promise.resolve( + JSON.stringify( { + port: 1000, + } ) + ) + ); + const config = await readConfig( '.wp-env.json' ); + // Custom port is overriden while testsPort gets the deault value. + expect( config ).toMatchObject( { + port: 1000, + testsPort: 8889, + } ); + } ); + + it( 'should throw an error if the port number environment variable is invalid', async () => { + readFile.mockImplementation( () => + Promise.resolve( JSON.stringify( {} ) ) + ); + const oldPort = process.env.WP_ENV_PORT; + process.env.WP_ENV_PORT = 'hello'; + try { + await readConfig( '.wp-env.json' ); + } catch ( error ) { + expect( error ).toBeInstanceOf( ValidationError ); + expect( error.message ).toContain( + 'Invalid environment variable: WP_ENV_PORT must be a number.' + ); + } + process.env.WP_ENV_PORT = oldPort; + } ); + + it( 'should throw an error if the tests port number environment variable is invalid', async () => { + readFile.mockImplementation( () => + Promise.resolve( JSON.stringify( {} ) ) + ); + const oldPort = process.env.WP_ENV_TESTS_PORT; + process.env.WP_ENV_TESTS_PORT = 'hello'; + try { + await readConfig( '.wp-env.json' ); + } catch ( error ) { + expect( error ).toBeInstanceOf( ValidationError ); + expect( error.message ).toContain( + 'Invalid environment variable: WP_ENV_TESTS_PORT must be a number.' + ); + } + process.env.WP_ENV_TESTS_PORT = oldPort; + } ); + + it( 'should use port environment values rather than config values if both are defined', async () => { + readFile.mockImplementation( () => + Promise.resolve( + JSON.stringify( { + port: 1000, + testsPort: 2000, + } ) + ) + ); + const oldPort = process.env.WP_ENV_PORT; + const oldTestsPort = process.env.WP_ENV_TESTS_PORT; + process.env.WP_ENV_PORT = 4000; + process.env.WP_ENV_TESTS_PORT = 3000; + + const config = await readConfig( '.wp-env.json' ); + expect( config ).toMatchObject( { + port: 4000, + testsPort: 3000, + } ); + + process.env.WP_ENV_PORT = oldPort; + process.env.WP_ENV_TESTS_PORT = oldTestsPort; + } ); + + it( 'should use 8888 and 8889 as the default port and testsPort values if nothing else is specified', async () => { + readFile.mockImplementation( () => + Promise.resolve( JSON.stringify( {} ) ) + ); + + const config = await readConfig( '.wp-env.json' ); + expect( config ).toMatchObject( { + port: 8888, + testsPort: 8889, + } ); + } ); + + it( 'should use the WP_ENV_HOME environment variable only if specified', async () => { + readFile.mockImplementation( () => + Promise.resolve( JSON.stringify( {} ) ) + ); + const oldEnvHome = process.env.WP_ENV_HOME; + + expect.assertions( 2 ); + + process.env.WP_ENV_HOME = 'here/is/a/path'; + const configWith = await readConfig( '.wp-env.json' ); + expect( + configWith.workDirectoryPath.includes( 'here/is/a/path' ) + ).toBe( true ); + + process.env.WP_ENV_HOME = undefined; + const configWithout = await readConfig( '.wp-env.json' ); + expect( + configWithout.workDirectoryPath.includes( 'here/is/a/path' ) + ).toBe( false ); + + process.env.WP_ENV_HOME = oldEnvHome; + } ); + + it( 'should use the WP_ENV_HOME environment variable on Linux', async () => { + readFile.mockImplementation( () => + Promise.resolve( JSON.stringify( {} ) ) + ); + const oldEnvHome = process.env.WP_ENV_HOME; + const oldOsPlatform = os.platform; + os.platform = () => 'linux'; + + expect.assertions( 2 ); + + process.env.WP_ENV_HOME = 'here/is/a/path'; + const configWith = await readConfig( '.wp-env.json' ); + expect( + configWith.workDirectoryPath.includes( 'here/is/a/path' ) + ).toBe( true ); + + process.env.WP_ENV_HOME = undefined; + const configWithout = await readConfig( '.wp-env.json' ); + expect( + configWithout.workDirectoryPath.includes( 'here/is/a/path' ) + ).toBe( false ); + + process.env.WP_ENV_HOME = oldEnvHome; + os.platform = oldOsPlatform; + } ); + + it( 'should use a non-private folder on Linux', async () => { + readFile.mockImplementation( () => + Promise.resolve( JSON.stringify( {} ) ) + ); + const oldOsPlatform = os.platform; + os.platform = () => 'linux'; + + expect.assertions( 2 ); + + const config = await readConfig( '.wp-env.json' ); + expect( config.workDirectoryPath.includes( '.wp-env' ) ).toBe( false ); + expect( config.workDirectoryPath.includes( 'wp-env' ) ).toBe( true ); + + os.platform = oldOsPlatform; + } ); } ); + +/** + * Tests that readConfig will throw errors when invalid port numbers are passed. + * + * @param {string} portName The name of the port to test ('port' or 'testsPort') + * @param {any} value A value which should throw an error. + */ +async function testPortNumberValidation( portName, value ) { + readFile.mockImplementation( () => + Promise.resolve( JSON.stringify( { [ portName ]: value } ) ) + ); + try { + await readConfig( '.wp-env.json' ); + } catch ( error ) { + expect( error ).toBeInstanceOf( ValidationError ); + expect( error.message ).toContain( + `Invalid .wp-env.json: "${ portName }" must be an integer.` + ); + } + jest.clearAllMocks(); +} diff --git a/packages/rich-text/src/to-dom.js b/packages/rich-text/src/to-dom.js index 2e5c54e769b1e..0a63e94a95e3a 100644 --- a/packages/rich-text/src/to-dom.js +++ b/packages/rich-text/src/to-dom.js @@ -302,5 +302,24 @@ export function applySelection( { startPath, endPath }, current ) { } selection.addRange( range ); - activeElement.focus(); + + // This function is not intended to cause a shift in focus. Since the above + // selection manipulations may shift focus, ensure that focus is restored to + // its previous state. `activeElement` can be `null` or the body element if + // there is no focus, which is accounted for here in the explicit `blur` to + // restore to a state of non-focus. + if ( activeElement !== document.activeElement ) { + // The `instanceof` checks protect against edge cases where the focused + // element is not of the interface HTMLElement (does not have a `focus` + // or `blur` property). + // + // See: https://github.com/Microsoft/TypeScript/issues/5901#issuecomment-431649653 + if ( activeElement ) { + if ( activeElement instanceof window.HTMLElement ) { + activeElement.focus(); + } + } else if ( document.activeElement instanceof window.HTMLElement ) { + document.activeElement.blur(); + } + } }