From 01b354392704d22cd021ce9b4a983def9b358ed6 Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:01:20 +0800 Subject: [PATCH] feat(web): set daemon settings per daemon, with batch apply Settings now has two groups. App holds this page's own settings (Themes, and Daemons and Notifications once their pages exist). Daemon holds the rest, read from and written to the daemon picked above them: the one on screen by default, kept in the address as ?daemon=. General, Fallback, Runtimes and Backup gain Batch apply, which sends what the page holds to each daemon ticked and says what each answered. Accounts say their secrets stay put. Co-Authored-By: Claude Opus 5.5 --- docs/user/web.md | 23 +- src/hmz/web/static/paths.js | 7 +- src/hmz/web/static/pickers.js | 11 +- src/hmz/web/static/style.css | 67 +++++ src/hmz/web/static/views/settings.js | 240 +++++++++++++++--- src/hmz/web/static/views/settings/accounts.js | 17 +- src/hmz/web/static/views/settings/backup.js | 8 +- src/hmz/web/static/views/settings/fallback.js | 11 +- src/hmz/web/static/views/settings/general.js | 23 +- src/hmz/web/static/views/settings/runtimes.js | 35 +-- src/hmz/web/static/views/settings/usage.js | 11 +- .../web/static/views/settings/workspace.js | 11 +- .../integration/test_web_settings_daemons.py | 174 +++++++++++++ 13 files changed, 541 insertions(+), 97 deletions(-) create mode 100644 tests/integration/test_web_settings_daemons.py diff --git a/docs/user/web.md b/docs/user/web.md index 7b3598a57..e9612dcd5 100644 --- a/docs/user/web.md +++ b/docs/user/web.md @@ -247,12 +247,17 @@ conversation. ### Settings -The same pages as [`/settings`](/user/settings), in the same order, and what the epics spent: +Settings has two groups. **App** is the page's own: how it is drawn, the daemons it reaches and +what it tells you of them. It is the same whichever daemon is on screen. **Daemon** is the same +pages as [`/settings`](/user/settings), in the same order, and what the epics spent, all of one +daemon: | Page | What is there | | --- | --- | -| **General** | Whether a turn's working is shown, the [`/btw` agent](/user/btw), and [error reports](/user/reporting) | | **Themes** | The [themes](#themes) of this browser, and which two **Auto** follows your system with | +| **Daemons** | The daemons this page reaches: **Local**, and each SSH host added or imported | +| **Notifications** | Which notifications appear, from every daemon, and whether the system shows them | +| **General** | Whether a turn's working is shown, the [`/btw` agent](/user/btw), and [error reports](/user/reporting) | | **Accounts** | Every [account](/user/settings#accounts) by its CLI, way in and the names of what it sets: ask what it runs again, **Edit** it (what you leave blank stays as it was), copy it to another CLI, remove it, or make one | | **Fallback** | Each [step](/user/settings#fallback): where a turn goes when a place cannot take it, and how it is tried again | | **Runtimes** | Every runtime: add one from its backend's fields, change, check or remove it, or bring hosts in from your ssh config | @@ -260,6 +265,20 @@ The same pages as [`/settings`](/user/settings), in the same order, and what the | **Workspace** | What a directory was last set up with, and forgetting it -- which also takes it out of the sidebar where no epic ran there | | **Usage** | What the epics spent, of every directory or of one, by day and by flow, looking back 7, 30, 90 or 365 days | +Above the **Daemon** pages, **Daemon** picks which daemon they read and write: the one on screen +until another is picked. It goes into the address as `?daemon=`, so a daemon's page can be +kept and opened again. Picking one changes nothing else on screen. + +**Batch apply**, on **General**, **Fallback**, **Runtimes** and **Backup**, copies what the page +holds to other daemons at once. Tick the daemons, or **All**, then **Apply**. Each daemon then +says **Applied** or why it refused. **General** sends the form as it stands, saved or not. +**Backup** sends every target and default, in place of the daemon's own. A target signed into its URL +is refused there, since the URL is never shown whole. **Fallback** and +**Runtimes** send each step or runtime, written over the one of the same name there, or added. +Nothing a daemon already has is taken away. **Accounts** cannot be copied, since their secrets +are never shown (**Secrets stay put**). **Workspace** and **Usage** are of one daemon's +directories, and have no **Batch apply**. + An account is never shown by its values: a page lists the names of the variables it sets, and nothing else. A way in that runs a CLI's own sign-in, such as `login`, needs a terminal: it is offered as `(at a terminal)` and made from `/settings` in `hmz`. diff --git a/src/hmz/web/static/paths.js b/src/hmz/web/static/paths.js index ccae0649e..2fed069c3 100644 --- a/src/hmz/web/static/paths.js +++ b/src/hmz/web/static/paths.js @@ -14,8 +14,9 @@ import { follow } from './shell.js' * @param {string} [made.placeholder] * @param {string} [made.id] * @param {(path: string) => void} [made.changed] Told the path whenever it changes. + * @param {string} [made.from] Whose paths: '' for the daemon the page is on, else `/d/`. */ -export function pathPicker({ value = '', files = false, label, placeholder = files ? '~/file' : '~/folder', id, changed = () => {} }) { +export function pathPicker({ value = '', files = false, label, placeholder = files ? '~/file' : '~/folder', id, changed = () => {}, from = '' }) { const list = `paths-${Math.random().toString(36).slice(2, 8)}` const input = h('input', { type: 'text', value, placeholder, 'aria-label': label, id, list, autocomplete: 'off', spellcheck: 'false', 'data-hmz': 'path-input' }) const offered = h('datalist', { id: list }) @@ -37,7 +38,7 @@ export function pathPicker({ value = '', files = false, label, placeholder = fil clearTimeout(typed) typed = setTimeout(async () => { try { - const { paths } = await get(`/api/paths?typed=${encodeURIComponent(input.value)}`) + const { paths } = await get(`${from}/api/paths?typed=${encodeURIComponent(input.value)}`) fill(offered, paths.map((one) => h('option', { value: one }))) } catch { fill(offered) @@ -48,7 +49,7 @@ export function pathPicker({ value = '', files = false, label, placeholder = fil async function show(dir) { let read try { - read = await get(`/api/files?at=${encodeURIComponent(dir)}${files ? '&files=1' : ''}${hidden ? '&hidden=1' : ''}`) + read = await get(`${from}/api/files?at=${encodeURIComponent(dir)}${files ? '&files=1' : ''}${hidden ? '&hidden=1' : ''}`) } catch (error) { if (dir !== '~') return show('~') fill(panel, h('p', { class: 'error' }, error.message)) diff --git a/src/hmz/web/static/pickers.js b/src/hmz/web/static/pickers.js index c875a906f..2c1247755 100644 --- a/src/hmz/web/static/pickers.js +++ b/src/hmz/web/static/pickers.js @@ -17,10 +17,13 @@ const failed = new Map() /** Reads the CLIs installed again -- as a workspace runs them -- and tells every picker. */ let reading = '' +/** Whose CLIs they are: '' for the daemon the page is on, else `/d/`, as Settings picks. */ +let base = '' -export async function readBackends(workspace = reading) { +export async function readBackends(workspace = reading, from = '') { reading = workspace - backends = await get(`/api/backends?workspace=${encodeURIComponent(workspace)}`) + base = from + backends = await get(`${from}/api/backends?workspace=${encodeURIComponent(workspace)}`) for (const one of told) one() return backends } @@ -31,12 +34,12 @@ function ask(cli, account) { if (!asking.has(key)) { asking.set( key, - post('/api/accounts/models', { cli, name: account }) + post(`${base}/api/accounts/models`, { cli, name: account }) .then(() => failed.delete(key)) .catch((error) => failed.set(key, error.message)) .finally(() => { asking.delete(key) - return readBackends() + return readBackends(reading, base) }), ) } diff --git a/src/hmz/web/static/style.css b/src/hmz/web/static/style.css index fecebe479..99cfb8638 100644 --- a/src/hmz/web/static/style.css +++ b/src/hmz/web/static/style.css @@ -3845,3 +3845,70 @@ input[aria-invalid='true'] { -webkit-user-select: none; user-select: none; } + +/* settings daemons */ +.settings-group { + margin: 12px 10px 4px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--ink-3); +} + +.settings-nav h2 + .settings-group { + margin-top: 0; +} + +.settings-daemon { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 20px; + border-bottom: 1px solid var(--line); +} + +.settings-daemon select { + min-width: 0; + max-width: 100%; +} + +.batch-dialog { + width: min(calc(100vw - 32px), 420px); + max-height: calc(100vh - 32px); + overflow-y: auto; + padding: 16px 20px; + color: var(--ink); + background: var(--paper); + border: 2px solid var(--ink); + box-shadow: 8px 8px 0 var(--cyan); +} + +.batch-dialog::backdrop { + background: var(--scrim); +} + +.batch-dialog h3 { + margin: 0 0 4px; +} + +.batch-results { + margin: 12px 0; + padding: 0; + list-style: none; + display: grid; + gap: 4px; + overflow-wrap: anywhere; +} + +@media (max-width: 860px) { + .settings-group { + margin: 0 4px 0 8px; + align-self: center; + white-space: nowrap; + } + + .settings-daemon { + padding: 8px 12px; + } +} diff --git a/src/hmz/web/static/views/settings.js b/src/hmz/web/static/views/settings.js index b84805854..25f8e10da 100644 --- a/src/hmz/web/static/views/settings.js +++ b/src/hmz/web/static/views/settings.js @@ -1,59 +1,241 @@ // What humanize remembers, as ChatGPT keeps its settings: a dialog over the page with its pages -// down the side, in the order the terminal interface's `/settings` lists them -- what is true -// wherever humanize runs, who its agents are and what takes over when one fails, where their -// work goes, where epics are backed up, this one directory -- and last what the runs here spent; and, after what is true -// wherever humanize runs, how this browser draws the page. +// down the side, in two groups. The app's own -- how this page is drawn, which daemons it +// reaches and what it tells you of them -- are this browser's, or this machine's, whichever +// daemon is on screen. The daemon's -- what is true wherever humanize runs, who its agents are +// and what takes over when one fails, where their work goes, where epics are backed up, one +// directory, and last what the runs there spent -- are read from and written to the daemon +// picked above them, the one on screen unless another is, and each page that saves can copy +// what it holds to other daemons at once. -import { shut } from '../app.js' +import * as api from '../api.js' +import { shut, stays } from '../app.js' import { fill, h, iconButton, problem } from '../dom.js' import { dragged } from '../shell.js' -/** Every page, by the word in the address that opens it. */ +/** Every page, by the word in the address that opens it, in its group. */ const PAGES = { - general: { label: 'General', about: 'Display, side questions and reporting', load: () => import('./settings/general.js') }, - themes: { label: 'Themes', about: 'Colours and pictures of this browser’s page', load: () => import('./settings/themes.js') }, - accounts: { label: 'Accounts', about: 'Credentials agents run as', load: () => import('./settings/accounts.js') }, - fallback: { label: 'Fallback', about: 'Where a failed turn goes next', load: () => import('./settings/fallback.js') }, - runtimes: { label: 'Runtimes', about: 'Machines environments run on', load: () => import('./settings/runtimes.js') }, - backup: { label: 'Backup', about: 'Git repositories epics go to', load: () => import('./settings/backup.js') }, - workspace: { label: 'Workspace', about: 'This directory’s saved setup', load: () => import('./settings/workspace.js') }, - usage: { label: 'Usage', about: 'Spending by day and flow', load: () => import('./settings/usage.js') }, + themes: { group: 'app', label: 'Themes', about: 'Colours and pictures of this browser’s page', load: () => import('./settings/themes.js') }, + // Each only where its module is there to load. + daemons: { group: 'app', optional: true, label: 'Daemons', about: 'Machines this page reaches', load: () => import('./settings/daemons.js') }, + notifications: { group: 'app', optional: true, label: 'Notifications', about: 'What every daemon tells you', load: () => import('./settings/notifications.js') }, + general: { group: 'daemon', label: 'General', about: 'Display, side questions and reporting', load: () => import('./settings/general.js') }, + accounts: { group: 'daemon', label: 'Accounts', about: 'Credentials agents run as', load: () => import('./settings/accounts.js') }, + fallback: { group: 'daemon', label: 'Fallback', about: 'Where a failed turn goes next', load: () => import('./settings/fallback.js') }, + runtimes: { group: 'daemon', label: 'Runtimes', about: 'Machines environments run on', load: () => import('./settings/runtimes.js') }, + backup: { group: 'daemon', label: 'Backup', about: 'Git repositories epics go to', load: () => import('./settings/backup.js') }, + workspace: { group: 'daemon', label: 'Workspace', about: 'This directory’s saved setup', load: () => import('./settings/workspace.js') }, + usage: { group: 'daemon', label: 'Usage', about: 'Spending by day and flow', load: () => import('./settings/usage.js') }, +} + +const GROUPS = [ + ['app', 'App'], + ['daemon', 'Daemon'], +] + +/** The daemon the page is on, where the page knows of more than one. */ +function current() { + return (typeof api.daemon === 'function' && api.daemon()) || 'local' +} + +/** Where a daemon's API is reached from here: as the page reaches it, or through this server. */ +function prefix(name) { + return name === current() ? '' : `/d/${encodeURIComponent(name)}` +} + +/** What a daemon is called on screen. */ +function called(one) { + return one.label || (one.name === 'local' ? 'Local' : one.name) +} + +/** Every daemon this page reaches: Local alone where this server keeps no list. */ +async function daemons() { + try { + const answer = await api.get('/api/daemons') + const listed = Array.isArray(answer) ? answer : answer.daemons + if (Array.isArray(listed) && listed.length) return listed + } catch { + // An older server, or none of its own: Local is all there is. + } + return [{ name: 'local' }] } export function mount(root, { parts, query }) { const name = parts[1] in PAGES ? parts[1] : 'general' const page = PAGES[name] - const body = h('div', { class: 'settings-body', 'data-hmz': `settings-${name}` }, h('p', { class: 'loading', role: 'status' }, 'Reading the settings…')) + let body = h('div', { class: 'settings-body', 'data-hmz': `settings-${name}` }, h('p', { class: 'loading', role: 'status' }, 'Reading the settings…')) + const links = {} + let chosen = query.get('daemon') || current() + const href = (key) => `#/settings/${key}${PAGES[key].group === 'daemon' && query.get('daemon') ? `?daemon=${encodeURIComponent(chosen)}` : ''}` + const nav = h( + 'nav', + { class: 'settings-nav', 'aria-label': 'Settings pages' }, + h('h2', {}, 'Settings'), + GROUPS.map(([group, label]) => [ + h('h4', { class: 'settings-group' }, label), + Object.entries(PAGES) + .filter(([, one]) => one.group === group) + .map(([key, one]) => (links[key] = h('a', { href: href(key), 'aria-current': key === name ? 'page' : null, hidden: Boolean(one.optional) && key !== name }, one.label))), + ]), + ) + const picker = h('select', { 'aria-label': 'Daemon', 'data-hmz': 'settings-daemon' }, h('option', { value: chosen }, called({ name: chosen }))) + const head = h('div', { class: 'settings-head' }, h('div', {}, h('h3', {}, page.label), h('p', { class: 'dim' }, page.about)), iconButton('close', 'Close', { onclick: shut })) root.classList.add('dialog-settings') root.setAttribute('aria-label', 'Settings') fill( root, // The top row of a narrow window, which it fills. - dragged( - h( - 'nav', - { class: 'settings-nav', 'aria-label': 'Settings pages' }, - h('h2', {}, 'Settings'), - Object.entries(PAGES).map(([key, one]) => h('a', { href: `#/settings/${key}`, 'aria-current': key === name ? 'page' : null }, one.label)), - ), - ), + dragged(nav), h( 'div', { class: 'settings-page' }, - h('div', { class: 'settings-head' }, h('div', {}, h('h3', {}, page.label), h('p', { class: 'dim' }, page.about)), iconButton('close', 'Close', { onclick: shut })), + head, + page.group === 'daemon' ? h('div', { class: 'settings-daemon' }, h('span', { class: 'label' }, 'Daemon'), picker) : null, body, ), ) + + // Each optional page offered once it is known to load. + for (const [key, one] of Object.entries(PAGES)) { + if (one.optional && key !== name) one.load().then(() => (links[key].hidden = false), () => {}) + } + + let known = [{ name: 'local' }] let leaving = null let gone = false - page - .load() - .then((module) => { - if (!gone) leaving = module.draw(body, query, parts) || null + let drawn = 0 + + /** What every page on the daemon picked reads, writes and copies with. */ + const scope = () => { + const base = prefix(chosen) + return { + name: chosen, + base, + at: (path) => `${base}${path}`, + // What a page puts in the address beside its own, so the daemon picked stays in it. + query: query.get('daemon') ? { daemon: chosen } : {}, + batch: (posts, { off = '' } = {}) => batchButton(root, chosen, () => known, posts, off), + } + } + + const draw = () => { + const mine = ++drawn + if (leaving) leaving() + leaving = null + // A body of its own: what the last daemon's page reads late is drawn where nobody sees it. + const fresh = body.cloneNode(false) + body.replaceWith(fresh) + body = fresh + fill(fresh, h('p', { class: 'loading', role: 'status' }, 'Reading the settings…')) + page + .load() + .then((module) => { + if (!gone && mine === drawn) leaving = module.draw(fresh, query, parts, scope()) || null + }) + .catch((error) => fill(fresh, problem(error))) + } + + if (page.group === 'daemon') { + daemons().then((listed) => { + if (gone) return + known = listed + if (!known.some((one) => one.name === chosen)) known = [...known, { name: chosen }] + fill(picker, known.map((one) => h('option', { value: one.name, selected: one.name === chosen }, called(one)))) }) - .catch((error) => fill(body, problem(error))) + picker.addEventListener('change', () => { + chosen = picker.value + // Kept in the address, so that this daemon's page is one that can be kept; what the + // page said of the last daemon's directories says nothing of this one's. + query = new URLSearchParams({ daemon: chosen }) + stays({ daemon: chosen }) + for (const key of Object.keys(PAGES)) links[key].href = href(key) + draw() + }) + } + draw() return () => { gone = true if (leaving) leaving() + for (const one of root.querySelectorAll('dialog')) one.close() } } + +/** + * Copies what a page holds to other daemons: a dialog of every daemon but the one it came from, + * ticked as they are wanted, and what each said once it was sent there. + * + * @param {HTMLElement} root Where the dialog is drawn. + * @param {string} from The daemon the page holds what it holds of. + * @param {() => {name: string}[]} known Every daemon there is, as last read. + * @param {(() => Array<[string, object]>) | null} posts What is sent: each address and body. + * @param {string} off Why nothing can be, where nothing can. + */ +function batchButton(root, from, known, posts, off) { + const button = h('button', { class: 'btn quiet', type: 'button', 'data-hmz': 'batch-apply', disabled: Boolean(off) }, 'Batch apply') + if (off) return h('span', { class: 'row' }, button, h('span', { class: 'hint', 'data-hmz': 'batch-off' }, off)) + button.addEventListener('click', () => { + const others = known().filter((one) => one.name !== from) + const boxes = others.map((one) => ({ one, box: h('input', { type: 'checkbox', 'data-daemon': one.name }) })) + const all = h('input', { type: 'checkbox', 'data-hmz': 'batch-all' }) + all.addEventListener('change', () => boxes.forEach(({ box }) => (box.checked = all.checked))) + for (const { box } of boxes) box.addEventListener('change', () => (all.checked = boxes.every((each) => each.box.checked))) + const results = h('ul', { class: 'batch-results', 'data-hmz': 'batch-results', 'aria-live': 'polite' }) + const apply = h('button', { class: 'btn primary', type: 'button', 'data-hmz': 'batch-go' }, 'Apply') + const dialog = h( + 'dialog', + { class: 'batch-dialog', 'data-hmz': 'batch-dialog', 'aria-label': 'Batch apply' }, + h('h3', {}, 'Batch apply'), + h('p', { class: 'dim' }, `From ${called({ name: from })}`), + others.length + ? h('div', { class: 'form' }, h('label', { class: 'check' }, all, h('b', {}, 'All')), boxes.map(({ one, box }) => h('label', { class: 'check' }, box, called(one)))) + : h('p', { class: 'dim' }, 'No other daemon.'), + results, + h('div', { class: 'row' }, others.length ? apply : null, h('button', { class: 'btn quiet', type: 'button', onclick: () => dialog.close() }, 'Close')), + ) + // Escape closes this dialog, not Settings under it, wherever the focus is. + const escape = (event) => { + if (event.key !== 'Escape') return + event.stopPropagation() + event.preventDefault() + dialog.close() + } + document.addEventListener('keydown', escape, true) + dialog.addEventListener('close', () => { + document.removeEventListener('keydown', escape, true) + dialog.remove() + }) + apply.addEventListener('click', async () => { + const picked = boxes.filter(({ box }) => box.checked).map(({ one }) => one) + if (!picked.length) return + apply.disabled = true + fill(results) + let sent + try { + sent = await posts() + if (!sent.length) throw new Error('Nothing to apply.') + } catch (error) { + fill(results, h('li', {}, problem(error))) + apply.disabled = false + return + } + await Promise.all( + picked.map(async (one) => { + const line = h('li', { 'data-hmz': 'batch-result', 'data-daemon': one.name }, h('b', {}, called(one)), ' ', h('span', { class: 'dim' }, 'Applying…')) + results.append(line) + const base = prefix(one.name) + try { + for (const [path, said] of sent) await api.post(`${base}${path}`, said) + fill(line, h('b', {}, called(one)), ' ', h('span', { class: 'ok' }, 'Applied')) + line.dataset.ok = 'true' + } catch (error) { + fill(line, h('b', {}, called(one)), ' ', h('span', { class: 'bad' }, error.message)) + line.dataset.ok = 'false' + } + }), + ) + apply.disabled = false + }) + root.append(dialog) + dialog.showModal() + }) + return button +} diff --git a/src/hmz/web/static/views/settings/accounts.js b/src/hmz/web/static/views/settings/accounts.js index 87959725c..88b826278 100644 --- a/src/hmz/web/static/views/settings/accounts.js +++ b/src/hmz/web/static/views/settings/accounts.js @@ -8,7 +8,7 @@ import { get, post } from '../../api.js' import { acting, ago, confirming, fill, h, problem } from '../../dom.js' -export function draw(holder) { +export function draw(holder, query, parts, scope) { const listing = h('section', { class: 'panel', 'data-hmz': 'accounts' }) const making = h('section', { class: 'panel', 'data-hmz': 'make-account' }) fill( @@ -38,7 +38,7 @@ export function draw(holder) { h( 'div', { class: 'row' }, - acting('Refresh models', async () => shown(await post('/api/accounts/models', { cli: one.cli, name: one.name })), { kind: 'btn small quiet', busy: 'Asking…' }), + acting('Refresh models', async () => shown(await post(scope.at('/api/accounts/models'), { cli: one.cli, name: one.name })), { kind: 'btn small quiet', busy: 'Asking…' }), one.serves.length ? copying(one) : null, one.name ? h('button', { class: 'btn small quiet', type: 'button', onclick: () => correcting(one) }, 'Edit') : null, one.name @@ -47,7 +47,7 @@ export function draw(holder) { ask: `Remove ${one.cli}@${one.name}, credentials and all?`, yes: 'Remove', small: true, - act: async () => shown(await post('/api/accounts/remove', { cli: one.cli, name: one.name })), + act: async () => shown(await post(scope.at('/api/accounts/remove'), { cli: one.cli, name: one.name })), }) : null, ), @@ -56,7 +56,8 @@ export function draw(holder) { ) fill( listing, - h('h2', {}, 'Accounts'), + // Nothing an account holds is ever shown, so nothing of it can be copied elsewhere. + h('header', {}, h('h2', {}, 'Accounts'), scope.batch(null, { off: 'Secrets stay put' })), rows.length ? h( 'div', @@ -74,7 +75,7 @@ export function draw(holder) { 'span', { class: 'row' }, into, - acting('Copy', async () => shown(await post('/api/accounts/copy', { cli: one.cli, name: one.name, into: into.value })), { kind: 'btn small quiet', busy: 'Copying…' }), + acting('Copy', async () => shown(await post(scope.at('/api/accounts/copy'), { cli: one.cli, name: one.name, into: into.value })), { kind: 'btn small quiet', busy: 'Copying…' }), ) } @@ -113,7 +114,7 @@ export function draw(holder) { for (const input of form.querySelectorAll('input[data-env]')) if (input.value) given[input.dataset.env] = input.value save.disabled = true try { - shown(await post('/api/accounts/correct', { cli: one.cli, name: one.name, answers: given })) + shown(await post(scope.at('/api/accounts/correct'), { cli: one.cli, name: one.name, answers: given })) fill(said, h('span', { class: 'dim', role: 'status' }, 'Saved.')) for (const input of form.querySelectorAll('input')) input.value = '' } catch (error) { @@ -203,7 +204,7 @@ export function draw(holder) { for (const input of answers.querySelectorAll('input[data-env]')) if (input.value) given[input.dataset.env] = input.value make.disabled = true try { - shown(await post('/api/accounts', { cli: cli.value, name: name.value.trim(), way: way.value, answers: given })) + shown(await post(scope.at('/api/accounts'), { cli: cli.value, name: name.value.trim(), way: way.value, answers: given })) fill(said, h('span', { class: 'dim', role: 'status' }, `Made ${cli.value}@${name.value.trim()}.`)) name.value = '' for (const input of answers.querySelectorAll('input')) input.value = '' @@ -217,7 +218,7 @@ export function draw(holder) { drawWays() } - get('/api/accounts') + get(scope.at('/api/accounts')) .then((answer) => { shown(answer) drawMaking() diff --git a/src/hmz/web/static/views/settings/backup.js b/src/hmz/web/static/views/settings/backup.js index 1380af93f..c118ed94a 100644 --- a/src/hmz/web/static/views/settings/backup.js +++ b/src/hmz/web/static/views/settings/backup.js @@ -20,7 +20,7 @@ function whenSaid(one) { return said.length ? said.join(' · ') : 'Manual' } -export function draw(holder) { +export function draw(holder, query, parts, scope) { const listing = h('section', { class: 'panel', 'data-hmz': 'backups' }) const writing = h('section', { class: 'panel', 'data-hmz': 'write-backup' }) fill(holder, listing, writing) @@ -35,7 +35,7 @@ export function draw(holder) { /** Writes every target down, as they are with one change made, and lists them again. */ const save = async (targets, defaults) => { - data = (await post('/api/settings', { backup: { targets, defaults } })).backup + data = (await post(scope.at('/api/settings'), { backup: { targets, defaults } })).backup drawListing() } @@ -85,7 +85,7 @@ export function draw(holder) { ) fill( listing, - h('h2', {}, 'Targets'), + h('header', {}, h('h2', {}, 'Targets'), scope.batch(() => [['/api/settings', { backup: data }]])), rows.length ? h('div', { class: 'table-wrap' }, h('table', {}, h('thead', {}, h('tr', {}, h('th', {}, 'Name'), h('th', {}, 'Repository'), h('th', {}, 'When'), h('th', {}, ''), h('th', {}, ''))), h('tbody', {}, rows))) : h('p', { class: 'dim' }, 'None yet: no epic is backed up.'), @@ -147,7 +147,7 @@ export function draw(holder) { fill(writing, h('h2', {}, editing ? `Edit ${editing.name}` : 'New target'), form) } - get('/api/settings') + get(scope.at('/api/settings')) .then(shown) .catch((error) => fill(listing, problem(error))) } diff --git a/src/hmz/web/static/views/settings/fallback.js b/src/hmz/web/static/views/settings/fallback.js index ded517ce0..88218a2b3 100644 --- a/src/hmz/web/static/views/settings/fallback.js +++ b/src/hmz/web/static/views/settings/fallback.js @@ -6,7 +6,7 @@ import { get, post } from '../../api.js' import { acting, confirming, fill, h, iconButton, lasting, problem } from '../../dom.js' import { placePicker, readBackends } from '../../pickers.js' -export function draw(holder) { +export function draw(holder, query, parts, scope) { const listing = h('section', { class: 'panel', 'data-hmz': 'fallbacks' }) const writing = h('section', { class: 'panel', 'data-hmz': 'write-fallback' }) const places = h('datalist', { id: 'places' }) @@ -36,14 +36,15 @@ export function draw(holder) { 'div', { class: 'row' }, h('button', { class: 'btn small quiet', type: 'button', onclick: () => edit(one) }, 'Edit'), - confirming({ label: 'Clear', ask: `Let ${one.spec} fall back nowhere again?`, yes: 'Clear', small: true, act: async () => shown(await post('/api/fallbacks/clear', { spec: one.spec })) }), + confirming({ label: 'Clear', ask: `Let ${one.spec} fall back nowhere again?`, yes: 'Clear', small: true, act: async () => shown(await post(scope.at('/api/fallbacks/clear'), { spec: one.spec })) }), ), ), ), ) fill( listing, - h('h2', {}, 'Steps'), + // Each step written over the one of its place there, or added; none there taken away. + h('header', {}, h('h2', {}, 'Steps'), scope.batch(() => data.steps.map(({ spec, to, tries, policy, timeout }) => ['/api/fallbacks', { spec, to, tries, policy, timeout }]))), rows.length ? h('div', { class: 'table-wrap' }, h('table', {}, h('thead', {}, h('tr', {}, h('th', {}, 'Place'), h('th', {}, 'Tried'), h('th', {}, 'Then'), h('th', {}, ''))), h('tbody', {}, rows))) : h('p', { class: 'dim' }, 'No steps. A failed turn fails where it ran.'), @@ -103,7 +104,7 @@ export function draw(holder) { 'Save', async () => { const to = [...chain.children].map((line) => line.picked.value()).filter(Boolean) - const answer = await post('/api/fallbacks', { spec: spec.value(), to, tries: Number(tries.value || 0), policy: policy.value, timeout: Number(timeout.value || 0) }) + const answer = await post(scope.at('/api/fallbacks'), { spec: spec.value(), to, tries: Number(tries.value || 0), policy: policy.value, timeout: Number(timeout.value || 0) }) editing = null shown(answer) }, @@ -118,7 +119,7 @@ export function draw(holder) { } let pickers = [] - Promise.all([get('/api/fallbacks'), readBackends()]) + Promise.all([get(scope.at('/api/fallbacks')), readBackends(undefined, scope.base)]) .then(([answer]) => shown(answer)) .catch((error) => fill(listing, problem(error))) } diff --git a/src/hmz/web/static/views/settings/general.js b/src/hmz/web/static/views/settings/general.js index 0f24f520a..4d0622888 100644 --- a/src/hmz/web/static/views/settings/general.js +++ b/src/hmz/web/static/views/settings/general.js @@ -5,7 +5,7 @@ import { get, post } from '../../api.js' import { acting, fill, h, problem } from '../../dom.js' import { agentPicker, readBackends } from '../../pickers.js' -export function draw(holder) { +export function draw(holder, query, parts, scope) { const shown = (settings) => { const details = h('input', { type: 'checkbox', checked: settings.details }) // Who a side question goes to: the flow's first agent, or one picked as an agent is picked. @@ -21,16 +21,13 @@ export function draw(holder) { h('option', { value: 'yes', selected: settings.reports === true }, 'On'), h('option', { value: 'no', selected: settings.reports === false }, 'Off'), ) - const save = acting( - 'Save', - async () => { - const said = { btw: whom.value === 'chosen' ? picker.value() : '', details: details.checked } - if (reports.value) said.reports = reports.value === 'yes' - // Saved where it stands: the button says so, rather than the form being drawn again over it. - await post('/api/settings', said) - }, - { kind: 'btn primary', busy: 'Saving…', done: 'Saved' }, - ) + const said = () => { + const given = { btw: whom.value === 'chosen' ? picker.value() : '', details: details.checked } + if (reports.value) given.reports = reports.value === 'yes' + return given + } + // Saved where it stands: the button says so, rather than the form being drawn again over it. + const save = acting('Save', () => post(scope.at('/api/settings'), said()), { kind: 'btn primary', busy: 'Saving…', done: 'Saved' }) fill( holder, h( @@ -42,14 +39,14 @@ export function draw(holder) { h('div', { class: 'field' }, h('span', { class: 'label' }, 'Details'), h('label', { class: 'check' }, details, 'Tool calls and thinking')), h('div', { class: 'field', 'data-hmz': 'btw-agent' }, h('span', { class: 'label' }, 'btw agent'), whom, picker.node, h('span', { class: 'hint' }, 'Who a side question about a whole epic is asked of.')), h('div', { class: 'field' }, h('span', { class: 'label' }, 'Error reports'), reports, h('span', { class: 'hint' }, 'Whether humanize sends a report of its own crashes. Nothing anybody typed, no file and no credential is ever sent.')), - h('div', { class: 'row' }, save), + h('div', { class: 'row' }, save, scope.batch(() => [['/api/settings', said()]])), ), ), ) } let picker = null let backends = null - Promise.all([get('/api/settings'), readBackends()]) + Promise.all([get(scope.at('/api/settings')), readBackends(undefined, scope.base)]) .then(([settings, read]) => { backends = read shown(settings) diff --git a/src/hmz/web/static/views/settings/runtimes.js b/src/hmz/web/static/views/settings/runtimes.js index a27ad2f46..95973f3dd 100644 --- a/src/hmz/web/static/views/settings/runtimes.js +++ b/src/hmz/web/static/views/settings/runtimes.js @@ -9,7 +9,7 @@ import { pathPicker } from '../../paths.js' const GIB = 2 ** 30 -export function draw(holder) { +export function draw(holder, query, parts, scope) { const listing = h('section', { class: 'panel', 'data-hmz': 'runtimes' }) const writing = h('section', { class: 'panel', 'data-hmz': 'write-runtime' }) const importing = h('section', { class: 'panel', 'data-hmz': 'import-runtimes' }) @@ -43,20 +43,21 @@ export function draw(holder) { acting( 'Check', async () => { - checked[key] = await post('/api/runtimes/check', { backend: one.backend, name: one.name }) + checked[key] = await post(scope.at('/api/runtimes/check'), { backend: one.backend, name: one.name }) drawListing() }, { kind: 'btn small quiet', busy: 'Asking…' }, ), h('button', { class: 'btn small quiet', type: 'button', onclick: () => edit(one) }, 'Edit'), - confirming({ label: 'Remove', ask: `Remove ${one.name}?`, yes: 'Remove', small: true, act: async () => shown(await post('/api/runtimes/remove', { backend: one.backend, name: one.name })) }), + confirming({ label: 'Remove', ask: `Remove ${one.name}?`, yes: 'Remove', small: true, act: async () => shown(await post(scope.at('/api/runtimes/remove'), { backend: one.backend, name: one.name })) }), ), ), ) }) fill( listing, - h('h2', {}, 'Runtimes'), + // Each runtime written over the one of its name there, or added; none there taken away. + h('header', {}, h('h2', {}, 'Runtimes'), scope.batch(() => data.runtimes.map(({ backend, name, made, ...fields }) => ['/api/runtimes', { backend, name, fields, replace: true }]))), rows.length ? h('div', { class: 'table-wrap' }, h('table', {}, h('thead', {}, h('tr', {}, h('th', {}, 'Backend'), h('th', {}, 'Name'), h('th', {}, 'Address'), h('th', {}, 'Resources'), h('th', {}, ''))), h('tbody', {}, rows))) : h('p', { class: 'dim' }, 'None yet: every environment is this machine, or spelled out where it is named.'), @@ -79,7 +80,7 @@ export function draw(holder) { fill(fields) for (const [key, empty] of Object.entries(blank)) { const value = editing ? editing[key] : empty - const [input, read] = field(key, empty, value, { backend: backend.value, name: name.value.trim(), runtimes: data.runtimes }) + const [input, read] = field(key, empty, value, { backend: backend.value, name: name.value.trim(), runtimes: data.runtimes, from: scope.base }) readers[key] = read fields.append(h('div', { class: ['field', (Array.isArray(empty) || (empty && typeof empty === 'object')) && 'wide'] }, h('label', { for: `runtime-${key}` }, labelOf(key)), input)) } @@ -103,7 +104,7 @@ export function draw(holder) { async () => { const given = {} for (const [key, read] of Object.entries(readers)) given[key] = read() - const answer = await post('/api/runtimes', { backend: backend.value, name: name.value.trim(), fields: given, replace: Boolean(editing) }) + const answer = await post(scope.at('/api/runtimes'), { backend: backend.value, name: name.value.trim(), fields: given, replace: Boolean(editing) }) editing = null shown(answer) }, @@ -121,7 +122,7 @@ export function draw(holder) { const read = acting( 'Read SSH config', async () => { - const { hosts } = await get('/api/runtimes/hosts') + const { hosts } = await get(scope.at('/api/runtimes/hosts')) const boxes = hosts.map((one) => ({ one, box: h('input', { type: 'checkbox', disabled: one.saved }) })) fill( importing, @@ -143,7 +144,7 @@ export function draw(holder) { async () => { const names = boxes.filter(({ box }) => box.checked).map(({ one }) => one.alias) if (!names.length) return - shown(await post('/api/runtimes/import', { names })) + shown(await post(scope.at('/api/runtimes/import'), { names })) drawImporting() }, { kind: 'btn primary', busy: 'Importing…' }, @@ -158,7 +159,7 @@ export function draw(holder) { fill(importing, h('h2', {}, 'SSH config'), h('p', { class: 'dim' }, 'Import hosts from your SSH config as runtimes.'), read) } - get('/api/runtimes') + get(scope.at('/api/runtimes')) .then((answer) => { shown(answer) drawImporting() @@ -194,11 +195,11 @@ function labelOf(key) { return `${words.slice(0, 1).toUpperCase()}${words.slice(1)}${key.endsWith('memory') ? ', GiB' : ''}` } -/** The SSH hosts your config names, read once, for the fields that name one. */ -let hosts = null -function sshHosts() { - hosts ||= get('/api/runtimes/hosts').then((answer) => answer.hosts.map((one) => one.alias), () => []) - return hosts +/** The SSH hosts each daemon's config names, read once, for the fields that name one. */ +const hosts = new Map() +function sshHosts(from) { + if (!hosts.has(from)) hosts.set(from, get(`${from}/api/runtimes/hosts`).then((answer) => answer.hosts.map((one) => one.alias), () => [])) + return hosts.get(from) } /** @@ -207,10 +208,10 @@ function sshHosts() { * a directory browsed for, the runtimes to fall back to and where a harness runs picked from * what is saved and put in order, options as names and values, and a list a row at a time. */ -function field(key, empty, value, { backend, name, runtimes }) { +function field(key, empty, value, { backend, name, runtimes, from }) { const id = `runtime-${key}` if (key === 'identity_file' || key === 'config' || key === 'tls_dir') { - const picked = pathPicker({ value: value || '', files: key !== 'tls_dir', label: labelOf(key), id, placeholder: key === 'config' ? '~/.ssh/config' : key === 'tls_dir' ? '~/.docker' : '~/.ssh/id_ed25519' }) + const picked = pathPicker({ value: value || '', files: key !== 'tls_dir', label: labelOf(key), id, from, placeholder: key === 'config' ? '~/.ssh/config' : key === 'tls_dir' ? '~/.docker' : '~/.ssh/id_ed25519' }) return [picked.node, () => picked.value()] } if (key === 'fallback') { @@ -244,7 +245,7 @@ function field(key, empty, value, { backend, name, runtimes }) { // A host your SSH config names, picked; any other written. const offered = h('datalist', { id: `${id}-hosts` }) const input = h('input', { id, type: 'text', value: value || '', list: offered.id, autocomplete: 'off', placeholder: key === 'alias' ? 'A host in your SSH config' : key === 'host' ? 'box.example' : 'user@jump' }) - sshHosts().then((names) => fill(offered, names.map((one) => h('option', { value: one })))) + sshHosts(from).then((names) => fill(offered, names.map((one) => h('option', { value: one })))) return [h('div', { class: 'row' }, input, offered), () => input.value.trim()] } const input = h('input', { id, type: 'text', value: value || '', autocomplete: 'off' }) diff --git a/src/hmz/web/static/views/settings/usage.js b/src/hmz/web/static/views/settings/usage.js index dfa5001ae..9da627d34 100644 --- a/src/hmz/web/static/views/settings/usage.js +++ b/src/hmz/web/static/views/settings/usage.js @@ -2,19 +2,18 @@ // chart is drawn in plain HTML with a table beside it, so no figure rests on a colour or on a // pointer hovering over it. -import { address, poll } from '../../api.js' +import { address, get, poll } from '../../api.js' import { stays } from '../../app.js' import { badge, fill, flowName, h, problem } from '../../dom.js' -import { workspaces } from '../../places.js' /** How far back the page can look, in days. */ const RANGES = [7, 30, 90, 365] -export function draw(holder, query) { +export function draw(holder, query, parts, scope) { let days = Number(query.get('days')) || 30 let workspace = query.get('workspace') || '' const places = h('select', { 'aria-label': 'Workdir' }, h('option', { value: '' }, 'All workdirs')) - workspaces().then((answer) => { + get(scope.at('/api/workspaces')).then((answer) => { fill(places, h('option', { value: '' }, 'All workdirs'), answer.workspaces.map((one) => h('option', { value: one.path, selected: one.path === workspace, title: one.path }, one.name))) }, () => {}) places.addEventListener('change', () => { @@ -28,7 +27,7 @@ export function draw(holder, query) { let stop = () => {} const watch = () => { stop() - stays({ days, workspace }) + stays({ ...scope.query, days, workspace }) fill( range, RANGES.map((one) => { @@ -40,7 +39,7 @@ export function draw(holder, query) { return button }), ) - stop = poll(address('/api/usage', { days, workspace }), 30000, (data, error) => draw(data, error)) + stop = poll(address(scope.at('/api/usage'), { days, workspace }), 30000, (data, error) => draw(data, error)) } function draw(data, error) { diff --git a/src/hmz/web/static/views/settings/workspace.js b/src/hmz/web/static/views/settings/workspace.js index 6a5a8ac58..c9ff19fea 100644 --- a/src/hmz/web/static/views/settings/workspace.js +++ b/src/hmz/web/static/views/settings/workspace.js @@ -1,11 +1,10 @@ // What a workspace was last set up with: the flow it opens on and every flow set up there, and // forgetting all of it -- which also stops it being offered, where no epic ran there. -import { get, post } from '../../api.js' +import { address, get, post } from '../../api.js' import { confirming, fill, h, pathText, problem } from '../../dom.js' -import { workspaces } from '../../places.js' -export function draw(holder, query) { +export function draw(holder, query, parts, scope) { let workspace = query.get('workspace') || '' const choosing = h('select', { 'aria-label': 'Workdir', 'data-hmz': 'settings-workdir' }) const body = h('div') @@ -41,7 +40,7 @@ export function draw(holder, query) { yes: 'Forget', no: 'Cancel', act: async () => { - const answer = await post('/api/settings/forget', { workspace: settings.workspace }) + const answer = await post(scope.at('/api/settings/forget'), { workspace: settings.workspace }) shown(answer, answer.forgot) }, }), @@ -51,12 +50,12 @@ export function draw(holder, query) { } function read() { - get(`/api/settings?workspace=${encodeURIComponent(workspace)}`) + get(address(scope.at('/api/settings'), { workspace })) .then((settings) => shown(settings)) .catch((error) => fill(body, problem(error))) } - workspaces().then((answer) => { + get(scope.at('/api/workspaces')).then((answer) => { if (!workspace) workspace = answer.here fill(choosing, answer.workspaces.map((one) => h('option', { value: one.path, selected: one.path === workspace }, one.path))) read() diff --git a/tests/integration/test_web_settings_daemons.py b/tests/integration/test_web_settings_daemons.py new file mode 100644 index 000000000..c8385990f --- /dev/null +++ b/tests/integration/test_web_settings_daemons.py @@ -0,0 +1,174 @@ +"""Settings, per daemon: the app's own pages apart from a daemon's, which daemon those read and +write, and what a page holds copied to several daemons at once. + +The daemons are the browser's to fake: the list of them is answered in the page's browser, and +so is everything sent to the remote ones -- `gpu` mirrors what Local reads and takes whatever +is written to it, and `lab` refuses everything, as a daemon that is not up does. +""" + +from __future__ import annotations + +import json +import re +from typing import TYPE_CHECKING, Any + +import pytest +from playwright.sync_api import expect + +from tests.integration.doubles_core import install +from tests.integration.doubles_web import ENGINES, go, opened, served + +if TYPE_CHECKING: + from pathlib import Path + + from playwright.sync_api import Page, Route + +pytestmark = pytest.mark.parametrize("engine", ENGINES) + +#: Every daemon the page is told of: Local, one that is up and one that is not. +_DAEMONS = { + "daemons": [ + {"name": "local", "state": "up"}, + {"name": "gpu", "host": "gpu.example", "state": "up"}, + {"name": "lab", "host": "lab.example", "state": "failed"}, + ] +} + + +@pytest.fixture +def project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, asking: None) -> Path: + """The stand-in `claude` alone on PATH, asked what it runs, and a project to serve.""" + return install(tmp_path, monkeypatch) + + +def _daemons(page: Page) -> list[tuple[str, str, Any]]: + """Fakes every daemon but Local, keeping what each was asked: its name, method and body.""" + asked: list[tuple[str, str, Any]] = [] + + def listed(route: Route) -> None: + route.fulfill(json=_DAEMONS) + + def remote(route: Route) -> None: + request = route.request + found = re.match(r".*/d/([^/]+)(/api/.*)", request.url) + assert found + name, path = found.groups() + body = json.loads(request.post_data) if request.post_data else None + asked.append((name, f"{request.method} {path.split('?')[0]}", body)) + if name == "lab": + route.fulfill(status=409, json={"error": "lab is not up."}) + elif request.method == "POST": + route.fulfill(json={}) + else: + route.fulfill(response=route.fetch(url=request.url.replace("/d/gpu", ""))) + + page.route(re.compile(r".*/api/daemons$"), listed) + page.route(re.compile(r".*/d/[^/]+/api/.*"), remote) + return asked + + +def test_the_app_and_the_daemon_are_set_apart(project: Path, engine: str) -> None: + with served() as site, opened(site, engine=engine) as page: + go(page, "/settings/general") + nav = page.locator(".settings-nav") + expect(nav.locator(".settings-group")).to_have_text(["App", "Daemon"]) + expect(nav.locator("a:visible").first).to_have_text("Themes") + expect(nav.locator("a:visible")).to_contain_text( + ["Themes", "General", "Accounts", "Fallback", "Runtimes", "Backup"] + ) + picker = page.locator("[data-hmz=settings-daemon]") + expect(picker.locator("option")).to_have_text(["Local"]) + go(page, "/settings/themes") + expect(page.locator("[data-hmz=settings-daemon]")).to_have_count(0) + + +def test_the_daemon_picked_is_the_one_read_and_written( + project: Path, engine: str +) -> None: + with served() as site, opened(site, engine=engine) as page: + asked = _daemons(page) + go(page, "/settings/general") + picker = page.locator("[data-hmz=settings-daemon]") + expect(picker.locator("option")).to_have_text(["Local", "gpu", "lab"]) + expect(picker).to_have_value("local") + assert not asked + + picker.select_option("gpu") + expect(page.locator("[data-hmz=btw-whom]")).to_be_visible() + assert ("gpu", "GET /api/settings", None) in asked + assert ("gpu", "GET /api/backends", None) in asked + assert "daemon=gpu" in page.url + expect(page.locator(".settings-nav a", has_text="Backup")).to_have_attribute( + "href", "#/settings/backup?daemon=gpu" + ) + + page.get_by_role("button", name="Save").click() + expect(page.get_by_role("button", name="Saved")).to_be_visible() + assert any( + one[:2] == ("gpu", "POST /api/settings") + for one in asked + ) + + # Kept in the address, the daemon is the one the page opens on again. + asked.clear() + go(page, "/settings/backup?daemon=gpu") + expect(page.locator("[data-hmz=backups] h2")).to_have_text("Targets") + expect(page.locator("[data-hmz=settings-daemon]")).to_have_value("gpu") + assert ("gpu", "GET /api/settings", None) in asked + + +def test_batch_apply_sends_the_page_to_each_daemon_ticked( + project: Path, engine: str +) -> None: + with served() as site, opened(site, engine=engine) as page: + asked = _daemons(page) + go(page, "/settings/general") + page.locator("[data-hmz=settings-general] input[type=checkbox]").check() + page.locator("[data-hmz=batch-apply]").click() + dialog = page.locator("[data-hmz=batch-dialog]") + expect(dialog.locator("label.check")).to_have_text(["All", "gpu", "lab"]) + dialog.locator("[data-hmz=batch-all]").check() + dialog.locator("[data-hmz=batch-go]").click() + + results = dialog.locator("[data-hmz=batch-result]") + expect(results.and_(page.locator("[data-daemon=gpu]"))).to_contain_text( + "Applied" + ) + expect(results.and_(page.locator("[data-daemon=lab]"))).to_contain_text( + "lab is not up." + ) + sent = [one for one in asked if one[1] == "POST /api/settings"] + assert sorted(one[0] for one in sent) == ["gpu", "lab"] + assert all(one[2]["details"] is True for one in sent) + + # Closed, it leaves Settings open; nothing was written on Local. + page.keyboard.press("Escape") + expect(dialog).to_have_count(0) + expect(page.locator("[data-hmz=settings-general]")).to_be_visible() + + +def test_accounts_keep_their_secrets_on_their_daemon( + project: Path, engine: str +) -> None: + with served() as site, opened(site, engine=engine) as page: + _daemons(page) + go(page, "/settings/accounts") + expect(page.locator("[data-hmz=batch-apply]")).to_be_disabled() + expect(page.locator("[data-hmz=batch-off]")).to_have_text("Secrets stay put") + go(page, "/settings/usage") + expect(page.locator("[data-hmz=usage] .panel").first).to_be_visible() + expect(page.locator("[data-hmz=batch-apply]")).to_have_count(0) + + +def test_settings_fit_a_phone(project: Path, engine: str) -> None: + with served() as site, opened(site, engine=engine, width=390, height=800) as page: + _daemons(page) + go(page, "/settings/runtimes") + expect(page.locator("[data-hmz=settings-daemon]")).to_be_visible() + page.locator("[data-hmz=batch-apply]").click() + dialog = page.locator("[data-hmz=batch-dialog]") + expect(dialog).to_be_visible() + box = dialog.bounding_box() + assert box + assert box["x"] >= 0 + assert box["x"] + box["width"] <= 390