mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
feat(desktop): nav hiddenPages config + optional pages opt-in
hiddenPages three-state config (undefined default / [] show-all / [list] custom) with Playground and Missions default-hidden. Nav is now opt-in via Settings > Optional pages section with immediate toggle rendering (no restart). Change surface: nav-config-model.js (new, 64 lines), main.js (+24), preload.js (+9), renderer.js (+41), index.html (+27/-1), settings-page.js (+87), style.css (+49), and test/nav-config-model.test.js (new 22 tests, +291). docs/qa-nav-optional/*.png × 3 capture the three states. 12 files, +846/-1. Test count on mainline: 1806 total / 1806 pass / 0 fail.
This commit is contained in:
BIN
examples/desktop/docs/qa-nav-optional/01-default-hidden.png
Normal file
BIN
examples/desktop/docs/qa-nav-optional/01-default-hidden.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
BIN
examples/desktop/docs/qa-nav-optional/02-empty-array.png
Normal file
BIN
examples/desktop/docs/qa-nav-optional/02-empty-array.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
BIN
examples/desktop/docs/qa-nav-optional/03-custom-list.png
Normal file
BIN
examples/desktop/docs/qa-nav-optional/03-custom-list.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
255
examples/desktop/scripts/qa-cdp-shoot-nav-optional.mjs
Normal file
255
examples/desktop/scripts/qa-cdp-shoot-nav-optional.mjs
Normal file
@@ -0,0 +1,255 @@
|
||||
// QA verification script for lane-nav-optional. Boots an isolated
|
||||
// Electron on CDP :9272, rewrites the shell config for three fixture
|
||||
// cases (missing hiddenPages / empty [] / custom list), and takes one
|
||||
// sidebar screenshot for each case into docs/qa-nav-optional/.
|
||||
//
|
||||
// Isolation follows the 2026-07-18 postmortem in
|
||||
// scripts/qa-cdp-shoot-affordance.mjs:
|
||||
// 1. --user-data-dir=<tmp> isolates Chromium userdata.
|
||||
// 2. DSH_DESKTOP_HOME=<tmp> isolates main-process config root so we
|
||||
// never write into ~/.dsh-desktop.
|
||||
//
|
||||
// Why three passes not one boot with hot reloads: the hidden-page filter
|
||||
// reads config.json through IPC at renderer init; we could plumb a
|
||||
// window.__dshNavFilter.apply() to re-read after we mutate the file,
|
||||
// but the point of the shoot is to prove the boot-time filter honors
|
||||
// the file as-shipped. So we relaunch three times with different
|
||||
// on-disk configs — a clean-room reproduction of the three cases a
|
||||
// researcher would actually hit.
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { resolve, join } from 'node:path'
|
||||
import { setTimeout as sleep } from 'node:timers/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
|
||||
const WORKTREE = resolve(process.env.DSH_WORKTREE || process.cwd())
|
||||
const PARENT = resolve(process.env.DSH_REPO || '/Users/ziya/harness/dsh-desktop-demo')
|
||||
const ELECTRON = join(PARENT, 'node_modules/.bin/electron')
|
||||
// One CDP port per case — Electron helper processes can linger past the
|
||||
// main process death and hold the debug port bound, which quietly forces
|
||||
// case-2/3 to attach to case-1's DevTools endpoint. Isolating ports side-
|
||||
// steps the whole race.
|
||||
const CDP_PORT_BASE = Number(process.env.DSH_NAV_OPTIONAL_PORT || 9272)
|
||||
const OUTDIR = join(WORKTREE, 'docs/qa-nav-optional')
|
||||
|
||||
if (!existsSync(ELECTRON)) {
|
||||
console.error(`electron binary not found at ${ELECTRON}`)
|
||||
process.exit(2)
|
||||
}
|
||||
mkdirSync(OUTDIR, { recursive: true })
|
||||
|
||||
// Three fixture cases per task spec:
|
||||
// • case-1: config.json missing hiddenPages → default (playground+mission hidden)
|
||||
// • case-2: hiddenPages = [] → all pages visible
|
||||
// • case-3: hiddenPages = ["prs","growth"] → PRs + Growth hidden
|
||||
const CASES = [
|
||||
{
|
||||
name: '01-default-hidden',
|
||||
label: 'missing hiddenPages → default (playground+mission hidden)',
|
||||
config: { role: 'coding', approvalMode: 'never' },
|
||||
},
|
||||
{
|
||||
name: '02-empty-array',
|
||||
label: 'hiddenPages=[] → everything visible',
|
||||
config: { role: 'coding', approvalMode: 'never', hiddenPages: [] },
|
||||
},
|
||||
{
|
||||
name: '03-custom-list',
|
||||
label: 'hiddenPages=["prs","growth"] → PRs+Growth hidden',
|
||||
config: { role: 'coding', approvalMode: 'never', hiddenPages: ['prs', 'growth'] },
|
||||
},
|
||||
]
|
||||
|
||||
function seedHome(dshHome, cfg) {
|
||||
// Every fresh boot writes a minimal overlay + config.json + .onboarded
|
||||
// sentinel so the first-run modal doesn't fire and rewrite our seed.
|
||||
const seedOverlay = [
|
||||
'# QA nav-optional-shoot seed overlay (tmp, per-run).',
|
||||
'plugins:',
|
||||
` - "@cordisjs/plugin-include":`,
|
||||
` path: ${join(WORKTREE, 'config/daemon-echo.yml')}`,
|
||||
'',
|
||||
].join('\n')
|
||||
writeFileSync(join(dshHome, 'user-overlay.cordis.yml'), seedOverlay)
|
||||
writeFileSync(join(dshHome, 'config.json'), JSON.stringify(cfg, null, 2))
|
||||
writeFileSync(join(dshHome, '.onboarded'), new Date().toISOString())
|
||||
}
|
||||
|
||||
async function bootElectron(dshHome, userData, port) {
|
||||
const child = spawn(ELECTRON, [
|
||||
`--remote-debugging-port=${port}`,
|
||||
`--user-data-dir=${userData}`,
|
||||
'--disable-gpu',
|
||||
'--no-sandbox',
|
||||
'.',
|
||||
], {
|
||||
cwd: WORKTREE,
|
||||
env: {
|
||||
...process.env,
|
||||
DSH_DESKTOP_HOME: dshHome,
|
||||
DSH_MAXIMIZE: '1',
|
||||
// Deliberately NOT setting DSH_QA=1: qa-harness.js §11 clicks every
|
||||
// onboarding button on boot including the skip path, which triggers
|
||||
// onboarding:apply and rewrites config.json without our seeded
|
||||
// hiddenPages. See main.js:763 — `writeShellConfig({role,approvalMode,createdAt})`
|
||||
// drops any other fields on the floor.
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
const logs = []
|
||||
child.stdout.on('data', d => { const s = String(d); logs.push(s); if (s.includes('[nav:')) process.stdout.write('MAIN_STDOUT: ' + s) })
|
||||
child.stderr.on('data', d => { const s = String(d); logs.push(s); if (s.includes('[nav:')) process.stdout.write('MAIN_STDERR: ' + s) })
|
||||
for (let i = 0; i < 40; i++) {
|
||||
await sleep(500)
|
||||
try {
|
||||
const r = await fetch(`http://localhost:${port}/json/list`)
|
||||
if (r.ok) return { child, logs }
|
||||
} catch {}
|
||||
}
|
||||
child.kill('SIGKILL')
|
||||
console.error('electron CDP did not come up in 20s. logs:\n' + logs.join(''))
|
||||
process.exit(3)
|
||||
}
|
||||
|
||||
async function newCdp(port) {
|
||||
const targets = await (await fetch(`http://localhost:${port}/json/list`)).json()
|
||||
const target = targets.find(t => t.type === 'page')
|
||||
if (!target) throw new Error('no page target on port ' + port)
|
||||
const ws = new WebSocket(target.webSocketDebuggerUrl)
|
||||
await new Promise((ok, err) => { ws.onopen = ok; ws.onerror = e => err(e) })
|
||||
let id = 1
|
||||
const pending = new Map()
|
||||
const chunks = []
|
||||
ws.onmessage = ev => {
|
||||
const data = typeof ev.data === 'string' ? ev.data : String(ev.data)
|
||||
let msg
|
||||
try { msg = JSON.parse(data) } catch { chunks.push(data); return }
|
||||
if (msg.id != null && pending.has(msg.id)) {
|
||||
const [ok, err] = pending.get(msg.id); pending.delete(msg.id)
|
||||
if (msg.error) err(new Error(msg.error.message)); else ok(msg.result)
|
||||
}
|
||||
}
|
||||
const call = (m, p = {}, ms = 15000) => new Promise((ok, err) => {
|
||||
const _id = id++
|
||||
const t = setTimeout(() => { pending.delete(_id); err(new Error('cdp timeout: ' + m)) }, ms)
|
||||
pending.set(_id, [v => { clearTimeout(t); ok(v) }, e => { clearTimeout(t); err(e) }])
|
||||
ws.send(JSON.stringify({ id: _id, method: m, params: p }))
|
||||
})
|
||||
const evj = async expr => {
|
||||
const r = await call('Runtime.evaluate', {
|
||||
expression: `(async()=>{try{return (${expr})}catch(e){return {__err:String(e)}}})()`,
|
||||
returnByValue: true, awaitPromise: true,
|
||||
})
|
||||
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text)
|
||||
return r.result?.value
|
||||
}
|
||||
return { ws, call, evj }
|
||||
}
|
||||
|
||||
async function runCase(kase, idx) {
|
||||
const port = CDP_PORT_BASE + idx
|
||||
const dshHome = join(tmpdir(), `dsh-nav-optional-home-${kase.name}`)
|
||||
const userData = join(tmpdir(), `dsh-nav-optional-userdata-${kase.name}`)
|
||||
for (const dir of [dshHome, userData]) {
|
||||
try { rmSync(dir, { recursive: true, force: true }) } catch {}
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
seedHome(dshHome, kase.config)
|
||||
console.log(`[${kase.name}] booting on port ${port}: ${kase.label}`)
|
||||
const { child } = await bootElectron(dshHome, userData, port)
|
||||
try {
|
||||
// Give the renderer a beat to run applyNavHiddenPages.
|
||||
await sleep(1500)
|
||||
const { call, evj } = await newCdp(port)
|
||||
await call('Page.enable')
|
||||
|
||||
// Diagnostics before assertion: prove the IPC path is reachable and
|
||||
// the model resolves the fixture case we expect. Without this, a
|
||||
// race between the applyNavHiddenPages await and the shot capture
|
||||
// leaves us reading DOM that hasn't been filtered yet.
|
||||
const diag = await evj(`
|
||||
(async () => {
|
||||
const M = window.__dshNavConfigModel || null
|
||||
const raw = window.dsh && window.dsh.nav && typeof window.dsh.nav.getHiddenPages === 'function'
|
||||
? await window.dsh.nav.getHiddenPages()
|
||||
: '<no-ipc>'
|
||||
const resolved = M ? M.resolveHiddenPages(raw || {}) : '<no-model>'
|
||||
// Force-run the filter now so the DOM is definitely up-to-date
|
||||
// before we assert. This is what the settings-page.js checkbox
|
||||
// path calls on toggle too.
|
||||
if (window.__dshNavFilter && typeof window.__dshNavFilter.apply === 'function') {
|
||||
await window.__dshNavFilter.apply()
|
||||
}
|
||||
return { hasModel: !!M, ipcResult: raw, resolved }
|
||||
})()
|
||||
`)
|
||||
console.log(` diag :`, JSON.stringify(diag))
|
||||
|
||||
// Assert the DOM matches expectation before we capture — cheap
|
||||
// sanity so a broken build fails loud instead of shipping a bad shot.
|
||||
const visible = await evj(`
|
||||
Array.from(document.querySelectorAll('.sidebar-nav .tab-btn'))
|
||||
.filter(b => !b.classList.contains('nav-item--hidden'))
|
||||
.map(b => b.dataset.tab)
|
||||
`)
|
||||
const hidden = await evj(`
|
||||
Array.from(document.querySelectorAll('.sidebar-nav .tab-btn'))
|
||||
.filter(b => b.classList.contains('nav-item--hidden'))
|
||||
.map(b => b.dataset.tab)
|
||||
`)
|
||||
console.log(` visible:`, visible)
|
||||
console.log(` hidden :`, hidden)
|
||||
|
||||
// Screenshot: clip to the left sidebar so we get a tight frame of
|
||||
// the nav filter effect (not a giant chat pane full of empty state).
|
||||
const clip = await evj(`
|
||||
(() => {
|
||||
const s = document.querySelector('.sidebar')
|
||||
if (!s) return null
|
||||
const r = s.getBoundingClientRect()
|
||||
return { x: r.x, y: r.y, width: r.width, height: r.height, scale: 1 }
|
||||
})()
|
||||
`)
|
||||
const shotArgs = { format: 'png', captureBeyondViewport: true }
|
||||
if (clip) shotArgs.clip = clip
|
||||
const shot = await call('Page.captureScreenshot', shotArgs, 30000)
|
||||
if (!shot || !shot.data) throw new Error('captureScreenshot returned no data')
|
||||
const outPath = join(OUTDIR, `${kase.name}.png`)
|
||||
writeFileSync(outPath, Buffer.from(shot.data, 'base64'))
|
||||
console.log(` wrote ${outPath}`)
|
||||
return { visible, hidden, path: outPath }
|
||||
} finally {
|
||||
try { child.kill('SIGKILL') } catch {}
|
||||
// Aggressively wait for the CDP port to actually free — Electron
|
||||
// spawns a helper process (GPU / renderer) that can linger after the
|
||||
// main process death, keeping the port bound. Poll until /json/list
|
||||
// stops answering (or 10s cap).
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await sleep(500)
|
||||
try {
|
||||
await fetch(`http://localhost:${port}/json/list`)
|
||||
} catch {
|
||||
break // port is free
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const results = []
|
||||
for (let i = 0; i < CASES.length; i++) {
|
||||
const kase = CASES[i]
|
||||
const r = await runCase(kase, i)
|
||||
results.push({ name: kase.name, label: kase.label, ...r })
|
||||
}
|
||||
console.log('\n--- SUMMARY ---')
|
||||
for (const r of results) {
|
||||
console.log(`${r.name}: ${r.label}`)
|
||||
console.log(` visible: ${JSON.stringify(r.visible)}`)
|
||||
console.log(` hidden : ${JSON.stringify(r.hidden)}`)
|
||||
console.log(` shot : ${r.path}`)
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => { console.error(err); process.exit(1) })
|
||||
@@ -784,6 +784,30 @@ app.whenReady().then(async () => {
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
// -- left-nav hidden-pages config (lane-nav-optional) ---------------------
|
||||
// Reads/writes the `hiddenPages` array on ~/.dsh-desktop/config.json so a
|
||||
// researcher can hide any left-nav page by id (matches the `data-tab`
|
||||
// attribute in index.html). Missing field falls through to the two-page
|
||||
// default (Playground shim + Missions) so fresh installs surface fewer
|
||||
// demo-tier pages up front. Renderer honors the returned list on init +
|
||||
// whenever the Settings > Optional pages checkboxes flip.
|
||||
ipcMain.handle('nav:getHiddenPages', () => {
|
||||
const cfg = P.readShellConfig() || {}
|
||||
return { hiddenPages: cfg.hiddenPages }
|
||||
})
|
||||
ipcMain.handle('nav:setHiddenPages', (_e, { hiddenPages } = {}) => {
|
||||
if (!Array.isArray(hiddenPages)) {
|
||||
return { ok: false, reason: 'hiddenPages must be an array of pageId strings' }
|
||||
}
|
||||
// Defensive filter — the caller should already have cleaned this, but
|
||||
// dropping non-strings here means an accidental `null` in the payload
|
||||
// can't corrupt the on-disk config for the next boot.
|
||||
const cleaned = hiddenPages.filter((x) => typeof x === 'string' && x.length > 0)
|
||||
const existing = P.readShellConfig() || {}
|
||||
P.writeShellConfig({ ...existing, hiddenPages: cleaned })
|
||||
return { ok: true, hiddenPages: cleaned }
|
||||
})
|
||||
|
||||
// Growth log — the append-only jsonl of runtime-shaping events that the
|
||||
// Growth page reads. Returns entries + a hint on the installedAt anchor
|
||||
// (config.json.createdAt, when onboarding wrote it) so the renderer can
|
||||
|
||||
@@ -142,6 +142,15 @@ contextBridge.exposeInMainWorld('dsh', {
|
||||
apply: (role, approvalMode) => ipcRenderer.invoke('onboarding:apply', { role, approvalMode }),
|
||||
reset: () => ipcRenderer.invoke('onboarding:reset'),
|
||||
},
|
||||
// -- left-nav hidden-pages config (lane-nav-optional) ---------------------
|
||||
// Read + write the `hiddenPages` array in ~/.dsh-desktop/config.json.
|
||||
// Returned value follows the same three-state semantic the renderer
|
||||
// filter honors: `undefined` = default (playground+mission hidden),
|
||||
// `[]` = show everything, non-empty array = honored as-is.
|
||||
nav: {
|
||||
getHiddenPages: () => ipcRenderer.invoke('nav:getHiddenPages'),
|
||||
setHiddenPages: (hiddenPages) => ipcRenderer.invoke('nav:setHiddenPages', { hiddenPages }),
|
||||
},
|
||||
// -- growth log (self-evolution audit trail) -------------------------------
|
||||
// The main process appends jsonl entries at ~/.dsh-desktop/growth-log.jsonl
|
||||
// on runtime-shaping events (plugin add/toggle, overlay apply, vibe
|
||||
|
||||
@@ -1116,6 +1116,27 @@
|
||||
<tbody data-settings-keys-tbody></tbody>
|
||||
</table>
|
||||
</section>
|
||||
<!-- Optional pages (lane-nav-optional). Playground + Missions
|
||||
ship hidden out of the box to cut first-run cognitive load;
|
||||
a researcher opts them back in here. Any custom
|
||||
`hiddenPages` entries the user hand-edits into
|
||||
~/.dsh-desktop/config.json are honored by the renderer
|
||||
filter but do not surface as checkboxes — this section
|
||||
only lists the two demo-tier pages we hide by default. -->
|
||||
<section class="settings-section" data-settings-optional-pages>
|
||||
<div class="settings-section-head">
|
||||
<h3 class="settings-section-title">Optional pages</h3>
|
||||
<div class="settings-section-sub muted">
|
||||
Enable extra pages in the left sidebar. Hidden by default
|
||||
to reduce the first-run surface; toggles persist to
|
||||
<code>~/.dsh-desktop/config.json</code> under
|
||||
<code>hiddenPages</code>. Advanced users can hide any page
|
||||
by adding its id to that array manually (e.g.
|
||||
<code>["prs","growth"]</code>).
|
||||
</div>
|
||||
</div>
|
||||
<ul class="settings-optional-list" data-settings-optional-list aria-label="Optional pages"></ul>
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
@@ -1418,7 +1439,11 @@
|
||||
<script src="./pr-page.js"></script>
|
||||
<!-- Lane-nav (task #189/#193): runtimes page + settings page + pure
|
||||
settings-model helpers. settings-model must load BEFORE
|
||||
settings-page so the DOM controller finds the pure module. -->
|
||||
settings-page so the DOM controller finds the pure module.
|
||||
nav-config-model is loaded here too so settings-page can list
|
||||
the Optional pages checkboxes and renderer.js can filter the
|
||||
sidebar on init. -->
|
||||
<script src="./nav-config-model.js"></script>
|
||||
<script src="./settings-model.js"></script>
|
||||
<script src="./runtimes-page.js"></script>
|
||||
<script src="./settings-page.js"></script>
|
||||
|
||||
64
examples/desktop/src/renderer/nav-config-model.js
Normal file
64
examples/desktop/src/renderer/nav-config-model.js
Normal file
@@ -0,0 +1,64 @@
|
||||
// nav-config-model.js — pure config helpers for the left-nav hidden-page
|
||||
// filter. Two responsibilities:
|
||||
// • DEFAULT_HIDDEN — the two demo-tier pages we now hide out of the box
|
||||
// (Playground shim, Missions) so a fresh install shows fewer surfaces
|
||||
// up front. Users opt these in from Settings > Optional pages.
|
||||
// • resolveHiddenPages(cfg) — coerces a shell-config blob into the final
|
||||
// hidden-page array following these rules:
|
||||
// - `hiddenPages` missing entirely → default hidden set
|
||||
// - `hiddenPages` is an empty array → nothing hidden (all pages show)
|
||||
// - `hiddenPages` is a non-empty array of strings → honored as-is
|
||||
// - anything else (non-array, garbage) → default hidden set (safe)
|
||||
//
|
||||
// The renderer applies the result by iterating `.tab-btn[data-tab=…]`
|
||||
// and toggling a `.nav-item--hidden` class. Kept as a pure module so the
|
||||
// three fixture cases (missing / empty / custom) are unit-testable
|
||||
// without spinning up Electron. Wrapped in an IIFE (like settings-model)
|
||||
// so no top-level bindings leak into the shared renderer global scope
|
||||
// (test/renderer-collisions.test.js enforces this).
|
||||
|
||||
'use strict'
|
||||
;(function () {
|
||||
|
||||
const DEFAULT_HIDDEN = Object.freeze(['playground-shim', 'mission'])
|
||||
|
||||
// Page ids that a user can opt into from the Settings > Optional pages
|
||||
// section. Kept as a small explicit list rather than "everything in the
|
||||
// default hidden set" because the Settings section is meant to be a
|
||||
// curated on/off surface for the demo-tier pages we hide by default —
|
||||
// arbitrary custom hidden pages (via manual config.json edit) are still
|
||||
// honored by resolveHiddenPages, they just don't get a Settings toggle.
|
||||
// Label is what the checkbox shows; the id must match a `data-tab`
|
||||
// value in index.html so the renderer filter can find the button.
|
||||
const OPTIONAL_PAGES = Object.freeze([
|
||||
Object.freeze({ id: 'playground-shim', label: 'Playground', hint: 'Demo playground shim (opens Plugins tab).' }),
|
||||
Object.freeze({ id: 'mission', label: 'Missions', hint: 'Mission Control — task graph over sessions.' }),
|
||||
])
|
||||
|
||||
function resolveHiddenPages(cfg) {
|
||||
if (!cfg || typeof cfg !== 'object') return DEFAULT_HIDDEN.slice()
|
||||
const raw = cfg.hiddenPages
|
||||
if (raw === undefined) return DEFAULT_HIDDEN.slice()
|
||||
if (!Array.isArray(raw)) return DEFAULT_HIDDEN.slice()
|
||||
// Explicit empty array means "show everything" — the user has opted
|
||||
// every optional page in. Filter out non-strings/blanks defensively so
|
||||
// a malformed entry can't crash the renderer filter.
|
||||
const cleaned = raw.filter((x) => typeof x === 'string' && x.length > 0)
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// Small helper the Settings page uses to compute the next hiddenPages
|
||||
// array given a checkbox flip. `enable=true` means "the user wants this
|
||||
// page visible" so we remove it from hidden; `enable=false` adds it back.
|
||||
function toggleOptionalPage(current, pageId, enable) {
|
||||
const set = new Set(Array.isArray(current) ? current : [])
|
||||
if (enable) set.delete(pageId)
|
||||
else set.add(pageId)
|
||||
return Array.from(set)
|
||||
}
|
||||
|
||||
const navConfigApi = { DEFAULT_HIDDEN, OPTIONAL_PAGES, resolveHiddenPages, toggleOptionalPage }
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = navConfigApi
|
||||
if (typeof window !== 'undefined') window.__dshNavConfigModel = navConfigApi
|
||||
|
||||
})();
|
||||
@@ -7530,6 +7530,47 @@ async function bootUi() {
|
||||
})
|
||||
window.__dshTabs = { switchTo }
|
||||
|
||||
// Left-nav hidden-pages filter (lane-nav-optional). Reads the shell
|
||||
// config's `hiddenPages` array through the nav IPC and toggles a
|
||||
// `nav-item--hidden` class on every `.tab-btn[data-tab=id]` in the
|
||||
// hidden set. Also hides the enclosing `.nav-group` when every button
|
||||
// inside it is hidden so an empty group header doesn't linger. If the
|
||||
// active tab has been hidden by the flip, we fall back to Chat so
|
||||
// the shell never lands on a pane whose entry point is invisible.
|
||||
async function applyNavHiddenPages() {
|
||||
const M = window.__dshNavConfigModel
|
||||
if (!M) return
|
||||
let cfg = {}
|
||||
try {
|
||||
if (window.dsh && window.dsh.nav && typeof window.dsh.nav.getHiddenPages === 'function') {
|
||||
cfg = await window.dsh.nav.getHiddenPages() || {}
|
||||
}
|
||||
} catch (_) { /* absent IPC (unit test / stripped preload) → fall through to defaults */ }
|
||||
const hidden = M.resolveHiddenPages(cfg)
|
||||
const hiddenSet = new Set(hidden)
|
||||
for (const btn of document.querySelectorAll('.sidebar-nav .tab-btn')) {
|
||||
const id = btn.dataset.tab
|
||||
btn.classList.toggle('nav-item--hidden', hiddenSet.has(id))
|
||||
}
|
||||
for (const group of document.querySelectorAll('.sidebar-nav .nav-group')) {
|
||||
const buttons = group.querySelectorAll('.tab-btn')
|
||||
if (buttons.length === 0) continue
|
||||
const everyHidden = Array.from(buttons).every((b) => b.classList.contains('nav-item--hidden'))
|
||||
group.classList.toggle('nav-group--hidden', everyHidden)
|
||||
}
|
||||
// If a hidden tab was the active one, fall back to Chat so the
|
||||
// main pane isn't left showing a surface whose button is gone.
|
||||
const activeBtn = document.querySelector('.sidebar-nav .tab-btn.active')
|
||||
if (activeBtn && activeBtn.classList.contains('nav-item--hidden')) {
|
||||
switchTo('chat')
|
||||
}
|
||||
return hidden
|
||||
}
|
||||
window.__dshNavFilter = { apply: applyNavHiddenPages }
|
||||
// Kick the filter after tab wiring lands so a fresh boot doesn't
|
||||
// paint Playground/Missions before the hide toggles for one frame.
|
||||
void applyNavHiddenPages()
|
||||
|
||||
// Rate-trajectory button — opens the annotation drawer scoped
|
||||
// to the current chat session. Falls back to a fixture-session picker
|
||||
// when there is no active session (first-run demo shape).
|
||||
|
||||
@@ -170,6 +170,62 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Optional pages (lane-nav-optional). Render a checkbox row for each
|
||||
// page listed in nav-config-model's OPTIONAL_PAGES. `checked=true`
|
||||
// means the page is CURRENTLY VISIBLE (not in the hiddenPages array).
|
||||
// On toggle, write the updated array back through window.dsh.nav.set
|
||||
// and ask the renderer to re-apply the sidebar filter so the change
|
||||
// takes effect without a reload.
|
||||
async function readCurrentHidden() {
|
||||
try {
|
||||
if (window.dsh && window.dsh.nav && typeof window.dsh.nav.getHiddenPages === 'function') {
|
||||
const res = await window.dsh.nav.getHiddenPages()
|
||||
const M = window.__dshNavConfigModel
|
||||
return M ? M.resolveHiddenPages(res || {}) : (Array.isArray(res && res.hiddenPages) ? res.hiddenPages : [])
|
||||
}
|
||||
} catch (_) { /* fall through */ }
|
||||
const M = window.__dshNavConfigModel
|
||||
return M ? M.DEFAULT_HIDDEN.slice() : []
|
||||
}
|
||||
|
||||
function renderOptionalRow(page, isVisible) {
|
||||
const li = document.createElement('li')
|
||||
li.className = 'settings-optional-row'
|
||||
li.dataset.pageId = page.id
|
||||
const checkbox = document.createElement('input')
|
||||
checkbox.type = 'checkbox'
|
||||
checkbox.checked = !!isVisible
|
||||
checkbox.id = `settings-optional-${page.id}`
|
||||
checkbox.dataset.pageId = page.id
|
||||
const label = document.createElement('label')
|
||||
label.className = 'settings-optional-label'
|
||||
label.setAttribute('for', checkbox.id)
|
||||
const name = document.createElement('span')
|
||||
name.className = 'settings-optional-name'
|
||||
name.textContent = page.label
|
||||
const hint = document.createElement('span')
|
||||
hint.className = 'settings-optional-hint muted'
|
||||
hint.textContent = page.hint
|
||||
label.appendChild(name)
|
||||
label.appendChild(hint)
|
||||
li.appendChild(checkbox)
|
||||
li.appendChild(label)
|
||||
return li
|
||||
}
|
||||
|
||||
async function renderOptionalPages(container) {
|
||||
const list = container.querySelector('[data-settings-optional-list]')
|
||||
if (!list) return
|
||||
const M = window.__dshNavConfigModel
|
||||
if (!M) return
|
||||
const currentHidden = await readCurrentHidden()
|
||||
const hiddenSet = new Set(currentHidden)
|
||||
list.innerHTML = ''
|
||||
for (const page of M.OPTIONAL_PAGES) {
|
||||
list.appendChild(renderOptionalRow(page, !hiddenSet.has(page.id)))
|
||||
}
|
||||
}
|
||||
|
||||
async function readKeyPresence() {
|
||||
// Only DEEPSEEK is knowable today because the main process spawns
|
||||
// stdio-deepseek with that key threaded through the profile. Other
|
||||
@@ -202,6 +258,7 @@
|
||||
if (!root) return
|
||||
const priceContainer = root.querySelector('[data-settings-pricing]')
|
||||
const keysContainer = root.querySelector('[data-settings-keys]')
|
||||
const optionalContainer = root.querySelector('[data-settings-optional-pages]')
|
||||
const model = window.__dshSettingsModel
|
||||
if (!model) return
|
||||
if (priceContainer) {
|
||||
@@ -221,6 +278,9 @@
|
||||
const rows = model.classifyKeys(presence)
|
||||
renderKeys(keysContainer, rows)
|
||||
}
|
||||
if (optionalContainer) {
|
||||
await renderOptionalPages(optionalContainer)
|
||||
}
|
||||
}
|
||||
|
||||
function attachListeners(root) {
|
||||
@@ -260,6 +320,33 @@
|
||||
void refresh(root)
|
||||
})
|
||||
}
|
||||
// Optional pages checkbox listener — one delegated handler for all
|
||||
// rows. The bound flag guards a double-bind if show() runs twice
|
||||
// (which happens when the researcher visits Settings after having
|
||||
// opened it once already, since renderer.js:switchTo('settings')
|
||||
// calls show() unconditionally).
|
||||
const optionalList = root.querySelector('[data-settings-optional-list]')
|
||||
if (optionalList && !optionalList.__dshBound) {
|
||||
optionalList.__dshBound = true
|
||||
optionalList.addEventListener('change', async (ev) => {
|
||||
const t = ev.target
|
||||
if (!(t instanceof HTMLInputElement) || t.type !== 'checkbox') return
|
||||
const pageId = t.dataset.pageId
|
||||
const M = window.__dshNavConfigModel
|
||||
if (!pageId || !M) return
|
||||
// Read current, compute next, persist, re-apply filter.
|
||||
const current = await readCurrentHidden()
|
||||
const next = M.toggleOptionalPage(current, pageId, t.checked)
|
||||
try {
|
||||
if (window.dsh && window.dsh.nav && typeof window.dsh.nav.setHiddenPages === 'function') {
|
||||
await window.dsh.nav.setHiddenPages(next)
|
||||
}
|
||||
} catch (_) { /* IPC absent — the DOM class flip below still updates the sidebar for this session */ }
|
||||
if (window.__dshNavFilter && typeof window.__dshNavFilter.apply === 'function') {
|
||||
await window.__dshNavFilter.apply()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function show() {
|
||||
|
||||
@@ -12700,3 +12700,52 @@ button.artifact-version:hover {
|
||||
}
|
||||
.artifact-timeline-open:hover { color: var(--accent); background: var(--accent-soft); }
|
||||
|
||||
|
||||
/* ---- lane-nav-optional: hiddenPages filter + Optional pages Settings ----
|
||||
`.nav-item--hidden` is added by renderer.js's applyNavHiddenPages() for
|
||||
every left-nav button whose data-tab id appears in the shell config's
|
||||
`hiddenPages` array (or the default set: playground-shim + mission).
|
||||
`.nav-group--hidden` collapses the enclosing group header when every
|
||||
button inside got hidden — otherwise a lonely "iteration" label would
|
||||
sit above an empty column. */
|
||||
.sidebar .sidebar-nav .nav-item--hidden,
|
||||
.sidebar .sidebar-nav .nav-group--hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Optional pages checkbox list in Settings > Optional pages. Compact
|
||||
two-line rows (label + hint) so the section stays skimmable and does
|
||||
not fight the pricing/keys tables above it. */
|
||||
.settings-optional-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.settings-optional-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) 0;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.settings-optional-row:first-child { border-top: none; }
|
||||
.settings-optional-row input[type="checkbox"] {
|
||||
margin-top: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.settings-optional-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.settings-optional-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
.settings-optional-hint {
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--fs-small, 12px);
|
||||
}
|
||||
|
||||
291
examples/desktop/test/nav-config-model.test.js
Normal file
291
examples/desktop/test/nav-config-model.test.js
Normal file
@@ -0,0 +1,291 @@
|
||||
// nav-config-model.test.js — pure config helpers + static HTML gates for
|
||||
// the left-nav hiddenPages filter (lane-nav-optional).
|
||||
//
|
||||
// Three fixture cases (task spec):
|
||||
// • config.json missing `hiddenPages` → default hidden set (Playground + Missions)
|
||||
// • config.json has empty array → nothing hidden (all pages show)
|
||||
// • config.json has custom list → honored as-is (e.g. ["prs","growth"])
|
||||
//
|
||||
// Plus DOM-shape assertions on index.html so a future rewrite of the
|
||||
// sidebar can't silently strip the Optional pages section.
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const M = require('../src/renderer/nav-config-model.js')
|
||||
|
||||
// -- resolveHiddenPages ----------------------------------------------------
|
||||
|
||||
test('resolveHiddenPages: no config → default hidden set', () => {
|
||||
assert.deepStrictEqual(M.resolveHiddenPages(null), ['playground-shim', 'mission'])
|
||||
assert.deepStrictEqual(M.resolveHiddenPages(undefined), ['playground-shim', 'mission'])
|
||||
assert.deepStrictEqual(M.resolveHiddenPages({}), ['playground-shim', 'mission'])
|
||||
})
|
||||
|
||||
test('resolveHiddenPages: hiddenPages missing on partial config → default set', () => {
|
||||
const cfg = { role: 'engineer', approvalMode: 'ask', createdAt: 1234 }
|
||||
assert.deepStrictEqual(M.resolveHiddenPages(cfg), ['playground-shim', 'mission'])
|
||||
})
|
||||
|
||||
test('resolveHiddenPages: empty array → show everything', () => {
|
||||
assert.deepStrictEqual(M.resolveHiddenPages({ hiddenPages: [] }), [])
|
||||
})
|
||||
|
||||
test('resolveHiddenPages: custom list → honored as-is', () => {
|
||||
assert.deepStrictEqual(
|
||||
M.resolveHiddenPages({ hiddenPages: ['prs', 'growth'] }),
|
||||
['prs', 'growth']
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveHiddenPages: non-array garbage → falls back to defaults (safe)', () => {
|
||||
assert.deepStrictEqual(M.resolveHiddenPages({ hiddenPages: 'playground' }), ['playground-shim', 'mission'])
|
||||
assert.deepStrictEqual(M.resolveHiddenPages({ hiddenPages: 42 }), ['playground-shim', 'mission'])
|
||||
assert.deepStrictEqual(M.resolveHiddenPages({ hiddenPages: { pages: [] } }), ['playground-shim', 'mission'])
|
||||
})
|
||||
|
||||
test('resolveHiddenPages: filters out non-string / blank entries', () => {
|
||||
assert.deepStrictEqual(
|
||||
M.resolveHiddenPages({ hiddenPages: ['prs', '', null, 42, 'growth'] }),
|
||||
['prs', 'growth']
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveHiddenPages: returns a fresh array (mutation safety)', () => {
|
||||
const a = M.resolveHiddenPages({})
|
||||
a.push('sneaky')
|
||||
const b = M.resolveHiddenPages({})
|
||||
assert.deepStrictEqual(b, ['playground-shim', 'mission'],
|
||||
'a subsequent call must not see the mutation of a prior return')
|
||||
})
|
||||
|
||||
// -- toggleOptionalPage -----------------------------------------------------
|
||||
|
||||
test('toggleOptionalPage: enable removes the id from hidden', () => {
|
||||
assert.deepStrictEqual(
|
||||
M.toggleOptionalPage(['playground-shim', 'mission'], 'playground-shim', true).sort(),
|
||||
['mission']
|
||||
)
|
||||
})
|
||||
|
||||
test('toggleOptionalPage: disable adds the id to hidden', () => {
|
||||
assert.deepStrictEqual(
|
||||
M.toggleOptionalPage(['mission'], 'playground-shim', false).sort(),
|
||||
['mission', 'playground-shim']
|
||||
)
|
||||
})
|
||||
|
||||
test('toggleOptionalPage: idempotent — disable an already-hidden page = no change', () => {
|
||||
assert.deepStrictEqual(
|
||||
M.toggleOptionalPage(['mission'], 'mission', false).sort(),
|
||||
['mission']
|
||||
)
|
||||
})
|
||||
|
||||
test('toggleOptionalPage: idempotent — enable an already-visible page = no change', () => {
|
||||
assert.deepStrictEqual(
|
||||
M.toggleOptionalPage(['mission'], 'playground-shim', true).sort(),
|
||||
['mission']
|
||||
)
|
||||
})
|
||||
|
||||
test('toggleOptionalPage: non-array current is tolerated (start fresh)', () => {
|
||||
assert.deepStrictEqual(
|
||||
M.toggleOptionalPage(undefined, 'mission', false).sort(),
|
||||
['mission']
|
||||
)
|
||||
assert.deepStrictEqual(
|
||||
M.toggleOptionalPage(null, 'mission', true).sort(),
|
||||
[]
|
||||
)
|
||||
})
|
||||
|
||||
// -- OPTIONAL_PAGES shape ---------------------------------------------------
|
||||
|
||||
test('OPTIONAL_PAGES: matches DEFAULT_HIDDEN 1:1 by id', () => {
|
||||
const optIds = M.OPTIONAL_PAGES.map((p) => p.id).sort()
|
||||
const defIds = M.DEFAULT_HIDDEN.slice().sort()
|
||||
assert.deepStrictEqual(optIds, defIds,
|
||||
'every default-hidden page should have a Settings checkbox and vice versa')
|
||||
})
|
||||
|
||||
test('OPTIONAL_PAGES: every entry has id/label/hint strings', () => {
|
||||
for (const page of M.OPTIONAL_PAGES) {
|
||||
assert.ok(typeof page.id === 'string' && page.id.length > 0, `id: ${JSON.stringify(page)}`)
|
||||
assert.ok(typeof page.label === 'string' && page.label.length > 0, `label: ${JSON.stringify(page)}`)
|
||||
assert.ok(typeof page.hint === 'string' && page.hint.length > 0, `hint: ${JSON.stringify(page)}`)
|
||||
}
|
||||
})
|
||||
|
||||
// -- static HTML gate: Optional pages section --------------------------------
|
||||
|
||||
const HTML = fs.readFileSync(
|
||||
path.resolve(__dirname, '..', 'src/renderer/index.html'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('Optional pages section is declared in the Settings pane', () => {
|
||||
assert.match(HTML, /data-settings-optional-pages/,
|
||||
'Optional pages section marker present')
|
||||
assert.match(HTML, /data-settings-optional-list/,
|
||||
'Optional pages list hook present')
|
||||
assert.match(HTML, /Optional pages/,
|
||||
'Section title copy present')
|
||||
})
|
||||
|
||||
test('Optional pages section lives inside the Settings pane (not orphaned)', () => {
|
||||
// Grab settings-pane block through its closing tag and assert the
|
||||
// Optional marker sits inside. Guards against a merge that moves the
|
||||
// block out of the pane by accident.
|
||||
const paneMatch = HTML.match(/id="settings-pane"[\s\S]*?<\/section>\s*<\/section>/)
|
||||
assert.ok(paneMatch, 'settings-pane block found')
|
||||
assert.match(paneMatch[0], /data-settings-optional-pages/,
|
||||
'Optional pages section must live inside #settings-pane')
|
||||
})
|
||||
|
||||
test('nav-config-model.js is loaded before settings-page.js', () => {
|
||||
const navMatch = HTML.match(/<script[^>]*src="\.\/nav-config-model\.js"/)
|
||||
const settingsMatch = HTML.match(/<script[^>]*src="\.\/settings-page\.js"/)
|
||||
assert.ok(navMatch, 'nav-config-model.js script tag present')
|
||||
assert.ok(settingsMatch, 'settings-page.js script tag present')
|
||||
const navIdx = HTML.indexOf(navMatch[0])
|
||||
const settingsIdx = HTML.indexOf(settingsMatch[0])
|
||||
assert.ok(settingsIdx > navIdx,
|
||||
'nav-config-model must load before settings-page so OPTIONAL_PAGES is defined at render time')
|
||||
})
|
||||
|
||||
test('Playground-shim + mission buttons expose the data-tab ids the filter matches', () => {
|
||||
// These two ids are the default-hidden set — if a lane renames either
|
||||
// without also updating nav-config-model.DEFAULT_HIDDEN, the filter
|
||||
// would silently no-op and both buttons would show up on fresh installs.
|
||||
assert.match(HTML, /data-tab="playground-shim"/,
|
||||
'playground-shim button id must match DEFAULT_HIDDEN entry')
|
||||
assert.match(HTML, /data-tab="mission"/,
|
||||
'mission button id must match DEFAULT_HIDDEN entry')
|
||||
})
|
||||
|
||||
// -- DOM-level filter proof (three fixture cases) --------------------------
|
||||
// Mirror the loop inside renderer.js:applyNavHiddenPages() against a tiny
|
||||
// shim so each fixture case is proven end-to-end: config in →
|
||||
// hiddenPages resolved → class toggled on matching button. Using a
|
||||
// hand-rolled shim rather than jsdom (not a dev-dep) or the full
|
||||
// renderer-harness (evals 8k lines of renderer.js for a one-loop check).
|
||||
|
||||
function makeSidebar() {
|
||||
const buttons = []
|
||||
const groups = []
|
||||
function makeBtn(dataTab) {
|
||||
return {
|
||||
dataset: { tab: dataTab },
|
||||
classList: {
|
||||
_s: new Set(),
|
||||
toggle(n, force) {
|
||||
if (force) this._s.add(n); else this._s.delete(n)
|
||||
return force
|
||||
},
|
||||
contains(n) { return this._s.has(n) },
|
||||
},
|
||||
}
|
||||
}
|
||||
function makeGroup(btns) {
|
||||
const g = {
|
||||
classList: {
|
||||
_s: new Set(),
|
||||
toggle(n, force) {
|
||||
if (force) this._s.add(n); else this._s.delete(n)
|
||||
return force
|
||||
},
|
||||
contains(n) { return this._s.has(n) },
|
||||
},
|
||||
_btns: btns,
|
||||
}
|
||||
for (const b of btns) buttons.push(b)
|
||||
groups.push(g)
|
||||
return g
|
||||
}
|
||||
makeGroup([
|
||||
makeBtn('chat'),
|
||||
makeBtn('tree'),
|
||||
makeBtn('context'),
|
||||
makeBtn('tracing'),
|
||||
])
|
||||
// "iteration" group — playground-shim (default hidden) sits here.
|
||||
makeGroup([
|
||||
makeBtn('playground-shim'),
|
||||
makeBtn('hub'),
|
||||
makeBtn('bench'),
|
||||
])
|
||||
// "runtime" group — mission (default hidden) sits here.
|
||||
makeGroup([
|
||||
makeBtn('rubrics'),
|
||||
makeBtn('plugins'),
|
||||
makeBtn('runtimes'),
|
||||
makeBtn('mission'),
|
||||
makeBtn('growth'),
|
||||
makeBtn('prs'),
|
||||
])
|
||||
return { buttons, groups }
|
||||
}
|
||||
|
||||
function applyFilter(sidebar, cfg) {
|
||||
const hidden = new Set(M.resolveHiddenPages(cfg))
|
||||
for (const b of sidebar.buttons) {
|
||||
b.classList.toggle('nav-item--hidden', hidden.has(b.dataset.tab))
|
||||
}
|
||||
for (const g of sidebar.groups) {
|
||||
const allHidden = g._btns.every((b) => b.classList.contains('nav-item--hidden'))
|
||||
g.classList.toggle('nav-group--hidden', allHidden)
|
||||
}
|
||||
}
|
||||
|
||||
test('DOM filter (case 1): missing hiddenPages → playground-shim + mission hidden, others visible', () => {
|
||||
const sb = makeSidebar()
|
||||
applyFilter(sb, {})
|
||||
const hidden = sb.buttons.filter((b) => b.classList.contains('nav-item--hidden'))
|
||||
assert.deepStrictEqual(
|
||||
hidden.map((b) => b.dataset.tab).sort(),
|
||||
['mission', 'playground-shim']
|
||||
)
|
||||
})
|
||||
|
||||
test('DOM filter (case 2): empty array → nothing hidden (all pages show)', () => {
|
||||
const sb = makeSidebar()
|
||||
applyFilter(sb, { hiddenPages: [] })
|
||||
const hidden = sb.buttons.filter((b) => b.classList.contains('nav-item--hidden'))
|
||||
assert.strictEqual(hidden.length, 0, 'no buttons should be hidden with []')
|
||||
const groupHidden = sb.groups.filter((g) => g.classList.contains('nav-group--hidden'))
|
||||
assert.strictEqual(groupHidden.length, 0, 'no groups should be collapsed with []')
|
||||
})
|
||||
|
||||
test('DOM filter (case 3): custom list → matching buttons hidden, others untouched', () => {
|
||||
const sb = makeSidebar()
|
||||
applyFilter(sb, { hiddenPages: ['prs', 'growth'] })
|
||||
const hidden = sb.buttons.filter((b) => b.classList.contains('nav-item--hidden'))
|
||||
assert.deepStrictEqual(
|
||||
hidden.map((b) => b.dataset.tab).sort(),
|
||||
['growth', 'prs']
|
||||
)
|
||||
// Playground + mission are NOT hidden because the researcher opted them in
|
||||
// via an explicit list; only the ids in the list are hidden.
|
||||
const play = sb.buttons.find((b) => b.dataset.tab === 'playground-shim')
|
||||
const mission = sb.buttons.find((b) => b.dataset.tab === 'mission')
|
||||
assert.ok(!play.classList.contains('nav-item--hidden'), 'playground-shim visible on custom list')
|
||||
assert.ok(!mission.classList.contains('nav-item--hidden'), 'mission visible on custom list')
|
||||
})
|
||||
|
||||
test('DOM filter: group collapses when every button inside is hidden', () => {
|
||||
const sb = makeSidebar()
|
||||
// Hide every button in the iteration group.
|
||||
applyFilter(sb, { hiddenPages: ['playground-shim', 'hub', 'bench'] })
|
||||
const iterGroup = sb.groups[1] // second group is "iteration"
|
||||
assert.ok(iterGroup.classList.contains('nav-group--hidden'),
|
||||
'iteration group should be collapsed when all its buttons are hidden')
|
||||
// The other groups still have visible members.
|
||||
assert.ok(!sb.groups[0].classList.contains('nav-group--hidden'))
|
||||
assert.ok(!sb.groups[2].classList.contains('nav-group--hidden'))
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user