diff --git a/docs/guide/settings.md b/docs/guide/settings.md index 0e572c4f..12af7ce9 100644 --- a/docs/guide/settings.md +++ b/docs/guide/settings.md @@ -5,6 +5,8 @@ The app's settings are in one dialog, opened from the cog at the bottom right of ## General - **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. +- **Start the server when I open a site** and **Start the build watch when I open a site** — off unless you turn them on. On, opening a site that is set up starts its [development server](./running-the-site), its build watch, or both, as it would if you pressed Start; on WordPress Core the server's start brings the watch with it. Nothing starts for a site whose setup, update or deletion is under way, and nothing already running is started again. +- **When I quit, running servers and build watches** — quitting always stops them, as it always has. **Stop them, and start them again next time** remembers which sites had a server or a watch running and starts them again when the app next opens, whichever site it opens on. - **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 diff --git a/src/main.js b/src/main.js index 854ca3c4..d6c2da65 100644 --- a/src/main.js +++ b/src/main.js @@ -61,7 +61,8 @@ const { const { fetchPullRequestHead, describePullRequestHead, pullRequestBranchState, checkoutPullRequest, leavePullRequest } = require('./pr-checkout'); const { prSubmissionRefusal, prCheckoutRefusal } = require('./renderer/pr-checkout.cjs'); const { createProgressThrottle, describeSwitchProgress } = require('./switch-progress.cjs'); -const { getStore } = require('./settings-store'); +const { getStore, peekStore } = require('./settings-store'); +const { sitesToResume, readResume } = require('./resume-sites.cjs'); // One name for the send-only progress channel (#173), shared with preload.js // through the tests rather than by import — the renderer bundle and the main @@ -362,6 +363,9 @@ const runningScripts = {}; const cancelledChildren = new WeakSet(); /** @type {Record} */ const runIdByDirectory = {}; +// What each running script is, by run (#559): the quit reads which of them +// are a site's build watch, to start those again at the next launch. +const scriptByRunId = {}; // The same directory index for installs. The renderer knows a script's runId // (`npm:run-script` returns it before the first log line) but never an // installId — `runNpmInstall` keeps that correlation id to itself in the @@ -2681,7 +2685,50 @@ app.on('window-all-closed', function () { // Known residual gap on Windows: taskkill /T walks parent links at kill time, // so a grandchild whose intermediate parent is already gone can survive // (observed with grunt _watch) — tracked in #83. +// With the quit setting on 'restart' (#559), what is running is written down +// before it is swept, for the next launch to start again. Written through +// the store's synchronous accessor: this handler is not awaited, and the +// store has been made by now, at startup, for the locale. A store not yet +// made is a launch that read nothing, with nothing running to remember. +function rememberRunningSites() { + const s = peekStore(); + if (!s) return; + const preferences = s.get('preferences') || {}; + if (readSettings(preferences).quitBehavior !== 'restart') return; + const resume = sitesToResume({ + servers: Object.keys(playgroundServers).filter((sitePath) => playgroundServers[sitePath]?.child), + scripts: Object.keys(runningScripts).map((runId) => scriptByRunId[runId]).filter(Boolean), + watchFor: (sitePath) => { + const meta = (s.get('siteMeta') || {})[sitePath]; + return meta ? projectTypeForSite(meta).build.watch : null; + } + }); + logEvent('quit', `remembering ${resume.servers.length} server(s) and ${resume.watches.length} watch(es) to start again`); + s.set('preferences', { ...preferences, resume }); +} + +// The list the quit left, read once by the window as it opens and then +// forgotten, so a launch that ends badly does not start it all again twice. +ipcMain.handle('sites:resume', async () => { + const s = await getStore(); + const preferences = s.get('preferences') || {}; + const resume = readResume(preferences); + if ('resume' in preferences) { + const { resume: _taken, ...rest } = preferences; + s.set('preferences', rest); + } + return { ok: true, ...resume }; +}); + app.on('before-quit', () => { + // Before the sweep, since it reads what is running; and unable to stop + // the sweep, since a store that cannot be written is no reason to leave + // every server and watch running. + try { + rememberRunningSites(); + } catch (e) { + logError('quit', `could not remember what is running: ${String(e && e.message ? e.message : e)}`); + } logEvent('quit', 'sweeping child processes'); const children = [ ...Object.values(runningInstalls), @@ -3758,6 +3805,7 @@ ipcMain.handle('npm:run-script', async (event, directoryPath, scriptName, script relaxEnginesFromStart: true, register: (child) => { runningScripts[runId] = child; + scriptByRunId[runId] = { directoryPath, scriptName, scriptArgs }; runIdByDirectory[directoryPath] = runId; trackDirectoryChild(directoryPath, child); }, @@ -3768,6 +3816,7 @@ ipcMain.handle('npm:run-script', async (event, directoryPath, scriptName, script event.sender.send('npm:run-script:done', { runId, code }); untrackDirectoryChild(directoryPath, runningScripts[runId]); delete runningScripts[runId]; + delete scriptByRunId[runId]; if (runIdByDirectory[directoryPath] === runId) { delete runIdByDirectory[directoryPath]; } diff --git a/src/preload.js b/src/preload.js index a5f4226b..d42243f0 100644 --- a/src/preload.js +++ b/src/preload.js @@ -113,6 +113,10 @@ contextBridge.exposeInMainWorld('api', { , // The PHP versions a site's server can run on, for the settings. listPhpVersions: () => ipcRenderer.invoke('playground:php-versions') +, + // The sites whose server or watch the last quit stopped and is to start + // again (#559). Read once: the list is forgotten as it is read. + takeResumeList: () => ipcRenderer.invoke('sites:resume') , // The menu's "Settings…" asked for the dialog. subscribeSettingsOpen: (handler) => { diff --git a/src/renderer/auto-start.cjs b/src/renderer/auto-start.cjs new file mode 100644 index 00000000..da6e5d46 --- /dev/null +++ b/src/renderer/auto-start.cjs @@ -0,0 +1,37 @@ +'use strict'; + +/** + * What opening a site starts (#559): the server, the watch, as the settings + * say, and what the last quit left for the site to start again. + * + * Each is an edge, consumed once: the row becoming the open one, and the + * list the quit left being read. The row keeps two flags for them and asks + * here, once the site is ready for a start, what to consume and what to + * start. Kept as a pure module so `node --test` covers the decision; the + * row keeps the gates (the site's status read, nothing under way) and the + * calls. + * + * @param {Object} root0 + * @param {boolean} root0.open The row became the open one and has not asked since. + * @param {boolean} root0.isActive The row is the open one now. + * @param {boolean} root0.resumed The list the quit left has been acted on. + * @param {?Object} root0.resume What the list says for this site, `{ server, watch }`, or null for nothing. + * @param {Object} root0.settings The settings, with `autoStartServer` and `autoStartWatch`. + * @return {{consumeOpen: boolean, consumeResume: boolean, server: boolean, watch: boolean}} Which edges to consume, and what to start. + */ +function autoStartPlan({ open, isActive, resumed, resume, settings }) { + const plan = { consumeOpen: false, consumeResume: false, server: false, watch: false }; + if (open && isActive) { + plan.consumeOpen = true; + plan.server = Boolean(settings.autoStartServer); + plan.watch = Boolean(settings.autoStartWatch); + } + if (!resumed && resume) { + plan.consumeResume = true; + if (resume.server) plan.server = true; + if (resume.watch) plan.watch = true; + } + return plan; +} + +module.exports = { autoStartPlan }; diff --git a/src/renderer/components/settings-dialog.jsx b/src/renderer/components/settings-dialog.jsx index b82077b8..4f21117a 100644 --- a/src/renderer/components/settings-dialog.jsx +++ b/src/renderer/components/settings-dialog.jsx @@ -6,7 +6,7 @@ import { useEffect, useId, useMemo, useState } from 'react'; import { __experimentalToggleGroupControl as ToggleGroupControl, __experimentalToggleGroupControlOption as ToggleGroupControlOption } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; 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 { githubAccountLine, newSiteLocationNote, languageItems, languageValue, languageChanged, phpVersionChoice, quitItems, 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 @@ -81,6 +81,56 @@ function LanguageControl({ settings, loaded, onChange }) { ); } +// What a site opened starts, and what the quit does with what is running. +// The quit stops servers and watches either way: the one choice is whether +// the next launch starts them again. +function OpeningAndQuitting({ settings, 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 items = quitItems(); + return ( + + }>{__('Opening and quitting')} + keep('autoStartServer', checked)} + /> + keep('autoStartWatch', checked)} + /> + keep('quitBehavior', value)} + > + {items.map((item) => ( + + {item.label} + + ))} + + {error ? ( + + {error} + + ) : null} + + ); +} + // The folder new sites go in. The system's dialog chooses it, main checks // it, and what main then holds is what is shown: a folder it refused is // said under the field and nothing changes. @@ -108,6 +158,7 @@ function GeneralTab({ settings, loaded, onChange }) { }>{__('Appearance')} + }>{__('New sites')} { + let cancelled = false; + window.api.takeResumeList() + .then((res) => { if (!cancelled) setResume(res?.ok ? { servers: res.servers, watches: res.watches } : { servers: [], watches: [] }); }) + .catch(() => { if (!cancelled) setResume({ servers: [], watches: [] }); }); + return () => { cancelled = true; }; + }, []); const [settingsOpen, setSettingsOpen] = useState(false); const openSettings = useCallback(() => setSettingsOpen(true), []); const closeSettings = useCallback(() => setSettingsOpen(false), []); @@ -690,6 +701,7 @@ function App() { projectType={siteMeta?.[s]?.projectType} settings={settings} startingPhp={startingPhp} + resume={resumeFor(resume, s)} onInitialized={onInitialized} onSiteMetaPatch={onSiteMetaPatch} onDelete={onDelete} @@ -745,7 +757,7 @@ function App() { ); } -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 }) { +function SiteRow({ sitePath, initialized, createdAt, label, projectType = null, settings = null, startingPhp = null, resume = 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. @@ -1248,6 +1260,39 @@ function SiteRow({ sitePath, initialized, createdAt, label, projectType = null, // through everything above, and because what follows reads whether an // update is under way. const { updateState, isUpdating, updateHeld, updateWaitingOnWatch, updateSteps, updateStepStates, lastUpdateSummary, setLastUpdateSummary, dirtyModalOpen, setDirtyModalOpen, dirtySaving, dirtyFiles, dirtyError, startTrunkUpdate, dirtySaveAndUpdate, dirtyDiscardAndUpdate, retryInstallAndBuild } = useTrunkUpdate({ sitePath, confirm, confirmAnd, installing, building, runInstall, runScript, killCurrent, terminalStateRef, terminalKillRef, markTerminalRunning, writeToTerminal, refuseInTerminal, revealTerminal, watchStateRef, watchWaitersRef, pauseWatcher, resumeWatcher, watchRebuildsOnStart, loadStatus, refreshDirty, applyDiscardToNote }); + // What opening the site starts (#559): the server, the watch, as the + // settings say, and what the last quit left for this site to start again. + // Which edge to consume and what to start is auto-start.cjs's; what is + // here is the gates and the calls. What an edge asks for waits until the + // site is ready for it, its status read and its setup done, and is + // dropped by a deactivation first. Nothing starts under an update, a + // setup or a deletion, and nothing that is already running or starting is + // started again: the watch is started after the server's start has + // answered, and only where that start did not bring it up. + const autoStart = useRef({ open: false, resumed: false }); + useEffect(() => { autoStart.current.open = isActive; }, [isActive]); + const starters = useRef({ toggleDevServer, startBuildWatch }); + useEffect(() => { starters.current = { toggleDevServer, startBuildWatch }; }); + useEffect(() => { + if (statusLoading || !skipInit || !settings) return; + if (isPending || isDeleting || isUpdating || setupChainState !== 'idle') return; + const plan = autoStartPlan({ open: autoStart.current.open, isActive, resumed: autoStart.current.resumed, resume, settings }); + if (plan.consumeOpen) autoStart.current.open = false; + if (plan.consumeResume) autoStart.current.resumed = true; + if (!plan.server && !plan.watch) return; + const watchUp = () => ['watching', 'building'].includes(watchStateRef.current); + const serverTried = plan.server && !isDevProcessActive; + (async () => { + if (serverTried) await starters.current.toggleDevServer(); + // A server's start that found the terminal held has already been + // refused the watch, and said so; the watch is not asked for again. + if (plan.watch && !watchUp() && !(serverTried && terminalStateRef.current.running)) starters.current.startBuildWatch(); + })().catch((err) => { + // eslint-disable-next-line no-console -- reaches the log file, see the note in useDetectedEditors. + console.error('Could not start what opening the site asks for:', err); + }); + }, [isActive, resume, settings, statusLoading, skipInit, isPending, isDeleting, isUpdating, setupChainState, isDevProcessActive, watchStateRef, terminalStateRef]); + // What the page says about the site's two processes (#557), in the header // and in the details alike. Decided in site-processes.cjs, and worked out // here because an update of trunk holds both. diff --git a/src/renderer/settings-view.cjs b/src/renderer/settings-view.cjs index 4fe99473..fc6aa021 100644 --- a/src/renderer/settings-view.cjs +++ b/src/renderer/settings-view.cjs @@ -109,4 +109,33 @@ function phpVersionChoice({ versions, fallback, stored }) { }; } -module.exports = { githubAccountLine, newSiteLocationNote, languageItems, languageValue, languageChanged, phpVersionChoice, SYSTEM_LANGUAGE }; +/** + * The entries of the control for what happens on quit (#559). Two and not + * the prototype's three: its "keep sites running" would leave children past + * the quit, which the quit sweep exists to end. + * + * @return {Array<{value: string, label: string}>} + */ +function quitItems() { + return [ + { value: 'stop', label: __('Stop them') }, + { value: 'restart', label: __('Stop them, and start them again next time') } + ]; +} + +/** + * What the next launch starts for a site, from the list the last quit left: + * its server, its watch, both, or nothing. + * + * @param {?{servers: string[], watches: string[]}} resume The list, or null while it has not been read. + * @param {string} sitePath + * @return {?{server: boolean, watch: boolean}} What to start, or null for nothing. + */ +function resumeFor(resume, sitePath) { + if (!resume) return null; + const server = (resume.servers || []).includes(sitePath); + const watch = (resume.watches || []).includes(sitePath); + return server || watch ? { server, watch } : null; +} + +module.exports = { githubAccountLine, newSiteLocationNote, languageItems, languageValue, languageChanged, phpVersionChoice, quitItems, resumeFor, SYSTEM_LANGUAGE }; diff --git a/src/resume-sites.cjs b/src/resume-sites.cjs new file mode 100644 index 00000000..dcf88857 --- /dev/null +++ b/src/resume-sites.cjs @@ -0,0 +1,55 @@ +'use strict'; + +/** + * What the app starts again at its next launch (#559): with the quit setting + * on 'restart', the sites whose development server or build watch was + * running when it quit. The quit sweep stops them either way; this is the + * list that brings them back. + * + * Pure, so `node --test` covers it: main hands it what it tracks of its + * children, and reads back what the store holds. + */ + +/** + * The sites to start again, from what is running now. + * + * A site with both a server and its watch is listed under both: the next + * launch starts the server, and then the watch where the server's start + * did not bring it with it. + * + * @param {Object} root0 + * @param {string[]} root0.servers The sites with a running server. + * @param {Array<{directoryPath: string, scriptName: string, scriptArgs: string[]}>} root0.scripts The scripts running, by site. + * @param {Function} root0.watchFor Given a site, what its project runs as its watch: `{ script, args }`. + * @return {{servers: string[], watches: string[]}} + */ +function sitesToResume({ servers = [], scripts = [], watchFor }) { + const withServer = [...new Set(servers)]; + const watches = []; + for (const { directoryPath, scriptName, scriptArgs = [] } of scripts) { + if (watches.includes(directoryPath)) continue; + const watch = watchFor(directoryPath); + if (!watch) continue; + const sameArgs = JSON.stringify(watch.args || []) === JSON.stringify(scriptArgs || []); + if (watch.script === scriptName && sameArgs) watches.push(directoryPath); + } + return { servers: withServer, watches }; +} + +/** + * What the store holds to start again, and only while the quit setting still + * says so: a list left by a launch under 'restart' is not followed under + * 'stop'. + * + * @param {Object} preferences The store's `preferences`. + * @return {{servers: string[], watches: string[]}} + */ +function readResume(preferences) { + const stored = preferences && typeof preferences === 'object' ? preferences : {}; + const list = stored.resume && typeof stored.resume === 'object' ? stored.resume : {}; + const paths = (value) => (Array.isArray(value) ? value.filter((entry) => typeof entry === 'string' && entry) : []); + if (stored.quitBehavior !== 'restart') return { servers: [], watches: [] }; + return { servers: paths(list.servers), watches: paths(list.watches) }; +} + +module.exports = { sitesToResume, readResume }; diff --git a/src/settings-store.js b/src/settings-store.js index 0ecf2524..be70d60f 100644 --- a/src/settings-store.js +++ b/src/settings-store.js @@ -36,4 +36,15 @@ async function getStore() { return store; } -module.exports = { getStore }; +/** + * The store, where it has already been made, or null: for the one caller + * that cannot wait, the quit, which runs in a handler that is not awaited + * (#559). By then the store has been read for the locale at startup. + * + * @return {?Object} + */ +function peekStore() { + return store || null; +} + +module.exports = { getStore, peekStore }; diff --git a/src/settings.cjs b/src/settings.cjs index fc6d23a7..e4ea16db 100644 --- a/src/settings.cjs +++ b/src/settings.cjs @@ -19,6 +19,9 @@ const { __ } = require('@wordpress/i18n'); +// What the quit setting can be. +const QUIT_BEHAVIOURS = ['stop', 'restart']; + // A switch: on or off, and nothing for the fallback. function acceptSwitch(value) { if (value === null || value === undefined) return { ok: true, value: null }; @@ -59,6 +62,23 @@ const SETTINGS = { // anyone's to change. wpDebug: { fallback: true, accept: acceptSwitch }, scriptDebug: { fallback: true, accept: acceptSwitch }, + // What starts when a site is opened: its development server, its build + // watch, both or neither. Off unless turned on: a server is minutes of + // CPU on a laptop at a Contributor Day. + autoStartServer: { fallback: false, accept: acceptSwitch }, + autoStartWatch: { fallback: false, accept: acceptSwitch }, + // What happens to running servers and watches when the app quits: they + // are stopped either way, since the quit sweep ends every child the app + // started; 'restart' remembers which sites had one and starts them + // again at the next launch. + quitBehavior: { + fallback: 'stop', + accept(value) { + if (value === null || value === undefined || value === '') return { ok: true, value: null }; + if (!QUIT_BEHAVIOURS.includes(value)) return { ok: false, error: __('Choose what happens when the app quits.') }; + return { ok: true, value }; + } + }, // 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 @@ -97,6 +117,9 @@ function readSettings(preferences = {}) { phpVersion: text('phpVersion'), wpDebug: flag('wpDebug'), scriptDebug: flag('scriptDebug'), + autoStartServer: flag('autoStartServer'), + autoStartWatch: flag('autoStartWatch'), + quitBehavior: QUIT_BEHAVIOURS.includes(stored.quitBehavior) ? stored.quitBehavior : SETTINGS.quitBehavior.fallback, newSiteLocation: text('newSiteLocation') }; } diff --git a/tests/e2e/journeys/auto-start.spec.js b/tests/e2e/journeys/auto-start.spec.js new file mode 100644 index 00000000..3d82b9ca --- /dev/null +++ b/tests/e2e/journeys/auto-start.spec.js @@ -0,0 +1,197 @@ +/** + * What opening a site starts, and what the next launch starts again (#559): + * the two settings that start the server or the build watch when a site is + * opened, and the quit setting that remembers what was running. + * + * Nothing is run. The handlers that start the server and a script are + * answered by stubs that keep what they were asked, as dev-server.spec.js + * does, and the test says what the main process would say. What the quit + * writes is ipc-wiring's subject: here the list is seeded as a quit would + * leave it, and what the launch does with it is what is watched. + * + * Assertions are marked INVARIANT or CHARACTERISATION; see + * ticket-branches.spec.js for why. + */ +const { test, expect } = require( '../helpers/app.cjs' ); +const ui = require( '../helpers/ui.cjs' ); +const { makeSite } = require( '../helpers/git-site.cjs' ); + +const URL = 'http://127.0.0.1:9400/'; +const CORE_WATCH = { name: 'grunt', args: [ '--', '_watch' ] }; + +// The stand-ins, in place before the window asks anything: a launch starts +// what it starts as soon as a site's status is read. So every journey here +// launches first with nothing to start, seeds the store once that launch is +// up, and relaunches with the stand-ins in place: a first launch with the +// setting on and no stand-in would start a real server. +async function standIn( app ) { + await app.evaluate( ( { ipcMain } ) => { + const asked = { starts: [], scripts: [] }; + global.__e2eAuto = asked; + const replace = ( channel, handler ) => { + ipcMain.removeHandler( channel ); + ipcMain.handle( channel, handler ); + }; + replace( 'playground:start', ( event, dir ) => { + asked.starts.push( dir ); + return { ok: true }; + } ); + replace( 'playground:stop', () => ( { ok: true } ) ); + replace( 'npm:run-script', ( event, dir, name, args ) => { + asked.scripts.push( { dir, name, args } ); + return { runId: `e2e-run-${ asked.scripts.length }` }; + } ); + replace( 'npm:kill', () => ( { ok: true } ) ); + replace( 'url:open', () => true ); + } ); +} +const asked = ( app ) => app.evaluate( () => global.__e2eAuto ); +const tell = ( app, channel, payload ) => app.evaluate( ( { BrowserWindow }, [ to, what ] ) => { + for ( const win of BrowserWindow.getAllWindows() ) win.webContents.send( to, what ); +}, [ channel, payload ] ); +// The signal for nothing having been started for the open site. A start is +// asked of main only after awaits of its own, so a round trip to main is not +// enough; what a start does first, before any await, is set the site's +// controls to "starting". So: the site's status is read (the terminal's +// hint says so), the settings are in the page (the details say what they +// hold), the effects have run (a frame and a tick), and then both controls +// still offer a start, and nothing was asked of main. +async function nothingStarted( page, app ) { + await expect( ui.terminalHint( page, 'npm run build' ) ).toBeVisible( { timeout: 30_000 } ); + await expect( page.getByRole( 'complementary', { name: /^Details of / } ).getByText( 'WordPress Core · PHP 8.3', { exact: true } ) ).toBeVisible(); + await page.evaluate( () => new Promise( ( done ) => window.requestAnimationFrame( () => window.setTimeout( done, 0 ) ) ) ); + await expect( ui.startDevServerButton( page ) ).toBeVisible(); + await expect( ui.startBuildWatchButton( page ) ).toBeVisible(); + await page.evaluate( () => window.api.getSitesWithMeta() ); + expect( await asked( app ) ).toEqual( { starts: [], scripts: [] } ); +} +// The same settling, where something was started and nothing more should be. +async function nothingMore( page ) { + await page.evaluate( () => new Promise( ( done ) => window.requestAnimationFrame( () => window.setTimeout( done, 0 ) ) ) ); + await page.evaluate( () => window.api.getSitesWithMeta() ); +} + +test( 'with the server set to start when a site is opened, opening one asks for its server once, and not again while it runs', async ( { session } ) => { + const first = await makeSite( session, { label: 'first' } ); + const second = await makeSite( session, { label: 'second' } ); + const settings = { + sites: [ first.dir, second.dir ], + siteMeta: { ...first.settings.siteMeta, ...second.settings.siteMeta }, + preferences: { autoStartServer: true } + }; + await session.start( { ...settings, preferences: {} } ); + session.writeSettings( settings ); + const { app, page } = await session.restart( { beforeWindow: standIn } ); + + // INVARIANT — the site the window opens on, whichever the list puts + // first, is opened: its server is asked for, once, and on Core the watch + // with it, as a press of Start does. The terminal is held while the + // server starts, so the terminal's hint is not what to wait for here; + // the asking is. + await expect.poll( async () => ( await asked( app ) ).starts.length, { timeout: 30_000 } ).toBe( 1 ); + const [ opened ] = ( await asked( app ) ).starts; + const [ openedSite, otherSite ] = opened === first.dir ? [ first, second ] : [ second, first ]; + await expect( ui.siteHeading( page, openedSite.settings.siteMeta[ openedSite.dir ].label ) ).toBeVisible(); + await expect.poll( async () => ( await asked( app ) ).scripts ).toEqual( [ { dir: openedSite.dir, ...CORE_WATCH } ] ); + await tell( app, 'playground:url', { sitePath: openedSite.dir, url: URL } ); + await expect( ui.stopDevServerButton( page ) ).toBeVisible(); + + // INVARIANT — opening another site asks for that site's server; coming + // back to one whose server runs asks for nothing more. + const otherLabel = otherSite.settings.siteMeta[ otherSite.dir ].label; + const openedLabel = openedSite.settings.siteMeta[ openedSite.dir ].label; + await ui.sidebarEntry( page, otherLabel ).click(); + await expect( ui.siteHeading( page, otherLabel ) ).toBeVisible(); + await expect.poll( async () => ( await asked( app ) ).starts ).toEqual( [ openedSite.dir, otherSite.dir ] ); + await ui.sidebarEntry( page, openedLabel ).click(); + await expect( ui.siteHeading( page, openedLabel ) ).toBeVisible(); + await ui.sidebarEntry( page, otherLabel ).click(); + await expect( ui.siteHeading( page, otherLabel ) ).toBeVisible(); + await nothingMore( page ); + expect( ( await asked( app ) ).starts ).toEqual( [ openedSite.dir, otherSite.dir ] ); + // The server that runs was not stopped by a second toggle: its control + // still offers to stop it. + await ui.sidebarEntry( page, openedLabel ).click(); + await expect( ui.stopDevServerButton( page ) ).toBeVisible(); +} ); + +test( 'with the watch set to start when a site is opened, opening one asks for the watch and not the server; with neither set, nothing', async ( { session } ) => { + const site = await makeSite( session ); + await session.start( site.settings ); + session.writeSettings( { ...site.settings, preferences: { autoStartWatch: true } } ); + const { app, page } = await session.restart( { beforeWindow: standIn } ); + + // INVARIANT — the watch, by its project's script, and no server. + await expect.poll( async () => ( await asked( app ) ).scripts, { timeout: 30_000 } ).toEqual( [ { dir: site.dir, ...CORE_WATCH } ] ); + await nothingMore( page ); + expect( ( await asked( app ) ).starts ).toEqual( [] ); + + // INVARIANT — turned off in the settings, opening the site again starts + // nothing: the watch was stopped by the test, so there is one to start. + await tell( app, 'npm:run-script:done', { runId: 'e2e-run-1', code: 0 } ); + await ui.settingsButton( page ).click(); + const dialog = ui.settingsDialog( page ); + await dialog.getByRole( 'switch', { name: 'Start the build watch when I open a site', exact: true } ).click(); + await expect.poll( () => session.readSettings().preferences?.autoStartWatch ).toBe( false ); + await ui.closeDialogButton( dialog ).click(); + const again = await session.restart( { beforeWindow: standIn } ); + await ui.openTray( again.page, 'Terminal' ); + await nothingStarted( again.page, again.app ); +} ); + +test( 'what the last quit stopped is started again at the next launch, once, and only while the quit setting says so', async ( { session } ) => { + const served = await makeSite( session, { label: 'served' } ); + const watched = await makeSite( session, { label: 'watched' } ); + const idle = await makeSite( session, { label: 'idle' } ); + const settings = { + sites: [ served.dir, watched.dir, idle.dir ], + siteMeta: { ...served.settings.siteMeta, ...watched.settings.siteMeta, ...idle.settings.siteMeta }, + // As a quit under 'restart' leaves the store: a Core site whose server + // runs has its watch running too, and is under both. + preferences: { quitBehavior: 'restart', resume: { servers: [ served.dir ], watches: [ watched.dir, served.dir ] } } + }; + // The first launch is given nothing, and the list is seeded for the + // launch that is watched only once that launch's main process is up: a + // quit reads the store as it is, and the first launch's quit, finding + // 'restart' and nothing running, would write an empty list over one + // seeded before it. + await session.start( { ...settings, preferences: { quitBehavior: 'stop' } } ); + const { app, page } = await session.restart( { beforeWindow: async ( launched ) => { session.writeSettings( settings ); await standIn( launched ); } } ); + + // INVARIANT — the served site's server and the watched site's watch are + // asked for, whichever site the window opened on, and the idle site's + // nothing; the served site's watch, listed too, is asked for once, since + // on Core it comes with its server. And the list is forgotten as it is + // read. + await expect.poll( async () => ( await asked( app ) ).starts, { timeout: 30_000 } ).toEqual( [ served.dir ] ); + const byDir = ( scripts ) => [ ...scripts ].sort( ( a, b ) => a.dir.localeCompare( b.dir ) ); + const expectedWatches = byDir( [ { dir: watched.dir, ...CORE_WATCH }, { dir: served.dir, ...CORE_WATCH } ] ); + await expect.poll( async () => byDir( ( await asked( app ) ).scripts ) ).toEqual( expectedWatches ); + await nothingMore( page ); + expect( byDir( ( await asked( app ) ).scripts ) ).toEqual( expectedWatches ); + await expect.poll( () => 'resume' in ( session.readSettings().preferences || {} ) ).toBe( false ); + + // INVARIANT — opened again with nothing left by a quit, nothing starts. + const again = await session.restart( { beforeWindow: standIn } ); + await ui.openTray( again.page, 'Terminal' ); + await nothingStarted( again.page, again.app ); + + // INVARIANT — a list left under 'restart' is not followed under 'stop'. + session.writeSettings( { ...settings, preferences: { quitBehavior: 'stop', resume: settings.preferences.resume } } ); + const stopped = await session.restart( { beforeWindow: standIn } ); + await ui.openTray( stopped.page, 'Terminal' ); + await nothingStarted( stopped.page, stopped.app ); +} ); + +test( 'the quit setting is chosen in the settings and kept', async ( { session } ) => { + const site = await makeSite( session ); + const { page } = await session.start( site.settings ); + await ui.settingsButton( page ).click(); + const dialog = ui.settingsDialog( page ); + const quit = dialog.getByRole( 'combobox', { name: 'When I quit, running servers and build watches', exact: true } ); + await expect( quit ).toHaveText( 'Stop them' ); + await quit.click(); + await page.getByRole( 'option', { name: 'Stop them, and start them again next time', exact: true } ).click(); + await expect( quit ).toHaveText( 'Stop them, and start them again next time' ); + await expect.poll( () => session.readSettings().preferences?.quitBehavior ).toBe( 'restart' ); +} ); diff --git a/tests/e2e/journeys/site-header.spec.js b/tests/e2e/journeys/site-header.spec.js index 00b28163..fe83e8b5 100644 --- a/tests/e2e/journeys/site-header.spec.js +++ b/tests/e2e/journeys/site-header.spec.js @@ -332,7 +332,18 @@ test( 'the details stay in view while the cards scroll for as long as they fit t // 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 ); + const { app, page } = await session.start( settings ); + // The window opens up to 1280×800, less on a smaller screen, and on a + // screen that gives it all of that the tray at its largest still leaves + // the page room for these details, which the second half needs it not + // to. Sized here as the smallest screens the suite runs on size it, so + // that both halves hold wherever the suite runs. + await app.evaluate( ( { BrowserWindow } ) => { + const win = BrowserWindow.getAllWindows()[ 0 ]; + const [ minWidth, minHeight ] = win.getMinimumSize(); + win.setSize( Math.max( 1024, minWidth ), Math.max( 700, minHeight ) ); + } ); + await expect.poll( () => page.evaluate( () => window.innerHeight ) ).toBeLessThanOrEqual( 700 ); // 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. await page.emulateMedia( { reducedMotion: 'reduce' } ); diff --git a/tests/e2e/packaged/smoke.spec.js b/tests/e2e/packaged/smoke.spec.js index 72950063..a5277fa0 100644 --- a/tests/e2e/packaged/smoke.spec.js +++ b/tests/e2e/packaged/smoke.spec.js @@ -113,6 +113,7 @@ const EXPECTED_API_KEYS = [ 'subscribeSetupStatus', 'subscribeSwitchProgress', 'switchBranch', + 'takeResumeList', 'updateTrunk', ]; diff --git a/tests/unit/auto-start.test.cjs b/tests/unit/auto-start.test.cjs new file mode 100644 index 00000000..3fe7fe40 --- /dev/null +++ b/tests/unit/auto-start.test.cjs @@ -0,0 +1,32 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { autoStartPlan } = require('../../src/renderer/auto-start.cjs'); + +const OFF = { autoStartServer: false, autoStartWatch: false }; +const nothing = { consumeOpen: false, consumeResume: false, server: false, watch: false }; + +test('a row that became the open one consumes that edge and asks for what the settings say', () => { + assert.deepEqual(autoStartPlan({ open: true, isActive: true, resumed: true, resume: null, settings: { autoStartServer: true, autoStartWatch: false } }), { consumeOpen: true, consumeResume: false, server: true, watch: false }); + assert.deepEqual(autoStartPlan({ open: true, isActive: true, resumed: true, resume: null, settings: { autoStartServer: false, autoStartWatch: true } }), { consumeOpen: true, consumeResume: false, server: false, watch: true }); + assert.deepEqual(autoStartPlan({ open: true, isActive: true, resumed: true, resume: null, settings: OFF }), { consumeOpen: true, consumeResume: false, server: false, watch: false }); +}); + +test('the open edge is not consumed by a row that is not the open one, nor twice', () => { + assert.deepEqual(autoStartPlan({ open: true, isActive: false, resumed: true, resume: null, settings: { autoStartServer: true, autoStartWatch: true } }), nothing); + assert.deepEqual(autoStartPlan({ open: false, isActive: true, resumed: true, resume: null, settings: { autoStartServer: true, autoStartWatch: true } }), nothing); +}); + +test('the list the quit left is acted on once, for the open row and the others alike, whatever the settings say', () => { + assert.deepEqual(autoStartPlan({ open: false, isActive: false, resumed: false, resume: { server: true, watch: false }, settings: OFF }), { consumeOpen: false, consumeResume: true, server: true, watch: false }); + assert.deepEqual(autoStartPlan({ open: false, isActive: false, resumed: false, resume: { server: false, watch: true }, settings: OFF }), { consumeOpen: false, consumeResume: true, server: false, watch: true }); + assert.deepEqual(autoStartPlan({ open: false, isActive: false, resumed: false, resume: { server: true, watch: true }, settings: OFF }), { consumeOpen: false, consumeResume: true, server: true, watch: true }); + assert.deepEqual(autoStartPlan({ open: false, isActive: false, resumed: true, resume: { server: true, watch: true }, settings: OFF }), nothing); + assert.deepEqual(autoStartPlan({ open: false, isActive: false, resumed: false, resume: null, settings: OFF }), nothing); +}); + +test('both edges at once ask for the union of what each wants', () => { + assert.deepEqual(autoStartPlan({ open: true, isActive: true, resumed: false, resume: { server: false, watch: true }, settings: { autoStartServer: true, autoStartWatch: false } }), { consumeOpen: true, consumeResume: true, server: true, watch: true }); +}); diff --git a/tests/unit/ipc-wiring.test.cjs b/tests/unit/ipc-wiring.test.cjs index f2cc3d60..6ed65621 100644 --- a/tests/unit/ipc-wiring.test.cjs +++ b/tests/unit/ipc-wiring.test.cjs @@ -416,7 +416,7 @@ function fakeSettingsStore(initial = {}) { get: (key) => structuredClone(values[key]), set: (key, value) => { values[key] = value; } }; - return { values, stubs: { './settings-store': { getStore: async () => store } } }; + return { values, stubs: { './settings-store': { getStore: async () => store, peekStore: () => store } } }; } // --- sites:delete -> src/site-registry.js -------------------------------- @@ -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, phpVersion: '8.3', wpDebug: true, scriptDebug: true, newSiteLocation: folder } }); + assert.deepEqual(await main.invoke('settings:set', 'newSiteLocation', folder), { ok: true, settings: { locale: null, phpVersion: '8.3', wpDebug: true, scriptDebug: true, autoStartServer: false, autoStartWatch: false, quitBehavior: 'stop', 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); @@ -2545,6 +2545,103 @@ test('quitting sweeps every kind of running child through kill-tree', async (t) } }); +// --- the quit remembers what to start again -> src/resume-sites.cjs (#559) --- + +// With the quit setting on 'restart', what is running is written down before +// the sweep: the sites with a server, and those running their project's +// watch. The decision of which script is a watch is the module's; what is +// here is that the handler hands it what main tracks, and writes the answer. +test('before-quit writes the sites to start again when the quit setting says restart, and nothing otherwise', async (t) => { + for (const quitBehavior of ['restart', 'stop']) { + const cp = stubbedSpawn(); + const settings = fakeSettingsStore({ + sites: ['/sites/wp', '/sites/gb'], + siteMeta: { '/sites/wp': {}, '/sites/gb': { projectType: 'gutenberg' } }, + preferences: { quitBehavior, wporgHandle: 'janedoe' } + }); + const main = loadMain({ + stubs: { + ...silentLogging(), + ...noSmtpServer(), + ...settings.stubs, + 'child_process': { spawn: cp.spawn }, + './npm-runner': { buildChildEnv: () => ({}) }, + './kill-tree': { killChildTree: () => {} } + } + }); + // A Gutenberg site running its watch, a Core site running a build, and + // a Core site's server. + await main.invoke('npm:run-script', '/sites/gb', 'dev'); + await main.invoke('npm:run-script', '/sites/wp', 'build'); + const pendingServer = main.invoke('playground:start', '/sites/wp'); + await waitForSpawnCount(cp, 3); + t.after(async () => { + cp.children[2].emit('close', 0, null); + await pendingServer; + }); + + await main.emitAppEvent('before-quit'); + + if (quitBehavior === 'restart') { + assert.deepEqual(settings.values.preferences.resume, { servers: ['/sites/wp'], watches: ['/sites/gb'] }); + assert.equal(settings.values.preferences.wporgHandle, 'janedoe', 'the rest of the preferences are kept'); + } else { + assert.equal('resume' in settings.values.preferences, false); + } + } +}); + +test('before-quit sweeps every child even when what is running cannot be written down, and says so in the log', async (t) => { + const cp = stubbedSpawn(); + const killChildTree = spy(() => {}); + const logError = spy(); + const settings = fakeSettingsStore({ sites: ['/sites/wp'], siteMeta: { '/sites/wp': {} }, preferences: { quitBehavior: 'restart' } }); + const store = settings.stubs['./settings-store']; + const main = loadMain({ + stubs: { + ...silentLogging(), + './logging': { ...silentLogging()['./logging'], logError }, + ...noSmtpServer(), + './settings-store': { + getStore: store.getStore, + peekStore: () => ({ get: (key) => structuredClone(settings.values[key]), set: () => { throw new Error('EPERM: settings.json is locked'); } }) + }, + 'child_process': { spawn: cp.spawn }, + './npm-runner': { buildChildEnv: () => ({}) }, + './kill-tree': { killChildTree } + } + }); + await main.invoke('npm:run-script', '/sites/wp', 'build'); + const pendingServer = main.invoke('playground:start', '/sites/wp'); + await waitForSpawnCount(cp, 2); + t.after(async () => { + cp.children[1].emit('close', 0, null); + await pendingServer; + }); + + await main.emitAppEvent('before-quit'); + + assert.equal(killChildTree.calls.length, cp.children.length, 'the sweep reached every child'); + assert.equal(logError.calls.length, 1); + assert.equal(logError.calls[0][0], 'quit'); + assert.match(logError.calls[0][1], /settings\.json is locked/); +}); + +test('sites:resume hands the window the list once, and forgets it, only while the setting still says restart', async () => { + const settings = fakeSettingsStore({ preferences: { quitBehavior: 'restart', resume: { servers: ['/sites/wp'], watches: ['/sites/gb'] }, locale: 'de' } }); + const main = loadMain({ stubs: { ...silentLogging(), ...settings.stubs } }); + + assert.deepEqual(await main.invoke('sites:resume'), { ok: true, servers: ['/sites/wp'], watches: ['/sites/gb'] }); + assert.equal('resume' in settings.values.preferences, false, 'read once'); + assert.equal(settings.values.preferences.locale, 'de'); + assert.deepEqual(await main.invoke('sites:resume'), { ok: true, servers: [], watches: [] }); + + const stopped = fakeSettingsStore({ preferences: { quitBehavior: 'stop', resume: { servers: ['/sites/wp'], watches: [] } } }); + const other = loadMain({ stubs: { ...silentLogging(), ...stopped.stubs } }); + assert.deepEqual(await other.invoke('sites:resume'), { ok: true, servers: [], watches: [] }); + assert.equal('resume' in stopped.values.preferences, false, 'a list left under restart is forgotten under stop'); +}); + // --- playground:* / playground-web:* -> the same two modules -------------- // Starting the per-site SMTP server ends by writing its port to electron-store, @@ -6447,10 +6544,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', phpVersion: '8.3', wpDebug: true, scriptDebug: true, newSiteLocation: null } }); + assert.deepEqual(await main.invoke('settings:set', 'locale', 'de'), { ok: true, settings: { locale: 'de', phpVersion: '8.3', wpDebug: true, scriptDebug: true, autoStartServer: false, autoStartWatch: false, quitBehavior: 'stop', 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, phpVersion: '8.3', wpDebug: true, scriptDebug: true, newSiteLocation: null } }); + assert.deepEqual(await main.invoke('settings:set', 'locale', null), { ok: true, settings: { locale: null, phpVersion: '8.3', wpDebug: true, scriptDebug: true, autoStartServer: false, autoStartWatch: false, quitBehavior: 'stop', newSiteLocation: null } }); }); test('app:relaunch relaunches through a quit, so the child sweep runs, without the launch\'s link or --lang (#559)', async (t) => { @@ -6559,6 +6656,7 @@ const WIRED = new Set([ 'provenance:set-event', 'settings:get', 'settings:set', + 'sites:resume', 'i18n:languages', 'github:account', 'github:sign-in', diff --git a/tests/unit/resume-sites.test.cjs b/tests/unit/resume-sites.test.cjs new file mode 100644 index 00000000..1b94677c --- /dev/null +++ b/tests/unit/resume-sites.test.cjs @@ -0,0 +1,48 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { sitesToResume, readResume } = require('../../src/resume-sites.cjs'); + +const CORE_WATCH = { script: 'grunt', args: ['--', '_watch'] }; +const watchFor = () => CORE_WATCH; + +test('the sites with a server are listed under the servers, once each', () => { + assert.deepEqual(sitesToResume({ servers: ['/a', '/b', '/a'], scripts: [], watchFor }), { servers: ['/a', '/b'], watches: [] }); +}); + +test('a site running its project\'s watch is listed under the watches, and a site running another script is not', () => { + const scripts = [ + { directoryPath: '/w', scriptName: 'grunt', scriptArgs: ['--', '_watch'] }, + { directoryPath: '/b', scriptName: 'build', scriptArgs: [] }, + { directoryPath: '/g', scriptName: 'grunt', scriptArgs: [] } + ]; + assert.deepEqual(sitesToResume({ servers: [], scripts, watchFor }), { servers: [], watches: ['/w'] }); +}); + +test('a site with both a server and its watch is listed under both', () => { + const scripts = [{ directoryPath: '/a', scriptName: 'grunt', scriptArgs: ['--', '_watch'] }]; + assert.deepEqual(sitesToResume({ servers: ['/a'], scripts, watchFor }), { servers: ['/a'], watches: ['/a'] }); +}); + +test('the watch is the project\'s own: a Gutenberg site\'s is npm run dev', () => { + const scripts = [{ directoryPath: '/gb', scriptName: 'dev', scriptArgs: [] }, { directoryPath: '/core', scriptName: 'dev', scriptArgs: [] }]; + const byProject = (dir) => (dir === '/gb' ? { script: 'dev', args: [] } : CORE_WATCH); + assert.deepEqual(sitesToResume({ servers: [], scripts, watchFor: byProject }), { servers: [], watches: ['/gb'] }); + assert.deepEqual(sitesToResume({ servers: [], scripts, watchFor: () => null }), { servers: [], watches: [] }); +}); + +test('nothing running is nothing to start again', () => { + assert.deepEqual(sitesToResume({ watchFor }), { servers: [], watches: [] }); +}); + +test('the list is read back only while the quit setting says restart, and only its paths', () => { + const list = { servers: ['/a', 42, ''], watches: ['/w'] }; + assert.deepEqual(readResume({ quitBehavior: 'restart', resume: list }), { servers: ['/a'], watches: ['/w'] }); + assert.deepEqual(readResume({ quitBehavior: 'stop', resume: list }), { servers: [], watches: [] }); + assert.deepEqual(readResume({ resume: list }), { servers: [], watches: [] }); + assert.deepEqual(readResume({ quitBehavior: 'restart' }), { servers: [], watches: [] }); + assert.deepEqual(readResume({ quitBehavior: 'restart', resume: 'servers' }), { servers: [], watches: [] }); + assert.deepEqual(readResume(undefined), { servers: [], watches: [] }); +}); diff --git a/tests/unit/settings-view.test.cjs b/tests/unit/settings-view.test.cjs index 76fd7885..0908a210 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, phpVersionChoice, SYSTEM_LANGUAGE } = require('../../src/renderer/settings-view.cjs'); +const { githubAccountLine, newSiteLocationNote, languageItems, languageValue, languageChanged, phpVersionChoice, quitItems, resumeFor, 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 }); @@ -68,3 +68,18 @@ test('a version set that the build no longer has shows the fallback as chosen, a note: 'PHP 7.4 was chosen, but this version of the app does not have it; servers start on PHP 8.3.' }); }); + +test('the quit control offers stop and restart, and not the prototype\'s leaving them running', () => { + assert.deepEqual(quitItems().map((item) => item.value), ['stop', 'restart']); + assert.ok(quitItems().every((item) => item.label)); +}); + +test('what the next launch starts for a site comes from the list the quit left', () => { + const resume = { servers: ['/a', '/both'], watches: ['/w', '/both'] }; + assert.deepEqual(resumeFor(resume, '/a'), { server: true, watch: false }); + assert.deepEqual(resumeFor(resume, '/w'), { server: false, watch: true }); + assert.deepEqual(resumeFor(resume, '/both'), { server: true, watch: true }); + assert.equal(resumeFor(resume, '/other'), null); + assert.equal(resumeFor(null, '/a'), null); + assert.equal(resumeFor({}, '/a'), null); +}); diff --git a/tests/unit/settings.test.cjs b/tests/unit/settings.test.cjs index c0a722e8..e0fbeae4 100644 --- a/tests/unit/settings.test.cjs +++ b/tests/unit/settings.test.cjs @@ -12,19 +12,19 @@ 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, phpVersion: '8.3', wpDebug: true, scriptDebug: true, newSiteLocation: null }; + const fallbacks = { locale: null, phpVersion: '8.3', wpDebug: true, scriptDebug: true, autoStartServer: false, autoStartWatch: false, quitBehavior: 'stop', newSiteLocation: null }; assert.deepEqual(readSettings(), fallbacks); assert.deepEqual(readSettings(undefined), fallbacks); assert.deepEqual(readSettings({}), fallbacks); - assert.deepEqual(readSettings({ newSiteLocation: 42, locale: ['de'], phpVersion: 8.4, wpDebug: 'false', scriptDebug: 0 }), fallbacks); + assert.deepEqual(readSettings({ newSiteLocation: 42, locale: ['de'], phpVersion: 8.4, wpDebug: 'false', scriptDebug: 0, autoStartServer: 'yes', autoStartWatch: 1, quitBehavior: 'leave' }), 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', phpVersion: '8.4', wpDebug: false, scriptDebug: false }), - { locale: 'de', phpVersion: '8.4', wpDebug: false, scriptDebug: false, newSiteLocation: '/Users/jane/sites' } + readSettings({ newSiteLocation: '/Users/jane/sites', locale: 'de', phpVersion: '8.4', wpDebug: false, scriptDebug: false, autoStartServer: true, autoStartWatch: true, quitBehavior: 'restart' }), + { locale: 'de', phpVersion: '8.4', wpDebug: false, scriptDebug: false, autoStartServer: true, autoStartWatch: true, quitBehavior: 'restart', newSiteLocation: '/Users/jane/sites' } ); }); @@ -83,10 +83,20 @@ test('a PHP version the bundled Playground has is kept, nothing means the fallba }); test('a debug flag is on or off, nothing means the fallback, and a string is refused', () => { - for (const key of ['wpDebug', 'scriptDebug']) { + for (const key of ['wpDebug', 'scriptDebug', 'autoStartServer', 'autoStartWatch']) { 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); } }); + +test('what happens on quit is stop or restart, nothing means the fallback, and anything else is refused', () => { + assert.deepEqual(acceptSetting('quitBehavior', 'restart', {}), { ok: true, value: 'restart' }); + assert.deepEqual(acceptSetting('quitBehavior', 'stop', {}), { ok: true, value: 'stop' }); + assert.deepEqual(acceptSetting('quitBehavior', null, {}), { ok: true, value: null }); + // The prototype's third answer, leaving servers running past the quit, + // is not one the app gives: the quit sweep ends every child it started. + assert.equal(acceptSetting('quitBehavior', 'leave', {}).ok, false); + assert.equal(acceptSetting('quitBehavior', true, {}).ok, false); +});