From 1adc88ed44692a4cf11277c3a09f36d3b8b78fbd Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:56:52 +0800 Subject: [PATCH] feat(web): Daemons and Notifications settings pages Settings gains a Daemons page -- Local and every SSH daemon with its state, an SSH form a part at a time, import from the SSH config, connect, and remove asked first -- and a Notifications page with the kinds, the system toggle and the browser's Allow. Both say Unavailable where the server does not serve their endpoints yet. Co-Authored-By: Claude Opus 5.5 --- docs/reference/web.md | 2 +- docs/user/web.md | 2 + src/hmz/web/static/style.css | 62 ++++ src/hmz/web/static/views/settings.js | 2 + src/hmz/web/static/views/settings/daemons.js | 267 ++++++++++++++++++ .../static/views/settings/notifications.js | 109 +++++++ tests/integration/test_web_settings_pages.py | 249 ++++++++++++++++ 7 files changed, 692 insertions(+), 1 deletion(-) create mode 100644 src/hmz/web/static/views/settings/daemons.js create mode 100644 src/hmz/web/static/views/settings/notifications.js create mode 100644 tests/integration/test_web_settings_pages.py diff --git a/docs/reference/web.md b/docs/reference/web.md index e4311393..2f90182a 100644 --- a/docs/reference/web.md +++ b/docs/reference/web.md @@ -143,7 +143,7 @@ Each view is an address after `#`, so it can be kept and opened again: | `#/flows?tab=verses[&verse=]` | The flowverses, one opened. | | `#/search[?status=&flow=&q=&offset=]` | A dialog finding epics. `status`: how it went, `running` or `unfinished`. | | `#/download[?target=]` | A dialog downloading an epic out of a backup target: the first in settings without `target`. | -| `#/settings/[?days=]` | A dialog: `general`, `themes`, `accounts`, `fallback`, `runtimes`, `backup`, `workspace` or `usage`; `days` is how far `usage` looks back (7, 30, 90 or 365; default 30), by UTC day. | +| `#/settings/[?days=]` | A dialog: `general`, `themes`, `accounts`, `fallback`, `runtimes`, `backup`, `workspace`, `usage`, `daemons` or `notifications`; `days` is how far `usage` looks back (7, 30, 90 or 365; default 30), by UTC day. | The page keeps a few conveniences in the browser's `localStorage`, each forgotten without harm: the colours chosen as `hmz-theme` (`light`, `dark`, or absent for the system's), a sidebar diff --git a/docs/user/web.md b/docs/user/web.md index 7b3598a5..f045db78 100644 --- a/docs/user/web.md +++ b/docs/user/web.md @@ -259,6 +259,8 @@ The same pages as [`/settings`](/user/settings), in the same order, and what the | **Backup** | Every [backup target](/user/backups): its repository and branch, when an epic goes to it (**On session close**, **Every N minutes**, **On epic end**, or none: by hand only), and whether it is a **Default**; add, edit or remove one. A URL is shown with nothing signed into it | | **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 | +| **Daemons** | **Local**, always there, and every SSH daemon with its state (up, connecting, installing, failed or idle): add or **Edit** one a part at a time (name, host, user, port, identity file, proxy jump, SSH options), **Import** hosts from your SSH config, **Connect** or **Remove** one, asked first | +| **Notifications** | Which notifications appear, of every daemon: **Done**, **Failed**, **Stopped**, **Questions** and **Chat turns**, and whether the **System** shows them too. In a browser, **Allow** asks it for permission; the desktop app needs nothing more. Kept on this machine, never per daemon | 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 diff --git a/src/hmz/web/static/style.css b/src/hmz/web/static/style.css index fecebe47..5a8af8b1 100644 --- a/src/hmz/web/static/style.css +++ b/src/hmz/web/static/style.css @@ -3845,3 +3845,65 @@ input[aria-invalid='true'] { -webkit-user-select: none; user-select: none; } + +/* settings: daemons, notifications */ +.daemon-state .glyph { + margin-right: 6px; +} + +.daemon-up .glyph { + color: var(--done); +} + +.daemon-failed .glyph { + color: var(--fail); +} + +.daemon-connecting .glyph, +.daemon-installing .glyph { + color: var(--ink-3); +} + +.daemon-detail { + font-size: 12px; + overflow-wrap: anywhere; +} + +.notification-kinds { + display: flex; + flex-wrap: wrap; + gap: 8px 16px; +} + +.daemons-table td:last-child .row { + flex-wrap: nowrap; + justify-content: flex-end; +} + +/* A phone's width: each daemon a block of its own, its buttons under it. */ +@media (max-width: 860px) { + .daemons-table thead { + display: none; + } + + .daemons-table, + .daemons-table tbody, + .daemons-table tr, + .daemons-table td { + display: block; + } + + .daemons-table tr { + padding: 8px 0; + } + + .daemons-table td { + border: 0; + padding: 2px 0; + } + + .daemons-table td:last-child .row { + flex-wrap: wrap; + justify-content: flex-start; + } +} diff --git a/src/hmz/web/static/views/settings.js b/src/hmz/web/static/views/settings.js index b8480585..cce510d8 100644 --- a/src/hmz/web/static/views/settings.js +++ b/src/hmz/web/static/views/settings.js @@ -18,6 +18,8 @@ const PAGES = { 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') }, + daemons: { label: 'Daemons', about: 'Machines this page can show', load: () => import('./settings/daemons.js') }, + notifications: { label: 'Notifications', about: 'What is told, of every daemon', load: () => import('./settings/notifications.js') }, } export function mount(root, { parts, query }) { diff --git a/src/hmz/web/static/views/settings/daemons.js b/src/hmz/web/static/views/settings/daemons.js new file mode 100644 index 00000000..27682ede --- /dev/null +++ b/src/hmz/web/static/views/settings/daemons.js @@ -0,0 +1,267 @@ +// The daemons the page can show: Local, this machine's own, which is always there, and every +// SSH host written down beside it -- each with how far reaching it has got. A new one is written +// a part at a time, as a runtime's SSH host is, or brought in from the hosts an SSH config names; +// one is reached again, changed, or taken away, asked first. + +import { get, post } from '../../api.js' +import { acting, confirming, empty, fill, h, icon, iconButton, problem } from '../../dom.js' +import { pathPicker } from '../../paths.js' + +/** How far reaching each state has got, by a glyph, so nothing is said by colour alone. */ +const STATES = { + up: '●', + connecting: '◐', + installing: '◐', + failed: '✕', + idle: '○', +} + +export function draw(holder) { + const listing = h('section', { class: 'panel', 'data-hmz': 'daemons' }) + const writing = h('section', { class: 'panel', 'data-hmz': 'write-daemon' }) + const importing = h('section', { class: 'panel', 'data-hmz': 'import-daemons' }) + fill(holder, listing, writing, importing) + let daemons = [] + let editing = null + let again = null + let reading = 0 + let gone = false + const states = new Map() + + /** + * Reads the daemons again, and draws them; while one is still being reached, again soon. The + * same daemons as before have only their states drawn again, so a question asked on a row, or + * a refusal said there, stays. + */ + async function read() { + clearTimeout(again) + const mine = ++reading + const answer = await get('/api/daemons') + if (gone || mine !== reading) return + const was = daemons.map((one) => one.name).join('\n') + daemons = (answer.daemons || []).filter((one) => one.name !== 'local') + if (was && was === daemons.map((one) => one.name).join('\n')) for (const one of daemons) fill(states.get(one.name), shownState(stateOf(one))) + else drawListing() + if (daemons.some((one) => ['connecting', 'installing'].includes(stateOf(one).state))) again = setTimeout(() => read().catch(() => {}), 2000) + } + + function drawListing() { + const local = h( + 'tr', + { 'data-hmz': 'daemon', 'data-name': 'local' }, + h('td', {}, h('b', {}, 'Local')), + h('td', { class: 'mono dim' }, 'this machine'), + h('td', {}, shownState({ state: 'up' })), + h('td', {}), + ) + states.clear() + const rows = daemons.map((one) => + h( + 'tr', + { 'data-hmz': 'daemon', 'data-name': one.name }, + h('td', {}, h('b', {}, one.name)), + h('td', { class: 'mono' }, reached(one)), + (states.set(one.name, h('td', {}, shownState(stateOf(one)))), states.get(one.name)), + h( + 'td', + {}, + h( + 'div', + { class: 'row' }, + acting( + 'Connect', + async () => { + await post(`/api/daemons/${encodeURIComponent(one.name)}/connect`) + await read() + }, + { kind: 'btn small quiet', busy: 'Connecting…' }, + ), + 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 () => { + await post('/api/daemons/remove', { name: one.name }) + if (editing && editing.name === one.name) (editing = null), drawWriting() + await read() + }, + }), + ), + ), + ), + ) + fill( + listing, + h('h2', {}, 'Daemons'), + h('div', { class: 'table-wrap' }, h('table', { class: 'daemons-table' }, h('thead', {}, h('tr', {}, h('th', {}, 'Name'), h('th', {}, 'Address'), h('th', {}, 'State'), h('th', {}, ''))), h('tbody', {}, local, rows))), + ) + } + + function edit(one) { + editing = one + drawWriting() + writing.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + + function drawWriting() { + const was = editing || {} + const text = (id, value, placeholder, extra = {}) => h('input', { type: 'text', id, value: value || '', placeholder, autocomplete: 'off', spellcheck: 'false', ...extra }) + const name = text('daemon-name', was.name, 'gpu-box', { required: true, disabled: Boolean(editing) }) + const host = text('daemon-host', was.host || was.alias, 'box.example') + const user = text('daemon-user', was.user, 'me') + const port = h('input', { type: 'number', id: 'daemon-port', min: '1', max: '65535', step: '1', value: was.port ? String(was.port) : '', placeholder: '22' }) + const key = pathPicker({ value: was.identity_file || '', files: true, label: 'Identity file', id: 'daemon-identity', placeholder: '~/.ssh/id_ed25519' }) + const jump = text('daemon-jump', was.proxy_jump, 'user@jump') + const options = pairs(was.options || [], 'daemon-options') + const part = (label, id, input, wide = false) => h('div', { class: ['field', wide && 'wide'] }, h('label', { for: id }, label), input) + fill( + writing, + h('h2', {}, editing ? `Edit ${editing.name}` : 'New daemon'), + h( + 'form', + { class: 'form', onsubmit: (event) => event.preventDefault() }, + h( + 'div', + { class: 'grid' }, + part('Name', 'daemon-name', name), + part('Host', 'daemon-host', host), + part('User', 'daemon-user', user), + part('Port', 'daemon-port', port), + part('Identity file', 'daemon-identity', key.node), + part('Proxy jump', 'daemon-jump', jump), + part('SSH options', 'daemon-options', options.node, true), + ), + h( + 'div', + { class: 'row' }, + acting( + editing ? 'Save' : 'Add', + async () => { + const entry = { + ...(editing || {}), + name: name.value.trim(), + host: host.value.trim(), + user: user.value.trim(), + port: port.value === '' ? null : Number(port.value), + identity_file: key.value(), + proxy_jump: jump.value.trim(), + options: options.read(), + } + delete entry.state + delete entry.detail + delete entry.version + await post('/api/daemons', entry) + editing = null + drawWriting() + await read() + }, + { kind: 'btn primary', busy: 'Saving…' }, + ), + editing ? h('button', { class: 'btn quiet', type: 'button', onclick: () => ((editing = null), drawWriting()) }, 'New daemon') : null, + ), + ), + ) + } + + function drawImporting() { + const reading = acting( + 'Read SSH config', + async () => { + const hosts = ((await get('/api/daemons/hosts')).hosts || []).map((one) => (typeof one === 'string' ? { alias: one } : one)) + const boxes = hosts.map((one) => ({ one, box: h('input', { type: 'checkbox', 'data-alias': one.alias }) })) + fill( + importing, + h('h2', {}, 'SSH config'), + hosts.length + ? [ + h( + 'div', + { class: 'form' }, + boxes.map(({ one, box }) => h('label', { class: 'check' }, box, h('b', { class: 'mono' }, one.alias), one.host ? h('span', { class: 'dim' }, reached(one)) : null)), + ), + h( + 'p', + { class: 'row' }, + acting( + 'Import', + async () => { + const aliases = boxes.filter(({ box }) => box.checked).map(({ one }) => one.alias) + if (!aliases.length) return + await post('/api/daemons/import', { aliases }) + await read() + drawImporting() + }, + { kind: 'btn primary', busy: 'Importing…' }, + ), + ), + ] + : h('p', { class: 'dim' }, 'Every host it names is added already.'), + ) + }, + { kind: 'btn', busy: 'Reading…' }, + ) + fill(importing, h('h2', {}, 'SSH config'), h('p', { class: 'dim' }, 'Import hosts from your SSH config as daemons.'), reading) + } + + read() + .catch((error) => { + if (error.status === 404) return fill(holder, empty('Unavailable', 'This humanize does not keep daemons.')) + fill(listing, h('h2', {}, 'Daemons'), problem(error)) + }) + .then(() => { + if (holder.contains(writing)) drawWriting(), drawImporting() + }) + return () => { + gone = true + clearTimeout(again) + } +} + +/** A daemon's state, as the list says it: `state` an object, or a word beside `detail`. */ +function stateOf(one) { + if (one.state && typeof one.state === 'object') return one.state + return { state: one.state || 'idle', detail: one.detail || '', version: one.version || '' } +} + +function shownState({ state, detail = '', version = '' }) { + return h( + 'div', + { class: ['daemon-state', `daemon-${state}`], 'data-hmz': 'daemon-state' }, + h('span', { class: 'glyph', 'aria-hidden': 'true' }, STATES[state] || '·'), + h('span', {}, state), + version ? h('span', { class: 'dim' }, ` ${version}`) : null, + detail ? h('div', { class: 'dim daemon-detail' }, detail) : null, + ) +} + +/** Where a daemon is reached, as somebody would write it. */ +function reached(one) { + return `${one.user ? `${one.user}@` : ''}${one.host || one.alias || one.name}${one.port && one.port !== 22 ? `:${one.port}` : ''}` +} + +/** SSH options, a name and its value a row, in order and any name more than once: one more added, any taken away. */ +function pairs(values, id) { + const rows = h('div', { class: 'list-rows', id, 'data-hmz': 'pair-editor' }) + const add = h('button', { class: 'btn small quiet', type: 'button' }, icon('plus'), 'Add') + const row = (key, value) => { + const line = h( + 'div', + { class: 'list-row' }, + h('input', { type: 'text', value: key, placeholder: 'Name', 'aria-label': 'Option', class: 'pair-key' }), + h('input', { type: 'text', value, placeholder: 'Value', 'aria-label': 'Value', class: 'pair-value' }), + iconButton('close', 'Remove', { class: 'small', onclick: () => line.remove() }), + ) + rows.append(line) + } + for (const [key, value] of values) row(key, String(value)) + add.addEventListener('click', () => { + row('', '') + rows.lastElementChild.querySelector('input').focus() + }) + return { + node: h('div', { class: 'list-editor' }, rows, add), + read: () => [...rows.children].map((line) => [line.querySelector('.pair-key').value.trim(), line.querySelector('.pair-value').value.trim()]).filter(([key]) => key), + } +} diff --git a/src/hmz/web/static/views/settings/notifications.js b/src/hmz/web/static/views/settings/notifications.js new file mode 100644 index 00000000..58fed786 --- /dev/null +++ b/src/hmz/web/static/views/settings/notifications.js @@ -0,0 +1,109 @@ +// Which notifications may appear, of every daemon at once: an epic done, failed or stopped, a +// question to you, a chat turn over -- and whether they are shown by the system as well as on +// the page. This app's own, kept on this machine, never a daemon's. A browser shows them only +// once you allow it, from a click here; the desktop app needs nothing more. + +import { get, post } from '../../api.js' +import { empty, fill, h, problem } from '../../dom.js' +import { inApp } from '../../shell.js' + +/** Every kind, by its word in the settings, and what the page calls it. */ +const KINDS = [ + ['done', 'Done'], + ['failed', 'Failed'], + ['stopped', 'Stopped'], + ['asked', 'Questions'], + ['listening', 'Chat turns'], +] + +/** What the browser says of showing notifications, as the page says it. */ +const PERMISSIONS = { granted: 'Allowed', denied: 'Blocked', default: 'Not asked' } + +export function draw(holder) { + const shown = (settings) => { + const kinds = { ...Object.fromEntries(KINDS.map(([kind]) => [kind, true])), ...(settings.kinds || {}) } + let system = settings.system !== false + const said = h('span', { class: 'hint', 'aria-live': 'polite', 'data-hmz': 'notifications-said' }) + const save = async () => { + said.replaceChildren() + try { + await post('/api/notifications/settings', { kinds, system }) + said.textContent = 'Saved' + } catch (error) { + said.replaceChildren(problem(error)) + } + } + const toggle = (label, checked, changed, hook) => { + const box = h('input', { type: 'checkbox', checked, 'data-hmz': hook }) + box.addEventListener('change', () => { + changed(box.checked) + save() + }) + return h('label', { class: 'check' }, box, label) + } + const allowing = h('div', { class: 'row', 'data-hmz': 'notifications-permission' }) + const drawAllowing = () => { + if (inApp) return fill(allowing) + if (!('Notification' in window)) return fill(allowing, h('span', { class: 'dim' }, 'Unsupported')) + const now = Notification.permission + fill( + allowing, + h('span', { class: 'dim', 'data-hmz': 'permission-state' }, PERMISSIONS[now] || now), + now === 'granted' || now === 'denied' + ? null + : h( + 'button', + { + class: 'btn small', + type: 'button', + 'data-hmz': 'allow-notifications', + onclick: async () => { + try { + // A promise where it is one; older Safari answers by calling back instead. + await Notification.requestPermission(drawAllowing) + } finally { + drawAllowing() + } + }, + }, + 'Allow', + ), + ) + } + drawAllowing() + fill( + holder, + h( + 'section', + { class: 'panel notification-settings' }, + h( + 'div', + { class: 'form' }, + h( + 'div', + { class: 'field' }, + h('span', { class: 'label' }, 'Kinds'), + h( + 'div', + { class: 'notification-kinds' }, + KINDS.map(([kind, label]) => toggle(label, kinds[kind], (on) => (kinds[kind] = on), `notify-${kind}`)), + ), + h('span', { class: 'hint' }, 'Of every daemon, as they happen.'), + ), + h( + 'div', + { class: 'field' }, + h('span', { class: 'label' }, 'System'), + toggle('On', system, (on) => (system = on), 'notify-system'), + allowing, + h('span', { class: 'hint' }, inApp ? 'Shown by macOS while the window is away.' : 'Shown by the browser while the page is away.'), + ), + said, + ), + ), + ) + } + get('/api/notifications/settings') + .then(shown) + .catch((error) => fill(holder, error.status === 404 ? empty('Unavailable', 'This humanize does not keep notifications.') : problem(error))) +} diff --git a/tests/integration/test_web_settings_pages.py b/tests/integration/test_web_settings_pages.py new file mode 100644 index 00000000..12ad7baa --- /dev/null +++ b/tests/integration/test_web_settings_pages.py @@ -0,0 +1,249 @@ +"""`hmz web`'s Daemons and Notifications settings pages, driven in a browser. + +The page is the real one, served in the test's own process; what it asks of the daemons and the +notification settings is answered by fakes routed in the browser, which keep what is written to +them, so that what the page sent is what is checked. Each test runs in Chromium and in WebKit. +""" + +from __future__ import annotations + +import json +import urllib.parse +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, Request, Route + +pytestmark = pytest.mark.parametrize("engine", ENGINES) + + +@pytest.fixture +def project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, asking: None) -> Path: + """The stand-in `claude` alone on PATH, and a project to serve.""" + return install(tmp_path, monkeypatch) + + +class _Daemons: + """The daemon registry, as the page asks it: kept in a list, and every write recorded.""" + + def __init__(self) -> None: + self.daemons: list[dict[str, Any]] = [ + { + "name": "box", + "host": "box.example", + "user": "me", + "port": 22, + "state": "up", + } + ] + self.hosts = [{"alias": "gpu", "host": "gpu.example"}, {"alias": "cpu"}] + self.writes: list[tuple[str, Any]] = [] + + def listed(self) -> dict[str, Any]: + return {"daemons": [{"name": "local", "state": "up"}, *self.daemons]} + + def answer(self, route: Route) -> None: + path = urllib.parse.urlsplit(route.request.url).path + body = json.loads(route.request.post_data or "{}") + if route.request.method == "POST": + self.writes.append((path, body)) + if path == "/api/daemons/hosts": + said: Any = {"hosts": self.hosts} + elif path == "/api/daemons/import": + self.daemons += [{"name": one, "host": one} for one in body["aliases"]] + self.hosts = [ + one for one in self.hosts if one["alias"] not in body["aliases"] + ] + said = self.listed() + elif path == "/api/daemons/remove": + self.daemons = [one for one in self.daemons if one["name"] != body["name"]] + said = self.listed() + elif path.endswith("/connect"): + route.fulfill(status=409, json={"error": "it did not answer"}) + return + elif route.request.method == "POST": + self.daemons = [one for one in self.daemons if one["name"] != body["name"]] + self.daemons.append(body) + said = self.listed() + else: + said = self.listed() + route.fulfill(json=said) + + +def _daemons(page: Page) -> _Daemons: + fake = _Daemons() + page.route("**/api/daemons**", fake.answer) + return fake + + +def test_a_daemon_is_added_a_part_at_a_time(project: Path, engine: str) -> None: + with served() as site, opened(site, engine=engine) as page: + fake = _daemons(page) + go(page, "/settings/daemons") + rows = page.locator("[data-hmz=daemon]") + expect(rows).to_have_count(2) + expect(rows.first).to_contain_text("Local") + expect(rows.first.get_by_role("button")).to_have_count(0) + form = page.locator("[data-hmz=write-daemon]") + form.get_by_label("Name").fill("lab") + form.get_by_label("Host").fill("lab.example") + form.get_by_label("User").fill("ann") + form.get_by_label("Port").fill("2222") + form.locator("#daemon-identity").fill("~/.ssh/lab") + form.get_by_label("Proxy jump").fill("me@jump") + form.get_by_role("button", name="Add", exact=True).first.click() + form.locator(".pair-key").fill("ServerAliveInterval") + form.locator(".pair-value").fill("30") + form.get_by_role("button", name="Add", exact=True).last.click() + expect(rows).to_have_count(3) + expect(page.locator("[data-hmz=daemon][data-name=lab]")).to_contain_text( + "ann@lab.example:2222" + ) + assert fake.writes[-1] == ( + "/api/daemons", + { + "name": "lab", + "host": "lab.example", + "user": "ann", + "port": 2222, + "identity_file": "~/.ssh/lab", + "proxy_jump": "me@jump", + "options": [["ServerAliveInterval", "30"]], + }, + ) + + +def test_hosts_are_imported_from_the_ssh_config(project: Path, engine: str) -> None: + with served() as site, opened(site, engine=engine) as page: + fake = _daemons(page) + go(page, "/settings/daemons") + importing = page.locator("[data-hmz=import-daemons]") + importing.get_by_role("button", name="Read SSH config").click() + importing.locator("[data-alias=gpu]").check() + importing.get_by_role("button", name="Import").click() + expect(page.locator("[data-hmz=daemon][data-name=gpu]")).to_be_visible() + assert fake.writes[-1] == ("/api/daemons/import", {"aliases": ["gpu"]}) + + +def test_a_daemon_is_removed_once_confirmed(project: Path, engine: str) -> None: + with served() as site, opened(site, engine=engine) as page: + fake = _daemons(page) + go(page, "/settings/daemons") + row = page.locator("[data-hmz=daemon][data-name=box]") + row.get_by_role("button", name="Remove").click() + assert not fake.writes + row.get_by_role("button", name="Cancel").click() + row.get_by_role("button", name="Remove").click() + row.get_by_role("button", name="Remove").click() + expect(row).to_have_count(0) + assert fake.writes == [("/api/daemons/remove", {"name": "box"})] + + +def test_a_refused_connection_is_said_where_it_happened( + project: Path, engine: str +) -> None: + with served() as site, opened(site, engine=engine) as page: + _daemons(page) + go(page, "/settings/daemons") + row = page.locator("[data-hmz=daemon][data-name=box]") + row.get_by_role("button", name="Connect").click() + expect(row.locator(".error")).to_have_text("it did not answer") + + +def test_without_the_endpoints_the_pages_say_unavailable( + project: Path, engine: str +) -> None: + with served() as site, opened(site, engine=engine) as page: + for name in ("daemons", "notifications"): + go(page, f"/settings/{name}") + body = page.locator(f"[data-hmz=settings-{name}]") + expect(body.locator(".empty")).to_contain_text("Unavailable") + expect(body.locator(".error")).to_have_count(0) + + +def _posted(request: Request) -> bool: + return request.method == "POST" + + +#: The browser's notifications, as a page that was never asked sees them, granting when asked. +_NOTIFICATION = """ +window.Notification = class { constructor() {} } +window.Notification.permission = 'default' +window.Notification.requestPermission = async () => { + window.__asked = (window.__asked || 0) + 1 + window.Notification.permission = 'granted' + return 'granted' +} +""" + + +def test_notification_kinds_are_saved_as_they_are_toggled( + project: Path, engine: str +) -> None: + with served() as site, opened(site, engine=engine, init=_NOTIFICATION) as page: + saved: list[Any] = [] + settings = {"kinds": {"done": True, "failed": False}, "system": True} + + def answer(route: Route) -> None: + if route.request.method == "POST": + saved.append(json.loads(route.request.post_data or "{}")) + settings.update(saved[-1]) + route.fulfill(json=settings) + + page.route("**/api/notifications/settings", answer) + go(page, "/settings/notifications") + expect(page.locator("[data-hmz=notify-failed]")).not_to_be_checked() + expect(page.locator("[data-hmz=notify-asked]")).to_be_checked() + for label in ("Done", "Failed", "Stopped", "Questions", "Chat turns"): + expect(page.get_by_label(label, exact=True)).to_be_visible() + with page.expect_request(_posted): + page.get_by_label("Questions", exact=True).uncheck() + expect(page.locator("[data-hmz=notifications-said]")).to_have_text("Saved") + with page.expect_request(_posted): + page.locator("[data-hmz=notify-system]").uncheck() + expect(page.locator("[data-hmz=notifications-said]")).to_have_text("Saved") + assert saved == [ + { + "kinds": { + "done": True, + "failed": False, + "stopped": True, + "asked": False, + "listening": True, + }, + "system": True, + }, + { + "kinds": { + "done": True, + "failed": False, + "stopped": True, + "asked": False, + "listening": True, + }, + "system": False, + }, + ] + + +def test_notifications_are_allowed_from_a_click(project: Path, engine: str) -> None: + with served() as site, opened(site, engine=engine, init=_NOTIFICATION) as page: + page.route( + "**/api/notifications/settings", + lambda route: route.fulfill(json={"kinds": {}, "system": True}), + ) + go(page, "/settings/notifications") + state = page.locator("[data-hmz=permission-state]") + expect(state).to_have_text("Not asked") + page.locator("[data-hmz=allow-notifications]").click() + expect(state).to_have_text("Allowed") + expect(page.locator("[data-hmz=allow-notifications]")).to_have_count(0) + assert page.evaluate("window.__asked") == 1