├── .editorconfig ├── .eslintrc ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── question.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .nvmrc ├── .stylelintrc.json ├── .travis.yml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── assets ├── icon.ico ├── icon.png ├── tray-logoTemplate.png └── tray-logoTemplate@2x.png ├── docs ├── readme.md └── release.md ├── package-lock.json ├── package.json ├── public ├── index.html ├── logo.ico └── manifest.json └── src ├── _colors.scss ├── app ├── index.js ├── index.test.js └── style.scss ├── components ├── about-panel │ ├── index.js │ └── style.scss ├── external-link │ ├── index.js │ └── style.scss ├── pages │ ├── index.js │ └── style.scss ├── preferences-button │ └── index.js ├── preferences-panel │ ├── basic-preferences.js │ ├── index.js │ ├── site-preferences.js │ └── style.scss ├── status-panel │ ├── index.js │ ├── missing-daemon-info.js │ ├── missing-folder-info.js │ ├── not-ready-info.js │ ├── ready-info.js │ └── style.scss └── tabs │ ├── index.js │ └── style.scss ├── electron-runner.js ├── index.js ├── index.scss ├── preferences └── index.js ├── registerServiceWorker.js ├── services ├── constants.js ├── docker │ ├── default.conf │ ├── index.js │ ├── php-config.ini │ └── phpunit-config.ini ├── grunt │ └── index.js ├── index.js ├── node-downloader │ └── index.js └── npm-watcher │ └── index.js └── utils ├── network.js └── status.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # This file is for unifying the coding style for different editors and IDEs 2 | # editorconfig.org 3 | 4 | # WordPress Coding Standards 5 | # https://make.wordpress.org/core/handbook/coding-standards/ 6 | 7 | root = true 8 | 9 | [*] 10 | charset = utf-8 11 | end_of_line = lf 12 | insert_final_newline = true 13 | trim_trailing_whitespace = true 14 | indent_style = tab 15 | 16 | [*.yml] 17 | indent_style = space 18 | indent_size = 2 19 | 20 | [*.md] 21 | trim_trailing_whitespace = false 22 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ "plugin:@wordpress/eslint-plugin/recommended" ] 3 | } 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | **Describe the bug** 8 | A clear and concise description of what the bug is. 9 | 10 | **To Reproduce** 11 | Steps to reproduce the behavior: 12 | 1. Go to '...' 13 | 2. Click on '....' 14 | 3. Scroll down to '....' 15 | 4. See error 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Screenshots** 21 | If applicable, add screenshots to help explain your problem. 22 | 23 | **Device:** 24 | - OS and Version: [e.g. MacOS 10.14.3, Windows 10 Pro] 25 | - TestPress Version: [e.g. 1.0] 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | **Is your feature request related to a problem? Please describe.** 8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Describe alternatives you've considered** 14 | A clear and concise description of any alternative solutions or features you've considered. 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Have a question that isn't a bug report or feature request? 4 | 5 | --- 6 | 7 | Search first! Your issue may have already been reported. 8 | 9 | Remember that TestPress is intended to be an opinionated project: we appreciate all feedback, and are always happy to discuss decisions, but we may sometimes choose to not implement features, if they don't fit with the overall vision. 10 | 11 | Thank you! -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 4 | ## How has this been tested? 5 | 6 | 7 | 8 | 9 | ## Screenshots 10 | 11 | ## Types of changes 12 | 13 | 14 | 15 | 16 | 17 | ## Checklist: 18 | - [ ] My code is tested. 19 | - [ ] My code follows the WordPress code style. 20 | - [ ] My code follows the accessibility standards. 21 | - [ ] My code has proper inline documentation. 22 | - [ ] I've included developer documentation if appropriate. -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | 4 | # testing 5 | /coverage 6 | 7 | # production 8 | /build 9 | /dist 10 | 11 | # misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 8 -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-wordpress", 3 | "rules": { 4 | "at-rule-empty-line-before": null, 5 | "at-rule-no-unknown": null, 6 | "comment-empty-line-before": null, 7 | "declaration-block-no-duplicate-properties": null, 8 | "declaration-property-unit-whitelist": null, 9 | "font-weight-notation": null, 10 | "max-line-length": null, 11 | "no-descending-specificity": null, 12 | "no-duplicate-selectors": null, 13 | "rule-empty-line-before": null, 14 | "selector-class-pattern": null, 15 | "value-keyword-case": null 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: generic 2 | 3 | notifications: 4 | email: 5 | on_success: never 6 | on_failure: change 7 | 8 | branches: 9 | only: 10 | - master 11 | 12 | before_install: 13 | - nvm install 14 | - npm install -g npm 15 | 16 | install: 17 | - npm ci 18 | 19 | jobs: 20 | include: 21 | - name: License Compatibility 22 | script: 23 | - npm run check-licenses 24 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | This project comes under the WordPress [Etiquette](https://wordpress.org/about/etiquette/) guidelines: 2 | 3 | In the WordPress open source project, we realize that our biggest asset is the community that we foster. The project, as a whole, follows these basic philosophical principles from The Cathedral and The Bazaar. 4 | 5 | - Contributions to the WordPress open source project are for the benefit of the WordPress community as a whole, not specific businesses or individuals. All actions taken as a contributor should be made with the best interests of the community in mind. 6 | - Participation in the WordPress open source project is open to all who wish to join, regardless of ability, skill, financial status, or any other criteria. 7 | - The WordPress open source project is a volunteer-run community. Even in cases where contributors are sponsored by companies, that time is donated for the benefit of the entire open source community. 8 | - Any member of the community can donate their time and contribute to the project in any form including design, code, documentation, community building, etc. For more information, go to make.wordpress.org. 9 | - The WordPress open source community cares about diversity. We strive to maintain a welcoming environment where everyone can feel included, by keeping communication free of discrimination, incitement to violence, promotion of hate, and unwelcoming behavior. 10 | 11 | The team involved will block any user who causes any breach in this. 12 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for thinking about contributing to TestPress! If you're unsure of anything, know that you're 💯 welcome to submit an issue or pull request on any topic. The worst that can happen is that you'll be politely directed to the best location to ask your question or to change something in your pull request. We appreciate any sort of contribution and don't want a wall of rules to get in the way of that. 4 | 5 | As with all WordPress projects, we want to ensure a welcoming environment for everyone. With that in mind, all contributors are expected to follow our [Code of Conduct](/CODE_OF_CONDUCT.md). 6 | 7 | All WordPress projects are [licensed under the GPLv2+](/LICENSE.md), and all contributions to TestPress will be released under the GPLv2+ license. You maintain copyright over any contribution you make, and by submitting a pull request, you are agreeing to release that contribution under the GPLv2+ license. 8 | 9 | This document covers the technical details around setup, and submitting your contribution to the TestPress project. 10 | 11 | ## Getting Started 12 | 13 | TestPress is an Electron application, built primarily in JavaScript. 14 | 15 | To get started, you will need to [Node.js installed](https://nodejs.org/en/). Most MacOS and Linux systems will have Node already installed, but you may need to download and install it on Windows. 16 | 17 | TestPress can sometimes need a different version of Node than you have on your system, you can use [nvm](https://github.com/creationix/nvm) to change node versions on the command line: 18 | 19 | ``` 20 | nvm install 21 | ``` 22 | 23 | You also should have the latest release of [npm installed][npm]. npm is a separate project from Node.js and is updated frequently. If you've just installed Node.js which includes a version of npm within the installation you most likely will need also to update your npm installation. To update npm, type this into your terminal: `nvm install-latest-npm` 24 | 25 | Finally, you need to install Docker in order to use TestPress. You can download it for [MacOS](https://download.docker.com/mac/stable/Docker.dmg), [Windows 10 Pro](https://download.docker.com/win/stable/Docker%20for%20Windows%20Installer.exe), [other versions of Windows](https://github.com/docker/toolbox/releases), and [all other operating systems](https://hub.docker.com/search/?type=edition&offering=community). 26 | 27 | ### Building 28 | 29 | After using NVM to ensure the correct version of Node is installed, building TestPress can be done using the following commands: 30 | 31 | ``` 32 | npm install 33 | npm run dev 34 | ``` 35 | 36 | ### Debugging 37 | 38 | Before running `npm run dev`, you can set TestPress to display debug messages, like so: 39 | 40 | ``` 41 | export DEBUG=testpress:* 42 | npm run dev 43 | ``` 44 | 45 | This enable all debugging messages, then builds and starts TestPress. If you find the debugging messages to be too noisy, you can restrict them by stopping TestPress (quit the application, or press `Ctrl+C` in your terminal), and altering the `DEBUG` environment variable. For example, if you're only interested in debug messages from the Docker service: 46 | 47 | ``` 48 | export DEBUG=testpress:services:docker 49 | npm run dev 50 | ``` 51 | 52 | In Windows, you can set the `DEBUG` environment variable using the appropriate method for your shell: 53 | 54 | * **CMD**: `set DEBUG=testpress:*` 55 | * **PowerShell**: `$env:DEBUG = "testpress:*"` 56 | 57 | If you need to debug the the TestPress window, holding down Cmd+Shift while clicking the icon will open Dev Tools. 58 | 59 | ## Workflow 60 | 61 | A good workflow for new contributors to follow is listed below: 62 | - Fork TestPress repository in GitHub 63 | - Clone your forked repository to your computer, using [GitHub Desktop](https://desktop.github.com/) 64 | - Create a new branch 65 | - Make code changes 66 | - Commit your changes within the newly created branch 67 | - Push the branch to your forked repository 68 | - Submit a Pull Request to the TestPress repository 69 | 70 | Ideally name your branches with prefixes and descriptions, like this: `[type]/[change]`. A good prefix would be: 71 | 72 | - `add/` = add a new feature 73 | - `try/` = experimental feature, "tentatively add" 74 | - `update/` = update an existing feature 75 | 76 | For example, `add/linux-support` means you're working on adding Linux support. 77 | 78 | You can pick among all the [issues](https://github.com/pento/testpress/issues), or some of the ones labelled [Good First Issue](https://github.com/pento/testpress/labels/Good%20First%20Issue). 79 | 80 | ## How Can Designers Contribute? 81 | 82 | If you'd like to contribute to the application design, feel free to contribute to tickets labelled [Needs Design](https://github.com/pento/testpress/labels/Needs%20Design) or [Needs Design Feedback](https://github.com/pento/testpress/labels/Needs%20Design%20Feedback). We could use your thoughtful replies, mockups, animatics, sketches, doodles. Proposed changes are best done as minimal and specific iterations on the work that precedes it, so they can be easily compared. 83 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TestPress 2 | 3 | TestPress is a tool for contributing to WordPress. It automatically configures a development environment on your computer, allowing you to focus on making WordPress! 4 | 5 | You'll need to install Docker in order to use TestPress. You can download it for [MacOS](https://download.docker.com/mac/stable/Docker.dmg), [Windows 10 Pro](https://download.docker.com/win/stable/Docker%20for%20Windows%20Installer.exe), [other versions of Windows](https://github.com/docker/toolbox/releases), and [all other operating systems](https://hub.docker.com/search/?type=edition&offering=community). 6 | 7 | This is currently tested on MacOS and Windows, but it should probably run on Linux, too. Bug reports and PRs are greatly appreciated! 💖 8 | 9 | ## Getting Started 10 | 11 | - **Download**: If you want to use the latest TestPress release, you can [download it here](https://github.com/pento/testpress/releases). 12 | - **Discuss**: If you have a question about TestPress, you can [ask it here](https://github.com/pento/testpress/issues/new?template=question.md). 13 | - **Contribute**: Development of TestPress happens in this GitHub repository. Get started by [reading the contributing guidelines](/CONTRIBUTING.md). 14 | -------------------------------------------------------------------------------- /assets/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pento/testpress/70182b438c646119fa08b8a321b702b34639f69c/assets/icon.ico -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pento/testpress/70182b438c646119fa08b8a321b702b34639f69c/assets/icon.png -------------------------------------------------------------------------------- /assets/tray-logoTemplate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pento/testpress/70182b438c646119fa08b8a321b702b34639f69c/assets/tray-logoTemplate.png -------------------------------------------------------------------------------- /assets/tray-logoTemplate@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pento/testpress/70182b438c646119fa08b8a321b702b34639f69c/assets/tray-logoTemplate@2x.png -------------------------------------------------------------------------------- /docs/readme.md: -------------------------------------------------------------------------------- 1 | # Documentation 2 | 3 | This is a repository of project documentation. 4 | 5 | ## Project Processes 6 | 7 | * [Releasing new versions of TestPress](./release.md) 8 | -------------------------------------------------------------------------------- /docs/release.md: -------------------------------------------------------------------------------- 1 | # TestPress Release Process 2 | 3 | It's currently only possible to build release packages on MacOS and Windows. 4 | 5 | ## Signing 6 | 7 | * **GitHub**: [Create a new token](https://github.com/settings/tokens/new) with the `repo` permissions, and assign it to the `GH_TOKEN` environment variable. 8 | * **MacOS**: Install a MacOS Developer ID Application Key in your keychain. 9 | * **Windows**: Install a Windows Authenticode certificate. 10 | 11 | ## MacOS Key 12 | 13 | You need a MacOS Developer ID Application key. Ensure it's installed in your keychain, and up-to-date. 14 | 15 | ## Windows Key 16 | 17 | You need a Windows Authenticode certificate file. Ensure it's up-to-date. 18 | 19 | Set the file location like so: 20 | 21 | ``` 22 | $env:CSC_LINK = "C:\Users\Me\Desktop\code-signing.pfx" 23 | $env:CSC_KEY_PASSWORD = "MySuperSecureCertificatePassword" 24 | ``` 25 | 26 | ## Building and Releasing 27 | 28 | * Update the version in `package.json` in a PR, and merge. 29 | * On `master`, run `npm run dist` to create the package for testing. 30 | * After smoke testing has passed, run `npm run publish` to push the package to GitHub. 31 | * Visit the [Releases page](https://github.com/pento/testpress/releases), edit the release notes, and publish. 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "testpress", 3 | "version": "0.3.1", 4 | "description": "An easy method for getting a WordPress Core test environment up and running.", 5 | "author": "The WordPress Contributors", 6 | "license": "GPL-2.0-or-later", 7 | "keywords": [ 8 | "WordPress", 9 | "development", 10 | "dev", 11 | "contribution" 12 | ], 13 | "homepage": "./", 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/pento/testpress" 17 | }, 18 | "bugs": "https://github.com/pento/testpress/issues", 19 | "engines": { 20 | "node": ">=8.0.0", 21 | "npm": ">=6.9.0" 22 | }, 23 | "main": "src/electron-runner.js", 24 | "dependencies": { 25 | "@wordpress/components": "7.1.0", 26 | "@wordpress/hooks": "2.0.5", 27 | "await-sleep": "0.0.1", 28 | "chokidar": "3.0.1", 29 | "compare-versions": "3.4.0", 30 | "csvtojson": "2.0.8", 31 | "debug": "4.1.1", 32 | "decompress-zip": "0.3.2", 33 | "electron-positioner": "4.1.0", 34 | "electron-updater": "4.0.6", 35 | "hasha": "3.0.0", 36 | "hazardous": "0.3.0", 37 | "intercept-stdout": "0.1.2", 38 | "js-yaml": "3.12.1", 39 | "node-fetch": "2.3.0", 40 | "node-schedule": "1.3.2", 41 | "promisepipe": "3.0.0", 42 | "promisify-child-process": "3.1.0", 43 | "react": "16.8.3", 44 | "react-dom": "16.8.3", 45 | "react-transition-group": "2.5.3", 46 | "strip-color": "0.1.0", 47 | "tar": "4.4.8" 48 | }, 49 | "devDependencies": { 50 | "@wordpress/scripts": "3.2.1", 51 | "concurrently": "4.1.0", 52 | "cross-env": "5.2.0", 53 | "electron": "4.0.5", 54 | "electron-builder": "20.38.5", 55 | "husky": "1.3.1", 56 | "lint-staged": "8.1.5", 57 | "node-sass": "npm:sass@1.17.3", 58 | "react-scripts": "2.1.5", 59 | "wait-on": "3.2.0" 60 | }, 61 | "scripts": { 62 | "start": "npm run electron-start", 63 | "dev": "cross-env DEBUG_COLORS=1 concurrently --kill-others \"npm run react-start\" \"wait-on http://localhost:3000/ && npm run electron-start\"", 64 | "build": "react-scripts build", 65 | "test": "react-scripts test --env=jsdom", 66 | "eject": "react-scripts eject", 67 | "electron-start": "cross-env ELECTRON_START_URL=http://localhost:3000/ electron .", 68 | "react-start": "cross-env BROWSER=none NODE_ENV=development react-scripts start", 69 | "pack": "electron-builder --dir", 70 | "dist": "npm run build && electron-builder", 71 | "publish": "npm run build && electron-builder --publish always", 72 | "postinstall": "electron-builder install-app-deps", 73 | "lint-css": "wp-scripts lint-style '**/*.css'", 74 | "lint-css:fix": "npm run lint-css -- --fix", 75 | "lint-js": "wp-scripts lint-js .", 76 | "lint-js:fix": "npm run lint-js -- --fix", 77 | "lint-pkg-json": "wp-scripts lint-pkg-json .", 78 | "check-licenses": "concurrently \"wp-scripts check-licenses --dev\" \"wp-scripts check-licenses --prod --gpl2\"" 79 | }, 80 | "build": { 81 | "appId": "org.wordpress.testpress", 82 | "productName": "TestPress", 83 | "asarUnpack": [ 84 | "src/services/docker/default.conf", 85 | "src/services/docker/php-config.ini", 86 | "src/services/docker/phpunit-config.ini" 87 | ], 88 | "extends": null, 89 | "files": [ 90 | "assets/**/*", 91 | "build/**/*", 92 | "node_modules/**/*", 93 | "src/**/*" 94 | ], 95 | "publish": "github", 96 | "mac": { 97 | "category": "public.app-category.developer-tools", 98 | "icon": "assets/icon.png", 99 | "darkModeSupport": true, 100 | "extendInfo": { 101 | "LSEnvironment": { 102 | "PATH": "/usr/local/bin:/usr/bin:/bin" 103 | } 104 | } 105 | }, 106 | "win": { 107 | "icon": "assets/icon.ico" 108 | } 109 | }, 110 | "lint-staged": { 111 | "package.json": [ 112 | "wp-scripts lint-pkg-json" 113 | ], 114 | "*.scss": [ 115 | "wp-scripts lint-style" 116 | ], 117 | "*.js": [ 118 | "wp-scripts lint-js" 119 | ] 120 | }, 121 | "husky": { 122 | "hooks": { 123 | "pre-commit": "lint-staged" 124 | } 125 | }, 126 | "browserslist": "electron >= 4.0" 127 | } 128 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | TestPress 9 | 10 | 11 | 14 |
15 | 16 | 17 | -------------------------------------------------------------------------------- /public/logo.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pento/testpress/70182b438c646119fa08b8a321b702b34639f69c/public/logo.ico -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "TestPress", 3 | "icons": [ { 4 | "src": "favicon.ico", 5 | "sizes": "64x64 32x32 24x24 16x16", 6 | "type": "image/x-icon" 7 | } ], 8 | "start_url": "./index.html", 9 | "display": "standalone", 10 | "theme_color": "#000000", 11 | "background_color": "#ffffff" 12 | } 13 | -------------------------------------------------------------------------------- /src/_colors.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Colors 3 | */ 4 | 5 | // Hugo's new WordPress shades of gray, from http://codepen.io/hugobaeta/pen/grJjVp. 6 | $black: #000; 7 | $dark-gray-900: #191e23; 8 | $dark-gray-800: #23282d; 9 | $dark-gray-700: #32373c; 10 | $dark-gray-600: #40464d; 11 | $dark-gray-500: #555d66; // Use this most of the time for dark items. 12 | $dark-gray-400: #606a73; 13 | $dark-gray-300: #6c7781; // Lightest gray that can be used for AA text contrast. 14 | $dark-gray-200: #7e8993; 15 | $dark-gray-150: #8d96a0; // Lightest gray that can be used for AA non-text contrast. 16 | $dark-gray-100: #8f98a1; 17 | $light-gray-900: #a2aab2; 18 | $light-gray-800: #b5bcc2; 19 | $light-gray-700: #ccd0d4; 20 | $light-gray-600: #d7dade; 21 | $light-gray-500: #e2e4e7; // Good for "grayed" items and borders. 22 | $light-gray-400: #e8eaeb; // Good for "readonly" input fields and special text selection. 23 | $light-gray-300: #edeff0; 24 | $light-gray-200: #f3f4f5; 25 | $light-gray-100: #f8f9f9; 26 | $white: #fff; 27 | 28 | // Dark opacities, for use with light themes. 29 | $dark-opacity-900: rgba(#000510, 0.9); 30 | $dark-opacity-800: rgba(#00000a, 0.85); 31 | $dark-opacity-700: rgba(#06060b, 0.8); 32 | $dark-opacity-600: rgba(#000913, 0.75); 33 | $dark-opacity-500: rgba(#0a1829, 0.7); 34 | $dark-opacity-400: rgba(#0a1829, 0.65); 35 | $dark-opacity-300: rgba(#0e1c2e, 0.62); 36 | $dark-opacity-200: rgba(#162435, 0.55); 37 | $dark-opacity-100: rgba(#223443, 0.5); 38 | $dark-opacity-light-900: rgba(#304455, 0.45); 39 | $dark-opacity-light-800: rgba(#425863, 0.4); 40 | $dark-opacity-light-700: rgba(#667886, 0.35); 41 | $dark-opacity-light-600: rgba(#7b86a2, 0.3); 42 | $dark-opacity-light-500: rgba(#9197a2, 0.25); 43 | $dark-opacity-light-400: rgba(#95959c, 0.2); 44 | $dark-opacity-light-300: rgba(#829493, 0.15); 45 | $dark-opacity-light-200: rgba(#8b8b96, 0.1); 46 | $dark-opacity-light-100: rgba(#747474, 0.05); 47 | 48 | // Light opacities, for use with dark themes. 49 | $light-opacity-900: rgba($white, 1); 50 | $light-opacity-800: rgba($white, 0.9); 51 | $light-opacity-700: rgba($white, 0.85); 52 | $light-opacity-600: rgba($white, 0.8); 53 | $light-opacity-500: rgba($white, 0.75); 54 | $light-opacity-400: rgba($white, 0.7); 55 | $light-opacity-300: rgba($white, 0.65); 56 | $light-opacity-200: rgba($white, 0.6); 57 | $light-opacity-100: rgba($white, 0.55); 58 | $light-opacity-light-900: rgba($white, 0.5); 59 | $light-opacity-light-800: rgba($white, 0.45); 60 | $light-opacity-light-700: rgba($white, 0.4); 61 | $light-opacity-light-600: rgba($white, 0.35); 62 | $light-opacity-light-500: rgba($white, 0.3); 63 | $light-opacity-light-400: rgba($white, 0.25); 64 | $light-opacity-light-300: rgba($white, 0.2); 65 | $light-opacity-light-200: rgba($white, 0.15); 66 | $light-opacity-light-100: rgba($white, 0.1); 67 | 68 | // Additional colors. 69 | // Some are from https://make.wordpress.org/design/handbook/foundations/colors/. 70 | $blue-wordpress-700: #00669b; 71 | $blue-dark-900: #0071a1; 72 | 73 | $blue-medium-900: #006589; 74 | $blue-medium-800: #00739c; 75 | $blue-medium-700: #007fac; 76 | $blue-medium-600: #008dbe; 77 | $blue-medium-500: #00a0d2; 78 | $blue-medium-400: #33b3db; 79 | $blue-medium-300: #66c6e4; 80 | $blue-medium-200: #bfe7f3; 81 | $blue-medium-100: #e5f5fa; 82 | $blue-medium-highlight: #b3e7fe; 83 | $blue-medium-focus: #007cba; 84 | 85 | // Alert colors. 86 | $alert-yellow: #f0b849; 87 | $alert-red: #d94f4f; 88 | $alert-green: #4ab866; 89 | -------------------------------------------------------------------------------- /src/app/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * External dependencies 3 | */ 4 | import React, { Component } from 'react'; 5 | 6 | /** 7 | * Internal dependencies 8 | */ 9 | import Pages from '../components/pages'; 10 | import PreferencesPanel from '../components/preferences-panel'; 11 | import StatusPanel from '../components/status-panel'; 12 | import AboutPanel from '../components/about-panel'; 13 | 14 | import './style.scss'; 15 | 16 | class TestPress extends Component { 17 | constructor() { 18 | super( ...arguments ); 19 | 20 | this.pages = [ { 21 | heading: 'TestPress', 22 | panel: ( ), 23 | }, { 24 | heading: 'Preferences', 25 | panel: ( ), 26 | }, { 27 | heading: 'About TestPress', 28 | panel: ( ), 29 | } ]; 30 | } 31 | 32 | render() { 33 | return ( 34 |
35 |
36 |
37 | 38 |
39 |
40 | ); 41 | } 42 | } 43 | 44 | export default TestPress; 45 | -------------------------------------------------------------------------------- /src/app/index.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * External dependencies 3 | */ 4 | import React from 'react'; 5 | import ReactDOM from 'react-dom'; 6 | /** 7 | * Internal dependencies 8 | */ 9 | import TestPress from '.'; 10 | 11 | it( 'renders without crashing', () => { 12 | const div = document.createElement( 'div' ); 13 | ReactDOM.render( , div ); 14 | ReactDOM.unmountComponentAtNode( div ); 15 | } ); 16 | -------------------------------------------------------------------------------- /src/app/style.scss: -------------------------------------------------------------------------------- 1 | @import "../_colors.scss"; 2 | 3 | .tray-pointer { 4 | border-bottom: 8px solid $light-gray-100; 5 | border-left: 8px solid transparent; 6 | border-right: 8px solid transparent; 7 | height: 0; 8 | margin: 0 auto; 9 | width: 0; 10 | 11 | .theme-dark & { 12 | border-bottom-color: $dark-gray-400; 13 | } 14 | } 15 | 16 | .traybottomcenter, 17 | .traybottomright { 18 | .tray-pointer { 19 | position: absolute; 20 | bottom: 2px; 21 | left: 0; 22 | right: 0; 23 | border-bottom: unset; 24 | border-top: 8px solid $light-gray-100; 25 | } 26 | } 27 | 28 | body.trayright .tray-pointer { 29 | margin-right: 10px; 30 | } 31 | 32 | .testpress { 33 | background-color: #fff; 34 | color: $dark-gray-900; 35 | height: 340px; 36 | 37 | .theme-dark & { 38 | background-color: $dark-gray-400; 39 | color: $light-gray-200; 40 | } 41 | } 42 | 43 | .components-button.is-large { 44 | .theme-dark & { 45 | color: $light-gray-200; 46 | background-color: $dark-gray-400; 47 | box-shadow: inset 0 -1px 0 $dark-gray-700; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/components/about-panel/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * External dependencies 3 | */ 4 | import React, { Component } from 'react'; 5 | import { normalize } from 'path'; 6 | 7 | /** 8 | * WordPress dependencies 9 | */ 10 | import { Button } from '@wordpress/components'; 11 | 12 | /** 13 | * Internal dependencies 14 | */ 15 | import ExternalLink from '../external-link'; 16 | import './style.scss'; 17 | 18 | const { shell, remote } = window.require( 'electron' ); 19 | 20 | const logPath = normalize( remote.app.getPath( 'userData' ) + '/debug.log' ); 21 | 22 | class AboutPanel extends Component { 23 | render() { 24 | const repositoryLink = ( 25 | GitHub repository 26 | ); 27 | 28 | return ( 29 |
30 |

31 | 32 | 33 | 💃 34 | 35 | TestPress 36 | 37 |

38 |

Version { remote.app.getVersion() }

39 |

40 | Please submit bug reports to the { repositoryLink } along with a copy of the debug 41 | log. 42 |

43 |

44 | 47 |

48 |
49 | ); 50 | } 51 | } 52 | 53 | export default AboutPanel; 54 | -------------------------------------------------------------------------------- /src/components/about-panel/style.scss: -------------------------------------------------------------------------------- 1 | @import "../../_colors.scss"; 2 | 3 | .about-panel { 4 | padding: 0 15px 15px; 5 | position: absolute; 6 | text-align: center; 7 | } 8 | -------------------------------------------------------------------------------- /src/components/external-link/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * External dependencies 3 | */ 4 | import React from 'react'; 5 | 6 | /** 7 | * Internal dependencies 8 | */ 9 | import './style.scss'; 10 | 11 | /** 12 | * Electron dependencies 13 | */ 14 | const { shell } = window.require( 'electron' ); 15 | 16 | export default function ExternalLink( { href, children } ) { 17 | return ( 18 | // Disable reason: We can't use regular s without an onClick handler in 19 | // Electron because the href will open within the app shell. 20 | // eslint-disable-next-line jsx-a11y/anchor-is-valid 21 | shell.openExternal( href ) } 26 | onKeyPress={ ( { key } ) => { 27 | if ( key === 'Enter' || key === ' ' ) { 28 | shell.openExternal( href ); 29 | } 30 | } } 31 | > 32 | { children } 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/components/external-link/style.scss: -------------------------------------------------------------------------------- 1 | @import "../../colors.scss"; 2 | 3 | .external-link { 4 | color: $blue-medium-500; 5 | cursor: pointer; 6 | text-decoration: underline; 7 | } 8 | -------------------------------------------------------------------------------- /src/components/pages/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * External dependencies 3 | */ 4 | import React, { Component } from 'react'; 5 | import { CSSTransition } from 'react-transition-group'; 6 | 7 | /** 8 | * Internal dependencies 9 | */ 10 | import PreferencesButton from '../preferences-button'; 11 | 12 | import './style.scss'; 13 | 14 | class Pages extends Component { 15 | constructor() { 16 | super( ...arguments ); 17 | 18 | this.state = { 19 | activePage: 0, 20 | }; 21 | 22 | this.setActivePage = this.setActivePage.bind( this ); 23 | } 24 | 25 | setActivePage( activePage ) { 26 | this.setState( { activePage } ); 27 | } 28 | 29 | render() { 30 | const { pages } = this.props; 31 | const { activePage } = this.state; 32 | 33 | return ( 34 |
35 | { 36 | pages.map( ( page, index ) => ( 37 | 43 | { () => ( 44 |
45 |
46 | 47 | 48 | 49 | 50 | 51 |

52 | { page.heading } 53 |

54 | 58 |
59 | { page.panel } 60 |
61 | ) } 62 |
63 | ) ) 64 | } 65 |
66 | ); 67 | } 68 | } 69 | 70 | export default Pages; 71 | -------------------------------------------------------------------------------- /src/components/pages/style.scss: -------------------------------------------------------------------------------- 1 | @import "../../_colors.scss"; 2 | 3 | .pages { 4 | height: 100%; 5 | position: relative; 6 | overflow: hidden; 7 | 8 | &__page { 9 | background-color: $light-gray-100; 10 | height: 100%; 11 | left: 350px; 12 | position: absolute; 13 | width: 350px; 14 | 15 | .theme-dark & { 16 | background-color: $dark-gray-400; 17 | } 18 | } 19 | 20 | &__page.page0, 21 | &__page-transition-enter-done, 22 | &__page-transition-exit { 23 | left: 0; 24 | } 25 | 26 | &__page-transition-enter-active { 27 | transition: 300ms; 28 | left: 0; 29 | } 30 | 31 | &__page-transition-exit-active { 32 | transition: 300ms; 33 | left: 350px; 34 | } 35 | 36 | &__page-header { 37 | align-items: center; 38 | border-bottom: 1px solid $light-gray-500; 39 | color: $dark-gray-500; 40 | display: flex; 41 | height: 35px; 42 | padding: 10px; 43 | 44 | .theme-dark & { 45 | border-bottom-color: $light-gray-100; 46 | color: $light-gray-200; 47 | } 48 | } 49 | 50 | &__page-title-logo { 51 | fill: $blue-wordpress-700; 52 | height: 25px; 53 | margin: 5px; 54 | 55 | .theme-dark & { 56 | fill: $blue-medium-100; 57 | } 58 | } 59 | 60 | .theme-dark & .components-icon-button .dashicon { 61 | fill: $blue-medium-100; 62 | } 63 | 64 | .theme-dark & .components-icon-button:not(:disabled):not([aria-disabled="true"]):not(.is-default):hover, 65 | .theme-dark & .components-icon-button:not(:disabled):not([aria-disabled="true"]):not(.is-default):active, 66 | .theme-dark & .components-icon-button:not(:disabled):not([aria-disabled="true"]):not(.is-default):focus { 67 | background-color: $dark-gray-700; 68 | } 69 | 70 | .components-button.is-link { 71 | color: $blue-medium-500; 72 | 73 | .theme-dark & { 74 | color: $blue-medium-200; 75 | } 76 | } 77 | 78 | &__page-title { 79 | font-size: 18px; 80 | font-weight: normal; 81 | margin: 0 0 0 5px; 82 | } 83 | 84 | .preferences-button { 85 | margin-left: auto; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/components/preferences-button/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * External dependencies 3 | */ 4 | import React, { Component } from 'react'; 5 | 6 | /** 7 | * WordPress dependencies 8 | */ 9 | import { IconButton } from '@wordpress/components'; 10 | 11 | const { ipcRenderer, remote } = window.require( 'electron' ); 12 | const { Menu, MenuItem, process } = remote; 13 | 14 | class PreferencesButton extends Component { 15 | constructor( props ) { 16 | super( props ); 17 | 18 | this.handleClick = this.handleClick.bind( this ); 19 | 20 | this.menu = new Menu(); 21 | 22 | const preferencesShortcut = process.platform === 'darwin' ? 'CmdOrCtrl+,' : 'CmdOrCtrl+P'; 23 | const quitShortcut = process.platform === 'darwin' ? 'CmdOrCtrl+Q' : 'Alt+F4'; 24 | 25 | this.menu.append( new MenuItem( { 26 | label: 'About TestPress', 27 | click: () => { 28 | this.props.setActivePage( 2 ); 29 | }, 30 | } ) ); 31 | 32 | this.menu.append( new MenuItem( { type: 'separator' } ) ); 33 | 34 | this.menu.append( new MenuItem( { 35 | label: 'Preferences...', 36 | accelerator: preferencesShortcut, 37 | click: () => { 38 | this.props.setActivePage( 1 ); 39 | }, 40 | } ) ); 41 | 42 | this.menu.append( new MenuItem( { type: 'separator' } ) ); 43 | 44 | this.menu.append( new MenuItem( { 45 | label: 'Quit', 46 | accelerator: quitShortcut, 47 | click: this.quit, 48 | } ) ); 49 | 50 | this.buttonStyles = { 51 | close: { 52 | icon: 'no-alt', 53 | text: 'Close Preferences', 54 | }, 55 | preferences: { 56 | icon: 'admin-generic', 57 | text: 'Settings', 58 | }, 59 | }; 60 | } 61 | 62 | handleClick() { 63 | const { activePage, setActivePage } = this.props; 64 | 65 | if ( activePage ) { 66 | setActivePage( 0 ); 67 | return; 68 | } 69 | 70 | this.menu.popup( { window: remote.getCurrentWindow() } ); 71 | } 72 | 73 | quit() { 74 | ipcRenderer.send( 'quit' ); 75 | } 76 | 77 | render() { 78 | const { activePage } = this.props; 79 | const { icon, text } = this.buttonStyles[ activePage ? 'close' : 'preferences' ]; 80 | 81 | return ( 82 | 89 | ); 90 | } 91 | } 92 | 93 | export default PreferencesButton; 94 | -------------------------------------------------------------------------------- /src/components/preferences-panel/basic-preferences.js: -------------------------------------------------------------------------------- 1 | /** 2 | * External dependencies 3 | */ 4 | import React from 'react'; 5 | 6 | export default function BasicPreferences( { directory, showDirectorySelect } ) { 7 | return ( 8 |
9 |
10 | 11 | { directory.wordpress ? directory.wordpress : 'No folder selected' } 12 | 18 |
19 |
20 | 21 | { directory.gutenberg ? directory.gutenberg : 'No folder selected' } 22 | 28 | 29 |
30 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/components/preferences-panel/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * External dependencies 3 | */ 4 | import React, { Component } from 'react'; 5 | /** 6 | * Internal dependencies 7 | */ 8 | import Tabs from '../tabs'; 9 | 10 | import './style.scss'; 11 | import BasicPreferences from './basic-preferences'; 12 | import SitePreferences from './site-preferences'; 13 | 14 | const { remote, ipcRenderer } = window.require( 'electron' ); 15 | 16 | class PreferencesPanel extends Component { 17 | constructor() { 18 | super( ...arguments ); 19 | 20 | const preferences = ipcRenderer.sendSync( 'getPreferences' ); 21 | 22 | this.state = { 23 | directory: { 24 | wordpress: preferences.basic[ 'wordpress-folder' ], 25 | gutenberg: preferences.basic[ 'gutenberg-folder' ], 26 | }, 27 | port: preferences.site.port, 28 | editedPort: preferences.site.port, 29 | }; 30 | 31 | this.showDirectorySelect = this.showDirectorySelect.bind( this ); 32 | this.directorySelected = this.directorySelected.bind( this ); 33 | this.portChanged = this.portChanged.bind( this ); 34 | } 35 | 36 | showDirectorySelect( name ) { 37 | remote.dialog.showOpenDialog( remote.BrowserWindow.getFocusedWindow(), { 38 | title: `Select ${ name } Folder`, 39 | properties: [ 40 | 'openDirectory', 41 | ], 42 | }, 43 | ( paths ) => this.directorySelected( paths, name ) ); 44 | } 45 | 46 | directorySelected( paths, name ) { 47 | const directory = paths ? paths.shift() : ''; 48 | const preference = name.toLowerCase() + '-folder'; 49 | 50 | if ( directory && directory !== this.state.directory[ name.toLowerCase() ] ) { 51 | const directoryState = this.state.directory; 52 | directoryState[ name.toLowerCase() ] = directory; 53 | this.setState( { directory: directoryState } ); 54 | ipcRenderer.send( 'updatePreference', 'basic', preference, directory ); 55 | } 56 | } 57 | 58 | portChanged() { 59 | const { editedPort, port } = this.state; 60 | 61 | if ( editedPort !== port ) { 62 | this.setState( { port: editedPort } ); 63 | ipcRenderer.send( 'updatePreference', 'site', 'port', editedPort ); 64 | } 65 | } 66 | 67 | render() { 68 | const { directory, editedPort } = this.state; 69 | 70 | const tabs = { 71 | Basic: , 75 | Site: this.setState( { editedPort: value } ) } 78 | onPortInputBlur={ this.portChanged } 79 | />, 80 | }; 81 | 82 | return ( 83 |
84 | 85 |
86 | ); 87 | } 88 | } 89 | 90 | export default PreferencesPanel; 91 | -------------------------------------------------------------------------------- /src/components/preferences-panel/site-preferences.js: -------------------------------------------------------------------------------- 1 | /** 2 | * External dependencies 3 | */ 4 | import React from 'react'; 5 | 6 | export default function SitePreferences( { port, onPortChange, onPortInputBlur } ) { 7 | return ( 8 |
9 |
10 | 11 | onPortChange( event.target.value ) } 16 | onBlur={ onPortInputBlur } 17 | /> 18 | 19 |
20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/components/preferences-panel/style.scss: -------------------------------------------------------------------------------- 1 | @import "../../_colors.scss"; 2 | 3 | .preferences-panel { 4 | &__directory-select > label, 5 | &__input > label { 6 | display: block; 7 | margin: 10px 0; 8 | color: $dark-gray-700; 9 | 10 | .theme-dark & { 11 | color: $light-gray-200; 12 | } 13 | } 14 | 15 | &__directory-select > button { 16 | background: none; 17 | border: none; 18 | color: $dark-gray-700; 19 | cursor: pointer; 20 | display: block; 21 | font-size: inherit; 22 | margin: 10px 0; 23 | padding: 0; 24 | text-decoration: underline; 25 | 26 | .theme-dark & { 27 | color: $light-gray-200; 28 | } 29 | } 30 | 31 | &__input--inline > label { 32 | display: inline-block; 33 | margin-right: 10px; 34 | } 35 | 36 | &__input > input { 37 | background-color: $light-gray-100; 38 | border-radius: 3px; 39 | border: 1px solid $light-gray-100; 40 | color: $dark-gray-700; 41 | padding: 5px 10px; 42 | 43 | .theme-dark & { 44 | color: $light-gray-200; 45 | background-color: $dark-gray-400; 46 | border-color: $light-gray-200; 47 | } 48 | } 49 | 50 | &__input--short > input { 51 | width: 50px; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/components/status-panel/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * External dependencies 3 | */ 4 | import React, { useState, useEffect } from 'react'; 5 | 6 | /** 7 | * Internal dependencies 8 | */ 9 | import MissingDaemonInfo from './missing-daemon-info'; 10 | import MissingFolderInfo from './missing-folder-info'; 11 | import NotReadyInfo from './not-ready-info'; 12 | import ReadyInfo from './ready-info'; 13 | import './style.scss'; 14 | 15 | /** 16 | * Electron dependencies 17 | */ 18 | const { ipcRenderer } = window.require( 'electron' ); 19 | 20 | function useStatuses() { 21 | const [ statuses, setStatuses ] = useState( {} ); 22 | 23 | useEffect( () => { 24 | function handleStatusChange( event, newStatuses ) { 25 | setStatuses( newStatuses ); 26 | } 27 | 28 | setStatuses( ipcRenderer.sendSync( 'getStatuses' ) ); 29 | 30 | ipcRenderer.on( 'status', handleStatusChange ); 31 | return () => ipcRenderer.off( 'status', handleStatusChange ); 32 | }, [ setStatuses ] ); 33 | 34 | return statuses; 35 | } 36 | 37 | export default function StatusPanel() { 38 | const statuses = useStatuses(); 39 | 40 | if ( statuses.docker === 'missing-daemon' ) { 41 | return ; 42 | } 43 | 44 | if ( statuses.docker === 'missing-wordpress-folder' ) { 45 | return ; 46 | } 47 | 48 | const isWaitingForWordPress = statuses.wordpress !== 'ready'; 49 | const isWaitingForGrunt = statuses.grunt !== 'ready' && statuses.grunt !== 'rebuilding'; 50 | if ( isWaitingForWordPress || isWaitingForGrunt ) { 51 | return ; 52 | } 53 | 54 | return ; 55 | } 56 | -------------------------------------------------------------------------------- /src/components/status-panel/missing-daemon-info.js: -------------------------------------------------------------------------------- 1 | /** 2 | * External dependencies 3 | */ 4 | import React, { useState } from 'react'; 5 | 6 | /** 7 | * WordPress dependencies 8 | */ 9 | import { Button } from '@wordpress/components'; 10 | 11 | /** 12 | * Internal dependencies 13 | */ 14 | import ExternalLink from '../external-link'; 15 | 16 | /** 17 | * Electron dependencies 18 | */ 19 | const { platform } = window.require( 'process' ); 20 | const { existsSync } = window.require( 'fs' ); 21 | const { shell } = window.require( 'electron' ); 22 | 23 | let dockerDesktopURL = 'https://www.docker.com/get-started'; 24 | let dockerDesktopName = 'Docker Desktop'; 25 | let dockerDesktopPath = null; 26 | 27 | if ( platform === 'darwin' ) { 28 | dockerDesktopURL = 'https://store.docker.com/editions/community/docker-ce-desktop-mac'; 29 | dockerDesktopName = 'Docker Desktop for Mac'; 30 | dockerDesktopPath = '/Applications/Docker.app'; 31 | } 32 | 33 | if ( platform === 'win32' ) { 34 | dockerDesktopURL = 'https://store.docker.com/editions/community/docker-ce-desktop-windows'; 35 | dockerDesktopName = 'Docker Desktop for Windows'; 36 | // TODO: Set dockerDesktopPath to something reasonable for Windows. 37 | } 38 | 39 | function OpenDockerButton() { 40 | const [ isOpening, setIsOpening ] = useState( false ); 41 | 42 | return ( 43 | 53 | ); 54 | } 55 | 56 | export default function MissingDaemonInfo() { 57 | const link = { dockerDesktopName }; 58 | 59 | let button; 60 | 61 | if ( dockerDesktopPath && existsSync( dockerDesktopPath ) ) { 62 | button = ; 63 | } else { 64 | button = ( 65 | 68 | ); 69 | } 70 | 71 | return ( 72 |
73 |

74 | 75 | 76 | 👋 77 | 78 | Docker is not running 79 | 80 |

81 |

82 | A docker server must be running to use TestPress. The easiest way to make this 83 | happen is to download and install { link }. 84 |

85 |

{ button }

86 |
87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /src/components/status-panel/missing-folder-info.js: -------------------------------------------------------------------------------- 1 | /** 2 | * External dependencies 3 | */ 4 | import React from 'react'; 5 | 6 | /** 7 | * WordPress dependencies 8 | */ 9 | import { Button } from '@wordpress/components'; 10 | 11 | /** 12 | * Internal dependencies 13 | */ 14 | import ExternalLink from '../external-link'; 15 | 16 | /** 17 | * Electron dependencies 18 | */ 19 | const { remote, ipcRenderer } = window.require( 'electron' ); 20 | 21 | function selectWordPressFolder() { 22 | remote.dialog.showOpenDialog( 23 | remote.BrowserWindow.getFocusedWindow(), 24 | { 25 | title: 'Select WordPress Folder', 26 | properties: [ 'openDirectory' ], 27 | }, 28 | ( [ path ] ) => { 29 | if ( path ) { 30 | ipcRenderer.send( 'updatePreference', 'basic', 'wordpress-folder', path ); 31 | } 32 | } 33 | ); 34 | } 35 | 36 | export default function MissingFolderInfo() { 37 | const desktopLink = ( 38 | GitHub Desktop 39 | ); 40 | const repositoryLink = ( 41 | 42 | wordpress-develop 43 | 44 | ); 45 | 46 | return ( 47 |
48 |

49 | 50 | 51 | 👋 52 | 53 | Select a WordPress Folder 54 | 55 |

56 |

57 | Select the folder containing your local copy of the WordPress source code. The 58 | easiest way to get a copy of the WordPress source code is by using { desktopLink } to 59 | clone { repositoryLink }. 60 |

61 |

62 | 65 |

66 |
67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /src/components/status-panel/not-ready-info.js: -------------------------------------------------------------------------------- 1 | /** 2 | * External dependencies 3 | */ 4 | import React from 'react'; 5 | import { normalize } from 'path'; 6 | 7 | /** 8 | * WordPress dependencies 9 | */ 10 | import { Button, Icon, Spinner } from '@wordpress/components'; 11 | 12 | /** 13 | * Electron dependencies 14 | */ 15 | const { shell, remote } = window.require( 'electron' ); 16 | 17 | const logPath = normalize( remote.app.getPath( 'userData' ) + '/debug.log' ); 18 | 19 | function StatusMessage( { isReady, children } ) { 20 | return ( 21 |
22 | { isReady ? : } 23 | { children } 24 |
25 | ); 26 | } 27 | 28 | export default function NotReadyInfo( { statuses } ) { 29 | return ( 30 |
31 |

32 | 33 | 34 | ✋ 35 | 36 | Getting ready 37 | 38 |

39 |

Please wait a moment while TestPress gets everything ready.

40 |

41 | 42 | Starting Docker… 43 | 44 | { /* 45 | // TODO: make this work 46 | 47 | Installing node… 48 | 49 | */ } 50 | 51 | Compiling assets… 52 | 53 | 54 | Installing WordPress… 55 | 56 |

57 |

58 | 59 |

60 |
61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /src/components/status-panel/ready-info.js: -------------------------------------------------------------------------------- 1 | /** 2 | * External dependencies 3 | */ 4 | import React from 'react'; 5 | 6 | /** 7 | * WordPress dependencies 8 | */ 9 | import { Button } from '@wordpress/components'; 10 | 11 | /** 12 | * Internal dependencies 13 | */ 14 | import ExternalLink from '../external-link'; 15 | 16 | /** 17 | * Electron dependencies 18 | */ 19 | const { ipcRenderer, shell, remote } = window.require( 'electron' ); 20 | const { spawn } = window.require( 'child_process' ); 21 | 22 | async function openWPCLITerminal() { 23 | const preferences = ipcRenderer.sendSync( 'getPreferences' ); 24 | const appDataPath = remote.app.getPath( 'appData' ).replace( /(\s+)/g, '\\\\\\\\$1' ); 25 | const wordpressFolder = preferences.basic[ 'wordpress-folder' ].replace( /(\s+)/g, '\\\\\\\\$1' ); 26 | const dockerConfigFolder = appDataPath + '/testpress/tools/'; 27 | const dockerCompose = dockerConfigFolder + 'docker-compose.yml'; 28 | const dockerComposeScripts = dockerConfigFolder + 'docker-compose.scripts.yml'; 29 | 30 | const osascript = `"tell application \\"Terminal\\" 31 | activate 32 | set currentTab to do script \\"alias wp='docker-compose -f ${ dockerCompose } -f ${ dockerComposeScripts } run --rm cli'\\" 33 | delay 2 34 | do script \\"cd ${ wordpressFolder }\\" in currentTab 35 | end tell"`; 36 | await spawn( 'osascript', [ '-e', osascript ], { shell: true } ); 37 | } 38 | 39 | export default function ReadyInfo() { 40 | const { 41 | site: { port }, 42 | } = ipcRenderer.sendSync( 'getPreferences' ); 43 | const siteURL = `http://localhost:${ port }`; 44 | const adminURL = `http://localhost:${ port }/wp-admin`; 45 | 46 | return ( 47 |
48 |

49 | 50 | 51 | 🤘 52 | 53 | Ready to rock! 54 | 55 |

56 |

57 | TestPress is serving your local copy of the WordPress source code. Go on and build 58 | something amazing! 59 |

60 |

61 | { siteURL } 62 |
63 | Username: admin 64 |
65 | Password: password 66 |

67 |

68 | 71 | 74 | 77 |

78 |
79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /src/components/status-panel/style.scss: -------------------------------------------------------------------------------- 1 | @import "../../_colors.scss"; 2 | 3 | .status { 4 | padding: 0 15px 15px; 5 | position: absolute; 6 | text-align: center; 7 | 8 | .components-button { 9 | margin: 0 5px; 10 | } 11 | 12 | &__message-container { 13 | display: inline-block; 14 | margin: 0; 15 | } 16 | 17 | &__message { 18 | display: flex; 19 | align-items: center; 20 | line-height: 1.8; 21 | 22 | .components-spinner { 23 | transform: scale(0.8); 24 | background-color: currentColor; 25 | 26 | &::before { 27 | background-color: #fff; 28 | 29 | .theme-dark & { 30 | background-color: #000; 31 | } 32 | } 33 | } 34 | 35 | &-icon { 36 | transform: scale(1.25); 37 | } 38 | 39 | .components-spinner, 40 | &-icon { 41 | margin: 0 0.8ch 0 0; 42 | } 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /src/components/tabs/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * External dependencies 3 | */ 4 | import React, { Component } from 'react'; 5 | 6 | /** 7 | * Internal dependencies 8 | */ 9 | import './style.scss'; 10 | 11 | class Tabs extends Component { 12 | constructor() { 13 | super( ...arguments ); 14 | 15 | this.state = { 16 | activeTab: Object.keys( this.props.tabs )[ 0 ], 17 | }; 18 | } 19 | render() { 20 | const { activeTab } = this.state; 21 | const { tabs } = this.props; 22 | 23 | return ( 24 |
25 |
26 | { Object.keys( tabs ).map( ( label ) => { 27 | const statusClassName = label === activeTab ? 'tabs__heading--active' : 'tabs__heading--inactive'; 28 | return ( 29 | this.setState( { activeTab: label } ) } 32 | key={ label + '-tab' } 33 | > 34 | { label } 35 | 36 | ); 37 | } ) } 38 |
39 |
40 | { Object.keys( tabs ).map( ( label ) => { 41 | const statusClassName = label === activeTab ? 'tabs__page--active' : 'tabs__page--inactive'; 42 | return ( 43 |
47 | { tabs[ label ] } 48 |
49 | ); 50 | } ) } 51 |
52 |
53 | ); 54 | } 55 | } 56 | 57 | export default Tabs; 58 | -------------------------------------------------------------------------------- /src/components/tabs/style.scss: -------------------------------------------------------------------------------- 1 | @import "../../_colors.scss"; 2 | 3 | .tabs { 4 | &__headings { 5 | border-bottom: 1px solid #ccc; 6 | color: $dark-gray-700; 7 | padding: 20px 15px 10px; 8 | 9 | .theme-dark & { 10 | color: $light-gray-200; 11 | } 12 | } 13 | 14 | &__heading { 15 | margin-right: 20px; 16 | padding-bottom: 10px; 17 | cursor: pointer; 18 | } 19 | 20 | &__heading--active { 21 | color: $blue-wordpress-700; 22 | border-bottom: 1px solid $light-gray-100; 23 | 24 | .theme-dark & { 25 | color: $blue-medium-200; 26 | border-bottom-color: $dark-gray-400; 27 | } 28 | } 29 | 30 | &__pages { 31 | color: $dark-gray-700; 32 | padding: 20px 15px; 33 | 34 | .theme-dark & { 35 | color: $light-gray-200; 36 | } 37 | } 38 | 39 | &__page--active { 40 | display: block; 41 | } 42 | 43 | &__page--inactive { 44 | display: none; 45 | } 46 | } 47 | 48 | -------------------------------------------------------------------------------- /src/electron-runner.js: -------------------------------------------------------------------------------- 1 | const electron = require( 'electron' ); 2 | const { app, BrowserWindow, Tray, ipcMain } = electron; 3 | const { doAction } = require( '@wordpress/hooks' ); 4 | const path = require( 'path' ); 5 | const url = require( 'url' ); 6 | const Positioner = require( 'electron-positioner' ); 7 | const { autoUpdater } = require( 'electron-updater' ); 8 | const schedule = require( 'node-schedule' ); 9 | const { accessSync, mkdirSync, createWriteStream } = require( 'fs' ); 10 | const intercept = require( 'intercept-stdout' ); 11 | const stripColor = require( 'strip-color' ); 12 | const { normalize } = require( 'path' ); 13 | 14 | // We always want to capture debug info. 15 | if ( ! process.env.DEBUG ) { 16 | process.env.DEBUG = 'testpress:*'; 17 | } 18 | 19 | const debug = require( 'debug' )( 'testpress:runner' ); 20 | 21 | // Check that the userData directory exists, and create it if needed. 22 | try { 23 | accessSync( app.getPath( 'userData' ) ); 24 | } catch ( err ) { 25 | mkdirSync( app.getPath( 'userData' ) ); 26 | } 27 | 28 | const logFile = createWriteStream( normalize( app.getPath( 'userData' ) + '/debug.log' ), { flags: 'a' } ); 29 | intercept( ( message ) => logFile.write( stripColor( message ) ) ); 30 | 31 | logFile.write( '\n\n' ); 32 | const started = new Date(); 33 | debug( 'TestPress started: %s', started.toUTCString() ); 34 | logFile.write( '\n' ); 35 | 36 | const { registerJobs } = require( './services' ); 37 | const { setStatusWindow } = require( './utils/status' ); 38 | 39 | const assetsDirectory = path.join( __dirname, '/../assets/' ); 40 | 41 | let tray; 42 | let window; 43 | 44 | if ( 'darwin' === process.platform ) { 45 | app.dock.hide(); 46 | } 47 | 48 | const createTray = () => { 49 | tray = new Tray( path.join( assetsDirectory, 'tray-logoTemplate.png' ) ); 50 | tray.on( 'right-click', toggleWindow ); 51 | tray.on( 'double-click', toggleWindow ); 52 | tray.on( 'click', ( event ) => { 53 | toggleWindow().then( () => { 54 | // Show devtools when command clicked 55 | if ( window.isVisible() && process.defaultApp && event.metaKey ) { 56 | window.openDevTools( { mode: 'detach' } ); 57 | } 58 | } ); 59 | } ); 60 | }; 61 | 62 | function createWindow() { 63 | // Create the browser window. 64 | window = new BrowserWindow( { 65 | width: 350, 66 | height: 350, 67 | show: false, 68 | frame: false, 69 | fullscreenable: false, 70 | resizable: false, 71 | transparent: true, 72 | skipTaskbar: true, 73 | alwaysOnTop: true, 74 | webPreferences: { 75 | // Prevents renderer process code from not running when window is hidden 76 | backgroundThrottling: false, 77 | }, 78 | } ); 79 | 80 | setStatusWindow( window ); 81 | 82 | // and load the index.html of the app. 83 | const startUrl = process.env.ELECTRON_START_URL || url.format( { 84 | pathname: path.join( __dirname, '/../build/index.html' ), 85 | protocol: 'file:', 86 | slashes: true, 87 | } ); 88 | 89 | window.loadURL( startUrl ); 90 | 91 | window.on( 'blur', () => { 92 | if ( ! window.webContents.isDevToolsOpened() ) { 93 | window.hide(); 94 | } 95 | } ); 96 | 97 | if ( process.platform === 'darwin' ) { 98 | const { systemPreferences } = electron; 99 | 100 | const setSystemThemeClass = () => { 101 | const currentTheme = systemPreferences.isDarkMode() ? 'dark' : 'light'; 102 | const oldTheme = systemPreferences.isDarkMode() ? 'light' : 'dark'; 103 | return ` 104 | document.body.classList.remove( 'theme-${ oldTheme }' ); 105 | document.body.classList.add( 'theme-${ currentTheme }' ); 106 | `; 107 | }; 108 | 109 | window.webContents.executeJavaScript( setSystemThemeClass() ); 110 | 111 | systemPreferences.subscribeNotification( 112 | 'AppleInterfaceThemeChangedNotification', 113 | () => { 114 | window.webContents.executeJavaScript( setSystemThemeClass() ); 115 | } 116 | ); 117 | } 118 | 119 | window.on( 'closed', () => { 120 | window = null; 121 | } ); 122 | } 123 | 124 | const toggleWindow = () => { 125 | if ( window.isVisible() ) { 126 | return new Promise( ( resolve ) => { 127 | window.hide(); 128 | resolve(); 129 | } ); 130 | } 131 | return showWindow(); 132 | }; 133 | 134 | const showWindow = () => { 135 | const trayBounds = tray.getBounds(); 136 | const positioner = new Positioner( window ); 137 | const activeDisplay = electron.screen.getDisplayMatching( trayBounds ); 138 | 139 | let position = 'trayCenter'; 140 | 141 | // If the tray icon is too close to the edge of the screen, align the right edge 142 | // of the window with the icon, instead of centering it. 143 | if ( trayBounds.x > activeDisplay.bounds.x + activeDisplay.bounds.width - 175 ) { 144 | trayBounds.x += 7; 145 | position = 'trayRight'; 146 | } 147 | 148 | // If the tray isn't at the top, assume it's at the bottom. 149 | if ( trayBounds.y > 500 ) { 150 | position = position.replace( 'tray', 'trayBottom' ); 151 | } 152 | 153 | const addClassScript = ` 154 | document.body.classList.remove( 'traycenter', 'trayright', 'traybottomcenter', 'traybottomright' ); 155 | document.body.classList.add( '${ position.toLowerCase() }' ); 156 | `; 157 | 158 | return window.webContents.executeJavaScript( addClassScript ).then( () => { 159 | positioner.move( position, trayBounds ); 160 | window.show(); 161 | window.focus(); 162 | } ); 163 | }; 164 | 165 | // This method will be called when Electron has finished 166 | // initialization and is ready to create browser windows. 167 | // Some APIs can only be used after this event occurs. 168 | app.on( 'ready', () => { 169 | const rule = new schedule.RecurrenceRule(); 170 | rule.hour = [ 8, 20 ]; 171 | rule.minute = 0; 172 | 173 | schedule.scheduleJob( rule, autoUpdater.checkForUpdatesAndNotify.bind( autoUpdater ) ); 174 | autoUpdater.checkForUpdatesAndNotify(); 175 | 176 | createTray(); 177 | createWindow(); 178 | registerJobs(); 179 | 180 | window.once( 'ready-to-show', showWindow ); 181 | } ); 182 | 183 | // Quit when all windows are closed. 184 | app.on( 'window-all-closed', () => { 185 | // On OS X it is common for applications and their menu bar 186 | // to stay active until the user quits explicitly with Cmd + Q 187 | if ( process.platform !== 'darwin' ) { 188 | app.quit(); 189 | } 190 | } ); 191 | 192 | ipcMain.on( 'quit', () => { 193 | window.close(); 194 | app.quit(); 195 | } ); 196 | 197 | app.on( 'quit', () => { 198 | doAction( 'shutdown' ); 199 | } ); 200 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * External dependencies 3 | */ 4 | import React from 'react'; 5 | import ReactDOM from 'react-dom'; 6 | 7 | /** 8 | * Internal dependencies 9 | */ 10 | import TestPress from './app'; 11 | import registerServiceWorker from './registerServiceWorker'; 12 | 13 | import './index.scss'; 14 | 15 | ReactDOM.render( , document.getElementById( 'root' ) ); 16 | registerServiceWorker(); 17 | -------------------------------------------------------------------------------- /src/index.scss: -------------------------------------------------------------------------------- 1 | @import "../node_modules/@wordpress/components/build-style/style.css"; 2 | @import "./_colors.scss"; 3 | 4 | body { 5 | font-family: sans-serif; 6 | font-size: 16px; 7 | line-height: 1.35; 8 | margin: 0; 9 | padding: 0; 10 | } 11 | -------------------------------------------------------------------------------- /src/preferences/index.js: -------------------------------------------------------------------------------- 1 | const { app, ipcMain } = require( 'electron' ); 2 | const { doAction } = require( '@wordpress/hooks' ); 3 | const path = require( 'path' ); 4 | const debug = require( 'debug' )( 'testpress:preferences' ); 5 | const { existsSync, readFileSync, writeFileSync } = require( 'fs' ); 6 | 7 | class Preferences { 8 | constructor() { 9 | debug( 'Creating Preferences store' ); 10 | this.dataStore = path.resolve( app.getPath( 'userData' ), 'preferences.json' ); 11 | if ( ! existsSync( this.dataStore ) ) { 12 | writeFileSync( this.dataStore, '{}', { 13 | encoding: 'utf-8', 14 | } 15 | ); 16 | } 17 | 18 | this.defaults = { 19 | basic: { 20 | 'wordpress-folder': '', 21 | 'gutenberg-folder': '', 22 | }, 23 | site: { 24 | port: 9999, 25 | }, 26 | }; 27 | 28 | this.readPreferences(); 29 | 30 | ipcMain.on( 'getPreferences', ( event ) => { 31 | debug( 'Sending preferences to render process' ); 32 | event.returnValue = this.preferences; 33 | } ); 34 | 35 | ipcMain.on( 'updatePreference', ( event, section, preference, value ) => { 36 | debug( 'Recieved updatePreference signal from render process' ); 37 | this.update( section, preference, value ); 38 | } ); 39 | } 40 | 41 | /** 42 | * Reads the preferences from the file they've been save to on disk. 43 | */ 44 | readPreferences() { 45 | debug( 'Reading preferences from disk' ); 46 | this.preferences = JSON.parse( readFileSync( this.dataStore, { 47 | encoding: 'utf-8', 48 | } ) ); 49 | 50 | Object.keys( this.defaults ).forEach( ( section ) => { 51 | if ( ! this.preferences[ section ] ) { 52 | debug( `Creating missing preferences section "${ section }"` ); 53 | this.preferences[ section ] = {}; 54 | } 55 | 56 | Object.keys( this.defaults[ section ] ).forEach( ( pref ) => { 57 | if ( ! this.preferences[ section ][ pref ] ) { 58 | debug( `Adding missing preference "${ section }.${ pref }"` ); 59 | this.preferences[ section ][ pref ] = this.defaults[ section ][ pref ]; 60 | } 61 | } ); 62 | } ); 63 | 64 | this.writePreferences(); 65 | } 66 | 67 | /** 68 | * Writes the current preferences to disk. 69 | */ 70 | writePreferences() { 71 | debug( 'Writing preferences to disk' ); 72 | writeFileSync( this.dataStore, JSON.stringify( this.preferences ), { 73 | encoding: 'utf-8', 74 | } 75 | ); 76 | } 77 | 78 | /** 79 | * Updates a preference value. 80 | * 81 | * @param {string} section The preferences section to save the preference in. 82 | * @param {string} preference The preference to save. 83 | * @param {*} value The value of the preference. 84 | */ 85 | update( section, preference, value ) { 86 | debug( `Updated preference "${ section }.${ preference }" to "${ value }"` ); 87 | this.preferences[ section ][ preference ] = value; 88 | this.writePreferences(); 89 | 90 | doAction( 'preference_saved', section, preference, value, this.preferences ); 91 | } 92 | 93 | /** 94 | * Get the value of a preference. 95 | * 96 | * @param {string} section The preferences section that the preference is in. 97 | * @param {string} preference The preference to retrieve. 98 | * 99 | * @return The value of the preference. 100 | */ 101 | value( section, preference ) { 102 | return this.preferences[ section ][ preference ]; 103 | } 104 | } 105 | 106 | debug( 'Initialising preferences' ); 107 | const preferences = new Preferences(); 108 | 109 | module.exports = { 110 | preferences, 111 | }; 112 | -------------------------------------------------------------------------------- /src/registerServiceWorker.js: -------------------------------------------------------------------------------- 1 | // In production, we register a service worker to serve assets from local cache. 2 | 3 | // This lets the app load faster on subsequent visits in production, and gives 4 | // it offline capabilities. However, it also means that developers (and users) 5 | // will only see deployed updates on the "N+1" visit to a page, since previously 6 | // cached resources are updated in the background. 7 | 8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy. 9 | // This link also includes instructions on opting out of this behavior. 10 | 11 | const isLocalhost = Boolean( 12 | window.location.hostname === 'localhost' || 13 | // [::1] is the IPv6 localhost address. 14 | window.location.hostname === '[::1]' || 15 | // 127.0.0.1/8 is considered localhost for IPv4. 16 | window.location.hostname.match( 17 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 18 | ) 19 | ); 20 | 21 | export default function register() { 22 | if ( process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator ) { 23 | // The URL constructor is available in all browsers that support SW. 24 | const publicUrl = new URL( process.env.PUBLIC_URL, window.location ); 25 | if ( publicUrl.origin !== window.location.origin ) { 26 | // Our service worker won't work if PUBLIC_URL is on a different origin 27 | // from what our page is served on. This might happen if a CDN is used to 28 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374 29 | return; 30 | } 31 | 32 | window.addEventListener( 'load', () => { 33 | const swUrl = `${ process.env.PUBLIC_URL }/service-worker.js`; 34 | 35 | if ( isLocalhost ) { 36 | // This is running on localhost. Lets check if a service worker still exists or not. 37 | checkValidServiceWorker( swUrl ); 38 | 39 | // Add some additional logging to localhost, pointing developers to the 40 | // service worker/PWA documentation. 41 | navigator.serviceWorker.ready.then( () => { 42 | console.log( 43 | 'This web app is being served cache-first by a service ' + 44 | 'worker. To learn more, visit https://goo.gl/SC7cgQ' 45 | ); 46 | } ); 47 | } else { 48 | // Is not local host. Just register service worker 49 | registerValidSW( swUrl ); 50 | } 51 | } ); 52 | } 53 | } 54 | 55 | function registerValidSW( swUrl ) { 56 | navigator.serviceWorker 57 | .register( swUrl ) 58 | .then( ( registration ) => { 59 | registration.onupdatefound = () => { 60 | const installingWorker = registration.installing; 61 | installingWorker.onstatechange = () => { 62 | if ( installingWorker.state === 'installed' ) { 63 | if ( navigator.serviceWorker.controller ) { 64 | // At this point, the old content will have been purged and 65 | // the fresh content will have been added to the cache. 66 | // It's the perfect time to display a "New content is 67 | // available; please refresh." message in your web app. 68 | console.log( 'New content is available; please refresh.' ); 69 | } else { 70 | // At this point, everything has been precached. 71 | // It's the perfect time to display a 72 | // "Content is cached for offline use." message. 73 | console.log( 'Content is cached for offline use.' ); 74 | } 75 | } 76 | }; 77 | }; 78 | } ) 79 | .catch( ( error ) => { 80 | console.error( 'Error during service worker registration:', error ); 81 | } ); 82 | } 83 | 84 | function checkValidServiceWorker( swUrl ) { 85 | // Check if the service worker can be found. If it can't reload the page. 86 | fetch( swUrl ) 87 | .then( ( response ) => { 88 | // Ensure service worker exists, and that we really are getting a JS file. 89 | if ( 90 | response.status === 404 || 91 | response.headers.get( 'content-type' ).indexOf( 'javascript' ) === -1 92 | ) { 93 | // No service worker found. Probably a different app. Reload the page. 94 | navigator.serviceWorker.ready.then( ( registration ) => { 95 | registration.unregister().then( () => { 96 | window.location.reload(); 97 | } ); 98 | } ); 99 | } else { 100 | // Service worker found. Proceed as normal. 101 | registerValidSW( swUrl ); 102 | } 103 | } ) 104 | .catch( () => { 105 | console.log( 106 | 'No internet connection found. App is running in offline mode.' 107 | ); 108 | } ); 109 | } 110 | 111 | export function unregister() { 112 | if ( 'serviceWorker' in navigator ) { 113 | navigator.serviceWorker.ready.then( ( registration ) => { 114 | registration.unregister(); 115 | } ); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/services/constants.js: -------------------------------------------------------------------------------- 1 | const { app } = require( 'electron' ); 2 | const { normalize } = require( 'path' ); 3 | 4 | const TOOLS_DIR = normalize( app.getPath( 'userData' ) + '/tools' ); 5 | const ARCHIVE_DIR = normalize( TOOLS_DIR + '/archives' ); 6 | 7 | const NODE_DIR = normalize( TOOLS_DIR + '/node' ); 8 | const NPM_CACHE_DIR = normalize( ARCHIVE_DIR + '/npm-cache' ); 9 | 10 | const nodeBin = ( 'win32' === process.platform ) ? '/node.exe' : '/bin/node'; 11 | const npmBin = ( 'win32' === process.platform ) ? '/node_modules/npm/bin/npm-cli.js' : '/bin/npm'; 12 | 13 | const NODE_BIN = normalize( NODE_DIR + nodeBin ); 14 | const NPM_BIN = normalize( NODE_DIR + npmBin ); 15 | 16 | module.exports = { 17 | TOOLS_DIR, 18 | ARCHIVE_DIR, 19 | NODE_DIR, 20 | NPM_CACHE_DIR, 21 | NODE_BIN, 22 | NPM_BIN, 23 | }; 24 | -------------------------------------------------------------------------------- /src/services/docker/default.conf: -------------------------------------------------------------------------------- 1 | server { 2 | index index.php index.html; 3 | 4 | listen 80 default_server; 5 | listen [::]:80 default_server; 6 | 7 | absolute_redirect off; 8 | 9 | server_name localhost; 10 | 11 | client_max_body_size 1g; 12 | 13 | error_log /var/log/nginx/error.log; 14 | access_log /var/log/nginx/access.log; 15 | 16 | root /var/www/src; 17 | 18 | location / { 19 | try_files $uri $uri/ /index.php?$args; 20 | } 21 | 22 | location ~ \.php$ { 23 | try_files $uri =404; 24 | fastcgi_split_path_info ^(.+\.php)(/.+)$; 25 | fastcgi_pass php:9000; 26 | fastcgi_index index.php; 27 | include fastcgi_params; 28 | fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; 29 | fastcgi_param PATH_INFO $fastcgi_path_info; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/services/docker/index.js: -------------------------------------------------------------------------------- 1 | // Hazardous overrides some of the functions from "path" to make them work correctly when TestPress is packaged. 2 | require( 'hazardous' ); 3 | 4 | const yaml = require( 'js-yaml' ); 5 | const { copyFileSync, existsSync, renameSync, writeFileSync } = require( 'fs' ); 6 | const { spawn } = require( 'promisify-child-process' ); 7 | const process = require( 'process' ); 8 | const { addAction, didAction } = require( '@wordpress/hooks' ); 9 | const sleep = require( 'await-sleep' ); 10 | const debug = require( 'debug' )( 'testpress:services:docker' ); 11 | const { normalize } = require( 'path' ); 12 | const csv = require( 'csvtojson' ); 13 | 14 | const { TOOLS_DIR } = require( '../constants' ); 15 | const { preferences } = require( '../../preferences' ); 16 | const { setStatus } = require( '../../utils/status' ); 17 | 18 | const cwds = { 19 | 'wordpress-folder': '', 20 | 'gutenberg-folder': '', 21 | }; 22 | let port = 9999; 23 | 24 | const dockerEnv = {}; 25 | 26 | let USING_TOOLBOX = false; 27 | 28 | /** 29 | * Registers the Docker actions, then starts Docker. 30 | */ 31 | async function registerDockerJob() { 32 | debug( 'Registering job' ); 33 | 34 | if ( 'win32' === process.platform ) { 35 | USING_TOOLBOX = await detectToolbox(); 36 | } 37 | 38 | addAction( 'preference_saved', 'preferenceSaved', preferenceSaved, 9 ); 39 | addAction( 'shutdown', 'shutdown', shutdown ); 40 | 41 | startDocker(); 42 | } 43 | 44 | /** 45 | * Get docker up and running. 46 | */ 47 | async function startDocker() { 48 | if ( USING_TOOLBOX ) { 49 | await startDockerMachine(); 50 | } 51 | 52 | debug( 'Checking if daemon is running' ); 53 | while ( ! await detectDockerDaemon() ) { 54 | setStatus( 'docker', 'missing-daemon' ); 55 | await sleep( 1000 ); 56 | } 57 | 58 | debug( 'Preparing to start Docker' ); 59 | 60 | setStatus( 'docker', 'starting' ); 61 | 62 | cwds[ 'wordpress-folder' ] = preferences.value( 'basic', 'wordpress-folder' ); 63 | cwds[ 'gutenberg-folder' ] = preferences.value( 'basic', 'gutenberg-folder' ); 64 | 65 | port = preferences.value( 'site', 'port' ) || 9999; 66 | 67 | if ( ! cwds[ 'wordpress-folder' ] || ! port ) { 68 | setStatus( 'docker', 'missing-wordpress-folder' ); 69 | debug( 'Bailing, preferences not set' ); 70 | return; 71 | } 72 | 73 | const defaultOptions = { 74 | version: '3.7', 75 | services: { 76 | 'wordpress-develop': { 77 | image: 'nginx:alpine', 78 | ports: [ 79 | port + ':80', 80 | ], 81 | volumes: [ 82 | './default.conf:/etc/nginx/conf.d/default.conf', 83 | normalize( cwds[ 'wordpress-folder' ] ) + ':/var/www', 84 | ], 85 | links: [ 86 | 'php', 87 | ], 88 | }, 89 | php: { 90 | image: 'garypendergast/wordpress-develop-php', 91 | volumes: [ 92 | './php-config.ini:/usr/local/etc/php/conf.d/php-config.ini', 93 | normalize( cwds[ 'wordpress-folder' ] ) + ':/var/www', 94 | ], 95 | links: [ 96 | 'mysql', 97 | ], 98 | }, 99 | mysql: { 100 | image: 'mysql:5.7', 101 | environment: { 102 | MYSQL_ROOT_PASSWORD: 'password', 103 | MYSQL_DATABASE: 'wordpress_develop', 104 | }, 105 | healthcheck: { 106 | test: [ 'CMD', 'mysql', '-e', 'SHOW TABLES FROM wordpress_develop', '-uroot', '-ppassword', '-hmysql', '--protocol=tcp' ], 107 | interval: '1s', 108 | retries: '100', 109 | }, 110 | volumes: [ 111 | 'mysql:/var/lib/mysql', 112 | ], 113 | }, 114 | }, 115 | volumes: { 116 | mysql: {}, 117 | }, 118 | }; 119 | 120 | const scriptOptions = { 121 | version: '3.7', 122 | services: { 123 | cli: { 124 | image: 'wordpress:cli', 125 | volumes: [ 126 | normalize( cwds[ 'wordpress-folder' ] ) + ':/var/www', 127 | ], 128 | }, 129 | phpunit: { 130 | image: 'garypendergast/wordpress-develop-phpunit', 131 | volumes: [ 132 | './phpunit-config.ini:/usr/local/etc/php/conf.d/phpunit-config.ini', 133 | normalize( cwds[ 'wordpress-folder' ] ) + ':/wordpress-develop', 134 | 'phpunit-uploads:/wordpress-develop/src/wp-content/uploads', 135 | ], 136 | init: true, 137 | }, 138 | }, 139 | volumes: { 140 | 'phpunit-uploads': {}, 141 | }, 142 | }; 143 | 144 | if ( cwds[ 'gutenberg-folder' ] ) { 145 | const gutenbergVolume = normalize( cwds[ 'gutenberg-folder' ] ) + ':/var/www/src/wp-content/plugins/gutenberg'; 146 | defaultOptions.services[ 'wordpress-develop' ].volumes.push( gutenbergVolume ); 147 | defaultOptions.services.php.volumes.push( gutenbergVolume ); 148 | 149 | scriptOptions.services.cli.volumes.push( gutenbergVolume ); 150 | scriptOptions.services[ 'phpunit-gutenberg' ] = { 151 | image: 'garypendergast/wordpress-develop-phpunit', 152 | volumes: [ 153 | normalize( cwds[ 'wordpress-folder' ] ) + ':/wordpress-develop', 154 | normalize( cwds[ 'gutenberg-folder' ] ) + ':/wordpress-develop/src/wp-content/plugins/gutenberg', 155 | ], 156 | }; 157 | } 158 | 159 | const defaultOptionsYaml = yaml.safeDump( defaultOptions, { lineWidth: -1 } ); 160 | writeFileSync( normalize( TOOLS_DIR + '/docker-compose.yml' ), defaultOptionsYaml ); 161 | 162 | const scriptOptionsYaml = yaml.safeDump( scriptOptions, { lineWidth: -1 } ); 163 | writeFileSync( normalize( TOOLS_DIR + '/docker-compose.scripts.yml' ), scriptOptionsYaml ); 164 | 165 | copyFileSync( normalize( __dirname + '/default.conf' ), normalize( TOOLS_DIR + '/default.conf' ) ); 166 | copyFileSync( normalize( __dirname + '/php-config.ini' ), normalize( TOOLS_DIR + '/php-config.ini' ) ); 167 | copyFileSync( normalize( __dirname + '/phpunit-config.ini' ), normalize( TOOLS_DIR + '/phpunit-config.ini' ) ); 168 | 169 | debug( 'Starting docker containers' ); 170 | await spawn( 'docker-compose', [ 171 | '-f', 172 | 'docker-compose.yml', 173 | 'up', 174 | '-d', 175 | ], { 176 | cwd: TOOLS_DIR, 177 | encoding: 'utf8', 178 | env: { 179 | PATH: process.env.PATH, 180 | ...dockerEnv, 181 | }, 182 | } ).catch( ( { stderr } ) => debug( stderr ) ); 183 | 184 | debug( 'Docker containers started' ); 185 | 186 | setStatus( 'docker', 'ready' ); 187 | 188 | addAction( 'grunt_watch_first_run_finished', 'installWordPress', installWordPress ); 189 | 190 | if ( didAction( 'grunt_watch_first_run_finished' ) ) { 191 | installWordPress(); 192 | } 193 | } 194 | 195 | /** 196 | * When we're using Docker Toolbox, then we need to check that the host machine is up and running. 197 | */ 198 | async function startDockerMachine() { 199 | debug( 'Starting docker machine' ); 200 | await spawn( 'docker-machine', [ 201 | 'start', 202 | 'default', 203 | ], { 204 | cwd: TOOLS_DIR, 205 | encoding: 'utf8', 206 | env: { 207 | PATH: process.env.PATH, 208 | }, 209 | } ).catch( ( { stderr } ) => debug( stderr ) ); 210 | 211 | const vboxManage = normalize( process.env.VBOX_MSI_INSTALL_PATH + '/VBoxManage' ); 212 | 213 | debug( 'Configuring machine port forwarding' ); 214 | await spawn( '"' + vboxManage + '"', [ 215 | 'controlvm', 216 | '"default"', 217 | 'natpf1', 218 | 'delete', 219 | 'wphttp', 220 | ], { 221 | cwd: TOOLS_DIR, 222 | encoding: 'utf8', 223 | env: { 224 | PATH: process.env.PATH, 225 | }, 226 | shell: true, 227 | } ).catch( ( { stderr } ) => debug( stderr ) ); 228 | 229 | await spawn( '"' + vboxManage + '"', [ 230 | 'controlvm', 231 | '"default"', 232 | 'natpf1', 233 | 'wphttp,tcp,127.0.0.1,' + port + ',,' + port, 234 | ], { 235 | cwd: TOOLS_DIR, 236 | encoding: 'utf8', 237 | env: { 238 | PATH: process.env.PATH, 239 | }, 240 | shell: true, 241 | } ).catch( ( { stderr } ) => debug( stderr ) ); 242 | 243 | debug( 'Collecting docker environment info' ); 244 | await spawn( 'docker-machine', [ 245 | 'env', 246 | 'default', 247 | '--shell', 248 | 'cmd', 249 | ], { 250 | cwd: TOOLS_DIR, 251 | encoding: 'utf8', 252 | env: { 253 | PATH: process.env.PATH, 254 | }, 255 | } ) 256 | .then( ( { stdout } ) => { 257 | stdout.split( '\n' ).forEach( ( line ) => { 258 | // Environment info is in the form: SET ENV_VAR=value 259 | if ( ! line.startsWith( 'SET' ) ) { 260 | return; 261 | } 262 | 263 | const parts = line.trim().split( /[ =]/, 3 ); 264 | if ( 3 === parts.length ) { 265 | dockerEnv[ parts[ 1 ] ] = parts[ 2 ]; 266 | } 267 | } ); 268 | 269 | debug( 'Docker environment: %O', dockerEnv ); 270 | } ) 271 | .catch( ( { stderr } ) => debug( stderr ) ); 272 | } 273 | 274 | /** 275 | * Runs the WP-CLI commands to install WordPress. 276 | */ 277 | async function installWordPress() { 278 | setStatus( 'wordpress', 'installing' ); 279 | 280 | debug( 'Waiting for mysqld to start in the MySQL container' ); 281 | while ( 1 ) { 282 | const { stdout } = await spawn( 'docker', [ 283 | 'inspect', 284 | '--format', 285 | '{{json .State.Health.Status }}', 286 | 'tools_mysql_1', 287 | ], { 288 | cwd: TOOLS_DIR, 289 | encoding: 'utf8', 290 | env: { 291 | PATH: process.env.PATH, 292 | ...dockerEnv, 293 | }, 294 | } ); 295 | 296 | if ( stdout.trim() === '"healthy"' ) { 297 | break; 298 | } 299 | 300 | await sleep( 1000 ); 301 | } 302 | 303 | debug( 'Checking if a config file exists' ); 304 | const configExists = await runCLICommand( 'config', 'path' ); 305 | if ( ! configExists ) { 306 | debug( 'Creating wp-config.php file' ); 307 | await runCLICommand( 'config', 308 | 'create', 309 | '--dbname=wordpress_develop', 310 | '--dbuser=root', 311 | '--dbpass=password', 312 | '--dbhost=mysql', 313 | '--path=/var/www/build' ); 314 | 315 | if ( existsSync( normalize( cwds[ 'wordpress-folder' ] + '/build/wp-config.php' ) ) ) { 316 | debug( 'Moving wp-config.php out of the build directory' ); 317 | renameSync( 318 | normalize( cwds[ 'wordpress-folder' ] + '/build/wp-config.php' ), 319 | normalize( cwds[ 'wordpress-folder' ] + '/wp-config.php' ) 320 | ); 321 | } 322 | 323 | debug( 'Adding debug options to wp-config.php' ); 324 | await runCLICommand( 'config', 'set', 'WP_DEBUG', 'true', '--raw', '--type=constant' ); 325 | await runCLICommand( 'config', 'set', 'SCRIPT_DEBUG', 'true', '--raw', '--type=constant' ); 326 | await runCLICommand( 'config', 'set', 'WP_DEBUG_DISPLAY', 'true', '--raw', '--type=constant' ); 327 | } 328 | 329 | debug( 'Checking if WordPress is installed' ); 330 | const isInstalled = await runCLICommand( 'core', 'is-installed' ); 331 | if ( isInstalled ) { 332 | debug( 'Updating site URL' ); 333 | await runCLICommand( 'option', 'update', 'home', 'http://localhost:' + port ); 334 | await runCLICommand( 'option', 'update', 'siteurl', 'http://localhost:' + port ); 335 | } else { 336 | debug( 'Installing WordPress' ); 337 | await runCLICommand( 'core', 338 | 'install', 339 | '--url=localhost:' + port, 340 | '--title=WordPress Develop', 341 | '--admin_user=admin', 342 | '--admin_password=password', 343 | '--admin_email=test@test.test', 344 | '--skip-email' ); 345 | } 346 | 347 | setStatus( 'wordpress', 'ready' ); 348 | 349 | debug( 'WordPress ready at http://localhost:%d/', port ); 350 | } 351 | 352 | /** 353 | * Spawns a process to run a WP-CLI command in a Docker container. 354 | * 355 | * @param {...string} args The WP-CLI command and arguments to be run. 356 | * 357 | * @return {Promise} Promise that resolves to true if the command succeeded, false if it failed. 358 | */ 359 | function runCLICommand( ...args ) { 360 | return spawn( 'docker-compose', [ 361 | '-f', 362 | 'docker-compose.yml', 363 | '-f', 364 | 'docker-compose.scripts.yml', 365 | 'run', 366 | '--rm', 367 | 'cli', 368 | ...args, 369 | ], { 370 | cwd: TOOLS_DIR, 371 | encoding: 'utf8', 372 | env: { 373 | PATH: process.env.PATH, 374 | ...dockerEnv, 375 | }, 376 | } ) 377 | .then( () => true ) 378 | .catch( ( { stderr } ) => { 379 | debug( stderr.trim() ); 380 | return false; 381 | } ); 382 | } 383 | 384 | /** 385 | * Figure out if the Docker daemon is running. No daemon implies that the user 386 | * needs to install and/or open Docker. 387 | * 388 | * @return {boolean} true if the Docker daemon is running, false if it isn't. 389 | */ 390 | async function detectDockerDaemon() { 391 | try { 392 | await spawn( 'docker', [ 'info' ] ); 393 | return true; 394 | } catch { 395 | return false; 396 | } 397 | } 398 | 399 | /** 400 | * Figure out if we're using Docker Toolbox or not. Uses Docker for Windows' version and Hyper-V 401 | * requirements as a baseline to determine whether Toolbox is being used. 402 | * 403 | * @return {boolean} true if Docker Toolbox is being used, false if it isn't. 404 | */ 405 | async function detectToolbox() { 406 | debug( 'Detecting if we should use Docker Toolbox or not' ); 407 | return await spawn( 'systeminfo', [ 408 | '/FO', 409 | 'CSV', 410 | ], { 411 | encoding: 'utf8', 412 | env: { 413 | PATH: process.env.PATH, 414 | }, 415 | } ) 416 | .then( ( { stdout } ) => csv().fromString( stdout ) ) 417 | .then( ( info ) => { 418 | if ( ! info[ 0 ][ 'OS Name' ].includes( 'Pro' ) ) { 419 | debug( 'Not running Windows Pro' ); 420 | return true; 421 | } 422 | 423 | if ( info[ 0 ][ 'OS Version' ].match( /^\d+/ )[ 0 ] < 10 ) { 424 | debug( 'Not running Windows 10' ); 425 | return true; 426 | } 427 | 428 | if ( info[ 'OS Version' ].match( /\d+$/ )[ 0 ] < 14393 ) { 429 | debug( 'Not running build 14393 or later' ); 430 | return true; 431 | } 432 | 433 | const hyperv = info[ 0 ][ 'Hyper-V Requirements' ].split( ',' ); 434 | 435 | return hyperv.reduce( ( allowed, line ) => { 436 | const [ requirement, enabled ] = line.split( ':' ).map( ( val ) => val.trim().toLowerCase() ); 437 | if ( 'yes' !== enabled ) { 438 | debug( "Don't have Hyper-V requirement \"%s\" available", requirement ); 439 | return false; 440 | } 441 | return allowed; 442 | }, true ); 443 | } ) 444 | .catch( ( { stderr } ) => { 445 | debug( stderr ); 446 | return false; 447 | } ); 448 | } 449 | 450 | /** 451 | * Action handler for when preferences have been saved. 452 | * 453 | * @param {string} section The preferences section that the saved preference is in. 454 | * @param {string} preference The preferences that has been saved. 455 | * @param {*} value The value that the preference has been changed to. 456 | */ 457 | async function preferenceSaved( section, preference, value ) { 458 | let changed = false; 459 | 460 | if ( section === 'basic' && preference === 'wordpress-folder' && value !== cwds[ 'wordpress-folder' ] ) { 461 | changed = true; 462 | } 463 | 464 | if ( section === 'basic' && preference === 'gutenberg-folder' && value !== cwds[ 'gutenberg-folder' ] ) { 465 | changed = true; 466 | } 467 | 468 | if ( section === 'site' && preference === 'port' && value !== port ) { 469 | changed = true; 470 | } 471 | 472 | if ( ! changed ) { 473 | return; 474 | } 475 | 476 | debug( 'Preferences updated' ); 477 | 478 | if ( existsSync( normalize( TOOLS_DIR + '/docker-compose.yml' ) ) ) { 479 | debug( 'Stopping containers' ); 480 | await spawn( 'docker-compose', [ 481 | '-f', 482 | 'docker-compose.yml', 483 | 'down', 484 | ], { 485 | cwd: TOOLS_DIR, 486 | encoding: 'utf8', 487 | env: { 488 | PATH: process.env.PATH, 489 | ...dockerEnv, 490 | }, 491 | } ); 492 | } 493 | 494 | startDocker(); 495 | } 496 | 497 | /** 498 | * Shutdown handler, to ensure the docker containers are shut down cleanly. 499 | */ 500 | function shutdown() { 501 | debug( 'Shutdown, stopping containers' ); 502 | spawn( 'docker-compose', [ 503 | '-f', 504 | 'docker-compose.yml', 505 | 'down', 506 | ], { 507 | cwd: TOOLS_DIR, 508 | detached: true, 509 | encoding: 'utf8', 510 | env: { 511 | PATH: process.env.PATH, 512 | ...dockerEnv, 513 | }, 514 | stdio: [ 'ignore', 'ignore', 'ignore' ], 515 | } ); 516 | } 517 | 518 | module.exports = { 519 | registerDockerJob, 520 | }; 521 | -------------------------------------------------------------------------------- /src/services/docker/php-config.ini: -------------------------------------------------------------------------------- 1 | upload_max_filesize = 1G 2 | post_max_size = 1G 3 | -------------------------------------------------------------------------------- /src/services/docker/phpunit-config.ini: -------------------------------------------------------------------------------- 1 | upload_max_filesize = 1G 2 | post_max_size = 1G 3 | 4 | opcache.enable = 1 5 | opcache.enable_cli = 1 6 | opache.file_cache = /tmp/php-opcache 7 | -------------------------------------------------------------------------------- /src/services/grunt/index.js: -------------------------------------------------------------------------------- 1 | const { spawn } = require( 'child_process' ); 2 | const { existsSync } = require( 'fs' ); 3 | const { watch } = require( 'chokidar' ); 4 | const { addAction, doAction, didAction } = require( '@wordpress/hooks' ); 5 | const debug = require( 'debug' )( 'testpress:services:grunt' ); 6 | const { normalize } = require( 'path' ); 7 | 8 | const { NODE_BIN } = require( '../constants.js' ); 9 | const { preferences } = require( '../../preferences' ); 10 | const { setStatus } = require( '../../utils/status' ); 11 | 12 | let watchProcess = null; 13 | let cwd = ''; 14 | 15 | /** 16 | * Registers a watcher for when Grunt needs to run on the WordPress install. 17 | */ 18 | function registerGruntJob() { 19 | debug( 'Registering job' ); 20 | 21 | addAction( 'npm_install_finished', 'runGruntWatch', runGruntWatch ); 22 | addAction( 'preference_saved', 'preferenceSaved', preferenceSaved, 9 ); 23 | addAction( 'shutdown', 'shutdown', shutdown ); 24 | 25 | cwd = preferences.value( 'basic', 'wordpress-folder' ); 26 | 27 | if ( ! cwd ) { 28 | return; 29 | } 30 | 31 | const gruntfileJs = normalize( cwd + '/Gruntfile.js' ); 32 | 33 | if ( existsSync( gruntfileJs ) ) { 34 | debug( 'Registering Gruntfile.js watcher' ); 35 | watch( gruntfileJs ).on( 'change', () => { 36 | debug( 'Gruntfile.js change detected' ); 37 | runGruntWatch(); 38 | } ); 39 | } 40 | } 41 | 42 | /** 43 | * If the WordPress folder is defined, run `grunt watch` on it. 44 | * 45 | * @param {string=} folderPref The folder preference to try and watch. 46 | */ 47 | function runGruntWatch( folderPref = '' ) { 48 | // We only need to run `grunt watch` on WordPress folder updates. 49 | if ( folderPref && 'wordpress-folder' !== folderPref ) { 50 | return; 51 | } 52 | 53 | setStatus( 'grunt', 'building' ); 54 | 55 | debug( 'Preparing to run `grunt watch`' ); 56 | 57 | if ( watchProcess ) { 58 | debug( 'Ending previous `grunt watch` process' ); 59 | watchProcess.kill(); 60 | watchProcess = null; 61 | } 62 | 63 | if ( ! didAction( 'npm_install_finished' ) ) { 64 | debug( "Bailing, `npm install` isn't finished" ); 65 | return; 66 | } 67 | 68 | if ( ! cwd ) { 69 | debug( "Bailing, WordPress folder isn't set" ); 70 | return; 71 | } 72 | 73 | const grunt = cwd + '/node_modules/grunt/bin/grunt'; 74 | 75 | debug( 'Starting `grunt watch`' ); 76 | watchProcess = spawn( NODE_BIN, [ 77 | grunt, 78 | 'watch', 79 | '--dev', 80 | ], { 81 | cwd, 82 | encoding: 'utf8', 83 | env: {}, 84 | } ); 85 | 86 | let finishedFirstRun = false; 87 | let showedBuilding = true; 88 | 89 | watchProcess.stderr.on( 'data', ( data ) => debug( '`grunt warning` error: %s', data ) ); 90 | watchProcess.stdout.on( 'data', ( data ) => { 91 | const waiting = data.toString().trim().endsWith( 'Waiting...' ); 92 | 93 | if ( finishedFirstRun ) { 94 | if ( waiting ) { 95 | showedBuilding = false; 96 | setStatus( 'grunt', 'ready' ); 97 | debug( 'Ready' ); 98 | } else if ( ! showedBuilding ) { 99 | showedBuilding = true; 100 | setStatus( 'grunt', 'rebuilding' ); 101 | debug( 'Building...' ); 102 | } 103 | } else if ( waiting ) { 104 | setStatus( 'grunt', 'ready' ); 105 | debug( 'Ready' ); 106 | finishedFirstRun = true; 107 | showedBuilding = true; 108 | doAction( 'grunt_watch_first_run_finished' ); 109 | } 110 | } ); 111 | } 112 | 113 | /** 114 | * Action handler for when preferences have been saved. 115 | * 116 | * @param {string} section The preferences section that the saved preference is in. 117 | * @param {string} preference The preferences that has been saved. 118 | * @param {*} value The value that the preference has been changed to. 119 | */ 120 | function preferenceSaved( section, preference, value ) { 121 | if ( section !== 'basic' || preference !== 'wordpress-folder' ) { 122 | return; 123 | } 124 | 125 | if ( value === cwd ) { 126 | return; 127 | } 128 | 129 | debug( 'WordPress folder updated' ); 130 | 131 | cwd = value; 132 | 133 | if ( watchProcess ) { 134 | watchProcess.kill(); 135 | watchProcess = null; 136 | } 137 | } 138 | 139 | /** 140 | * Shutdown handler, to ensure the grunt watcher is stopped. 141 | */ 142 | function shutdown() { 143 | debug( 'Shutdown, stopping `grunt watch` process' ); 144 | if ( watchProcess ) { 145 | watchProcess.kill(); 146 | watchProcess = null; 147 | } 148 | } 149 | 150 | module.exports = { 151 | registerGruntJob, 152 | }; 153 | -------------------------------------------------------------------------------- /src/services/index.js: -------------------------------------------------------------------------------- 1 | const { mkdirSync, existsSync } = require( 'fs' ); 2 | 3 | const { TOOLS_DIR, ARCHIVE_DIR } = require( './constants.js' ); 4 | const { registerDockerJob } = require( './docker' ); 5 | const { registerNodeJob } = require( './node-downloader' ); 6 | const { registerNPMJob } = require( './npm-watcher' ); 7 | const { registerGruntJob } = require( './grunt' ); 8 | 9 | const registerJobs = () => { 10 | if ( ! existsSync( TOOLS_DIR ) ) { 11 | mkdirSync( TOOLS_DIR ); 12 | } 13 | 14 | if ( ! existsSync( ARCHIVE_DIR ) ) { 15 | mkdirSync( ARCHIVE_DIR ); 16 | } 17 | 18 | registerDockerJob(); 19 | registerNodeJob(); 20 | registerNPMJob(); 21 | registerGruntJob(); 22 | }; 23 | 24 | module.exports = { 25 | registerJobs, 26 | TOOLS_DIR, 27 | ARCHIVE_DIR, 28 | }; 29 | -------------------------------------------------------------------------------- /src/services/node-downloader/index.js: -------------------------------------------------------------------------------- 1 | const schedule = require( 'node-schedule' ); 2 | const compareVersions = require( 'compare-versions' ); 3 | const { createWriteStream, mkdirSync, existsSync, readFileSync, unlinkSync } = require( 'fs' ); 4 | const { normalize } = require( 'path' ); 5 | const tar = require( 'tar' ); 6 | const { spawn } = require( 'promisify-child-process' ); 7 | const hasha = require( 'hasha' ); 8 | const { doAction } = require( '@wordpress/hooks' ); 9 | const debug = require( 'debug' )( 'testpress:services:node-downloader' ); 10 | const DecompressZip = require( 'decompress-zip' ); 11 | 12 | const { ARCHIVE_DIR, NODE_DIR, NODE_BIN, NPM_BIN } = require( '../constants' ); 13 | const { fetch, fetchWrite } = require( '../../utils/network' ); 14 | 15 | const NODE_URL = 'https://nodejs.org/dist/latest-dubnium/'; 16 | 17 | const PLATFORM = ( 'win32' === process.platform ) ? 'win' : process.platform; 18 | const ARCH = ( 'x32' === process.arch ) ? 'x86' : process.arch; 19 | const ZIP = ( 'win32' === process.platform ) ? 'zip' : 'tar.gz'; 20 | 21 | const VERSION_REGEX = new RegExp( `href="(node-v([0-9\\.]+)-${ PLATFORM }-${ ARCH }\\.${ ZIP })` ); 22 | 23 | /** 24 | * Registers a check for new versions of Node every 12 hours. 25 | */ 26 | function registerNodeJob() { 27 | debug( 'Registering job' ); 28 | const rule = new schedule.RecurrenceRule(); 29 | rule.hour = [ 7, 19 ]; 30 | rule.minute = 0; 31 | 32 | schedule.scheduleJob( rule, checkAndInstallUpdates ); 33 | checkAndInstallUpdates(); 34 | } 35 | 36 | /** 37 | * Checks for a new version of Node, and installs it if needed. 38 | * 39 | * @return {boolean} false if the download failed. 40 | */ 41 | async function checkAndInstallUpdates() { 42 | debug( 'Checking for updates' ); 43 | const currentVersion = await getLocalVersion(); 44 | const remoteVersion = await getRemoteVersion(); 45 | 46 | if ( ! remoteVersion ) { 47 | debug( 'Unable to fetch remote version, bailing before install' ); 48 | // No point continuing if we can't find a remote version. If we have a local version, 49 | // we can trigger the next service and see if they can do anything. 50 | if ( currentVersion ) { 51 | triggerNextService(); 52 | } 53 | return false; 54 | } 55 | 56 | debug( 'Installed version: %s, remote version: %s', currentVersion, remoteVersion.version ); 57 | 58 | if ( ! currentVersion || compareVersions( remoteVersion.version, currentVersion ) > 0 ) { 59 | debug( 'Newer version found, starting install...' ); 60 | const filename = normalize( ARCHIVE_DIR + '/' + remoteVersion.filename ); 61 | 62 | if ( ! await checksumLocalArchive( remoteVersion.filename, remoteVersion.version ) ) { 63 | const url = NODE_URL + remoteVersion.filename; 64 | 65 | debug( 'Downloading from %s', url ); 66 | 67 | const writeFile = createWriteStream( 68 | filename, { 69 | encoding: 'binary', 70 | } ); 71 | 72 | const downloadedNode = await fetchWrite( url, writeFile ); 73 | 74 | if ( ! downloadedNode ) { 75 | debug( 'Download failed, bailing before install' ); 76 | triggerNextService(); 77 | return false; 78 | } 79 | 80 | debug( 'Finished downloading' ); 81 | 82 | if ( ! await checksumLocalArchive( remoteVersion.filename, remoteVersion.version ) ) { 83 | debug( 'Checksum failed, bailing before install' ); 84 | triggerNextService(); 85 | return false; 86 | } 87 | } 88 | 89 | if ( ! existsSync( NODE_DIR ) ) { 90 | debug( 'Creating node directory %s', NODE_DIR ); 91 | mkdirSync( NODE_DIR ); 92 | } 93 | 94 | debug( 'Extracting new version from %s to %s', filename, NODE_DIR ); 95 | 96 | if ( 'win32' === process.platform ) { 97 | unzipper = new DecompressZip( filename ); 98 | 99 | await new Promise( ( resolve, reject ) => { 100 | unzipper.on( 'extract', resolve ); 101 | unzipper.on( 'error', reject ); 102 | 103 | unzipper.extract( { 104 | path: NODE_DIR, 105 | strip: 1, 106 | filter: ( file ) => file.type !== 'Directory', 107 | } ); 108 | } ); 109 | } else { 110 | await tar.extract( { 111 | file: filename, 112 | cwd: NODE_DIR, 113 | strip: 1, 114 | onwarn: ( msg ) => console.log( msg ), 115 | } ); 116 | } 117 | } 118 | 119 | updateNPM(); 120 | 121 | return true; 122 | } 123 | 124 | /** 125 | * Get the version of the local install of Node. If there isn't a copy of Node installed, 126 | * it returns a generic version number of '0.0.0', to ensure version comparisons will assume it's outdated. 127 | * 128 | * @return {string|boolean} The version number of the local copy of node, or false if the version couldn't be retrieved. 129 | */ 130 | async function getLocalVersion() { 131 | if ( ! existsSync( NODE_BIN ) ) { 132 | return false; 133 | } 134 | 135 | const versionInfo = await spawn( NODE_BIN, [ '-v' ], { encoding: 'utf8' } ); 136 | 137 | return versionInfo.stdout.toString().replace( 'v', '' ).trim(); 138 | } 139 | 140 | /** 141 | * Retrieves the version (and filename) of the latest remote version of Node. 142 | * 143 | * @return {Object|boolean} Object containing the `version` and `filename`, or false if the version couldn't be fetched. 144 | */ 145 | async function getRemoteVersion() { 146 | const remotels = await fetch( NODE_URL ); 147 | 148 | if ( ! remotels ) { 149 | debug( 'Unable to fetch remote directory' ); 150 | return false; 151 | } 152 | 153 | const versionInfo = VERSION_REGEX.exec( remotels ); 154 | 155 | if ( ! versionInfo ) { 156 | debug( 'Remote version regex (%s) failed on html:\n%s\n%O', VERSION_REGEX, remotels, versionInfo ); 157 | return false; 158 | } 159 | 160 | return { 161 | version: versionInfo[ 2 ], 162 | filename: versionInfo[ 1 ], 163 | }; 164 | } 165 | 166 | /** 167 | * Checksums the local copy of the Node archive against the official checksums. 168 | * 169 | * @param {string} filename The filename to be using for checks. 170 | * @param {string} version The Node version corresponding to the archive. 171 | * 172 | * @return {boolean} True if the checksum matches, false if it doesn't. 173 | */ 174 | async function checksumLocalArchive( filename, version ) { 175 | const archiveFilename = normalize( ARCHIVE_DIR + '/' + filename ); 176 | const checksumFilename = normalize( ARCHIVE_DIR + '/' + `node${ version }-SHASUMS256.txt` ); 177 | 178 | if ( ! existsSync( archiveFilename ) ) { 179 | debug( "Checksum file doesn't exist" ); 180 | return false; 181 | } 182 | 183 | if ( ! existsSync( checksumFilename ) ) { 184 | debug( 'Downloading latest checksum file' ); 185 | const writeFile = createWriteStream( checksumFilename ); 186 | 187 | const downloadedChecksums = await fetchWrite( NODE_URL + 'SHASUMS256.txt', writeFile ); 188 | 189 | if ( ! downloadedChecksums ) { 190 | debug( 'Unable to download checksum file' ); 191 | return false; 192 | } 193 | } 194 | 195 | debug( 'Checking checksum of the local archive against checksum file' ); 196 | const localSum = hasha.fromFileSync( archiveFilename, { algorithm: 'sha256' } ); 197 | const checksums = readFileSync( checksumFilename ).toString(); 198 | 199 | const passed = checksums.split( '\n' ).reduce( ( allowed, line ) => { 200 | const [ checksum, checkname ] = line.split( /\s+/ ).map( ( value ) => value.trim() ); 201 | if ( checkname === filename && checksum === localSum ) { 202 | return true; 203 | } 204 | return allowed; 205 | }, false ); 206 | 207 | if ( passed ) { 208 | debug( 'Checksum passed' ); 209 | } else { 210 | debug( 'Checksum failed' ); 211 | } 212 | 213 | return passed; 214 | } 215 | 216 | /** 217 | * Install the latest version of NPM in our local copy of Node. 218 | */ 219 | async function updateNPM() { 220 | debug( 'Preparing to update npm' ); 221 | if ( ! existsSync( NODE_BIN ) ) { 222 | debug( "Bailing, couldn't find node binary" ); 223 | return; 224 | } 225 | 226 | const npmIsUpdated = await spawn( NODE_BIN, [ 227 | NPM_BIN, 228 | 'outdated', 229 | '-g', 230 | 'npm', 231 | ], { 232 | env: {}, 233 | } ).then( () => true ).catch( () => false ); 234 | 235 | if ( npmIsUpdated ) { 236 | debug( 'npm running latest version' ); 237 | triggerNextService(); 238 | return; 239 | } 240 | 241 | const requiredPackages = [ 'npm' ]; 242 | 243 | if ( 'win32' === process.platform ) { 244 | debug( 'Deleting npm files' ); 245 | // Windows needs these files removed before NPM will update. 246 | [ 'npm', 'npm.cmd', 'npx', 'npx.cmd' ].forEach( ( file ) => { 247 | const path = normalize( NODE_DIR + '/' + file ); 248 | try { 249 | unlinkSync( path ); 250 | } catch ( error ) {} 251 | } ); 252 | 253 | // Windows can't deal with long file paths, so needs to be flattened. 254 | requiredPackages.push( 'flatten-packages' ); 255 | } 256 | 257 | debug( 'Starting npm update' ); 258 | await spawn( NODE_BIN, [ 259 | NPM_BIN, 260 | 'install', 261 | '-g', 262 | ...requiredPackages, 263 | ], { 264 | env: {}, 265 | } ); 266 | 267 | debug( 'npm updated' ); 268 | 269 | if ( 'win32' === process.platform ) { 270 | debug( 'Flattening node packages' ); 271 | 272 | await spawn( NODE_BIN, [ 273 | normalize( NODE_DIR + '/node_modules/flatten-packages/bin/flatten' ), 274 | ], { 275 | cwd: NODE_DIR, 276 | env: {}, 277 | } ); 278 | 279 | debug( 'Packages flattened' ); 280 | } 281 | 282 | triggerNextService(); 283 | } 284 | 285 | function triggerNextService() { 286 | doAction( 'updated_node_and_npm' ); 287 | } 288 | 289 | module.exports = { 290 | registerNodeJob, 291 | }; 292 | -------------------------------------------------------------------------------- /src/services/npm-watcher/index.js: -------------------------------------------------------------------------------- 1 | const { spawn } = require( 'child_process' ); 2 | const { mkdirSync, existsSync } = require( 'fs' ); 3 | const { watch } = require( 'chokidar' ); 4 | const { normalize } = require( 'path' ); 5 | const { addAction, doAction, didAction } = require( '@wordpress/hooks' ); 6 | const process = require( 'process' ); 7 | const debug = require( 'debug' )( 'testpress:services:npm-watcher' ); 8 | 9 | const { NPM_CACHE_DIR, NODE_BIN, NPM_BIN } = require( '../constants.js' ); 10 | const { preferences } = require( '../../preferences' ); 11 | 12 | const installProcesses = { 13 | 'wordpress-folder': null, 14 | 'gutenberg-folder': null, 15 | }; 16 | let devProcess = null; 17 | const cwds = { 18 | 'wordpress-folder': '', 19 | 'gutenberg-folder': '', 20 | }; 21 | 22 | /** 23 | * Registers a watcher for when NPM needs to run on the WordPress install. 24 | */ 25 | function registerNPMJob() { 26 | debug( 'Registering job' ); 27 | addAction( 'preference_saved', 'preferenceSaved', preferenceSaved, 10 ); 28 | addAction( 'npm_install_finished', 'runNPMDev', runNPMDev ); 29 | addAction( 'shutdown', 'shutdown', shutdown ); 30 | 31 | Object.keys( cwds ).forEach( ( folderPref ) => { 32 | addAction( 'updated_node_and_npm', 'runNPMInstall', () => runNPMInstall( folderPref ) ); 33 | 34 | cwds[ folderPref ] = preferences.value( 'basic', folderPref ); 35 | if ( ! cwds[ folderPref ] ) { 36 | return; 37 | } 38 | 39 | const packageJson = normalize( cwds[ folderPref ] + '/package.json' ); 40 | 41 | if ( existsSync( packageJson ) ) { 42 | debug( '(%s) Registering package.json watcher', folderPref ); 43 | watch( packageJson ).on( 'change', () => { 44 | debug( '(%s) package.json change detected', folderPref ); 45 | runNPMInstall( folderPref ); 46 | } ); 47 | } 48 | } ); 49 | } 50 | 51 | /** 52 | * If the WordPress folder is defined, run `npm install` in it. 53 | */ 54 | function runNPMInstall( folderPref ) { 55 | debug( '(%s) Preparing for `npm install`', folderPref ); 56 | if ( installProcesses[ folderPref ] ) { 57 | debug( '(%s) Ending previous `npm install` process', folderPref ); 58 | installProcesses[ folderPref ].kill(); 59 | installProcesses[ folderPref ] = null; 60 | } 61 | 62 | if ( ! didAction( 'updated_node_and_npm' ) ) { 63 | debug( "(%s) Bailing, node hasn't finished installing", folderPref ); 64 | return; 65 | } 66 | 67 | if ( ! cwds[ folderPref ] ) { 68 | debug( "(%s) Bailing, folder isn't set", folderPref ); 69 | return; 70 | } 71 | 72 | if ( ! existsSync( NPM_CACHE_DIR ) ) { 73 | debug( '(%s) Creating npm cache directory %s', folderPref, NPM_CACHE_DIR ); 74 | mkdirSync( NPM_CACHE_DIR ); 75 | } 76 | 77 | debug( '(%s) Starting `npm install`', folderPref ); 78 | installProcesses[ folderPref ] = spawn( NODE_BIN, [ 79 | NPM_BIN, 80 | 'install', 81 | '--scripts-prepend-node-path=true', 82 | ], { 83 | cwd: cwds[ folderPref ], 84 | encoding: 'utf8', 85 | env: { 86 | npm_config_cache: NPM_CACHE_DIR, 87 | PATH: process.env.PATH, 88 | }, 89 | } ); 90 | 91 | installProcesses[ folderPref ].stderr.on( 'data', ( data ) => { 92 | debug( '(%s) `npm install` error: %s', folderPref, data ); 93 | } ); 94 | 95 | installProcesses[ folderPref ].on( 'exit', ( code ) => { 96 | if ( 0 !== code ) { 97 | debug( '(%s) `npm install` finished with an error', folderPref ); 98 | return; 99 | } 100 | debug( '(%s) `npm install` finished', folderPref ); 101 | doAction( 'npm_install_finished', folderPref ); 102 | } ); 103 | } 104 | 105 | function runNPMDev( folderPref ) { 106 | if ( 'gutenberg-folder' !== folderPref ) { 107 | return; 108 | } 109 | 110 | debug( 'Preparing to run `npm run dev`' ); 111 | 112 | if ( devProcess ) { 113 | debug( 'Ending previous `npm run dev` process' ); 114 | devProcess.kill(); 115 | devProcess = null; 116 | } 117 | 118 | if ( ! didAction( 'npm_install_finished' ) ) { 119 | debug( "Bailing, `npm install` isn't finished" ); 120 | return; 121 | } 122 | 123 | if ( ! cwds[ 'gutenberg-folder' ] ) { 124 | debug( "Bailing, Gutenberg folder isn't set" ); 125 | return; 126 | } 127 | 128 | debug( 'Starting `npm run dev`' ); 129 | devProcess = spawn( NODE_BIN, [ 130 | NPM_BIN, 131 | 'run', 132 | 'dev', 133 | ], { 134 | cwd: cwds[ 'gutenberg-folder' ], 135 | encoding: 'utf8', 136 | env: {}, 137 | } ); 138 | } 139 | 140 | /** 141 | * Action handler for when preferences have been saved. 142 | * 143 | * @param {string} section The preferences section that the saved preference is in. 144 | * @param {string} preference The preferences that has been saved. 145 | * @param {*} value The value that the preference has been changed to. 146 | */ 147 | function preferenceSaved( section, preference, value ) { 148 | if ( section !== 'basic' || ( preference !== 'wordpress-folder' && preference !== 'gutenberg-folder' ) ) { 149 | return; 150 | } 151 | 152 | if ( value === cwds[ preference ] ) { 153 | return; 154 | } 155 | 156 | debug( `${ preference } updated` ); 157 | 158 | cwds[ preference ] = value; 159 | 160 | if ( 'gutenberg-folder' === preference && devProcess ) { 161 | devProcess.kill(); 162 | devProcess = null; 163 | } 164 | 165 | runNPMInstall( preference ); 166 | } 167 | 168 | /** 169 | * Shutdown handler, to ensure the Gutenberg watcher is stopped. 170 | */ 171 | function shutdown() { 172 | debug( 'Shutdown, stopping `npm run dev` process' ); 173 | if ( devProcess ) { 174 | devProcess.kill(); 175 | devProcess = null; 176 | } 177 | } 178 | 179 | module.exports = { 180 | registerNPMJob, 181 | }; 182 | -------------------------------------------------------------------------------- /src/utils/network.js: -------------------------------------------------------------------------------- 1 | const debug = require( 'debug' )( 'testpress:utils:network' ); 2 | const nodeFetch = require( 'node-fetch' ); 3 | const promisePipe = require( 'promisepipe' ); 4 | 5 | /** 6 | * Fetches a URL, and returns the content. 7 | * 8 | * @param {string} url The URL to fetch. 9 | * 10 | * @return {Promise} A Promise that resolves to the content from the URL, or false if the fetch failed. 11 | */ 12 | async function fetch( url ) { 13 | return await nodeFetch( url ) 14 | .then( ( res ) => { 15 | return res.text(); 16 | } ) 17 | .catch( ( error ) => { 18 | debug( 'Unable to fetch %s: %s', url, error ); 19 | return false; 20 | } ); 21 | } 22 | 23 | /** 24 | * Fetches a URL, and writes it to a fileStream. 25 | * 26 | * @param {string} url The URL to fetch. 27 | * @param {tty.WriteStream} fileStream The file to write to. 28 | * 29 | * @return {Promise} A Promise that resolves to true if the fetch succeeded, false if it didn't. 30 | */ 31 | async function fetchWrite( url, fileStream ) { 32 | return await nodeFetch( url ) 33 | .then( ( res ) => promisePipe( res.body, fileStream ) ) 34 | .then( () => true ) 35 | .catch( ( error ) => { 36 | debug( 'Unable to fetch %s: %s', url, error ); 37 | return false; 38 | } ); 39 | } 40 | 41 | module.exports = { 42 | fetch, 43 | fetchWrite, 44 | }; 45 | -------------------------------------------------------------------------------- /src/utils/status.js: -------------------------------------------------------------------------------- 1 | const { ipcMain } = require( 'electron' ); 2 | const debug = require( 'debug' )( 'testpress:utils:status' ); 3 | 4 | let statusWindow; 5 | 6 | const statuses = {}; 7 | 8 | /** 9 | * Set the BrowserWindow that receives the status message. 10 | * 11 | * @param {BrowserWindow} window The window to use. 12 | */ 13 | function setStatusWindow( window ) { 14 | statusWindow = window; 15 | } 16 | 17 | /** 18 | * Send a status message to the status window. 19 | * 20 | * @param {string} service The service which has had a status change. 21 | * @param {string} status The new status of the service. 22 | */ 23 | function setStatus( service, status ) { 24 | if ( ! statusWindow ) { 25 | debug( 'setStatus() called before setStatusWindow, with service "%s", status "%s"', service, status ); 26 | return; 27 | } 28 | 29 | statuses[ service ] = status; 30 | 31 | statusWindow.send( 'status', statuses ); 32 | } 33 | 34 | /** 35 | * Gets the current statuses. 36 | * 37 | * @return {Object} An object which maps service names to the status of that service. 38 | */ 39 | function getStatuses() { 40 | return statuses; 41 | } 42 | 43 | ipcMain.on( 'getStatuses', ( event ) => { 44 | event.returnValue = getStatuses(); 45 | } ); 46 | 47 | module.exports = { 48 | setStatusWindow, 49 | setStatus, 50 | getStatuses, 51 | }; 52 | --------------------------------------------------------------------------------