diff --git a/README.md b/README.md index 14559a1f..9f5436ec 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ How the app works from a user's point of view — the toolchain it bundles, keep - Integrate Playground's XDebug. - Explore bundling MySQL server with the app. -- Potentially integrate with Studio to benefit from PHP version selector, wp-cli integration and other Studio features. +- Potentially integrate with Studio to benefit from wp-cli integration and other Studio features. - Resolve conflicts in the app. Branches, commits and pushes are already handled for each work item, but when a work item's branch cannot move onto current trunk, the app still hands it to a mentor. - Internationalization: the app's interface available in several languages, not only English. diff --git a/docs/guide/logs-and-debugging.md b/docs/guide/logs-and-debugging.md index 33a251e5..4fd77c37 100644 --- a/docs/guide/logs-and-debugging.md +++ b/docs/guide/logs-and-debugging.md @@ -18,14 +18,14 @@ All three panes read in the terminal's own monospace font, so the columns of a P ## The debug.log tab -Anything WordPress or your code writes to the PHP error log — `error_log()` calls, notices, warnings, deprecations, `_doing_it_wrong()`, fatals — appears here while the dev server runs. This works because every site is booted with WordPress's debug constants already set. They are not configurable: +Anything WordPress or your code writes to the PHP error log — `error_log()` calls, notices, warnings, deprecations, `_doing_it_wrong()`, fatals — appears here while the dev server runs. This works because every site is booted with WordPress's debug constants already set. Two of them, `WP_DEBUG` and `SCRIPT_DEBUG`, can be turned off under **Sites** in [Settings](./settings); the rest are not configurable: | Constant | Value | Effect | | --- | --- | --- | -| `WP_DEBUG` | `true` | Notices, warnings, and deprecations are reported. | +| `WP_DEBUG` | `true` | Notices, warnings, and deprecations are reported. Off in Settings, notices and deprecations are not; warnings and errors still reach the log and the browser, and `error_log()` calls still reach the log. | | `WP_DEBUG_LOG` | `true` | They are written to `wp-content/debug.log`, which this panel tails. | | `WP_DEBUG_DISPLAY` | `true` | Errors are also printed in the browser. | -| `SCRIPT_DEBUG` | `true` | Core serves unminified JS and CSS. | +| `SCRIPT_DEBUG` | `true` | Core serves unminified JS and CSS. Can be turned off in Settings. | | `WP_DISABLE_FATAL_ERROR_HANDLER` | `true` | A fatal shows the actual error instead of WordPress's "critical error" recovery screen. | | `AUTOMATIC_UPDATER_DISABLED` | `true` | Core's automatic updater does not run (and does not fill the log with its own messages). | diff --git a/docs/guide/settings.md b/docs/guide/settings.md index 77f7a7b2..0e572c4f 100644 --- a/docs/guide/settings.md +++ b/docs/guide/settings.md @@ -7,6 +7,14 @@ The app's settings are in one dialog, opened from the cog at the bottom right of - **Language** — which language the app is shown in: your system's language, which is the default, or one of the languages the app has a translation for. A change applies after a relaunch, which the dialog offers; relaunching stops running servers and builds, as quitting does. Translations come from [translate.wordpress.org](https://translate.wordpress.org/projects/meta/contributor-toolkit/) and ship with the app once they are mostly complete, so the list grows from release to release. - **New sites go here** — the folder new sites are created in, each in a subfolder of its own. With one set, the [create-site dialog](./creating-a-site) starts on it; you can still pick another folder for one site without changing the setting. **Forget this folder** clears it, and the dialog goes back to asking each time. A folder that no longer exists is refused when you choose it. +## Sites + +What every site's development server runs with. A change applies the next time a server starts; one that is running keeps what it started with until you stop and start it. The open site's details, in the right-hand column, show the PHP version beside the checkout and which debug constants are on. + +- **PHP version** — the PHP the site runs on, from the versions the bundled WordPress Playground has. 8.3 unless you choose another. +- **Report notices and deprecations (WP_DEBUG)** — on, notices and deprecations are reported along with warnings and errors, written to `debug.log` and shown in the browser; see [Logs and debugging](./logs-and-debugging). Off, notices and deprecations are not reported; warnings and errors still reach `debug.log` and the browser, and `error_log()` calls still reach `debug.log`: the app's PHP keeps logging and display on whatever WP_DEBUG says. +- **Use unminified scripts (SCRIPT_DEBUG)** — on, Core serves its JavaScript and CSS unminified. + ## Account - **WordPress.org username** and **Event** — who a patch you [hand to a mentor](./submit-mentor) says it is from, and where it was written. The same two answers the handoff asks for, so changing them here changes them there. Leave the event empty when you are not at one. A profile link pasted as the username is kept as the username it names. diff --git a/docs/public/screenshots/create-site-modal.png b/docs/public/screenshots/create-site-modal.png index f833e7a8..6f4d9108 100644 Binary files a/docs/public/screenshots/create-site-modal.png and b/docs/public/screenshots/create-site-modal.png differ diff --git a/docs/public/screenshots/debug-log.png b/docs/public/screenshots/debug-log.png index c8de269b..e54590e9 100644 Binary files a/docs/public/screenshots/debug-log.png and b/docs/public/screenshots/debug-log.png differ diff --git a/docs/public/screenshots/dev-server-running.png b/docs/public/screenshots/dev-server-running.png index f232efeb..69022f8b 100644 Binary files a/docs/public/screenshots/dev-server-running.png and b/docs/public/screenshots/dev-server-running.png differ diff --git a/docs/public/screenshots/empty-state.png b/docs/public/screenshots/empty-state.png index fab26390..53206eeb 100644 Binary files a/docs/public/screenshots/empty-state.png and b/docs/public/screenshots/empty-state.png differ diff --git a/docs/public/screenshots/gutenberg-site-view.png b/docs/public/screenshots/gutenberg-site-view.png index c015d482..6bc46b60 100644 Binary files a/docs/public/screenshots/gutenberg-site-view.png and b/docs/public/screenshots/gutenberg-site-view.png differ diff --git a/docs/public/screenshots/linked-pull-requests.png b/docs/public/screenshots/linked-pull-requests.png index 7f1cbe33..8c555912 100644 Binary files a/docs/public/screenshots/linked-pull-requests.png and b/docs/public/screenshots/linked-pull-requests.png differ diff --git a/docs/public/screenshots/mail-panel.png b/docs/public/screenshots/mail-panel.png index 4ced7cea..ab752298 100644 Binary files a/docs/public/screenshots/mail-panel.png and b/docs/public/screenshots/mail-panel.png differ diff --git a/docs/public/screenshots/site-menu.png b/docs/public/screenshots/site-menu.png index 7209a419..a5bfded7 100644 Binary files a/docs/public/screenshots/site-menu.png and b/docs/public/screenshots/site-menu.png differ diff --git a/docs/public/screenshots/site-view.png b/docs/public/screenshots/site-view.png index 2e75af53..15861685 100644 Binary files a/docs/public/screenshots/site-view.png and b/docs/public/screenshots/site-view.png differ diff --git a/docs/public/screenshots/site-with-tickets.png b/docs/public/screenshots/site-with-tickets.png index 7fcc4334..ed0c8f75 100644 Binary files a/docs/public/screenshots/site-with-tickets.png and b/docs/public/screenshots/site-with-tickets.png differ diff --git a/docs/public/screenshots/stale-site-notice.png b/docs/public/screenshots/stale-site-notice.png index cf782f2d..4378063f 100644 Binary files a/docs/public/screenshots/stale-site-notice.png and b/docs/public/screenshots/stale-site-notice.png differ diff --git a/docs/public/screenshots/trac-ticket-panel.png b/docs/public/screenshots/trac-ticket-panel.png index 15d2c9b7..556e4c45 100644 Binary files a/docs/public/screenshots/trac-ticket-panel.png and b/docs/public/screenshots/trac-ticket-panel.png differ diff --git a/scripts/screenshots/shots.cjs b/scripts/screenshots/shots.cjs index 8bb9e1f1..61acd811 100644 --- a/scripts/screenshots/shots.cjs +++ b/scripts/screenshots/shots.cjs @@ -212,6 +212,9 @@ const shots = [ { slug: 'gutenberg-site-view', tier: 'fixture', + // The details have grown a row (#559): tall enough for their Build + // watch section to be whole. + viewport: { width: 1200, height: 900 }, variant: 'gutenberg', prepare: async (page) => { await selectSite(page, 'my-gutenberg-fix'); @@ -231,6 +234,9 @@ const shots = [ { slug: 'site-menu', tier: 'fixture', + // The details have grown a row (#559): tall enough for their Build + // watch section to be whole. + viewport: { width: 1200, height: 900 }, variant: 'seeded', prepare: async (page) => { await selectSite(page, 'my-first-patch'); @@ -364,6 +370,9 @@ const shots = [ { slug: 'site-view', tier: 'fixture', + // The details have grown a row (#559): tall enough for their Build + // watch section to be whole. + viewport: { width: 1200, height: 900 }, variant: 'seeded', prepare: async (page) => { await openReadySite(page); diff --git a/src/main.js b/src/main.js index 3c7be6a5..854ca3c4 100644 --- a/src/main.js +++ b/src/main.js @@ -722,6 +722,22 @@ function languages() { ipcMain.handle('i18n:languages', async () => ({ ok: true, languages: await languages() })); +// The PHP versions the bundled Playground can run a site on (#559): what +// its own module says, read once it is asked for. The module is the server +// runner's and not otherwise main's, so it is loaded then and not at start. +let phpVersionsList = null; +function phpVersions() { + if (!phpVersionsList) { + // Not a declared dependency: it is @wp-playground/cli's, at whatever + // version that package pins, which is the one the runner serves with. + const { SupportedPHPVersions } = require('@php-wasm/universal'); + phpVersionsList = [...SupportedPHPVersions]; + } + return phpVersionsList; +} + +ipcMain.handle('playground:php-versions', () => ({ ok: true, versions: phpVersions(), fallback: SETTINGS.phpVersion.fallback })); + // What the settings dialog offers after the language is changed. `quit`, not // `exit`: the quit sweep ends every child the app started, as it does on any // quit, and the relaunch is a quit. The new instance gets this one's @@ -3501,7 +3517,8 @@ ipcMain.handle('settings:set', async (_e, key, value) => { const accepted = acceptSetting(key, value, { isAbsolute: path.isAbsolute, isDirectory: () => directory, - isLanguage: (tag) => known.some((language) => language.tag === tag) + isLanguage: (tag) => known.some((language) => language.tag === tag), + isPhpVersion: (version) => phpVersions().includes(version) }); if (!accepted.ok) return { ok: false, error: accepted.error }; await setPreference(key, accepted.value); @@ -3834,13 +3851,25 @@ ipcMain.handle('playground:start', async (event, sitePath) => { const serve = projectTypeForSite(await readSiteMeta(sitePath)).serve; const isPluginMount = serve.strategy === 'plugin-mount'; const buildDir = path.join(sitePath, 'build'); - const serveConfig = isPluginMount - ? { strategy: 'plugin-mount', pluginDir: sitePath, pluginSlug: serve.pluginSlug } - : { strategy: 'docroot', docroot: buildDir }; + // The PHP version and the debug flags the settings hold (#559), read at + // each start so a change applies to the next. A version the bundled + // Playground no longer has, after a bump, is passed over for the fallback. + const logScope = playgroundLogScope(sitePath); + const settings = readSettings((await getStore()).get('preferences')); + const phpVersion = phpVersions().includes(settings.phpVersion) ? settings.phpVersion : SETTINGS.phpVersion.fallback; + if (phpVersion !== settings.phpVersion) { + logEvent(logScope, `PHP ${settings.phpVersion} is set but this build does not have it; starting on PHP ${phpVersion}`); + } + const serveConfig = { + ...(isPluginMount + ? { strategy: 'plugin-mount', pluginDir: sitePath, pluginSlug: serve.pluginSlug } + : { strategy: 'docroot', docroot: buildDir }), + phpVersion, + debug: { wpDebug: settings.wpDebug, scriptDebug: settings.scriptDebug } + }; const serveCwd = isPluginMount ? sitePath : buildDir; const runnerPath = path.join(__dirname, 'server-runner.js'); - const logScope = playgroundLogScope(sitePath); - logEvent(logScope, `starting ${serve.strategy} server for ${serveCwd} (smtp port ${(smtp && smtp.port) ? smtp.port : 25})`); + logEvent(logScope, `starting ${serve.strategy} server for ${serveCwd} on PHP ${phpVersion}, WP_DEBUG ${settings.wpDebug ? 'on' : 'off'}, SCRIPT_DEBUG ${settings.scriptDebug ? 'on' : 'off'} (smtp port ${(smtp && smtp.port) ? smtp.port : 25})`); const child = spawnRunner(runnerPath, [JSON.stringify(serveConfig)], { cwd: serveCwd, extraEnv: { diff --git a/src/preload.js b/src/preload.js index 7bbb835a..a5f4226b 100644 --- a/src/preload.js +++ b/src/preload.js @@ -110,6 +110,9 @@ contextBridge.exposeInMainWorld('api', { listLanguages: () => ipcRenderer.invoke('i18n:languages') , relaunch: () => ipcRenderer.invoke('app:relaunch') +, + // The PHP versions a site's server can run on, for the settings. + listPhpVersions: () => ipcRenderer.invoke('playground:php-versions') , // The menu's "Settings…" asked for the dialog. subscribeSettingsOpen: (handler) => { diff --git a/src/renderer/components/settings-dialog.jsx b/src/renderer/components/settings-dialog.jsx index c4e9b39b..b82077b8 100644 --- a/src/renderer/components/settings-dialog.jsx +++ b/src/renderer/components/settings-dialog.jsx @@ -1,7 +1,12 @@ import { useEffect, useId, useMemo, useState } from 'react'; +// The segmented control the design has for a choice of a few. The design +// system has no other, and documents this one under these names: it is +// stable in use and has not been given its final export yet. +// eslint-disable-next-line @wordpress/no-unsafe-wp-apis -- see above. +import { __experimentalToggleGroupControl as ToggleGroupControl, __experimentalToggleGroupControlOption as ToggleGroupControlOption } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; -import { Button, Dialog, InputControl, Notice, SelectControl, Stack, Tabs, Text } from '@wordpress/ui'; -import { githubAccountLine, newSiteLocationNote, languageItems, languageValue, languageChanged, SYSTEM_LANGUAGE } from '../settings-view.cjs'; +import { Button, Dialog, InputControl, Notice, SelectControl, Stack, SwitchControl, Tabs, Text } from '@wordpress/ui'; +import { githubAccountLine, newSiteLocationNote, languageItems, languageValue, languageChanged, phpVersionChoice, SYSTEM_LANGUAGE } from '../settings-view.cjs'; import { FolderField } from './folder-field.jsx'; // A notice here is read by its role, and is not also spoken: the dialog it @@ -128,6 +133,63 @@ function GeneralTab({ settings, loaded, onChange }) { ); } +// What a site's development server runs with: the PHP it runs on, from the +// versions the bundled Playground has, and the two debug constants that can +// be turned off. Applied the next time a server starts; one that is running +// keeps what it started with until it is started again. +function SitesTab({ settings, php, onChange }) { + const [error, setError] = useState(''); + + const keep = async (key, value) => { + const result = await onChange(key, value); + setError(result?.ok ? '' : (result?.error || __('Could not keep that.'))); + }; + const choice = phpVersionChoice({ versions: php?.versions, fallback: php?.fallback, stored: settings ? settings.phpVersion : null }); + // The versions could not be read: said in place of a control with nothing + // on it, and the version stays as it is. + const unread = php?.error ? __('The PHP versions could not be read. Quit the app and open it again.') : ''; + + return ( + + }>{__('Development server')} + {__('Applies the next time a site’s server starts. A server that is running keeps what it started with.')} + { if (value) keep('phpVersion', value); }} + > + {(php?.versions || []).map((version) => ( + + ))} + + keep('wpDebug', checked)} + /> + keep('scriptDebug', checked)} + /> + {error ? ( + + {error} + + ) : null} + + ); +} + // Who the contributor is, as the mentor handoff asks it (#166) and through // the same answers (useContributorProvenance), so that the two never // disagree; and the GitHub account the app acts for (#167), which is signed @@ -241,7 +303,7 @@ function AccountTab({ wporg }) { // The tabs and what is on each. Inside the dialog's popup, which is there // while the dialog is open and not otherwise, so every opening starts on // General with nothing typed and not yet saved. -function SettingsPanels({ settings, loaded, onChange, wporg }) { +function SettingsPanels({ settings, loaded, php, onChange, wporg }) { const [tab, setTab] = useState('general'); return ( @@ -249,6 +311,7 @@ function SettingsPanels({ settings, loaded, onChange, wporg }) {
{__('General')} + {__('Sites')} {__('Account')}
@@ -256,6 +319,9 @@ function SettingsPanels({ settings, loaded, onChange, wporg }) { + + + @@ -278,11 +344,12 @@ function SettingsPanels({ settings, loaded, onChange, wporg }) { * @param {boolean} props.open Whether the dialog is open. * @param {?Object} props.settings The settings, or null while they are read. * @param {?Object} props.loaded The settings as the window first read them, for what takes a relaunch. + * @param {?Object} props.php The PHP versions the bundle has and the fallback (useSettings), null while unread, `{ error }` when it could not be. * @param {Function} props.onChange Changes one setting; resolves to `{ ok, settings }` or `{ ok: false, error }`. * @param {Object} props.wporg The contributor's details and how to change them. * @param {Function} props.onClose Asked for by the close button, Escape, or a press outside. */ -export function SettingsDialog({ open, settings, loaded, onChange, wporg, onClose }) { +export function SettingsDialog({ open, settings, loaded, php, onChange, wporg, onClose }) { return ( { if (!next) onClose(); }}> @@ -290,7 +357,7 @@ export function SettingsDialog({ open, settings, loaded, onChange, wporg, onClos {__('Settings')} - + ); diff --git a/src/renderer/hooks/use-settings.jsx b/src/renderer/hooks/use-settings.jsx index 60534d03..6e2e534d 100644 --- a/src/renderer/hooks/use-settings.jsx +++ b/src/renderer/hooks/use-settings.jsx @@ -6,9 +6,15 @@ import { useCallback, useEffect, useState } from 'react'; // dialog opened before then can say it is still reading them. `loaded` is // what main answered first and does not change: a setting that takes a // relaunch, the language, is one whose value now differs from it. +// `php` is what the bundled Playground can run a site on, `{ versions, +// fallback }`, read once with the settings: the dialog offers the versions +// and the details say which one a server starts on, which is the fallback +// where the one set is not among them. Null until read; `{ error }` when it +// could not be, so the dialog can say so rather than offer nothing. export function useSettings() { const [settings, setSettings] = useState(null); const [loaded, setLoaded] = useState(null); + const [php, setPhp] = useState(null); useEffect(() => { let cancelled = false; @@ -20,6 +26,16 @@ export function useSettings() { }) // eslint-disable-next-line no-console -- reaches the log file, see the note in useDetectedEditors. .catch((err) => console.error('Could not read the settings:', err)); + window.api.listPhpVersions() + .then((res) => { + if (cancelled) return; + setPhp(res?.ok ? { versions: res.versions, fallback: res.fallback } : { error: true }); + }) + .catch((err) => { + // eslint-disable-next-line no-console -- see the note above. + console.error('Could not read the PHP versions:', err); + if (!cancelled) setPhp({ error: true }); + }); return () => { cancelled = true; }; }, []); @@ -39,5 +55,5 @@ export function useSettings() { return result; }, []); - return { settings, loaded, change }; + return { settings, loaded, php, change }; } diff --git a/src/renderer/index.jsx b/src/renderer/index.jsx index bd06b695..6c3180c3 100644 --- a/src/renderer/index.jsx +++ b/src/renderer/index.jsx @@ -83,6 +83,7 @@ import { TicketListCard } from './components/ticket-list.jsx'; import { useDetectedEditors } from './hooks/use-detected-editors.jsx'; import { useContributorProvenance } from './hooks/use-contributor-provenance.jsx'; import { useSettings } from './hooks/use-settings.jsx'; +import { phpVersionChoice } from './settings-view.cjs'; import { useNextActionCue } from './hooks/use-next-action-cue.jsx'; import { useSites } from './hooks/use-sites.jsx'; import { usePullRequest } from './hooks/use-pull-request.jsx'; @@ -132,7 +133,10 @@ function App() { const wporg = useContributorProvenance(); // The app's settings (#559), and the dialog they are changed in. The menu // asks for the dialog too, over a subscription the whole window holds. - const { settings, loaded: loadedSettings, change: changeSetting } = useSettings(); + const { settings, loaded: loadedSettings, php: phpVersions, change: changeSetting } = useSettings(); + // The PHP a server starts on: the one set where the bundle has it, and + // the fallback where it does not, decided where the dialog decides it. + const startingPhp = settings ? phpVersionChoice({ versions: phpVersions?.versions, fallback: phpVersions?.fallback, stored: settings.phpVersion }).value : null; const [settingsOpen, setSettingsOpen] = useState(false); const openSettings = useCallback(() => setSettingsOpen(true), []); const closeSettings = useCallback(() => setSettingsOpen(false), []); @@ -684,6 +688,8 @@ function App() { createdAt={siteMeta?.[s]?.createdAt} label={siteMeta?.[s]?.label} projectType={siteMeta?.[s]?.projectType} + settings={settings} + startingPhp={startingPhp} onInitialized={onInitialized} onSiteMetaPatch={onSiteMetaPatch} onDelete={onDelete} @@ -720,7 +726,7 @@ function App() {
)} - + {/* One toast region for the window (#253, #557). In the bottom corner, @@ -739,7 +745,7 @@ function App() { ); } -function SiteRow({ sitePath, initialized, createdAt, label, projectType = null, onInitialized, onSiteMetaPatch, onDelete, onRename, onCreateSite, editor, wporg, isPending = false, isDeleting = false, setupLogs = '', isActive = false, switchProgress = null, carriedWork = null, onClearSwitchNotices = null, deepLink = null, onDeepLinkDone = null, detailsOpen = true, onToggleDetails = null, tray = null, onShowTray = null }) { +function SiteRow({ sitePath, initialized, createdAt, label, projectType = null, settings = null, startingPhp = null, onInitialized, onSiteMetaPatch, onDelete, onRename, onCreateSite, editor, wporg, isPending = false, isDeleting = false, setupLogs = '', isActive = false, switchProgress = null, carriedWork = null, onClearSwitchNotices = null, deepLink = null, onDeepLinkDone = null, detailsOpen = true, onToggleDetails = null, tray = null, onShowTray = null }) { // The window's confirmation queue (#253): confirm(message) after an action // completes, so the outcome is announced rather than left silent or buried in // the terminal. @@ -2233,7 +2239,17 @@ function SiteRow({ sitePath, initialized, createdAt, label, projectType = null, id={detailsId} open={detailsOpen} siteName={displayName} - facts={{ initialized, created: createdLabel, trunk: age, path: sitePath, checkout: project.label }} + facts={{ + initialized, + created: createdLabel, + trunk: age, + path: sitePath, + checkout: project.label, + // What every site's server starts with (#559), from the settings: + // the PHP it will start on, which is not always the one set. + phpVersion: startingPhp, + debug: settings ? { wpDebug: settings.wpDebug, scriptDebug: settings.scriptDebug } : null + }} pathCopied={pathCopied} onCopyPath={copyPath} server={skipInit ? { process: serverState, section: serverSectionState, onToggle: toggleDevServer, onOpen: openSiteLink } : null} diff --git a/src/renderer/settings-view.cjs b/src/renderer/settings-view.cjs index 08001a5a..4fe99473 100644 --- a/src/renderer/settings-view.cjs +++ b/src/renderer/settings-view.cjs @@ -86,4 +86,27 @@ function languageChanged(settings, loaded) { return (settings.locale || null) !== (loaded.locale || null); } -module.exports = { githubAccountLine, newSiteLocationNote, languageItems, languageValue, languageChanged, SYSTEM_LANGUAGE }; +/** + * Which PHP version the control shows as chosen, and what it says of one + * that is set but not among the versions the build has: a release can bump + * the bundled Playground past a version a contributor chose. The server + * then starts on the fallback, and the control shows that and says why, + * rather than showing a choice that is not what runs. + * + * @param {Object} root0 + * @param {?Array} root0.versions What the build has, or null while it has not answered. + * @param {?string} root0.fallback What a server starts on when nothing is chosen, or null likewise. + * @param {?string} root0.stored What is set, or null for the fallback. + * @return {{value: ?string, note: string}} The version to show as chosen, and a note or ''. + */ +function phpVersionChoice({ versions, fallback, stored }) { + if (!versions || !fallback) return { value: null, note: '' }; + if (!stored || versions.includes(stored)) return { value: stored || fallback, note: '' }; + return { + value: fallback, + // translators: %1$s: a PHP version that was chosen; %2$s: the PHP version used instead. + note: sprintf(__('PHP %1$s was chosen, but this version of the app does not have it; servers start on PHP %2$s.'), stored, fallback) + }; +} + +module.exports = { githubAccountLine, newSiteLocationNote, languageItems, languageValue, languageChanged, phpVersionChoice, SYSTEM_LANGUAGE }; diff --git a/src/renderer/site-details.cjs b/src/renderer/site-details.cjs index f3388e7c..ba0724ed 100644 --- a/src/renderer/site-details.cjs +++ b/src/renderer/site-details.cjs @@ -22,9 +22,11 @@ const { __, _n, sprintf } = require('@wordpress/i18n'); * @param {Object} [root0.trunk] From `trunkAgeInfo`. * @param {string} root0.path Where the checkout is. * @param {string} root0.checkout The project's name. + * @param {?string} [root0.phpVersion] The PHP a server starts on, from the settings (#559), or null while they are not read. + * @param {?Object} [root0.debug] `{ wpDebug, scriptDebug }` from the settings, or null likewise. * @return {Array<{id: string, label: string, value: string, note?: string, copyable?: boolean}>} */ -function siteDetailsRows({ initialized = false, created = '', trunk = null, path, checkout }) { +function siteDetailsRows({ initialized = false, created = '', trunk = null, path, checkout, phpVersion = null, debug = null }) { const rows = [ { id: 'setup', label: __('Setup'), value: initialized ? __('Initialized') : __('Uninitialized') } ]; @@ -38,7 +40,20 @@ function siteDetailsRows({ initialized = false, created = '', trunk = null, path rows.push(row); } rows.push({ id: 'path', label: __('Local path'), value: path, copyable: true }); - rows.push({ id: 'checkout', label: __('Checkout'), value: checkout }); + // What the settings hold for every site's server (#559): the PHP it + // starts on, beside what the checkout is, and the two debug constants + // that can be off. These are what the next start is given; a server that + // is running keeps what it started with. + rows.push({ + id: 'checkout', + label: __('Checkout'), + // translators: %1$s: the project a site is a checkout of; %2$s: a PHP version. + value: phpVersion ? sprintf(__('%1$s · PHP %2$s'), checkout, phpVersion) : checkout + }); + if (debug) { + const on = [debug.wpDebug ? 'WP_DEBUG' : null, debug.scriptDebug ? 'SCRIPT_DEBUG' : null].filter(Boolean); + rows.push({ id: 'debugging', label: __('Debugging'), value: on.length ? on.join(' · ') : __('Off') }); + } return rows; } diff --git a/src/server-runner.js b/src/server-runner.js index e0829fac..52d8261a 100644 --- a/src/server-runner.js +++ b/src/server-runner.js @@ -3,7 +3,7 @@ const fs = require('fs'); const { hideChildWindows } = require('./hide-child-windows'); const { bindLoopbackOnly } = require('./bind-loopback'); const { formatErrorChain } = require('./error-chain'); -const { WP_DEBUG_CONSTANTS } = require('./wp-debug-constants'); +const { debugConstants } = require('./wp-debug-constants'); const { planPlaygroundLaunch, planServeConstants } = require('./playground-plan.cjs'); // Must run before the Playground CLI is required, so anything it spawns is @@ -23,7 +23,8 @@ async function main() { // the strategy into the runCLI mount, install-mode and blueprint-step // options, and says which extra constants the strategy needs; the debug and // SMTP constants are added here because they come from this process's - // environment. + // environment, and the PHP version and the two debug flags the settings + // hold (#559) ride in the config. const raw = process.argv[2]; // The `return`s after each exit are for the test harness, which replaces // process.exit with a recorder: without them main() would carry on into @@ -71,13 +72,16 @@ async function main() { // WordPress, and the checkout is mounted under wp-content/plugins // and activated. ...launch, + // The PHP the site runs on, from the settings; the CLI's own default + // when the config names none, as it did before there was a setting. + ...(serveConfig.phpVersion ? { php: serveConfig.phpVersion } : {}), verbosity: 'debug', blueprint: { constants: { // Debug first, mail second. This is the only point at which // constants can be set: Playground generates the wp-config.php // itself, and these have to be defined before WordPress loads. - ...WP_DEBUG_CONSTANTS, + ...debugConstants(serveConfig.debug), 'WP_MAIL_SMTP_HOST': process.env.WP_MAIL_SMTP_HOST || '127.0.0.1', 'WP_MAIL_SMTP_PORT': Number(process.env.WP_MAIL_SMTP_PORT || 25), 'WP_MAIL_SMTP_AUTH': String(process.env.WP_MAIL_SMTP_AUTH || 'false') === 'true', diff --git a/src/settings.cjs b/src/settings.cjs index 17d792dd..fc6d23a7 100644 --- a/src/settings.cjs +++ b/src/settings.cjs @@ -19,6 +19,13 @@ const { __ } = require('@wordpress/i18n'); +// A switch: on or off, and nothing for the fallback. +function acceptSwitch(value) { + if (value === null || value === undefined) return { ok: true, value: null }; + if (typeof value !== 'boolean') return { ok: false, error: __('Choose on or off.') }; + return { ok: true, value }; +} + /** * Each setting by the key it is stored under: what it falls back to, and * `accept(value, deps)`, which gives back the value to store or a refusal @@ -37,6 +44,21 @@ const SETTINGS = { return { ok: true, value }; } }, + // The PHP a site's development server runs on, one of the versions the + // bundled Playground has; applied at the next start of a server. + phpVersion: { + fallback: '8.3', + accept(value, { isPhpVersion }) { + if (value === null || value === undefined || value === '') return { ok: true, value: null }; + if (typeof value !== 'string' || !isPhpVersion(value)) return { ok: false, error: __('The app does not have that PHP version.') }; + return { ok: true, value }; + } + }, + // WP_DEBUG and SCRIPT_DEBUG, on unless turned off here; the rest of the + // constants a server is booted with are wp-debug-constants.js's and not + // anyone's to change. + wpDebug: { fallback: true, accept: acceptSwitch }, + scriptDebug: { fallback: true, accept: acceptSwitch }, // The folder new sites are made in, each in a subfolder of its own. Unset, // the create-site dialog asks for one every time, as it did before. The // path is kept as the system's dialog gave it: a folder's name can end in @@ -69,8 +91,12 @@ const SETTINGS = { function readSettings(preferences = {}) { const stored = preferences && typeof preferences === 'object' ? preferences : {}; const text = (key) => (typeof stored[key] === 'string' && stored[key] ? stored[key] : SETTINGS[key].fallback); + const flag = (key) => (typeof stored[key] === 'boolean' ? stored[key] : SETTINGS[key].fallback); return { locale: text('locale'), + phpVersion: text('phpVersion'), + wpDebug: flag('wpDebug'), + scriptDebug: flag('scriptDebug'), newSiteLocation: text('newSiteLocation') }; } @@ -81,9 +107,10 @@ function readSettings(preferences = {}) { * @param {string} key * @param {*} value * @param {Object} deps - * @param {Function} deps.isAbsolute Whether a path is a full one on this platform. - * @param {Function} deps.isDirectory Whether a path is a folder on this machine. - * @param {Function} deps.isLanguage Whether a tag is one of the languages the app can show. + * @param {Function} deps.isAbsolute Whether a path is a full one on this platform. + * @param {Function} deps.isDirectory Whether a path is a folder on this machine. + * @param {Function} deps.isLanguage Whether a tag is one of the languages the app can show. + * @param {Function} deps.isPhpVersion Whether a version is one the bundled Playground has. * @return {{ok: true, value: *}|{ok: false, error: string}} The value to store, or why not. */ function acceptSetting(key, value, deps) { diff --git a/src/wp-debug-constants.js b/src/wp-debug-constants.js index d3b6abe2..dd2e7454 100644 --- a/src/wp-debug-constants.js +++ b/src/wp-debug-constants.js @@ -7,10 +7,13 @@ // work actually runs on — were invisible, and a fatal was replaced by // WordPress's recovery screen. // -// Not configurable, and deliberately so. A wordpress-develop checkout is a -// development environment by definition; nobody sets one up through this app to -// observe production behaviour. A settings panel here would be one more thing to -// find, get wrong, and support at a Contributor Day. +// Two of them, WP_DEBUG and SCRIPT_DEBUG, can be turned off in the settings +// (#559), for the contributor whose work is about what a site does with them +// off; the rest are not configurable, and deliberately so. A wordpress-develop +// checkout is a development environment by definition; nobody sets one up +// through this app to observe production behaviour, and a setting for each +// constant would be one more thing to find, get wrong, and support at a +// Contributor Day. `debugConstants` is the set as a server is booted with it. // // Kept free of Electron and Playground imports so it can be unit-tested: the // values are the whole of the behaviour, so a test that reads them is a test of @@ -66,4 +69,27 @@ const WP_DEBUG_CONSTANTS = Object.freeze({ // does not install yet. }); -module.exports = { WP_DEBUG_CONSTANTS }; +/** + * The constants a server is booted with: the set above, with the two the + * settings can turn off turned off where they are. Everything else stays as + * it is, and so does what Playground does on its own: its php.ini keeps + * display_errors and log_errors on, and its mu-plugin points error_log at + * debug.log while WP_DEBUG_LOG is on. So WP_DEBUG off takes away notices and + * deprecations, which is what WP_DEBUG governs in WordPress's error_reporting; + * warnings and errors still reach the log and the browser, and error_log() + * calls still reach the log. The settings say as much. + * + * @param {Object} [debug] + * @param {boolean} [debug.wpDebug] WP_DEBUG; on unless told otherwise. + * @param {boolean} [debug.scriptDebug] SCRIPT_DEBUG; on unless told otherwise. + * @return {Object} The constants, frozen. + */ +function debugConstants({ wpDebug = true, scriptDebug = true } = {}) { + return Object.freeze({ + ...WP_DEBUG_CONSTANTS, + WP_DEBUG: wpDebug !== false, + SCRIPT_DEBUG: scriptDebug !== false + }); +} + +module.exports = { WP_DEBUG_CONSTANTS, debugConstants }; diff --git a/tests/e2e/journeys/i18n.spec.js b/tests/e2e/journeys/i18n.spec.js index 0fff5eb6..5b03f0d8 100644 --- a/tests/e2e/journeys/i18n.spec.js +++ b/tests/e2e/journeys/i18n.spec.js @@ -22,6 +22,8 @@ const { pseudoLocalize } = require( '../../../src/renderer/pseudo-locale.cjs' ); // Names that stay as they are in every language. const UNTRANSLATED = new Set( [ 'WordPress Core', 'Gutenberg' ] ); +// So do the names of WordPress's constants, which the details list (#559). +const CONSTANT_NAMES = /^(WP_DEBUG|SCRIPT_DEBUG)( · (WP_DEBUG|SCRIPT_DEBUG))*$/; /** * Every visible text node, aria-label and placeholder inside `root` that is not @@ -46,7 +48,7 @@ async function unwrapped( locator ) { } return texts; } ); - return found.filter( ( text ) => text && ! /^\[[\s\S]*\]$/.test( text ) && ! UNTRANSLATED.has( text ) ); + return found.filter( ( text ) => text && ! /^\[[\s\S]*\]$/.test( text ) && ! UNTRANSLATED.has( text ) && ! CONSTANT_NAMES.test( text ) ); } test( 'the first-run screen and the create-site dialog are fully translatable', async ( { session } ) => { @@ -89,6 +91,12 @@ test( 'the settings dialog is fully translatable, on both of its tabs', async ( await expect( dialog.getByText( pseudoLocalize( 'Not set: the create-site dialog asks each time.' ), { exact: true } ) ).toBeVisible(); expect( await unwrapped( dialog ) ).toEqual( [] ); + // The Sites tab, once it has the PHP versions: their numbers are not + // words and stay as they are. + await dialog.getByRole( 'tab', { name: pseudoLocalize( 'Sites' ), exact: true } ).click(); + await expect( dialog.getByRole( 'radio', { name: '8.3', exact: true } ) ).toBeChecked(); + expect( ( await unwrapped( dialog ) ).filter( ( text ) => ! /^\d+\.\d+$/.test( text ) ) ).toEqual( [] ); + // The Account tab, once it has read the GitHub account: the line about it // is main's answer put into words here. await dialog.getByRole( 'tab', { name: pseudoLocalize( 'Account' ), exact: true } ).click(); diff --git a/tests/e2e/journeys/settings.spec.js b/tests/e2e/journeys/settings.spec.js index 37893e4f..c7775e4f 100644 --- a/tests/e2e/journeys/settings.spec.js +++ b/tests/e2e/journeys/settings.spec.js @@ -206,3 +206,46 @@ test( 'the language set in the settings is the one the app starts in, and a chan await expect( after.getByRole( 'combobox', { name: 'Language', exact: true } ) ).toHaveText( 'English' ); await expect( after.getByRole( 'button', { name: 'Relaunch now', exact: true } ) ).toHaveCount( 0 ); } ); + +test( 'the Sites tab keeps the PHP version and the debug flags the next server start is given', async ( { session } ) => { + const site = await makeSite( session ); + const { page } = await session.start( site.settings ); + + await ui.settingsButton( page ).click(); + const dialog = ui.settingsDialog( page ); + await ui.settingsTab( page, 'Sites' ).click(); + const versions = dialog.getByRole( 'radiogroup', { name: 'PHP version', exact: true } ); + const wpDebug = dialog.getByRole( 'switch', { name: 'Report notices and deprecations (WP_DEBUG)', exact: true } ); + const scriptDebug = dialog.getByRole( 'switch', { name: 'Use unminified scripts (SCRIPT_DEBUG)', exact: true } ); + + // INVARIANT — the fallbacks: 8.3, both constants on. + await expect( versions.getByRole( 'radio', { name: '8.3', exact: true } ) ).toBeChecked(); + await expect( wpDebug ).toBeChecked(); + await expect( scriptDebug ).toBeChecked(); + + // INVARIANT — a version the bundle has is kept, and so is a flag turned + // off; the other flag is left as it was. + await versions.getByRole( 'radio', { name: '8.4', exact: true } ).click(); + await expect( versions.getByRole( 'radio', { name: '8.4', exact: true } ) ).toBeChecked(); + await expect.poll( () => session.readSettings().preferences?.phpVersion ).toBe( '8.4' ); + await wpDebug.click(); + await expect( wpDebug ).not.toBeChecked(); + await expect.poll( () => session.readSettings().preferences?.wpDebug ).toBe( false ); + expect( session.readSettings().preferences.scriptDebug ).toBeUndefined(); + + // INVARIANT — the open site's details say what the next start is given. + await ui.closeDialogButton( dialog ).click(); + await expect( dialog ).toHaveCount( 0 ); + await expect( page.getByText( 'WordPress Core · PHP 8.4', { exact: true } ) ).toBeVisible(); + await expect( page.getByText( 'SCRIPT_DEBUG', { exact: true } ) ).toBeVisible(); + await expect( page.getByText( 'WP_DEBUG · SCRIPT_DEBUG', { exact: true } ) ).toHaveCount( 0 ); + + // INVARIANT — opened again after a restart, the tab shows what was kept. + const again = await session.restart(); + await ui.settingsButton( again.page ).click(); + await ui.settingsTab( again.page, 'Sites' ).click(); + const kept = ui.settingsDialog( again.page ); + await expect( kept.getByRole( 'radio', { name: '8.4', exact: true } ) ).toBeChecked(); + await expect( kept.getByRole( 'switch', { name: 'Report notices and deprecations (WP_DEBUG)', exact: true } ) ).not.toBeChecked(); + await expect( kept.getByRole( 'switch', { name: 'Use unminified scripts (SCRIPT_DEBUG)', exact: true } ) ).toBeChecked(); +} ); diff --git a/tests/e2e/journeys/site-header.spec.js b/tests/e2e/journeys/site-header.spec.js index 26b805fc..00b28163 100644 --- a/tests/e2e/journeys/site-header.spec.js +++ b/tests/e2e/journeys/site-header.spec.js @@ -258,12 +258,15 @@ test( 'the details say what the checkout is, copy its path, and can be put away await standInForClipboard( page ); await expect( ui.siteHeading( page, 'fresh-site' ) ).toBeVisible( { timeout: 30_000 } ); - // INVARIANT — the details are the open site's: its folder, its project, - // and that its setup is done. A recent trunk has no age to report. + // INVARIANT — the details are the open site's: its folder, its project + // with the PHP a server starts on and which debug constants are on, both + // from the settings at their fallbacks (#559), and that its setup is + // done. A recent trunk has no age to report. const fresh = details( page, 'fresh-site' ); await expect( fresh.getByRole( 'heading', { name: 'Details', exact: true } ) ).toBeVisible(); await expect( fresh.getByText( freshDir, { exact: true } ) ).toBeVisible(); - await expect( fresh.getByText( 'WordPress Core', { exact: true } ) ).toBeVisible(); + await expect( fresh.getByText( 'WordPress Core · PHP 8.3', { exact: true } ) ).toBeVisible(); + await expect( fresh.getByText( 'WP_DEBUG · SCRIPT_DEBUG', { exact: true } ) ).toBeVisible(); await expect( fresh.getByText( 'Initialized', { exact: true } ) ).toBeVisible(); await expect( fresh.getByText( /days? old$/ ) ).toHaveCount( 0 ); @@ -284,7 +287,7 @@ test( 'the details say what the checkout is, copy its path, and can be put away await expect( ui.siteHeading( page, 'old-trunk' ) ).toBeVisible(); const old = details( page, 'old-trunk' ); await expect( old.getByText( oldDir, { exact: true } ) ).toBeVisible(); - await expect( old.getByText( 'Gutenberg', { exact: true } ) ).toBeVisible(); + await expect( old.getByText( 'Gutenberg · PHP 8.3', { exact: true } ) ).toBeVisible(); await expect( old.getByText( '30 days old', { exact: true } ) ).toBeVisible(); await expect( fresh ).toHaveCount( 0 ); @@ -323,7 +326,12 @@ test( 'the details stay in view while the cards scroll for as long as they fit t // the cards scroll can only be told from details that go with them where // the cards are the taller. With the terminal, the logs and the mail gone // to the tray (#558) a site that is set up has it the other way round. - const { settings } = listedSites( session, [ { label: 'in-setup', skipInitWizard: false } ] ); + // The site has no recorded creation date and no trunk date, so its + // details have two rows fewer: the facts gained a Debugging row (#559), + // and with every row the facts alone no longer fit over the tray at its + // smallest on the runners' screens, which the first half below needs + // them to, with a few pixels to spare and no more. + const { settings } = listedSites( session, [ { label: 'in-setup', skipInitWizard: false, createdAt: null, trunkDate: null } ] ); const { page } = await session.start( settings ); // Without the glide the app brings a site's next step into view with, // which would still be moving the page while this reads where it is. diff --git a/tests/e2e/packaged/smoke.spec.js b/tests/e2e/packaged/smoke.spec.js index 2265d2e4..72950063 100644 --- a/tests/e2e/packaged/smoke.spec.js +++ b/tests/e2e/packaged/smoke.spec.js @@ -66,6 +66,7 @@ const EXPECTED_API_KEYS = [ 'listBranches', 'listEditors', 'listLanguages', + 'listPhpVersions', 'listTicketPatches', 'listTracAttachments', 'markSiteInitialized', diff --git a/tests/unit/ipc-wiring.test.cjs b/tests/unit/ipc-wiring.test.cjs index 61bb31c8..f2cc3d60 100644 --- a/tests/unit/ipc-wiring.test.cjs +++ b/tests/unit/ipc-wiring.test.cjs @@ -1872,7 +1872,7 @@ test('settings:set asks the disk whether the folder is there', async (t) => { const settings = fakeSettingsStore(); const main = loadMain({ stubs: { ...silentLogging(), ...settings.stubs } }); - assert.deepEqual(await main.invoke('settings:set', 'newSiteLocation', folder), { ok: true, settings: { locale: null, newSiteLocation: folder } }); + assert.deepEqual(await main.invoke('settings:set', 'newSiteLocation', folder), { ok: true, settings: { locale: null, phpVersion: '8.3', wpDebug: true, scriptDebug: true, newSiteLocation: folder } }); const gone = await main.invoke('settings:set', 'newSiteLocation', path.join(folder, 'gone')); assert.equal(gone.ok, false); assert.equal(settings.values.preferences.newSiteLocation, folder); @@ -2628,9 +2628,10 @@ test('playground:start spawns the server runner with the environment npm-runner assert.equal(buildChildEnv.calls[0][0].extraEnv.WP_MAIL_SMTP_PORT, '25'); assertCrossPlatformSpawnOptions(cp.spawned[0].options, 'playground:start'); // The runner is told what to serve as one JSON argument: a Core site's - // build/ as the docroot, run from that directory as before (#251). + // build/ as the docroot, run from that directory as before (#251), and + // what the settings hold for a server, at their fallbacks here (#559). const serve = JSON.parse(cp.spawned[0].args[1]); - assert.deepEqual(serve, { strategy: 'docroot', docroot: path.join('/sites/wp', 'build') }); + assert.deepEqual(serve, { strategy: 'docroot', docroot: path.join('/sites/wp', 'build'), phpVersion: '8.3', debug: { wpDebug: true, scriptDebug: true } }); assert.equal(cp.spawned[0].options.cwd, path.join('/sites/wp', 'build')); }); @@ -2651,11 +2652,53 @@ test('playground:start serves a Gutenberg site as a plugin mounted from the chec assert.equal(path.basename(cp.spawned[0].args[0]), 'server-runner.js'); const serve = JSON.parse(cp.spawned[0].args[1]); - assert.deepEqual(serve, { strategy: 'plugin-mount', pluginDir: '/sites/gb', pluginSlug: 'gutenberg' }); + assert.deepEqual(serve, { strategy: 'plugin-mount', pluginDir: '/sites/gb', pluginSlug: 'gutenberg', phpVersion: '8.3', debug: { wpDebug: true, scriptDebug: true } }); // There is no build/ docroot to run from: the checkout is the plugin. assert.equal(cp.spawned[0].options.cwd, '/sites/gb'); }); +// The settings reach the server through the config the runner is handed +// (#559), read at each start; a version the bundled Playground does not have, +// left in the store by a bump, is passed over for the fallback. +test('playground:start hands the runner the PHP version and the debug flags from the settings', async (t) => { + const settings = fakeSettingsStore({ preferences: { phpVersion: '8.4', wpDebug: false } }); + const cp = stubbedSpawn(); + const main = loadMain({ + stubs: { ...silentLogging(), ...noSmtpServer(), ...settings.stubs, 'child_process': { spawn: cp.spawn }, './npm-runner': { buildChildEnv: () => ({}) } } + }); + + await reachSpawn(t, cp, main.invoke('playground:start', '/sites/wp')); + + const serve = JSON.parse(cp.spawned[0].args[1]); + assert.equal(serve.phpVersion, '8.4'); + assert.deepEqual(serve.debug, { wpDebug: false, scriptDebug: true }); +}); + +test('playground:start passes over a stored PHP version the bundled Playground no longer has', async (t) => { + const settings = fakeSettingsStore({ preferences: { phpVersion: '5.6' } }); + const cp = stubbedSpawn(); + const main = loadMain({ + stubs: { ...silentLogging(), ...noSmtpServer(), ...settings.stubs, 'child_process': { spawn: cp.spawn }, './npm-runner': { buildChildEnv: () => ({}) } } + }); + + await reachSpawn(t, cp, main.invoke('playground:start', '/sites/wp')); + + assert.equal(JSON.parse(cp.spawned[0].args[1]).phpVersion, '8.3'); +}); + +test('playground:php-versions lists what the bundled Playground has, and settings:set keeps only one of those (#559)', async () => { + const settings = fakeSettingsStore(); + const main = loadMain({ stubs: { ...silentLogging(), ...settings.stubs } }); + + const { versions } = await main.invoke('playground:php-versions'); + assert.ok(versions.includes('8.3'), 'the fallback is one the bundle has'); + assert.deepEqual(await main.invoke('settings:set', 'phpVersion', versions[0]), { ok: true, settings: { ...(await main.invoke('settings:get')).settings, phpVersion: versions[0] } }); + assert.equal((await main.invoke('settings:set', 'phpVersion', '5.6')).ok, false); + assert.equal(settings.values.preferences.phpVersion, versions[0]); + assert.deepEqual(await main.invoke('settings:set', 'wpDebug', false), { ok: true, settings: { ...(await main.invoke('settings:get')).settings, wpDebug: false } }); + assert.equal((await main.invoke('settings:set', 'wpDebug', 'off')).ok, false); +}); + test('playground-web:start spawns its runner through npm-runner too', async (t) => { const env = { PATH: '/shims' }; const buildChildEnv = spy(() => env); @@ -6404,10 +6447,10 @@ test('settings:set keeps a language the build has, and refuses one it has not (# stubs: { ...silentLogging(), ...settings.stubs, './i18n.cjs': { resolveCatalog: async () => null, languageChoices: () => [{ tag: 'de', label: 'Deutsch' }, { tag: 'en', label: 'English' }] } } }); - assert.deepEqual(await main.invoke('settings:set', 'locale', 'de'), { ok: true, settings: { locale: 'de', newSiteLocation: null } }); + assert.deepEqual(await main.invoke('settings:set', 'locale', 'de'), { ok: true, settings: { locale: 'de', phpVersion: '8.3', wpDebug: true, scriptDebug: true, newSiteLocation: null } }); assert.equal((await main.invoke('settings:set', 'locale', 'fr')).ok, false); assert.equal(settings.values.preferences.locale, 'de'); - assert.deepEqual(await main.invoke('settings:set', 'locale', null), { ok: true, settings: { locale: null, newSiteLocation: null } }); + assert.deepEqual(await main.invoke('settings:set', 'locale', null), { ok: true, settings: { locale: null, phpVersion: '8.3', wpDebug: true, scriptDebug: true, newSiteLocation: null } }); }); test('app:relaunch relaunches through a quit, so the child sweep runs, without the launch\'s link or --lang (#559)', async (t) => { @@ -6537,6 +6580,7 @@ const NO_DELEGATION = new Map([ ['dialog:choose-dir', 'opens the directory dialog'], ['dialog:choose-patch-file', 'opens the file-open dialog and reads the chosen file'], ['playground-web:available', 'checks a path on disk'], + ['playground:php-versions', 'reads the list the bundled Playground publishes; asserted with settings:set above'], ['provenance:get', 'electron-store read'], ['smtp:get', 'electron-store read'], ['smtp:clear', 'electron-store write'], diff --git a/tests/unit/runner-wiring.test.cjs b/tests/unit/runner-wiring.test.cjs index 4ca9f274..a3c12a45 100644 --- a/tests/unit/runner-wiring.test.cjs +++ b/tests/unit/runner-wiring.test.cjs @@ -173,6 +173,25 @@ test('server-runner passes the WordPress debug constants to Playground', () => { } }); +// The two constants the settings reach (#559) ride in the serve config, and +// the PHP version with them; a config that names none of them boots the site +// as it always was. +test('server-runner turns the debug flags in the config off in the blueprint, and passes the PHP version to the CLI', () => { + const { cliOptions } = loadRunner(SERVER_RUNNER, [JSON.stringify({ strategy: 'docroot', docroot: CORE_DOCROOT, phpVersion: '8.4', debug: { wpDebug: false, scriptDebug: false } })]); + + assert.equal(cliOptions.php, '8.4'); + assert.strictEqual(cliOptions.blueprint.constants.WP_DEBUG, false); + assert.strictEqual(cliOptions.blueprint.constants.SCRIPT_DEBUG, false); + // The rest of the set is what it was. + assert.strictEqual(cliOptions.blueprint.constants.WP_DISABLE_FATAL_ERROR_HANDLER, true); + assert.strictEqual(cliOptions.blueprint.constants.AUTOMATIC_UPDATER_DISABLED, true); +}); + +test('a config that names no PHP version leaves the CLI to its own default', () => { + const { cliOptions } = loadRunner(SERVER_RUNNER, [CORE_SERVE]); + assert.equal('php' in cliOptions, false); +}); + // Spreading the debug constants in ahead of the mail ones must not have taken // the mail ones out: this is how a site's outgoing mail reaches the app's SMTP // catcher, and losing it is silent — mail simply stops arriving. diff --git a/tests/unit/settings-view.test.cjs b/tests/unit/settings-view.test.cjs index 0070dec0..76fd7885 100644 --- a/tests/unit/settings-view.test.cjs +++ b/tests/unit/settings-view.test.cjs @@ -3,7 +3,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); -const { githubAccountLine, newSiteLocationNote, languageItems, languageValue, languageChanged, SYSTEM_LANGUAGE } = require('../../src/renderer/settings-view.cjs'); +const { githubAccountLine, newSiteLocationNote, languageItems, languageValue, languageChanged, phpVersionChoice, SYSTEM_LANGUAGE } = require('../../src/renderer/settings-view.cjs'); test('the GitHub line says the account is still being read, and offers no sign-out, until it is', () => { assert.deepEqual(githubAccountLine(null), { text: 'Reading…', canSignOut: false }); @@ -54,3 +54,17 @@ test('the system\'s entry is kept as no choice, and a language as itself', () => assert.equal(languageValue('de'), 'de'); assert.equal(languageValue('en'), 'en'); }); + +test('the PHP version shown as chosen is the one set, the fallback when none is, and nothing until the build has answered', () => { + const versions = ['8.5', '8.4', '8.3']; + assert.deepEqual(phpVersionChoice({ versions: null, fallback: null, stored: '8.4' }), { value: null, note: '' }); + assert.deepEqual(phpVersionChoice({ versions, fallback: '8.3', stored: null }), { value: '8.3', note: '' }); + assert.deepEqual(phpVersionChoice({ versions, fallback: '8.3', stored: '8.4' }), { value: '8.4', note: '' }); +}); + +test('a version set that the build no longer has shows the fallback as chosen, and says why', () => { + assert.deepEqual(phpVersionChoice({ versions: ['8.5', '8.4', '8.3'], fallback: '8.3', stored: '7.4' }), { + value: '8.3', + note: 'PHP 7.4 was chosen, but this version of the app does not have it; servers start on PHP 8.3.' + }); +}); diff --git a/tests/unit/settings.test.cjs b/tests/unit/settings.test.cjs index d3d0ccdb..c0a722e8 100644 --- a/tests/unit/settings.test.cjs +++ b/tests/unit/settings.test.cjs @@ -9,19 +9,23 @@ const { SETTINGS, readSettings, acceptSetting } = require('../../src/settings.cj // What the disk says, as the test decides it. const disk = (folders) => ({ isAbsolute: path.posix.isAbsolute, isDirectory: (p) => folders.includes(p) }); const languages = (tags) => ({ isLanguage: (tag) => tags.includes(tag) }); +const php = (versions) => ({ isPhpVersion: (version) => versions.includes(version) }); test('readSettings falls back for a store with nothing in it, and for values of the wrong kind', () => { - const fallbacks = { locale: null, newSiteLocation: null }; + const fallbacks = { locale: null, phpVersion: '8.3', wpDebug: true, scriptDebug: true, newSiteLocation: null }; assert.deepEqual(readSettings(), fallbacks); assert.deepEqual(readSettings(undefined), fallbacks); assert.deepEqual(readSettings({}), fallbacks); - assert.deepEqual(readSettings({ newSiteLocation: 42, locale: ['de'] }), fallbacks); - assert.deepEqual(readSettings({ newSiteLocation: '', locale: '' }), fallbacks); + assert.deepEqual(readSettings({ newSiteLocation: 42, locale: ['de'], phpVersion: 8.4, wpDebug: 'false', scriptDebug: 0 }), fallbacks); + assert.deepEqual(readSettings({ newSiteLocation: '', locale: '', phpVersion: '', wpDebug: null, scriptDebug: null }), fallbacks); assert.deepEqual(readSettings('not an object'), fallbacks); }); test('readSettings gives back a stored folder without asking the disk about it', () => { - assert.deepEqual(readSettings({ newSiteLocation: '/Users/jane/sites', locale: 'de' }), { locale: 'de', newSiteLocation: '/Users/jane/sites' }); + assert.deepEqual( + readSettings({ newSiteLocation: '/Users/jane/sites', locale: 'de', phpVersion: '8.4', wpDebug: false, scriptDebug: false }), + { locale: 'de', phpVersion: '8.4', wpDebug: false, scriptDebug: false, newSiteLocation: '/Users/jane/sites' } + ); }); test('readSettings answers for every setting there is', () => { @@ -68,3 +72,21 @@ test('a language the app has no catalog for is refused, and so is anything that assert.equal(acceptSetting('locale', ['de'], languages(['de'])).ok, false); assert.equal(acceptSetting('locale', 42, languages(['de'])).ok, false); }); + +test('a PHP version the bundled Playground has is kept, nothing means the fallback, and any other is refused', () => { + assert.deepEqual(acceptSetting('phpVersion', '8.4', php(['8.5', '8.4', '8.3'])), { ok: true, value: '8.4' }); + for (const value of [null, undefined, '']) { + assert.deepEqual(acceptSetting('phpVersion', value, php([])), { ok: true, value: null }, String(value)); + } + assert.deepEqual(acceptSetting('phpVersion', '7.4', php(['8.5', '8.4', '8.3'])), { ok: false, error: 'The app does not have that PHP version.' }); + assert.equal(acceptSetting('phpVersion', 8.4, php(['8.4'])).ok, false); +}); + +test('a debug flag is on or off, nothing means the fallback, and a string is refused', () => { + for (const key of ['wpDebug', 'scriptDebug']) { + assert.deepEqual(acceptSetting(key, false, {}), { ok: true, value: false }, key); + assert.deepEqual(acceptSetting(key, true, {}), { ok: true, value: true }, key); + assert.deepEqual(acceptSetting(key, null, {}), { ok: true, value: null }, key); + assert.deepEqual(acceptSetting(key, 'false', {}), { ok: false, error: 'Choose on or off.' }, key); + } +}); diff --git a/tests/unit/site-details.test.cjs b/tests/unit/site-details.test.cjs index 2418bd3a..485cc572 100644 --- a/tests/unit/site-details.test.cjs +++ b/tests/unit/site-details.test.cjs @@ -47,3 +47,21 @@ test('only the path can be copied', () => { const rows = siteDetailsRows({ ...BASE, initialized: true, created: 'then', trunk: old }); assert.deepEqual(rows.filter((row) => row.copyable).map((row) => row.id), ['path']); }); + +// What the settings hold for a server (#559), where the window has read them. +test('the checkout names the PHP a server starts on, and a Debugging row says which constants are on', () => { + const rows = siteDetailsRows({ ...BASE, phpVersion: '8.4', debug: { wpDebug: true, scriptDebug: true } }); + assert.deepEqual(rows.slice(-2), [ + { id: 'checkout', label: 'Checkout', value: 'WordPress Core · PHP 8.4' }, + { id: 'debugging', label: 'Debugging', value: 'WP_DEBUG · SCRIPT_DEBUG' } + ]); + assert.equal(siteDetailsRows({ ...BASE, debug: { wpDebug: false, scriptDebug: true } }).at(-1).value, 'SCRIPT_DEBUG'); + assert.equal(siteDetailsRows({ ...BASE, debug: { wpDebug: true, scriptDebug: false } }).at(-1).value, 'WP_DEBUG'); + assert.equal(siteDetailsRows({ ...BASE, debug: { wpDebug: false, scriptDebug: false } }).at(-1).value, 'Off'); +}); + +test('until the settings are read, the checkout is named alone and there is no Debugging row', () => { + const rows = siteDetailsRows({ ...BASE }); + assert.deepEqual(rows.at(-1), { id: 'checkout', label: 'Checkout', value: 'WordPress Core' }); + assert.deepEqual(ids(siteDetailsRows({ ...BASE, phpVersion: null, debug: null })), ['setup', 'path', 'checkout']); +}); diff --git a/tests/unit/wp-debug-constants.test.cjs b/tests/unit/wp-debug-constants.test.cjs index 0c893228..59ec6137 100644 --- a/tests/unit/wp-debug-constants.test.cjs +++ b/tests/unit/wp-debug-constants.test.cjs @@ -1,7 +1,8 @@ 'use strict'; -// The debug constants are the whole of the feature: there is no UI, no setting -// and no branch, so a test that reads the values is a test of the behaviour. +// The debug constants are the whole of the feature: a test that reads the +// values is a test of the behaviour. Two of them have a setting since #559, +// and `debugConstants` is the set as a server gets it. // // What these guard against is a silent regression. The app has always tailed // build/wp-content/debug.log and streamed it to the renderer, and for as long as @@ -13,7 +14,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); -const { WP_DEBUG_CONSTANTS } = require('../../src/wp-debug-constants'); +const { WP_DEBUG_CONSTANTS, debugConstants } = require('../../src/wp-debug-constants'); test('the debug constants are exactly the six the app boots a site with', () => { assert.deepStrictEqual(Object.keys(WP_DEBUG_CONSTANTS).sort(), [ @@ -83,3 +84,20 @@ test('the exported object cannot be mutated by a caller', () => { assert.throws(() => { WP_DEBUG_CONSTANTS.WP_DEBUG = false; }, TypeError); assert.strictEqual(WP_DEBUG_CONSTANTS.WP_DEBUG, true); }); + +// The two the settings reach, and only those (#559): the rest of the set is +// what it was, whatever is asked. +test('a server is booted with the set as it is, unless the settings turn WP_DEBUG or SCRIPT_DEBUG off', () => { + assert.deepStrictEqual(debugConstants(), WP_DEBUG_CONSTANTS); + assert.deepStrictEqual(debugConstants({}), WP_DEBUG_CONSTANTS); + assert.deepStrictEqual(debugConstants({ wpDebug: true, scriptDebug: true }), WP_DEBUG_CONSTANTS); + assert.deepStrictEqual(debugConstants({ wpDebug: false }), { ...WP_DEBUG_CONSTANTS, WP_DEBUG: false }); + assert.deepStrictEqual(debugConstants({ scriptDebug: false }), { ...WP_DEBUG_CONSTANTS, SCRIPT_DEBUG: false }); + assert.deepStrictEqual(debugConstants({ wpDebug: false, scriptDebug: false }), { ...WP_DEBUG_CONSTANTS, WP_DEBUG: false, SCRIPT_DEBUG: false }); + assert.ok(Object.isFrozen(debugConstants({ wpDebug: false }))); +}); + +test('a value that is not false leaves a constant on: the string "false" is not off', () => { + assert.strictEqual(debugConstants({ wpDebug: 'false' }).WP_DEBUG, true); + assert.strictEqual(debugConstants({ wpDebug: null }).WP_DEBUG, true); +});