From 7f1d9675a5004cff02766d1fc6ad72f7e1eaf469 Mon Sep 17 00:00:00 2001 From: KT Date: Wed, 16 Sep 2026 23:26:47 +0800 Subject: [PATCH 01/83] test(ui-web): pin the stylesheet, the booted dom shape and the island dom before the refactor Three gates for the architecture refactor's promise that nothing the reader sees moves: - scripts/page-css-hash.test.mjs pins page.css by sha256; a deliberate style change updates the digest in the same PR. - scripts/boot-snapshot.mjs boots dist/index.html in happy-dom at ?stub=1 and compares the settled body shape (tag, id, classes, data-*; no text, no script or style elements) against scripts/__golden__/boot-stub.txt. build.py runs it after writing dist/, so every job that assembles the page checks it; a missing golden is written on a developer machine and refused under CI. - one DOM-shape snapshot case per island page test (31 cases across 18 files) through src/test/domSnapshot.ts, generated from today's render and verified deterministic by a second run. scripts/i18n-keys.test.mjs also lands here: every literal t('gui.x') must be in i18n/messages.json; the two keys missing today are allowlisted and tracked as a copy fix. Co-authored-by: Claude (claude-fable-5-1) --- ui-web/build.py | 13 +- ui-web/scripts/__golden__/boot-stub.txt | 427 ++++++++++++++++++ ui-web/scripts/boot-snapshot.mjs | 94 ++++ ui-web/scripts/i18n-keys.test.mjs | 44 ++ ui-web/scripts/page-css-hash.test.mjs | 17 + .../src/features/browser/BrowserPage.test.tsx | 25 + .../__snapshots__/BrowserPage.test.tsx.snap | 55 +++ .../features/composer/ComposerPage.test.tsx | 19 + .../__snapshots__/ComposerPage.test.tsx.snap | 27 ++ .../features/connections/ConnPage.test.tsx | 22 + .../__snapshots__/ConnPage.test.tsx.snap | 69 +++ ui-web/src/features/cron/CronPage.test.tsx | 22 + .../cron/__snapshots__/CronPage.test.tsx.snap | 108 +++++ .../features/knowledge/KnowledgePage.test.tsx | 25 + .../__snapshots__/KnowledgePage.test.tsx.snap | 92 ++++ .../src/features/memory/MemoryPage.test.tsx | 12 + .../__snapshots__/MemoryPage.test.tsx.snap | 41 ++ .../src/features/model/ModelPicker.test.tsx | 16 + .../__snapshots__/ModelPicker.test.tsx.snap | 176 ++++++++ .../src/features/onboard/OnboardPage.test.tsx | 31 ++ .../__snapshots__/OnboardPage.test.tsx.snap | 102 +++++ .../features/playbooks/PlaybooksPage.test.tsx | 23 + .../__snapshots__/PlaybooksPage.test.tsx.snap | 97 ++++ .../src/features/plugins/PluginsPage.test.tsx | 25 + .../__snapshots__/PluginsPage.test.tsx.snap | 82 ++++ ui-web/src/features/rail/RailPage.test.tsx | 15 + .../rail/__snapshots__/RailPage.test.tsx.snap | 74 +++ .../features/settings/SettingsPage.test.tsx | 15 + .../__snapshots__/SettingsPage.test.tsx.snap | 132 ++++++ .../src/features/skills/SkillsPage.test.tsx | 21 + .../__snapshots__/SkillsPage.test.tsx.snap | 82 ++++ .../features/subagents/SubagentsPage.test.tsx | 28 ++ .../__snapshots__/SubagentsPage.test.tsx.snap | 85 ++++ .../transcript/TranscriptPage.test.tsx | 22 + .../TranscriptPage.test.tsx.snap | 59 +++ .../src/features/workspace/DeskPage.test.tsx | 9 + .../features/workspace/WorkspacePage.test.tsx | 15 + .../__snapshots__/DeskPage.test.tsx.snap | 54 +++ .../__snapshots__/WorkspacePage.test.tsx.snap | 22 + ui-web/src/features/xa/XaPage.test.tsx | 20 + .../xa/__snapshots__/XaPage.test.tsx.snap | 80 ++++ ui-web/src/test/domSnapshot.ts | 29 ++ 42 files changed, 2424 insertions(+), 2 deletions(-) create mode 100644 ui-web/scripts/__golden__/boot-stub.txt create mode 100644 ui-web/scripts/boot-snapshot.mjs create mode 100644 ui-web/scripts/i18n-keys.test.mjs create mode 100644 ui-web/scripts/page-css-hash.test.mjs create mode 100644 ui-web/src/features/browser/__snapshots__/BrowserPage.test.tsx.snap create mode 100644 ui-web/src/features/composer/__snapshots__/ComposerPage.test.tsx.snap create mode 100644 ui-web/src/features/connections/__snapshots__/ConnPage.test.tsx.snap create mode 100644 ui-web/src/features/cron/__snapshots__/CronPage.test.tsx.snap create mode 100644 ui-web/src/features/knowledge/__snapshots__/KnowledgePage.test.tsx.snap create mode 100644 ui-web/src/features/memory/__snapshots__/MemoryPage.test.tsx.snap create mode 100644 ui-web/src/features/model/__snapshots__/ModelPicker.test.tsx.snap create mode 100644 ui-web/src/features/onboard/__snapshots__/OnboardPage.test.tsx.snap create mode 100644 ui-web/src/features/playbooks/__snapshots__/PlaybooksPage.test.tsx.snap create mode 100644 ui-web/src/features/plugins/__snapshots__/PluginsPage.test.tsx.snap create mode 100644 ui-web/src/features/rail/__snapshots__/RailPage.test.tsx.snap create mode 100644 ui-web/src/features/settings/__snapshots__/SettingsPage.test.tsx.snap create mode 100644 ui-web/src/features/skills/__snapshots__/SkillsPage.test.tsx.snap create mode 100644 ui-web/src/features/subagents/__snapshots__/SubagentsPage.test.tsx.snap create mode 100644 ui-web/src/features/transcript/__snapshots__/TranscriptPage.test.tsx.snap create mode 100644 ui-web/src/features/workspace/__snapshots__/DeskPage.test.tsx.snap create mode 100644 ui-web/src/features/workspace/__snapshots__/WorkspacePage.test.tsx.snap create mode 100644 ui-web/src/features/xa/__snapshots__/XaPage.test.tsx.snap create mode 100644 ui-web/src/test/domSnapshot.ts diff --git a/ui-web/build.py b/ui-web/build.py index 9493f0841..800c7e9f6 100644 --- a/ui-web/build.py +++ b/ui-web/build.py @@ -31,6 +31,7 @@ import hashlib import json import shutil +import subprocess from pathlib import Path ROOT = Path(__file__).resolve().parent @@ -196,7 +197,7 @@ def main() -> None: # LF to be the next byte). The artifact ships in the wheel, so it must not # depend on which platform assembled it either. (dist / "index.html").write_text(out, encoding="utf-8", newline="") - print(f"built dist/index.html ({len(out):,} bytes)") + print(f"built dist/index.html ({len(out):,} bytes)", flush=True) # Static assets stay files rather than data: URIs -- inlining 320 KB of # artwork would grow the page by a third again in base64 and re-download it @@ -209,7 +210,15 @@ def main() -> None: shutil.copytree(src_assets, out_assets) shutil.copy2(ROOT / "icon" / "raven.svg", out_assets / "raven.svg") total = sum(p.stat().st_size for p in out_assets.rglob("*") if p.is_file()) - print(f"copied dist/assets ({total:,} bytes)") + print(f"copied dist/assets ({total:,} bytes)", flush=True) + _check_boot_snapshot(dist / "index.html") + + +def _check_boot_snapshot(index: Path) -> None: + """Refuse a page whose booted DOM shape moved; see scripts/boot-snapshot.mjs.""" + result = subprocess.run(["node", str(ROOT / "scripts" / "boot-snapshot.mjs"), str(index)], check=False) + if result.returncode != 0: + raise SystemExit("ui-web/build.py: booted DOM shape differs from scripts/__golden__/boot-stub.txt") if __name__ == "__main__": diff --git a/ui-web/scripts/__golden__/boot-stub.txt b/ui-web/scripts/__golden__/boot-stub.txt new file mode 100644 index 000000000..9b34f1caf --- /dev/null +++ b/ui-web/scripts/__golden__/boot-stub.txt @@ -0,0 +1,427 @@ +div#onb +div.app[data-rail=on] + aside.rail + div.railtop + button#railBtn.ghost-ic.tipdn[data-i18n-aria=gui.collapse_rail][data-i18n-tip=gui.collapse_rail] + svg + rect + path + button#findBtn.ghost-ic.tipdn[data-i18n-aria=gui.search_sessions][data-i18n-tip=gui.search_sessions] + svg + circle + path + nav.rail-nav + button#newBtn.navi.newrun + svg + path + span[data-i18n=gui.new_task] + button#skillBtn.navi + svg + path + span[data-i18n=gui.tab.skills] + button#plugBtn.navi + svg + path + span[data-i18n=gui.tab.plugins] + button#pbBtn.navi + svg + circle + circle + circle + path + span[data-i18n=gui.nav.pb] + button#kbBtn.navi + svg + path + span[data-i18n=gui.nav.kb] + button#memBtn.navi + svg + path + span[data-i18n=gui.nav.mem] + div#moreFly.moresub[data-i18n-aria=gui.nav.more][data-open=false] + button#moreBtn.navi.more + svg.chev + path + span.l-more[data-i18n=gui.nav.more] + span.l-less[data-i18n=gui.nav.less] + div#findBox.find + svg.ic + circle + path + input#sfind[data-i18n-aria=gui.search_sessions][data-i18n-ph=gui.search_sessions] + button#sclr.clr[data-i18n-aria=gui.clear_search] + div#list.list + div.rail-foot + button#upnote.upnote + span.pip + span.t[data-i18n=gui.update.note] + span.rl[data-i18n=gui.update.reload] + button#meBtn.me[data-i18n-aria=gui.nav.set] + span.av.anon + svg + circle + path + span.who + span.n[data-i18n=gui.nav.set] + span#meSub.s + span#meKbd.kbd + div#railGrip.grip[data-i18n-aria=gui.resize_rail][data-i18n-title=gui.resize_rail] + div.main + div#split.split[data-full=false][data-open=false] + div.chat + div.top + h1#title + button#renameBtn.ghost-ic.tipdn[data-i18n-aria=gui.rename_session][data-i18n-tip=gui.rename_session] + svg + path + path + span.spacer + button#wsBtn.ghost-ic.wstog.tipdn[data-i18n-aria=gui.expand_ws][data-i18n-tip=gui.expand_ws][data-tip=Expand workspace] + svg + rect + path + span#wsBdg.bdg + div#scroll.scroll + div#bannerHost + div.banner + b + span + button + button.x + div#flash.flash + div#stage.col + div[data-tsl=1] + div.ask.in + div.b + div.ansfoot + div.acts + button[data-label=Copy][data-tip=Copy] + svg + rect + path + span.turnmeta + div.tfold + button.tfh + span.lb + span.tm + svg.cv + path + div.tfb + div.answer-turn + div.answer.in + div.prose + h2 + p + strong + div.tw + table + thead + tr + th + th + th + th + tbody + tr + td + td + td + td + tr + td + td + td + td + tr + td + td + td + td + h3 + ul + li + strong + li + strong + li + strong + h3 + p + strong + div.arts + section.asec.deliveries + div.ahd + span.ahm + span.lb + span.n + div.atiles + div.atile.missing + span.pic.none + svg.fi + path + span.ft + span.cap + span.nm + span.mt + button.hit + div.atile.missing + span.pic.none + svg.fi + path + span.ft + span.cap + span.nm + span.mt + button.hit + div.atile.missing + span.pic.none + svg.fi + path + span.ft + span.cap + span.nm + span.mt + button.hit + button.amore + section.asec.changes + div.ahd + span.ahm + span.lb + span.n + div.achanges + button.achange + span.ck.new + span.cn + span.ct + span.ca + span.cd + button.achange + span.ck.edit + span.cn + span.ct + span.ca + span.cd + button.achange + span.ck.edit + span.cn + span.ct + span.ca + span.cd + button.achange + span.ck.new + span.cn + span.ct + span.ca + span.cd + button.amore + div.ansfoot + div.acts + button[data-label=Copy][data-tip=Copy] + svg + rect + path + button[data-label=Branch into a new session][data-tip=Branch into a new session] + svg + circle + circle + circle + path + path + span.turnmeta + button#backpill.backpill + svg + path + div#wsGrip.grip[data-i18n-aria=gui.resize_ws][data-i18n-title=gui.resize_ws] + div#brand + span.mk + span.wl + div.dock + div.crew.crew-back + img.rv.rv-content + div#sheetRack.sheets + div.dock-in + div#queued.queued + div#atts.atts + div.field + textarea#ta[data-i18n-ph=gui.composer_ph] + div.under + button#attBtn.tool-btn[data-i18n-aria=gui.attach][data-i18n-tip=gui.attach] + svg + path + button#permChip.chip + svg.pico + path + path + path + span#permName + span#envChip.chip + span.led + span#envName + span#meter.meter + button#ctxChip.chip.ctx[data-tip=context 17k/200k 9%] + svg.ring + circle.bg + circle.fg + button#tierChip.chip + svg.pico + path + path + span#tierName + button#modelChip.chip + span#modelName + button#go.go[data-i18n-aria=gui.send] + svg + path + div#slashPop.pop.slash[data-i18n-aria=gui.commands][data-open=false] + div.hd + span.lab[data-i18n=gui.session_commands] + div#slashList + div#permPop.pop[data-i18n-aria=gui.perm.title][data-open=false] + div.hd + span.lab[data-i18n=gui.perm.title] + div#permList + div.note[data-i18n=gui.perm.note] + div#tierPop.pop[data-open=false] + div.hd + span#tierPopLab.lab + div#tierList + div.note + div.crew.crew-front + img.rv.rv-main + img.rv.rv-research + img.rv.rv-coding + img.rv.rv-data + aside#ws.ws[data-i18n-aria=gui.workspace] + div.ws-top + div#wsTabs.wseg + button[data-w=diff] + svg + rect + path + span.lb[data-i18n=gui.ws.changes] + span#wsUnseen.bdg + button[data-w=browser] + svg + circle + path + span.lb[data-i18n=gui.ws.browser] + button[data-w=agents] + svg + rect + path + span.lb[data-i18n=gui.ws.agents] + span#wsAgentRun.rundot + div.ws-acts + button#wsWide.ghost-ic.wsfull.tipdn[data-i18n-aria=gui.ws.expand_panel][data-i18n-tip=gui.ws.expand_panel][data-tip=Expand to full window] + svg.ex + path + svg.in + path + button#wsClose.ghost-ic.wstog.tipdn[data-i18n-aria=gui.collapse_ws][data-i18n-tip=gui.collapse_ws] + svg + rect + path + div#wsBody.ws-body +button#railShow.ghost-ic.tipdn[data-i18n-aria=gui.expand_rail][data-i18n-tip=gui.expand_rail] + svg + rect + path +section#capsPage.page[data-open=false] + header + h2#capsTitle[data-i18n=gui.tab.skills] + div.work + div.wrap + div#pageHero.pmhero + h3 + div.cbar + div.cfind + svg.ic + circle + path + input#cq[data-i18n-aria=gui.search] + div#cKind.pills[data-i18n-aria=gui.filter_status] + button.pill[data-i18n=gui.filter.all][data-k=all] + button.pill[data-i18n=gui.filter.on][data-k=on] + button.pill[data-i18n=gui.filter.todo][data-k=attn] + button.pill[data-i18n=gui.filter.add][data-k=add] + button.pminstbtn + span + button.pminstbtn + span + div#capsBody + div + details#advAdd.adv + summary[data-i18n=gui.adv_add] + p[data-i18n=gui.adv_hint] + div.row + input#mName[data-i18n-ph=gui.adv_name_ph] + input#mAddr[data-i18n-ph=gui.adv_addr_ph] + button#mAdd.mini[data-i18n=gui.add] +section#xaPage.page[data-i18n-aria=gui.page.agents][data-open=false] + header + h2[data-i18n=gui.page.agents] + div.work + div#xaBody.wrap +section#connPage.page[data-i18n-aria=gui.page.conn][data-open=false] + header + h2[data-i18n=gui.page.conn] + div.work + div#connBody.wrap +section#memPage.page[data-i18n-aria=gui.mem.hero][data-open=false] + header + h2[data-i18n=gui.nav.mem] + div.work + div#memBody.wrap +section#pbPage.page[data-i18n-aria=gui.nav.pb][data-open=false] + header + h2[data-i18n=gui.nav.pb] + div.work + div#pbBody.wrap +section#kbPage.page[data-i18n-aria=gui.nav.kb][data-open=false] + header + h2[data-i18n=gui.nav.kb] + div.work + div#kbBody.wrap +section#cronPage.page[data-i18n-aria=gui.page.cron][data-open=false] + header + h2[data-i18n=gui.page.cron] + div.work + div#cronBody.wrap +div#jobVeil.veil[data-open=false] +aside#detail.detail[data-i18n-aria=gui.cap_detail][data-open=false] + div.dpanel + header + b#dTitle + button#dClose.dx[data-i18n-aria=gui.close] + svg + path + div#dBody.body +div#setVeil.veil.setveil[data-open=false] + div#setModal.smodal[data-i18n-aria=gui.page.set] + nav#snav.snav + div.brandrow + span.wm[data-i18n=gui.page.set] + div#snavList.snavlist + div.sbody + header.shd + div.ttl + h3#setTitle + p#setSub.sub + button#setClose.icb[data-i18n-aria=gui.close][data-i18n-tip=gui.close] + svg + path + div#spanels.spanels +div#veil.veil[data-open=false] + div.sheet + header#cfTitle + div#cfBody.body + footer + button#cfNo.btn[data-i18n=gui.cancel] + button#cfYes.btn.bad +div#connVeil.veil[data-open=false] +div#menu.menu[data-open=false] +div#toasts.toasts +div.sbars +div +div#deskHost +div.tipp diff --git a/ui-web/scripts/boot-snapshot.mjs b/ui-web/scripts/boot-snapshot.mjs new file mode 100644 index 000000000..c142c9d11 --- /dev/null +++ b/ui-web/scripts/boot-snapshot.mjs @@ -0,0 +1,94 @@ +// Boots the assembled page in happy-dom at ?stub=1 and compares the DOM shape +// it settles into against a golden. This is the gate on the two structural +// switches of the refactor -- concatenated script to modules, then Python +// assembly to the Vite entry -- neither of which may move a single element. +// +// node scripts/boot-snapshot.mjs dist/index.html # compare +// node scripts/boot-snapshot.mjs dist/index.html --update # rewrite golden +// +// build.py runs the compare form after writing dist/. A missing golden is +// written on a developer machine and refused under CI, so a fresh checkout +// cannot pass by accident. +// +// What is recorded: tag, #id, .classes and [data-*] per element, indented by +// depth, text dropped, script and style skipped (the build changes how many +// there are). Determinism rests on the timer clamps below: the splash lifts on +// a 250ms floor plus a fade, and clamping every timer to 50ms makes the tree +// stop moving within the tick budget rather than depending on wall time. +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' + +import { Window } from 'happy-dom' + +const [, , distArg, flag] = process.argv +if (!distArg) { + console.error('usage: node scripts/boot-snapshot.mjs [--update]') + process.exit(2) +} +const GOLDEN = fileURLToPath(new URL('./__golden__/boot-stub.txt', import.meta.url)) +const TICKS = 40 + +const html = readFileSync(distArg, 'utf8') +const win = new Window({ + url: 'http://127.0.0.1:18792/?stub=1', + settings: { + enableJavaScriptEvaluation: true, + suppressInsecureJavaScriptEnvironmentWarning: true, + disableJavaScriptFileLoading: true, + disableCSSFileLoading: true, + enableImageFileLoading: false, + disableComputedStyleRendering: true, + handleDisabledFileLoadingAsSuccess: true, + timer: { maxTimeout: 50, maxIntervalTime: 50, maxIntervalIterations: 2, preventTimerLoops: true }, + }, +}) +const errors = [] +win.addEventListener('error', (e) => errors.push(String(e.error || e.message))) +win.document.write(html) +for (let i = 0; i < TICKS; i++) await new Promise((r) => setTimeout(r, 25)) + +const lines = [] +const walk = (node, depth) => { + if (node.nodeType !== 1) return + const tag = node.tagName.toLowerCase() + if (tag === 'script' || tag === 'style') return + const parts = [tag] + if (node.id) parts.push(`#${node.id}`) + const classes = (node.getAttribute('class') || '').trim() + if (classes) parts.push(`.${classes.split(/\s+/).join('.')}`) + const data = node.getAttributeNames().filter((a) => a.startsWith('data-')).sort() + for (const a of data) parts.push(`[${a}=${node.getAttribute(a)}]`) + lines.push(`${' '.repeat(depth)}${parts.join('')}`) + for (const child of node.children) walk(child, depth + 1) +} +for (const child of win.document.body.children) walk(child, 0) +await win.happyDOM.close() + +const actual = lines.join('\n') + '\n' +if (errors.length) { + console.error(`boot-snapshot: ${errors.length} page error(s) during boot:\n ${errors.join('\n ')}`) + process.exit(1) +} +if (flag === '--update' || (!existsSync(GOLDEN) && !process.env.CI)) { + mkdirSync(new URL('./__golden__/', import.meta.url), { recursive: true }) + writeFileSync(GOLDEN, actual) + console.log(`boot-snapshot: golden written (${lines.length} nodes)`) + process.exit(0) +} +if (!existsSync(GOLDEN)) { + console.error('boot-snapshot: golden missing under CI; run with --update on a developer machine and commit it') + process.exit(1) +} +const expected = readFileSync(GOLDEN, 'utf8') +if (expected === actual) { + console.log(`boot-snapshot: OK (${lines.length} nodes match golden)`) + process.exit(0) +} +const a = expected.split('\n') +const b = actual.split('\n') +let first = 0 +while (first < a.length && first < b.length && a[first] === b[first]) first++ +console.error(`boot-snapshot: DOM shape differs from golden (${a.length - 1} vs ${b.length - 1} nodes); first difference at line ${first + 1}:`) +console.error(` golden: ${a[first] ?? ''}`) +console.error(` actual: ${b[first] ?? ''}`) +process.exit(1) diff --git a/ui-web/scripts/i18n-keys.test.mjs b/ui-web/scripts/i18n-keys.test.mjs new file mode 100644 index 000000000..e55f991c8 --- /dev/null +++ b/ui-web/scripts/i18n-keys.test.mjs @@ -0,0 +1,44 @@ +// Every literal `t('gui.x')` / `T('gui.x')` in the page names a key the shared +// catalogue carries. T() falls back to the hard-coded English silently, so a +// misspelled or never-added key ships as untranslated text with no signal -- +// two did (see KNOWN_MISSING). Keys built by concatenation (`t('gui.x.' + n)`) +// are not checked: only a literal closed by `,` or `)` is a whole key. +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { join } from 'node:path' + +import { describe, expect, it } from 'vitest' + +const SRC = new URL('../src/', import.meta.url) +const CATALOGUE = new URL('../../i18n/messages.json', import.meta.url) + +// Absent from the catalogue today; tracked as a copy fix, not fixed here +// because i18n/messages.json is outside the page's own tree. +const KNOWN_MISSING = new Set(['gui.queue.title', 'gui.queue.remove']) + +function* sources(dir) { + for (const name of readdirSync(dir)) { + const path = join(dir, name) + if (statSync(path).isDirectory()) { + if (name !== 'node_modules') yield* sources(path) + } else if (/\.(ts|tsx|js|mjs)$/.test(name) && !/\.test\./.test(name)) { + yield path + } + } +} + +describe('i18n keys', () => { + it('every literal gui.* key exists in the catalogue', () => { + const ui = JSON.parse(readFileSync(CATALOGUE, 'utf8')).ui + const referenced = new Set() + for (const file of sources(SRC.pathname)) { + const text = readFileSync(file, 'utf8') + for (const m of text.matchAll(/\b[tT]\('(gui\.[A-Za-z0-9_.]+)'\s*[,)]/g)) referenced.add(m[1]) + } + const missing = [...referenced] + .filter((key) => !(key in ui) && !KNOWN_MISSING.has(key)) + .sort() + expect(missing).toEqual([]) + const healed = [...KNOWN_MISSING].filter((key) => key in ui) + expect(healed, 'remove healed keys from KNOWN_MISSING').toEqual([]) + }) +}) diff --git a/ui-web/scripts/page-css-hash.test.mjs b/ui-web/scripts/page-css-hash.test.mjs new file mode 100644 index 000000000..ed980700b --- /dev/null +++ b/ui-web/scripts/page-css-hash.test.mjs @@ -0,0 +1,17 @@ +// The stylesheet is frozen for the architecture refactor: every stage promises +// zero change to what the reader sees, and the cheapest half of that promise +// is that page.css itself does not move. A deliberate style change updates +// the digest below in the same PR that makes it, so the diff carries both. +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' + +import { describe, expect, it } from 'vitest' + +const PINNED = 'a42577123b92385bf4829e9018a90c4174f73caaeda59ce5d405fccb8e62a04b' + +describe('page.css', () => { + it('is byte-identical to the pinned digest', () => { + const css = readFileSync(new URL('../src/styles/page.css', import.meta.url)) + expect(createHash('sha256').update(css).digest('hex')).toBe(PINNED) + }) +}) diff --git a/ui-web/src/features/browser/BrowserPage.test.tsx b/ui-web/src/features/browser/BrowserPage.test.tsx index e1030a3f6..5cba8e792 100644 --- a/ui-web/src/features/browser/BrowserPage.test.tsx +++ b/ui-web/src/features/browser/BrowserPage.test.tsx @@ -5,6 +5,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { BrowserApp } from './BrowserPage' import * as store from './store' +import { domSnapshot } from '../../test/domSnapshot' + import type { Shell } from '../../shell/bridge' import type { BrowserSource, ChromiumSource, LinksSource, UrlRow } from './types' @@ -119,6 +121,16 @@ describe('browser island, links shape (the fixture source)', () => { items[1]!.fn() expect(shellCalls).toContainEqual(['copy', 'https://a.example/x']) }) + + it('keeps its rendered shape', () => { + links([ + { url: 'https://api.example.com/docs', kind: 'fetch', at: 'just now' }, + { url: 'rate limits', kind: 'search', at: 'earlier' }, + ]) + mount() + screen.getByText('api.example.com/docs') + expect(domSnapshot(document.getElementById('wsBody')!)).toMatchSnapshot() + }) }) describe('browser island, embedded shape (the rpc source)', () => { @@ -268,4 +280,17 @@ describe('browser island, embedded shape (the rpc source)', () => { }) expect(calls.filter(([k]) => k === 'open').length).toBe(asked + 1) }) + + it('keeps its rendered shape', async () => { + chromium({ + frame: async () => ({ started: true, url: 'https://example.com/a', title: 'Example' }), + tabs: async () => ({ + started: true, + tabs: [{ index: 0, url: 'https://example.com/a', title: 'Example', active: true }], + }), + }) + mount() + await screen.findByText('Example') + expect(domSnapshot(document.getElementById('wsBody')!)).toMatchSnapshot() + }) }) diff --git a/ui-web/src/features/browser/__snapshots__/BrowserPage.test.tsx.snap b/ui-web/src/features/browser/__snapshots__/BrowserPage.test.tsx.snap new file mode 100644 index 000000000..34a63eccd --- /dev/null +++ b/ui-web/src/features/browser/__snapshots__/BrowserPage.test.tsx.snap @@ -0,0 +1,55 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`browser island, embedded shape (the rpc source) > keeps its rendered shape 1`] = ` +"div.btabs + div.btab + img.fav + span.tt + button.bx + button.btab-new +div.bbar + div.nav + button.ghost-ic.bk + svg + path + button.ghost-ic.fw + svg + path + button.ghost-ic.brl + svg + path + span.burl + span.sec.ok + svg + path + input.url + div.bprog + i + button.ghost-ic + svg + path + button.ghost-ic + svg + path +div.bstage + canvas.shot + input.kbsink + div.waiting" +`; + +exports[`browser island, links shape (the fixture source) > keeps its rendered shape 1`] = ` +"button.urow + svg + path + span.u + small + svg.ext + path +button.urow + svg + path + span.u + small + svg.ext + path" +`; diff --git a/ui-web/src/features/composer/ComposerPage.test.tsx b/ui-web/src/features/composer/ComposerPage.test.tsx index 5cb109333..6e31e4cc9 100644 --- a/ui-web/src/features/composer/ComposerPage.test.tsx +++ b/ui-web/src/features/composer/ComposerPage.test.tsx @@ -8,6 +8,8 @@ import * as turn from './turn' import * as attachmentCache from '../../shell/attachment-cache' import * as tail from '../transcript/tail' +import { domSnapshot } from '../../test/domSnapshot' + import type { ComposerSource, SlashCmd } from './types' import type { Shell } from '../../shell/bridge' @@ -372,6 +374,13 @@ describe('the queue rows', () => { expect(store.queueSnapshot()).toEqual(['second']) expect(document.querySelectorAll('#queued .qrow').length).toBe(1) }) + + it('keeps its rendered shape', () => { + wire() + store.queueRestore(['first', 'second']) + mountQueue() + expect(domSnapshot(document.getElementById('queued')!)).toMatchSnapshot() + }) }) describe('the live turn row', () => { @@ -598,6 +607,16 @@ describe('the attachment tray', () => { expect(opened).not.toHaveBeenCalled() expect(calls.toasts).toEqual(['demo: pick a file here']) }) + + it('keeps its rendered shape', async () => { + wire({ upload: async () => ({ path: 'uploads/notes.txt', size: 300 }) }) + const box = mountTray() + await act(async () => { + store.addFiles([new File(['x'], 'notes.txt', { type: 'text/plain' })]) + await flush() + }) + expect(domSnapshot(box)).toMatchSnapshot() + }) }) describe('the slash palette', () => { diff --git a/ui-web/src/features/composer/__snapshots__/ComposerPage.test.tsx.snap b/ui-web/src/features/composer/__snapshots__/ComposerPage.test.tsx.snap new file mode 100644 index 000000000..11503cb8a --- /dev/null +++ b/ui-web/src/features/composer/__snapshots__/ComposerPage.test.tsx.snap @@ -0,0 +1,27 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`the attachment tray > keeps its rendered shape 1`] = ` +"div.att + span.nm + span.sz + button.rm" +`; + +exports[`the queue rows > keeps its rendered shape 1`] = ` +"div.qrow + span.v + button.icb[data-tip=编辑] + svg + path + button.icb[data-tip=删掉] + svg + path +div.qrow + span.v + button.icb[data-tip=编辑] + svg + path + button.icb[data-tip=删掉] + svg + path" +`; diff --git a/ui-web/src/features/connections/ConnPage.test.tsx b/ui-web/src/features/connections/ConnPage.test.tsx index bc791d385..96e6e89f8 100644 --- a/ui-web/src/features/connections/ConnPage.test.tsx +++ b/ui-web/src/features/connections/ConnPage.test.tsx @@ -5,6 +5,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { ConnApp } from './ConnPage' import * as store from './store' +import { domSnapshot } from '../../test/domSnapshot' + import type { Shell } from '../../shell/bridge' import type { ConnChannel, ConnQr, ConnSource } from './types' @@ -1245,4 +1247,24 @@ describe('connections island', () => { expect(body.querySelector('.suwiz')).toBeNull() expect(body.querySelector('.sustate')!.textContent).toContain('gui.conn.st_live') }) + + it('keeps its rendered shape, list', async () => { + install([ + chan({ on: true, running: true }), + chan({ id: 'telegram', name: 'Telegram' }), + chan({ id: 'email', key: 'gui.chan.email' }), + ]) + await mount() + await screen.findByText('Slack') + expect(domSnapshot(document.getElementById('connBody')!)).toMatchSnapshot() + }) + + it('keeps its rendered shape, dialog', async () => { + install([chan({ fields: [{ key: 'bot_token', required: true }], missing: ['bot_token'] })]) + await mount() + await act(async () => { + rowBtn('Slack').click() + }) + expect(domSnapshot(document.getElementById('connVeil')!)).toMatchSnapshot() + }) }) diff --git a/ui-web/src/features/connections/__snapshots__/ConnPage.test.tsx.snap b/ui-web/src/features/connections/__snapshots__/ConnPage.test.tsx.snap new file mode 100644 index 000000000..6693e6585 --- /dev/null +++ b/ui-web/src/features/connections/__snapshots__/ConnPage.test.tsx.snap @@ -0,0 +1,69 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`connections island > keeps its rendered shape, dialog 1`] = ` +"div.sheet + div.suhead + span.pmtile.th0 + div.meta + div.l1 + b + button.mini.ghost.suclose + svg + path + div#connDlgBody.subody + div.sucreds + span.k + span.n + a.jump + svg + path + div.sugroup + div.sufields + div.sufield + label + input + div.sufoot + span.n + button.mini.key" +`; + +exports[`connections island > keeps its rendered shape, list 1`] = ` +"div.pmhero + h3 +div.sustate + span.led + span +div.sugrp + div.hd + b + span.n + div.sulist + div.surow + span.pmtile.th0 + div.nm + span.led + b + div.suact + button.mini.ghost +div.sugrp + div.hd + b + span.n + div.sulist + div.surow + span.pmtile.th1 + div.nm + span.led.off + b + span.kd + div.suact + button.mini + div.surow + span.pmtile.th3 + div.nm + span.led.off + b + span.kd + div.suact + button.mini" +`; diff --git a/ui-web/src/features/cron/CronPage.test.tsx b/ui-web/src/features/cron/CronPage.test.tsx index 1da25c24e..4523aab7a 100644 --- a/ui-web/src/features/cron/CronPage.test.tsx +++ b/ui-web/src/features/cron/CronPage.test.tsx @@ -5,6 +5,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { CronApp } from './CronPage' import * as store from './store' +import { domSnapshot } from '../../test/domSnapshot' + import type { Shell } from '../../shell/bridge' import type { CronJob, CronSource } from './types' @@ -329,4 +331,24 @@ describe('cron island', () => { expect(calls).toContain('toggle') expect(rowsSpy.mock.calls.length).toBeGreaterThan(1) }) + + it('keeps its rendered shape, list', async () => { + install([ + job({ runs: [{ at: 'today', ok: false, note: 'boom' }] }), + job({ id: 'b', name: 'weekly report', runs: [{ at: 'today', ok: true, note: 'fine' }] }), + job({ id: 'c', name: 'paused one', on: false }), + ]) + await mount() + await screen.findByText('morning digest') + expect(domSnapshot(document.getElementById('cronBody')!)).toMatchSnapshot() + }) + + it('keeps its rendered shape, job page', async () => { + install([job({ runs: [{ at: 'today 08:00', ok: false, note: 'boom' }] })]) + await mount() + await act(async () => { + ;(await screen.findByText('morning digest')).click() + }) + expect(domSnapshot(document.getElementById('cronBody')!)).toMatchSnapshot() + }) }) diff --git a/ui-web/src/features/cron/__snapshots__/CronPage.test.tsx.snap b/ui-web/src/features/cron/__snapshots__/CronPage.test.tsx.snap new file mode 100644 index 000000000..795beb935 --- /dev/null +++ b/ui-web/src/features/cron/__snapshots__/CronPage.test.tsx.snap @@ -0,0 +1,108 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`cron island > keeps its rendered shape, job page 1`] = ` +"div.pmback + button.mini.ghost +div.suhead + div.meta + div.l1 + b + div.l2 + button.mini.ghost.sumenu +div.sustate + span.led + span + span.a + button.swi +div.tabbar + button + button +div.ff + label + input +div.ff + label + textarea +div.ff + label + div + div.seg + button + button + button + button + button + input +div.ff + label + div.sustate + span + span.x + span.a + button.mini.ghost +div.sufoot + span.n + button.mini.key" +`; + +exports[`cron island > keeps its rendered shape, list 1`] = ` +"div.herorow + div.pmhero + h3 + button.mini.gold +div.seg.cronfilter + button + span.n + button + span.n + button + span.n +div.sulist + div.surow.notile.bad + div.nm + span.led.bad + b + div.suact + button.swi + button.mini.ghost + div.sufacts.bad + div.suextra + span + span.nx + div.sufoot2 + button.rl + span.st.bad + span.at + span.nt + span.chev + div.surow.notile + div.nm + span.led + b + div.suact + button.swi + button.mini.ghost + div.sufacts + div.suextra + span + span.nx + div.sufoot2 + button.rl + span.st.ok + span.at + span.nt + span.chev + div.surow.notile + div.nm + span.led.off + b + div.suact + button.swi + button.mini.ghost + div.sufacts + div.suextra + span + span.nx + div.sufoot2 + span.never" +`; diff --git a/ui-web/src/features/knowledge/KnowledgePage.test.tsx b/ui-web/src/features/knowledge/KnowledgePage.test.tsx index 51bb51f58..906a8d9da 100644 --- a/ui-web/src/features/knowledge/KnowledgePage.test.tsx +++ b/ui-web/src/features/knowledge/KnowledgePage.test.tsx @@ -5,6 +5,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { KnowledgeApp } from './KnowledgePage' import * as store from './store' +import { domSnapshot } from '../../test/domSnapshot' + import type { KbBase, KbDoc, KbSearch, KnowledgeSource } from './types' import type { Shell } from '../../shell/bridge' @@ -303,6 +305,13 @@ describe('the knowledge page', () => { await mount() expect(screen.getByText('no knowledge source installed')).toBeTruthy() }) + + it('keeps its rendered shape, bases', async () => { + source({ bases: async () => [base({ id: 'b1', name: 'handbook', documents: 3 })] }) + const view = await mount() + expect(await screen.findByText('handbook')).toBeTruthy() + expect(domSnapshot(view.container)).toMatchSnapshot() + }) }) describe('opening the page', () => { @@ -731,6 +740,22 @@ describe('documents and search', () => { }) expect(screen.getByText('one')).toBeTruthy() }) + + it('keeps its rendered shape, documents', async () => { + source({ + bases: async () => [base({ id: 'b1' })], + documents: async () => [ + doc({ id: 'd1', source: 'done.md', status: 'ready', chunk_count: 2 }), + doc({ id: 'd3', source: 'broke.md', status: 'failed', error: 'endpoint said 400' }), + ], + }) + const view = await mount() + await act(async () => { + await store.open_('b1') + }) + await openRowMenu('done.md') + expect(domSnapshot(view.container)).toMatchSnapshot() + }) }) describe('the two-panel layout', () => { diff --git a/ui-web/src/features/knowledge/__snapshots__/KnowledgePage.test.tsx.snap b/ui-web/src/features/knowledge/__snapshots__/KnowledgePage.test.tsx.snap new file mode 100644 index 000000000..f52ac7b6f --- /dev/null +++ b/ui-web/src/features/knowledge/__snapshots__/KnowledgePage.test.tsx.snap @@ -0,0 +1,92 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`documents and search > keeps its rendered shape, documents 1`] = ` +"div.kbsplit + div.kbrail + button.mini.kbadd + div.kbrow + button.kbopenb + span.nm + span.who + div.kbops + button.mini.ghost.dots + div.kbpane + div.kbhd + b + span.mdl + button.mini.ghost + button.mini.ghost.kbgear + svg + path + path + div.kbsub + span.who + div.kbops.kbsrc + button.mini + input + input + div.kbtable + div.th.kbtick + input + div.th + div.th + div.th + div.th + div.th + div.td.kbtick + input + div.td.nm + button.kbopen + svg.kbico.kbf-md + path + path + rect + g + path + path + span + div.td + div.td.st.s-ready + div.td + div.td + div.kbops + button.mini.ghost.dots + div.kbmenu + button.mi + button.mi + button.mi + button.mi.danger + div.td.kbtick + input + div.td.nm + button.kbopen + svg.kbico.kbf-md + path + path + rect + g + path + path + span + div.td + div.td.st.s-failed + div.td + div.td + div.kbops + button.mini.ghost.dots" +`; + +exports[`the knowledge page > keeps its rendered shape, bases 1`] = ` +"div.kbsplit + div.kbrail + button.mini.kbadd + div.kbrow + button.kbopenb + span.nm + span.who + div.kbops + button.mini.ghost.dots + div.kbpane + div.empty-note + div.ttl" +`; diff --git a/ui-web/src/features/memory/MemoryPage.test.tsx b/ui-web/src/features/memory/MemoryPage.test.tsx index 7d8ef412f..11518bd2f 100644 --- a/ui-web/src/features/memory/MemoryPage.test.tsx +++ b/ui-web/src/features/memory/MemoryPage.test.tsx @@ -5,6 +5,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { MemoryApp } from './MemoryPage' import * as store from './store' +import { domSnapshot } from '../../test/domSnapshot' + import type { Shell } from '../../shell/bridge' import type { MemItem, MemStats, MemorySource } from './types' @@ -220,4 +222,14 @@ describe('memory island', () => { }) expect(asked).toContain('agent_case') }) + + it('keeps its rendered shape', async () => { + install( + { list: async () => ({ items: [item(), item({ id: 'm2', subject: 'fixed the flake' })], total: 2 }) }, + { episodes: 12, profiles: 1, agent_cases: 3, agent_skills: 4 }, + ) + await mount() + await screen.findByText('shipped the island') + expect(domSnapshot(document.getElementById('memBody')!)).toMatchSnapshot() + }) }) diff --git a/ui-web/src/features/memory/__snapshots__/MemoryPage.test.tsx.snap b/ui-web/src/features/memory/__snapshots__/MemoryPage.test.tsx.snap new file mode 100644 index 000000000..d0e6e1cd0 --- /dev/null +++ b/ui-web/src/features/memory/__snapshots__/MemoryPage.test.tsx.snap @@ -0,0 +1,41 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`memory island > keeps its rendered shape 1`] = ` +"div.pmhero + h3 +div.memstats + button.mstat + div.k + div.v + div.h + button.mstat + div.k + div.v + div.h + button.mstat + div.k + div.v + div.h + button.mstat + div.k + div.v + div.h +div.memtools + div.cfind + svg.ic + circle + path + input + span.n +div.memlist + div.memrow + div.t + b + span.when + div.s + div.memrow + div.t + b + span.when + div.s" +`; diff --git a/ui-web/src/features/model/ModelPicker.test.tsx b/ui-web/src/features/model/ModelPicker.test.tsx index 92b3d83e6..7c02b0a93 100644 --- a/ui-web/src/features/model/ModelPicker.test.tsx +++ b/ui-web/src/features/model/ModelPicker.test.tsx @@ -5,6 +5,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { ModelPickerApp } from './ModelPicker' import * as store from './store' +import { domSnapshot } from '../../test/domSnapshot' + import type { Shell } from '../../shell/bridge' import type { ModelSource, Provider } from './types' @@ -229,6 +231,13 @@ describe('the model picker', () => { empty message reachable here is the search one. */ expect(rows('provs').length).toBe(1) }) + + it('keeps its rendered shape', () => { + install() + const view = mount() + openIt() + expect(domSnapshot(view.container)).toMatchSnapshot() + }) }) describe('the model picker, where it lands', () => { @@ -511,6 +520,13 @@ describe('the capability icons', () => { dead markup repeated on every open. */ expect(document.querySelectorAll('.mpick .model-tag-defs').length).toBe(1) }) + + it('keeps its rendered shape', () => { + install({}, TAGGED) + const view = mount() + openIt() + expect(domSnapshot(view.container)).toMatchSnapshot() + }) }) describe('what the picker offers', () => { diff --git a/ui-web/src/features/model/__snapshots__/ModelPicker.test.tsx.snap b/ui-web/src/features/model/__snapshots__/ModelPicker.test.tsx.snap new file mode 100644 index 000000000..d02ad10f6 --- /dev/null +++ b/ui-web/src/features/model/__snapshots__/ModelPicker.test.tsx.snap @@ -0,0 +1,176 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`the capability icons > keeps its rendered shape 1`] = ` +"div.mpick + svg.model-tag-defs + defs + symbol#mtag-text + path + path + symbol#mtag-reasoning + path + path + symbol#mtag-function-call + path + symbol#mtag-structured-output + rect + path + symbol#mtag-image-recognition + rect + path + circle + symbol#mtag-audio-recognition + rect + path + symbol#mtag-video-recognition + rect + path + symbol#mtag-file-input + path + path + symbol#mtag-image-generation + rect + path + path + symbol#mtag-audio-generation + path + path + symbol#mtag-video-generation + rect + path + path + symbol#mtag-embedding + path + path + circle + circle + symbol#mtag-rerank + path + path + symbol#mtag-computer-use + rect + path + div.find + span + input + div.cols + div.provs + div.row.provider-choice-row + button.provider-choice-action + span.nm + img.provider-icon[data-provider=anthropic][data-tone=hybrid] + span + span.ct + span.provider-status.on + div.model-pane + div.models + button.row + span.nm.named + span.model-tags + svg.model-tag + title + use + svg.model-tag + title + use + svg.model-tag + title + use + span.model-window + button.row + span.nm + div.foot + button" +`; + +exports[`the model picker > keeps its rendered shape 1`] = ` +"div.mpick + svg.model-tag-defs + defs + symbol#mtag-text + path + path + symbol#mtag-reasoning + path + path + symbol#mtag-function-call + path + symbol#mtag-structured-output + rect + path + symbol#mtag-image-recognition + rect + path + circle + symbol#mtag-audio-recognition + rect + path + symbol#mtag-video-recognition + rect + path + symbol#mtag-file-input + path + path + symbol#mtag-image-generation + rect + path + path + symbol#mtag-audio-generation + path + path + symbol#mtag-video-generation + rect + path + path + symbol#mtag-embedding + path + path + circle + circle + symbol#mtag-rerank + path + path + symbol#mtag-computer-use + rect + path + div.find + span + input + div.cols + div.provs + div.row.provider-choice-row + button.provider-choice-action + span.nm + img.provider-icon.mark-light[data-provider=minimax] + img.provider-icon.mark-dark[data-provider=minimax] + span + span.ct + span.tick + span.provider-status.on + div.row.provider-choice-row + button.provider-choice-action + span.nm + img.provider-icon[data-provider=anthropic][data-tone=hybrid] + span + span.ct + span.provider-status.on + div.model-pane + div.model-toolbar + div.model-toolbar-copy + span.model-toolbar-label + span.model-toolbar-model + label.protocol-control + select + option + option + option + option + div.models + button.row + span.nm + span.tick + button.row + span.nm + div.foot + button" +`; diff --git a/ui-web/src/features/onboard/OnboardPage.test.tsx b/ui-web/src/features/onboard/OnboardPage.test.tsx index 916b7e058..fc8fc742b 100644 --- a/ui-web/src/features/onboard/OnboardPage.test.tsx +++ b/ui-web/src/features/onboard/OnboardPage.test.tsx @@ -5,6 +5,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { OnboardApp } from './OnboardPage' import * as store from './store' +import { domSnapshot } from '../../test/domSnapshot' + import type { Shell } from '../../shell/bridge' import type { OnboardProvider, OnboardSource } from './types' @@ -323,6 +325,15 @@ describe('the onboarding island', () => { expect(document.querySelector('.ob-err')?.textContent).toBe('gui.onb.err_generic:offline') expect(button('gui.onb.start').disabled).toBe(false) }) + + it('keeps its rendered shape, provider list', async () => { + install() + mount() + await open() + await start() + expect(document.querySelectorAll('.ob-row .nm')).toHaveLength(2) + expect(domSnapshot(document.getElementById('onb')!)).toMatchSnapshot() + }) }) describe('the onboarding model step', () => { @@ -355,4 +366,24 @@ describe('the onboarding model step', () => { ]) expect(document.querySelector('.ob-row .model-window')?.textContent).toBe('400K') }) + + it('keeps its rendered shape, model step', async () => { + const tagged: OnboardProvider = { + ...connected, + models: ['claude-opus-5'], + model_labels: { + 'claude-opus-5': { + label: 'Claude Opus 5', + capabilities: ['reasoning', 'function-call'], + context_window: 400000, + }, + }, + } + install([tagged]) + mount() + await open() + await start() + fireEvent.click(row('Anthropic')) + expect(domSnapshot(document.getElementById('onb')!)).toMatchSnapshot() + }) }) diff --git a/ui-web/src/features/onboard/__snapshots__/OnboardPage.test.tsx.snap b/ui-web/src/features/onboard/__snapshots__/OnboardPage.test.tsx.snap new file mode 100644 index 000000000..924904209 --- /dev/null +++ b/ui-web/src/features/onboard/__snapshots__/OnboardPage.test.tsx.snap @@ -0,0 +1,102 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`the onboarding island > keeps its rendered shape, provider list 1`] = ` +"div.ob + div.ob-step + div.ob-t + div.ob-s + div.ob-list + div.ob-row.provider-choice-row + button.provider-choice-action + span.nm + img.provider-icon[data-provider=anthropic][data-tone=hybrid] + a.provider-link + span + span.bd.on + span.provider-status.on + div.ob-row.provider-choice-row + button.provider-choice-action + span.nm + img.provider-icon.mark-light[data-provider=openai] + img.provider-icon.mark-dark[data-provider=openai] + span + span.bd + button.ob-ghost + div.ob-dots + i.on + i + i" +`; + +exports[`the onboarding model step > keeps its rendered shape, model step 1`] = ` +"div.ob + div.ob-step + div.ob-t + div.ob-s + div.ob-list + svg.model-tag-defs + defs + symbol#mtag-text + path + path + symbol#mtag-reasoning + path + path + symbol#mtag-function-call + path + symbol#mtag-structured-output + rect + path + symbol#mtag-image-recognition + rect + path + circle + symbol#mtag-audio-recognition + rect + path + symbol#mtag-video-recognition + rect + path + symbol#mtag-file-input + path + path + symbol#mtag-image-generation + rect + path + path + symbol#mtag-audio-generation + path + path + symbol#mtag-video-generation + rect + path + path + symbol#mtag-embedding + path + path + circle + circle + symbol#mtag-rerank + path + path + symbol#mtag-computer-use + rect + path + button.ob-row + span.nm + span.model-tags + svg.model-tag + title + use + svg.model-tag + title + use + span.model-window + button.ob-btn + div.ob-err + button.ob-ghost + div.ob-dots + i + i + i.on" +`; diff --git a/ui-web/src/features/playbooks/PlaybooksPage.test.tsx b/ui-web/src/features/playbooks/PlaybooksPage.test.tsx index ccd747963..b423bbacd 100644 --- a/ui-web/src/features/playbooks/PlaybooksPage.test.tsx +++ b/ui-web/src/features/playbooks/PlaybooksPage.test.tsx @@ -5,6 +5,8 @@ import { afterEach, describe, expect, it } from 'vitest' import { PlaybooksApp } from './PlaybooksPage' import * as store from './store' +import { domSnapshot } from '../../test/domSnapshot' + import type { PlaybookDetail, PlaybookNode, PlaybookRow, PlaybooksSource } from './types' import type { Shell } from '../../shell/bridge' @@ -652,6 +654,27 @@ describe('the playbook library', () => { store.closePage() expect(pages).toEqual(['pbPage', null]) }) + + it('keeps its rendered shape, library', async () => { + install({ + list: async () => [ + row({ name: 'issue-triage' }), + row({ name: 'release-notes', description: 'what changed in a version' }) + ] + }) + const view = await mount() + expect(await screen.findByText('issue-triage')).toBeTruthy() + expect(domSnapshot(view.container)).toMatchSnapshot() + }) + + it('keeps its rendered shape, graph', async () => { + install() + const view = await mount() + fireEvent.click(screen.getByText('issue-triage')) + await act(async () => {}) + expect(document.querySelectorAll('.pbnode')).toHaveLength(2) + expect(domSnapshot(view.container)).toMatchSnapshot() + }) }) describe('the graph-level fields', () => { diff --git a/ui-web/src/features/playbooks/__snapshots__/PlaybooksPage.test.tsx.snap b/ui-web/src/features/playbooks/__snapshots__/PlaybooksPage.test.tsx.snap new file mode 100644 index 000000000..803f9a2fc --- /dev/null +++ b/ui-web/src/features/playbooks/__snapshots__/PlaybooksPage.test.tsx.snap @@ -0,0 +1,97 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`the playbook library > keeps its rendered shape, graph 1`] = ` +"div.pbcrumb + button.mini.ghost +div.pbhead + span.pmtile.th0 + h1 +p.pbsum +div.pbmeta + div + span.pblab + span.pbval.mono + div + span.pblab + span.pbval.mono + div + span.pblab + span.pbval.mono +div.pbtabs + button.pbtab + button.pbtab +div.pbwork + div.pbboard + div.pbstage + div.pbview + div.pbcanvas + svg.pbedges + g + path.pbwire + path.pbtip + button.pbnode[data-picked=true] + span.l1 + span.l2 + span.ag + button.pbnode + span.l1 + span.l2 + span.ag + div.pbzoom + button.pbzb + button.pbzpct + button.pbzb + div.pbpanel + div.pbphead + b + p + dl.kv.pbkv + dt + dd + span.who + dt + dd + span.gap + dt + dd + span.gap + div.pbpsec + span.cap + pre.pbprompt + span" +`; + +exports[`the playbook library > keeps its rendered shape, library 1`] = ` +"div.pmhero + h3 +div.cbar + div.cfind + svg.ic + circle + path + input + span.pbcount +div.pbgrid + div.pbcard + div.pbtop + span.pmtile.th0 + span.pbnm + p.pbwhen + div.pbshape + svg.pbrib + g + path.pbedge + rect.pbcell + rect.pbcell + div.pbcard + div.pbtop + span.pmtile.th3 + span.pbnm + p.pbwhen + div.pbshape + svg.pbrib + g + path.pbedge + rect.pbcell + rect.pbcell" +`; diff --git a/ui-web/src/features/plugins/PluginsPage.test.tsx b/ui-web/src/features/plugins/PluginsPage.test.tsx index b3aa21728..9faf0c1ce 100644 --- a/ui-web/src/features/plugins/PluginsPage.test.tsx +++ b/ui-web/src/features/plugins/PluginsPage.test.tsx @@ -5,6 +5,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { PlugApp } from './PluginsPage' import * as store from './store' +import { domSnapshot } from '../../test/domSnapshot' + import type { Shell } from '../../shell/bridge' import type { DetailEntry, InstalledRow, MarketItem, PluginsSource } from './types' @@ -299,6 +301,29 @@ describe('plugins island', () => { expect(calls).toContain('reload') }) }) + + it('keeps its rendered shape, market', async () => { + install([item(), item({ id: 'notion', name: 'Notion', verified: false })]) + await mount() + await screen.findByText('websearch') + expect(domSnapshot(document.getElementById('capsBody')!)).toMatchSnapshot() + }) + + it('keeps its rendered shape, installed shelf', async () => { + install( + [item()], + [ + { id: 'sheets', name: 'sheets', src: 'raven-sheets', ver: '0.9.0', state: 'on' }, + { id: 'mcp:gh', name: 'gh', m: { name: 'gh', enabled: true, state: 'connected', transport: 'http', tool_count: 3 } }, + ], + ) + await mount() + act(() => { + store.toggleView() + }) + await screen.findByText('gui.plug.grp_builtin') + expect(domSnapshot(document.getElementById('capsBody')!)).toMatchSnapshot() + }) }) describe('a startup authorization the page never got an event for', () => { diff --git a/ui-web/src/features/plugins/__snapshots__/PluginsPage.test.tsx.snap b/ui-web/src/features/plugins/__snapshots__/PluginsPage.test.tsx.snap new file mode 100644 index 000000000..c5db027c8 --- /dev/null +++ b/ui-web/src/features/plugins/__snapshots__/PluginsPage.test.tsx.snap @@ -0,0 +1,82 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`plugins island > keeps its rendered shape, installed shelf 1`] = ` +"div.pmback + button.mini.ghost + b +div.csec + div.hd + b + span.n + div.hubgrid + div.hubcard.pmcard + div.top + div.pmhead + span.pmtile.th4 + div.pmnm + span + button.swi + p.one + div.foot + span.pmst.ok + i.pmdot + span + span.pmcnt + span.pmsign.faint +div.csec + div.hd + b + span.n + div.hubgrid + div.hubcard.pmcard + div.top + div.pmhead + span.pmtile.th1 + div.pmnm + span + button.swi + p.one + div.foot + span.pmst.ok + i.pmdot + span + div.act + button.mini.gold" +`; + +exports[`plugins island > keeps its rendered shape, market 1`] = ` +"div.pmchips + button.pill + button.pill +div.hubgrid + div.hubcard.pmcard + div.top + div.pmhead + span.pmtile.th4 + div.pmid + div.pmnm + span + span + svg.pmvfd + path + path + div.pmpub + p.one + div.foot + span.pmcnt + div.act + button.mini.gold + div.hubcard.pmcard + div.top + div.pmhead + span.pmtile.th5 + div.pmid + div.pmnm + span + div.pmpub + p.one + div.foot + span.pmcnt + div.act + button.mini.gold" +`; diff --git a/ui-web/src/features/rail/RailPage.test.tsx b/ui-web/src/features/rail/RailPage.test.tsx index e7abc88c9..3615175de 100644 --- a/ui-web/src/features/rail/RailPage.test.tsx +++ b/ui-web/src/features/rail/RailPage.test.tsx @@ -11,6 +11,8 @@ import { setCurrent, } from '../../shell/session' +import { domSnapshot } from '../../test/domSnapshot' + import type { Shell } from '../../shell/bridge' import type { MenuItem } from '../../shell/menu' import type { ToastAction } from '../../shell/toast' @@ -759,4 +761,17 @@ describe('rail island', () => { act(() => fold.click()) expect(host.querySelectorAll('.sess').length).toBe(15) }) + + it('keeps its rendered shape', () => { + install({ + rows: [ + row(), + row({ id: 'p', title: 'pinned one', pin: true }), + row({ id: 'k', title: 'daily digest', from: 'cron' }), + row({ id: 'e', title: '🚀 Ship it' }) + ] + }) + const host = mount() + expect(domSnapshot(host)).toMatchSnapshot() + }) }) diff --git a/ui-web/src/features/rail/__snapshots__/RailPage.test.tsx.snap b/ui-web/src/features/rail/__snapshots__/RailPage.test.tsx.snap new file mode 100644 index 000000000..9c4bcc3a9 --- /dev/null +++ b/ui-web/src/features/rail/__snapshots__/RailPage.test.tsx.snap @@ -0,0 +1,74 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`rail island > keeps its rendered shape 1`] = ` +"div.grp + span.lab + span.n + span.car + svg + path + span.rule +div.sess + div.t + span + span.w + span.wt + div.quick + button.quick-pin[data-active=true] + svg + path + button + svg + path +div.grp + span.lab + span.n + span.car + svg + path + span.rule + button.grp-go +div.sess + div.t + span + span.w + span.wt + div.quick + button.quick-pin + svg + path + button + svg + path +div.grp + span.lab + span.n + span.car + svg + path + span.rule +div.sess + div.t + span + span.w + span.wt + div.quick + button.quick-pin + svg + path + button + svg + path +div.sess + div.t + span + span.w + span.wt + div.quick + button.quick-pin + svg + path + button + svg + path" +`; diff --git a/ui-web/src/features/settings/SettingsPage.test.tsx b/ui-web/src/features/settings/SettingsPage.test.tsx index 1fd9e719a..3a64d5916 100644 --- a/ui-web/src/features/settings/SettingsPage.test.tsx +++ b/ui-web/src/features/settings/SettingsPage.test.tsx @@ -7,6 +7,8 @@ import * as store from './store' import * as lookStore from '../../shell/look' import * as notifications from '../../shell/notifications' +import { domSnapshot } from '../../test/domSnapshot' + import type { Shell } from '../../shell/bridge' import type { SettingsSnapshot, SettingsSource } from './types' @@ -1615,6 +1617,13 @@ describe('settings island', () => { expect(live[1]!.querySelector('.tkrow .nlmsg')).toBeNull() expect(live[0]!.querySelector('.nlmsg')).toBeNull() }) + + it('keeps its rendered shape', async () => { + install() + const view = await mount() + expect(screen.getByText('gui.set.nodata')).toBeTruthy() + expect(domSnapshot(view.container)).toMatchSnapshot() + }) }) /* The painting model sits with the other defaults now, not folded inside the @@ -2617,4 +2626,10 @@ describe('the models pane', () => { expect(document.querySelector('#spanels .pickm')).toBeNull() return h }) + + it('keeps its rendered shape', async () => { + await openModels() + expect(document.querySelector('.mpanel .mtitle')?.textContent).toContain('Anthropic') + expect(domSnapshot(document.getElementById('spanels')!)).toMatchSnapshot() + }) }) diff --git a/ui-web/src/features/settings/__snapshots__/SettingsPage.test.tsx.snap b/ui-web/src/features/settings/__snapshots__/SettingsPage.test.tsx.snap new file mode 100644 index 000000000..8633b79dd --- /dev/null +++ b/ui-web/src/features/settings/__snapshots__/SettingsPage.test.tsx.snap @@ -0,0 +1,132 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`settings island > keeps its rendered shape 1`] = ` +"div.panel[data-on=true] + div.scard + div.empty-note" +`; + +exports[`the models pane > keeps its rendered shape 1`] = ` +"div.panel[data-on=true] + div.msplit + svg.model-tag-defs + defs + symbol#mtag-text + path + path + symbol#mtag-reasoning + path + path + symbol#mtag-function-call + path + symbol#mtag-structured-output + rect + path + symbol#mtag-image-recognition + rect + path + circle + symbol#mtag-audio-recognition + rect + path + symbol#mtag-video-recognition + rect + path + symbol#mtag-file-input + path + path + symbol#mtag-image-generation + rect + path + path + symbol#mtag-audio-generation + path + path + symbol#mtag-video-generation + rect + path + path + symbol#mtag-embedding + path + path + circle + circle + symbol#mtag-rerank + path + path + symbol#mtag-computer-use + rect + path + div.mrailwrap + div.mrhead + input.mrsearch + button.mrfilter[data-narrowed=false] + svg + path + div.mrail + button.mrow + img.provider-icon[data-provider=anthropic][data-tone=hybrid] + span.nm + span.tagm + span.provider-status.on + button.mrow + img.provider-icon.mark-light[data-provider=openai_codex] + img.provider-icon.mark-dark[data-provider=openai_codex] + span.nm + span.provider-status.on + button.mrow + img.provider-icon.mark-light[data-provider=openai] + img.provider-icon.mark-dark[data-provider=openai] + span.nm + button.mrow + img.provider-icon.mark-light[data-provider=ollama] + img.provider-icon.mark-dark[data-provider=ollama] + span.nm + div.mpanel + div.mtitle + img.provider-icon[data-provider=anthropic][data-tone=hybrid] + a.provider-link + span + span.tagm + span.provider-status.on + div.msub + div.pform + div.msec[data-sec=key] + div.mhead + div.t + a.mdocs + div.keyrow + span.keyfield + input + button.peek + svg + path + path + path + path + button.mini + div.msec[data-sec=host] + div.mhead + div.t + div.keyrow + span.hostfield + input + button.hcopy[data-tip=gui.model.copy] + svg.copy-icon + rect + path + button.mini.update + svg.update-icon + path + path + path + path + div.msec[data-sec=models] + div.mhead + div.t + div.mbtns + button.mini.ghost + button.mini.ghost + div.mempty + button.mini.ghost.danger" +`; diff --git a/ui-web/src/features/skills/SkillsPage.test.tsx b/ui-web/src/features/skills/SkillsPage.test.tsx index 7a6fa11a7..b8bde5644 100644 --- a/ui-web/src/features/skills/SkillsPage.test.tsx +++ b/ui-web/src/features/skills/SkillsPage.test.tsx @@ -5,6 +5,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { SkillsApp } from './SkillsPage' import * as store from './store' +import { domSnapshot } from '../../test/domSnapshot' + import type { Shell } from '../../shell/bridge' import type { HubItem, InstalledSkill, SkillsSource } from './types' @@ -337,4 +339,23 @@ describe('skills island', () => { expect(calls).toContain('remove') expect(document.getElementById('detail')!.dataset.open).toBe('false') }) + + it('keeps its rendered shape, hub search', async () => { + install([hubItem(), hubItem({ id: 'sh-sql', name: 'sql-style', quality_score: 0.7 })]) + await mount() + await screen.findByText('code-review-checklist') + expect(domSnapshot(document.getElementById('capsBody')!)).toMatchSnapshot() + }) + + it('keeps its rendered shape, installed list', async () => { + install([], {}, [ + { id: 'sk1', name: 'house-style', one: 'the house voice', src: 'skillhub', hub: true, hubId: 'sh-hs' }, + ]) + await mount() + await act(async () => { + store.toggleView() + }) + await screen.findByText('house-style') + expect(domSnapshot(document.getElementById('capsBody')!)).toMatchSnapshot() + }) }) diff --git a/ui-web/src/features/skills/__snapshots__/SkillsPage.test.tsx.snap b/ui-web/src/features/skills/__snapshots__/SkillsPage.test.tsx.snap new file mode 100644 index 000000000..e2c0dbadf --- /dev/null +++ b/ui-web/src/features/skills/__snapshots__/SkillsPage.test.tsx.snap @@ -0,0 +1,82 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`skills island > keeps its rendered shape, hub search 1`] = ` +"div.pmchips + button.pill + button.pill + button.pill + button.pill + button.pill + button.pill + button.pill + button.pill + button.pill + button.pill + button.pill + button.pill + button.pill + button.pill + button.pill + button.pill + button.pill +div.hubgrid + div.hubcard.pmcard + div.top + div.pmhead + span.pmtile.th1 + div.pmid + div.pmnm + span + div.pmpub + p.one + div.foot + div.stars + span.st + span.st + span.st + span.st + span.st + span.sv + div.act + button.mini.gold + div.hubcard.pmcard + div.top + div.pmhead + span.pmtile.th2 + div.pmid + div.pmnm + span + div.pmpub + p.one + div.foot + div.stars + span.st + span.st + span.st + span.st + span.st + span.sv + div.act + button.mini.gold" +`; + +exports[`skills island > keeps its rendered shape, installed list 1`] = ` +"div.pmback + button.mini.ghost + b +div.hubgrid + div.hubcard.pmcard + div.top + div.pmhead + span.pmtile.th4 + div.pmid + div.pmnm + span + div.pmpub + p.one + div.foot + span + div.act + button.mini.ghost + button.mini.gold" +`; diff --git a/ui-web/src/features/subagents/SubagentsPage.test.tsx b/ui-web/src/features/subagents/SubagentsPage.test.tsx index 70cd01df1..c3e3e024f 100644 --- a/ui-web/src/features/subagents/SubagentsPage.test.tsx +++ b/ui-web/src/features/subagents/SubagentsPage.test.tsx @@ -15,6 +15,8 @@ import { import * as store from './store' import { _resetForTests as sessionReset, setCurrent } from '../../shell/session' +import { domSnapshot } from '../../test/domSnapshot' + import type { Shell } from '../../shell/bridge' import type { JSX } from 'react' import type { AgentCtx, AgentRow, AgentsSource, DirectTurn, InstanceRow, SubagentRow } from './types' @@ -778,6 +780,19 @@ describe('subagents island, the list', () => { .toEqual(['assets/agents/claudecode-color.svg', 'assets/agents/raven.svg', null]) expect(rows.every((r) => !!r.querySelector('.agent-mark'))).toBe(true) }) + + it('keeps its rendered shape', async () => { + instances([ + inst({ handle: 'live', status: 'running' }), + inst({ handle: 'waiting', status: 'idle' }), + inst({ handle: 'broke', status: 'failed' }), + inst({ handle: 'gone', status: 'interrupted' }), + inst({ handle: 'finished', status: 'completed' }), + ]) + await mount() + await screen.findByText('live') + expect(domSnapshot(document.getElementById('wsBody')!)).toMatchSnapshot() + }) }) /* A run detail is no longer reached from this list -- the panel lists instances @@ -1877,6 +1892,19 @@ describe('subagents island, an instance detail', () => { expect(paints).toHaveLength(2) expect((paints[1]!.ctx as { messages: unknown[] }).messages).toHaveLength(2) }) + + it('keeps its rendered shape', async () => { + const row = inst({ handle: 'research-a2a-726da8', status: 'completed', resumable: true }) + await openInstance(row, { + instanceHistory: async () => ({ + turns: [ + { call_id: 'c1', role: 'user' as const, content: 'investigate the protocol', at_ms: 1000 }, + { call_id: 'c1', role: 'assistant' as const, content: 'done', at_ms: 2000 }, + ], + }), + }) + expect(domSnapshot(document.getElementById('wsBody')!)).toMatchSnapshot() + }) }) /* The desk's list is what a reader watches while work runs, so what a row says diff --git a/ui-web/src/features/subagents/__snapshots__/SubagentsPage.test.tsx.snap b/ui-web/src/features/subagents/__snapshots__/SubagentsPage.test.tsx.snap new file mode 100644 index 000000000..1b9db2ed8 --- /dev/null +++ b/ui-web/src/features/subagents/__snapshots__/SubagentsPage.test.tsx.snap @@ -0,0 +1,85 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`subagents island, an instance detail > keeps its rendered shape 1`] = ` +"div.sahd + button.back + svg + path + span + div.trow + b + span.who +div.satx + p" +`; + +exports[`subagents island, the list > keeps its rendered shape 1`] = ` +"div.salist + div.sarow.inst + span.agent-mark + svg + rect + path + span.wkg.sw + i + i + i + div.bd + div.nm + div.st + span.who + button.mini.ghost + span.chev + div.sarow.inst + span.agent-mark + svg + rect + path + span.dot.que + div.bd + div.nm + div.st + span + span.who + button.mini.ghost + span.chev + div.sarow.inst + span.agent-mark + svg + rect + path + span.dot.bad + div.bd + div.nm + div.st + span + span.who + button.mini.ghost + span.chev + div.sarow.inst + span.agent-mark + svg + rect + path + span.dot.bad + div.bd + div.nm + div.st + span + span.who + button.mini.ghost + span.chev + div.sarow.inst + span.agent-mark + svg + rect + path + span.dot.ok + div.bd + div.nm + div.st + span + span.who + button.mini.ghost + span.chev" +`; diff --git a/ui-web/src/features/transcript/TranscriptPage.test.tsx b/ui-web/src/features/transcript/TranscriptPage.test.tsx index aaacc95b8..d1adf8eee 100644 --- a/ui-web/src/features/transcript/TranscriptPage.test.tsx +++ b/ui-web/src/features/transcript/TranscriptPage.test.tsx @@ -14,6 +14,8 @@ import * as attachmentCache from '../../shell/attachment-cache' import { markMissing as markDeliveryMissing } from '../workspace/deliveries' import { snapshot as deliveriesSnapshot } from '../workspace/deliveries' +import { domSnapshot } from '../../test/domSnapshot' + import type { Shell } from '../../shell/bridge' import type { ProseTarget } from '../../shell/prose' import type { HistoryMessage, SpawnListRow, TranscriptSource } from './types' @@ -895,6 +897,26 @@ describe('transcript island, history', () => { expect(notes[1]?.classList.contains('bad')).toBe(true) expect((notes[1] as HTMLElement).title).toBe('send failed · socket closed') }) + + it('keeps its rendered shape', () => { + act(() => { + mount.history([ + { role: 'user', text: 'read both' }, + { + role: 'assistant', text: '', + tool_calls: [ + { id: 'c1', name: 'read_file', arguments: '{"path":"/tmp/a.log"}' }, + { id: 'c2', name: 'read_file', arguments: '{"path":"/tmp/b.log"}' }, + ], + }, + { role: 'tool', tool_call_id: 'c1', name: 'read_file', text: 'line1' }, + { role: 'tool', tool_call_id: 'c2', name: 'read_file', text: 'Error: no such file' }, + { role: 'assistant', text: 'one of them is missing' }, + ]) + }) + openTurns() + expect(domSnapshot(document.getElementById('stage')!)).toMatchSnapshot() + }) }) /** Which fold is open, and who decided. diff --git a/ui-web/src/features/transcript/__snapshots__/TranscriptPage.test.tsx.snap b/ui-web/src/features/transcript/__snapshots__/TranscriptPage.test.tsx.snap new file mode 100644 index 000000000..90314d931 --- /dev/null +++ b/ui-web/src/features/transcript/__snapshots__/TranscriptPage.test.tsx.snap @@ -0,0 +1,59 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`transcript island, history > keeps its rendered shape 1`] = ` +"div[data-tsl=1] + div.ask.in + div.b + div.ansfoot + div.acts + button[data-label=en:gui.answer.copy][data-tip=en:gui.answer.copy] + svg + rect + path + span.turnmeta + div.tfold.open + button.tfh + span.lb + span.tm + svg.cv + path + div.tfb + div.step.in + div.think.tog + span.lb + svg.cv + path + div.cot + div.say.prose + div.wk + div.wrow.tog.sum.open + svg.ic + path + span.ar + span.chip.bad + svg.cv + path + div.wkin + div.wrow.tog + svg.ic + path + span.vb + svg.cv + path + div.wrow.bad.tog + svg.ic + path + span.vb + span.err + svg.cv + path + div.answer.in + div.prose + p + div.ansfoot + div.acts + button[data-label=en:gui.answer.copy][data-tip=en:gui.answer.copy] + svg + rect + path" +`; diff --git a/ui-web/src/features/workspace/DeskPage.test.tsx b/ui-web/src/features/workspace/DeskPage.test.tsx index 769a48354..995b2424b 100644 --- a/ui-web/src/features/workspace/DeskPage.test.tsx +++ b/ui-web/src/features/workspace/DeskPage.test.tsx @@ -10,6 +10,7 @@ import * as desk from './deskStore' import * as workspace from './store' import { setCurrent } from '../../shell/session' +import { domSnapshot } from '../../test/domSnapshot' import type { Shell } from '../../shell/bridge' import type { InstanceRow } from '../subagents/types' @@ -230,4 +231,12 @@ describe('asking for the instance list', () => { expect(asked.length).toBe(before) }) + + it('keeps its rendered shape', async () => { + const view = render() + await act(async () => { desk.update({ paletteOpen: true, tab: 'agents' }) }) + agentRows = [inst('h1', 'running')] + await tick() + expect(domSnapshot(view.container)).toMatchSnapshot() + }) }) diff --git a/ui-web/src/features/workspace/WorkspacePage.test.tsx b/ui-web/src/features/workspace/WorkspacePage.test.tsx index 2ab196870..938aa8ec8 100644 --- a/ui-web/src/features/workspace/WorkspacePage.test.tsx +++ b/ui-web/src/features/workspace/WorkspacePage.test.tsx @@ -6,6 +6,8 @@ import { WsApp } from './WorkspacePage' import * as deliveries from './deliveries' import * as store from './store' +import { domSnapshot } from '../../test/domSnapshot' + import type { Shell } from '../../shell/bridge' import type { WorkspaceSnapshot, WorkspaceSource, WsChange } from './types' @@ -687,4 +689,17 @@ describe('workspace island', () => { await mount() expect(await screen.findByText('gone for good')).toBeTruthy() }) + + it('keeps its rendered shape', async () => { + install(emptyWs({ + changes: [ + change({ turn: 2, open: false }), + change({ key: '/repo/README.md', dir: '', name: 'README.md', kind: 'write', turn: 1, add: 5, del: 0 }), + ], + turn: 2, + })) + await mount() + await screen.findByText('app.py') + expect(domSnapshot(document.getElementById('wsBody')!)).toMatchSnapshot() + }) }) diff --git a/ui-web/src/features/workspace/__snapshots__/DeskPage.test.tsx.snap b/ui-web/src/features/workspace/__snapshots__/DeskPage.test.tsx.snap new file mode 100644 index 000000000..7b573b734 --- /dev/null +++ b/ui-web/src/features/workspace/__snapshots__/DeskPage.test.tsx.snap @@ -0,0 +1,54 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`asking for the instance list > keeps its rendered shape 1`] = ` +"div.desk-palette[data-anchored=true][data-phase=in] + div.desk-drag + div.desk-tabs + button + svg + path + path + span.lb + button + svg + rect + path + span.lb + button + svg + rect + path + span.lb + div.desk-body + div.salist.agent-roster + section.agent-group + div.agent-headrow + button.agent-head + span.agent-fold[data-empty=false][data-open=true] + svg + path + span.agent-mark + svg + rect + path + b + span.agent-kind + div.agent-instances + div.sarow.inst + span.desk-status + span.wkg.sw + i + i + i + span.nm + button.inst-retire + svg + path + div.desk-resize +button.ghost-ic.desk-follow-toggle[data-working=true] + svg + path + circle + circle + circle" +`; diff --git a/ui-web/src/features/workspace/__snapshots__/WorkspacePage.test.tsx.snap b/ui-web/src/features/workspace/__snapshots__/WorkspacePage.test.tsx.snap new file mode 100644 index 000000000..71f494b81 --- /dev/null +++ b/ui-web/src/features/workspace/__snapshots__/WorkspacePage.test.tsx.snap @@ -0,0 +1,22 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`workspace island > keeps its rendered shape 1`] = ` +"div.wsgrp +div.chg + div.chghd + i.chgc.edit + span.chgp + span.dir + span.chgs + span.a + span.d + button.chgm +div.wsgrp +div.chg + div.chghd + i.chgc.write + span.chgp + span.chgs + span.a + button.chgm" +`; diff --git a/ui-web/src/features/xa/XaPage.test.tsx b/ui-web/src/features/xa/XaPage.test.tsx index 3b8c473d5..9415e9bc5 100644 --- a/ui-web/src/features/xa/XaPage.test.tsx +++ b/ui-web/src/features/xa/XaPage.test.tsx @@ -5,6 +5,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { XaApp } from './XaPage' import * as store from './store' +import { domSnapshot } from '../../test/domSnapshot' + import type { Shell } from '../../shell/bridge' import type { XaActArgs, XaRow, XaSource } from './types' @@ -1508,4 +1510,22 @@ describe('xa island', () => { }) }) + it('keeps its rendered shape, list', async () => { + install([ + row(), + row({ name: 'off_one', configured: true, enabled: false }), + row({ name: 'miro', kind: 'openai', configured: false, has_api_key: false }), + row({ name: 'codex', configured: false, probe_status: 'missing', probe_detail: 'codex: command not found' }), + ]) + await mount() + await screen.findByText('claude_code') + expect(domSnapshot(document.getElementById('xaBody')!)).toMatchSnapshot() + }) + + it('keeps its rendered shape, card', async () => { + install([row()]) + await mount() + await openCard('claude_code') + expect(domSnapshot(document.getElementById('detail')!)).toMatchSnapshot() + }) }) diff --git a/ui-web/src/features/xa/__snapshots__/XaPage.test.tsx.snap b/ui-web/src/features/xa/__snapshots__/XaPage.test.tsx.snap new file mode 100644 index 000000000..374d5744d --- /dev/null +++ b/ui-web/src/features/xa/__snapshots__/XaPage.test.tsx.snap @@ -0,0 +1,80 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`xa island > keeps its rendered shape, card 1`] = ` +"b#dTitle +div#dBody + div + div.pmdhead + span.agent-mark + img[data-agent=claude_code] + div.pmdmeta + div.l1 + b + button.xaedit + div.l2 + div.dact + button.mini.ghost + div.pmsec + div.cap + div.pmdesc + button.xaedit + div.pmsec + div.cap + div.sutest + span.led.off + span.vd + button.mini + div.pmdesc" +`; + +exports[`xa island > keeps its rendered shape, list 1`] = ` +"div.pmhero + h3 +div.sugrp + div.hd + b + span.n + div.sulist + div.surow + span.agent-mark + img[data-agent=claude_code] + div.nm + span.led + b + span.kd + div.suact + button.mini.ghost +div.sugrp + div.hd + b + span.n + div.sulist + div.surow + span.agent-mark + img[data-agent=claude_code] + div.nm + span.led.off + b + span.kd + div.suact + button.mini +div.sugrp + div.hd + b + span.n + div.sulist + div.surow + span.agent-mark + img[data-agent=claude_code] + div.nm + span.led.off + b + span.kd + div.suact + button.mini +div.sugrp + div.hd + b + span.n + button.gfold" +`; diff --git a/ui-web/src/test/domSnapshot.ts b/ui-web/src/test/domSnapshot.ts new file mode 100644 index 000000000..9baf1b015 --- /dev/null +++ b/ui-web/src/test/domSnapshot.ts @@ -0,0 +1,29 @@ +/* The shape of a rendered tree, and nothing else: one line per element with + * its tag, id, classes and data-* attributes, indented by depth. Text, inline + * styles and event handlers are left out on purpose -- this is the surface the + * refactor promises not to move, and a golden of it is what lets a test say a + * region still renders the same DOM after its owner changed. + * + * `script` and `style` elements are skipped because the build changes how + * many of them a page carries, and neither is part of what a reader sees. + */ +export function domSnapshot(root: Element): string { + const lines: string[] = [] + const walk = (node: Element, depth: number): void => { + const tag = node.tagName.toLowerCase() + if (tag === 'script' || tag === 'style') return + const parts = [tag] + if (node.id) parts.push(`#${node.id}`) + const classes = (node.getAttribute('class') ?? '').trim() + if (classes) parts.push(`.${classes.split(/\s+/).join('.')}`) + const data = node + .getAttributeNames() + .filter((name) => name.startsWith('data-')) + .sort() + for (const name of data) parts.push(`[${name}=${node.getAttribute(name) ?? ''}]`) + lines.push(`${' '.repeat(depth)}${parts.join('')}`) + for (const child of Array.from(node.children)) walk(child, depth + 1) + } + for (const child of Array.from(root.children)) walk(child, 0) + return lines.join('\n') +} From d0a1fefa23888b88b85cb01a87da913ccd93cf9a Mon Sep 17 00:00:00 2001 From: KT Date: Wed, 16 Sep 2026 23:43:04 +0800 Subject: [PATCH 02/83] feat(ui-web): a typed websocket transport with the page's reconnect behaviour WsTransport implements RpcTransport over the /rpc socket, porting every behaviour of the untyped rpc object in live/020-rpc.js: calls made while the socket is still opening wait for it, a socket closed before it opened reports false rather than deciding what it means, pending calls are rejected with code -1 on a drop, rejections carry the gateway's data.detail as their message with code, data and the raw frame kept, notifications fan out to a handler set, binary frames go to their own sink, and rejoin backs off from 1.5s by 1.6x to an 8s cap under a 20 minute ceiling, probing /health after each failed attempt to tell an absent gateway from a refused session. The interface grows the members the port needs: a 'reconnected' state, a per-state info object carrying the failed attempt count so the caller can show the upgrade shade exactly when the old tick did, binary(handler), and callUnchecked() -- the escape hatch for the two method names the page calls that the contract does not declare, so they keep failing the way they do. FixtureTransport implements the same. state/gateway.ts holds the installed transport for the callers the next steps rewire. UI actions stay outside the transport and are driven by its states in the integration step: the reconnecting status line, the reload on a moved build, the upgrade shade, the auth failure banner and the desktop-shell reauth. Co-authored-by: Claude (claude-fable-5-1) --- ui-web/src/rpc/fixtureTransport.test.ts | 24 ++ ui-web/src/rpc/fixtureTransport.ts | 44 ++- ui-web/src/rpc/transport.ts | 35 +- ui-web/src/rpc/wsTransport.test.ts | 476 ++++++++++++++++++++++++ ui-web/src/rpc/wsTransport.ts | 379 +++++++++++++++++++ ui-web/src/state/gateway.test.ts | 28 ++ ui-web/src/state/gateway.ts | 16 + 7 files changed, 993 insertions(+), 9 deletions(-) create mode 100644 ui-web/src/rpc/wsTransport.test.ts create mode 100644 ui-web/src/rpc/wsTransport.ts create mode 100644 ui-web/src/state/gateway.test.ts create mode 100644 ui-web/src/state/gateway.ts diff --git a/ui-web/src/rpc/fixtureTransport.test.ts b/ui-web/src/rpc/fixtureTransport.test.ts index 4d3e3e91f..6a6a997a5 100644 --- a/ui-web/src/rpc/fixtureTransport.test.ts +++ b/ui-web/src/rpc/fixtureTransport.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest' import { FixtureTransport } from './fixtureTransport' import { RpcError } from './transport' +import type { Fixtures } from './fixtureTransport' import type { ConnectionState } from './transport' describe('FixtureTransport', () => { @@ -58,6 +59,29 @@ describe('FixtureTransport', () => { expect(got).toEqual([{ text: 'x' }, { text: 'x' }, { text: 'y' }]) }) + it('answers an undeclared method name recorded by string', async () => { + const t = new FixtureTransport({ 'raven.mcp.list': { servers: [] } } as Fixtures) + await expect(t.callUnchecked('raven.mcp.list', {})).resolves.toEqual({ servers: [] }) + expect(t.calls.map((c) => c.method)).toEqual(['raven.mcp.list']) + }) + + it('rejects an unrecorded undeclared method with -32601, the way the gateway does', async () => { + const t = new FixtureTransport({}) + const err = await t.callUnchecked('raven.mcp.set', { on: true }).catch((e: unknown) => e) + expect(err).toBeInstanceOf(RpcError) + expect((err as RpcError).code).toBe(-32601) + }) + + it('fans a binary frame out to handlers, and detach removes one', () => { + const t = new FixtureTransport({}) + const got: number[] = [] + const detach = t.binary((buf) => got.push(buf.byteLength)) + t.emitBinary(new ArrayBuffer(4)) + detach() + t.emitBinary(new ArrayBuffer(8)) + expect(got).toEqual([4]) + }) + it('reports the current state immediately, then each transition', async () => { const t = new FixtureTransport({}) const states: ConnectionState[] = [] diff --git a/ui-web/src/rpc/fixtureTransport.ts b/ui-web/src/rpc/fixtureTransport.ts index 651ae4067..dd514b2f5 100644 --- a/ui-web/src/rpc/fixtureTransport.ts +++ b/ui-web/src/rpc/fixtureTransport.ts @@ -1,5 +1,12 @@ import type { ParamsOf, ResultOf, RpcMethod } from './generated' -import type { ConnectionState, NotificationHandler, RpcTransport } from './transport' +import type { + BinaryHandler, + ConnectionState, + NotificationHandler, + RpcTransport, + StateInfo, + StateListener, +} from './transport' import { RpcError } from './transport' @@ -15,10 +22,11 @@ export type Fixtures = { [M in RpcMethod]?: Responder } */ export class FixtureTransport implements RpcTransport { private readonly handlers = new Map>() - private readonly stateListeners = new Set<(s: ConnectionState) => void>() + private readonly binaryHandlers = new Set() + private readonly stateListeners = new Set() private state: ConnectionState = 'closed' /** Every call made, in order -- lets a test assert on traffic. */ - readonly calls: Array<{ method: RpcMethod; params: unknown }> = [] + readonly calls: Array<{ method: string; params: unknown }> = [] constructor(private readonly fixtures: Fixtures) {} @@ -36,6 +44,18 @@ export class FixtureTransport implements RpcTransport { return responder as ResultOf } + async callUnchecked(method: string, params: Record): Promise { + this.calls.push({ method, params }) + const responder: unknown = (this.fixtures as Record)[method] + if (responder === undefined) { + throw new RpcError(-32601, `fixture: no response recorded for ${method}`) + } + if (typeof responder === 'function') { + return await (responder as (p: Record) => unknown)(params) + } + return responder + } + on(method: string, handler: NotificationHandler): () => void { const set = this.handlers.get(method) ?? new Set() set.add(handler) @@ -43,6 +63,18 @@ export class FixtureTransport implements RpcTransport { return () => set.delete(handler) } + binary(handler: BinaryHandler): () => void { + this.binaryHandlers.add(handler) + return () => this.binaryHandlers.delete(handler) + } + + /** Test hook: push a binary frame into the app. */ + emitBinary(buf: ArrayBuffer): void { + for (const h of this.binaryHandlers) { + h(buf) + } + } + /** Test hook: push a server-style notification into the app. */ emit(method: string, params: unknown): void { for (const h of this.handlers.get(method) ?? []) { @@ -50,17 +82,17 @@ export class FixtureTransport implements RpcTransport { } } - onState(listener: (state: ConnectionState) => void): () => void { + onState(listener: StateListener): () => void { this.stateListeners.add(listener) listener(this.state) return () => this.stateListeners.delete(listener) } /** Test hook: simulate a connection-state transition. */ - setState(next: ConnectionState): void { + setState(next: ConnectionState, info?: StateInfo): void { this.state = next for (const l of this.stateListeners) { - l(next) + l(next, info) } } diff --git a/ui-web/src/rpc/transport.ts b/ui-web/src/rpc/transport.ts index 08c41b83c..d2032ac4d 100644 --- a/ui-web/src/rpc/transport.ts +++ b/ui-web/src/rpc/transport.ts @@ -4,17 +4,34 @@ export class RpcError extends Error { constructor( readonly code: number, message: string, - readonly data?: unknown + readonly data?: unknown, + /** The error frame as it came off the wire, for a caller that needs more. */ + readonly rpc?: unknown ) { super(message) this.name = 'RpcError' } } -export type ConnectionState = 'connecting' | 'open' | 'reconnecting' | 'auth-failed' | 'closed' +export type ConnectionState = + | 'connecting' + | 'open' + | 'reconnecting' + | 'reconnected' + | 'auth-failed' + | 'closed' + +export interface StateInfo { + /** Rejoin attempts that have failed so far; 0 at the moment of the drop. */ + attempt?: number +} + +export type StateListener = (state: ConnectionState, info?: StateInfo) => void export type NotificationHandler = (params: unknown) => void +export type BinaryHandler = (buf: ArrayBuffer) => void + /** * The typed end state of the DataSource seam. Two implementations planned: * @@ -39,8 +56,20 @@ export interface RpcTransport { * not pushes. */ on(method: string, handler: NotificationHandler): () => void + /** + * Attach a handler for binary frames (the screencast stream). Returns the + * detach function. + */ + binary(handler: BinaryHandler): () => void + /** + * The escape hatch for the two method names the page calls that the + * contract does not declare: `raven.mcp.list` and `raven.mcp.set`. They + * answer -32601 today and must go on doing so, so they keep their calls + * here instead of being typed into existence or silently dropped. + */ + callUnchecked(method: string, params: Record): Promise /** Observe connection-state changes (fires immediately with the current state). */ - onState(listener: (state: ConnectionState) => void): () => void + onState(listener: StateListener): () => void connect(): Promise close(): void } diff --git a/ui-web/src/rpc/wsTransport.test.ts b/ui-web/src/rpc/wsTransport.test.ts new file mode 100644 index 000000000..c9c8f8d43 --- /dev/null +++ b/ui-web/src/rpc/wsTransport.test.ts @@ -0,0 +1,476 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { RpcError } from './transport' +import { WsTransport } from './wsTransport' + +import type { ConnectionState } from './transport' + +/* The behaviour under test is the whole of ui-web/src/live/020-rpc.js's rpc + object, so the first five cases are the ones scripts/rpc-connect.test.mjs + made against that file, with its FakeSocket brought along. */ + +/* Enough of a socket to be opened, closed, written to and fed a frame. + `readyState` starts CONNECTING, which is the state the page's own first + loads meet. */ +class FakeSocket { + static CONNECTING = 0 + static OPEN = 1 + static CLOSED = 3 + + readyState = FakeSocket.CONNECTING + binaryType: 'blob' | 'arraybuffer' = 'blob' + readonly sent: Array> = [] + onopen: (() => void) | null = null + onclose: (() => void) | null = null + onmessage: ((ev: { data: unknown }) => void) | null = null + + private readonly listeners = new Map void; once: boolean }>>() + + addEventListener(type: string, fn: () => void, options?: { once?: boolean }): void { + const list = this.listeners.get(type) ?? [] + list.push({ fn, once: options?.once === true }) + this.listeners.set(type, list) + } + + send(text: string): void { + this.sent.push(JSON.parse(text) as Record) + } + + close(): void { + this.closed() + } + + /* The property handler first, then the listeners -- the order a browser + uses, and the order the held-call fix depends on: `connect()` assigns + `onopen` when it makes the socket, so by the time a held call is released + the client already knows it is open. */ + opened(): void { + this.readyState = FakeSocket.OPEN + this.onopen?.() + this.fire('open') + } + + closed(): void { + this.readyState = FakeSocket.CLOSED + this.onclose?.() + this.fire('close') + } + + receive(data: unknown): void { + this.onmessage?.({ data }) + } + + private fire(type: string): void { + const list = this.listeners.get(type) ?? [] + this.listeners.set( + type, + list.filter((l) => !l.once) + ) + for (const l of list) { + l.fn() + } + } +} + +/** What a socket this harness hands out does on its own, and when. */ +type Fate = 'hang' | 'fail' | 'open' + +interface Seen { + state: ConnectionState + attempt?: number +} + +interface Harness { + transport: WsTransport + sockets: FakeSocket[] + /** Every rejoin sleep asked for, in order. */ + delays: number[] + seen: Seen[] + probes: () => number + setFate: (fate: Fate) => void + setProbe: (answer: boolean) => void +} + +function harness(options: { fate?: Fate; probe?: boolean; now?: () => number } = {}): Harness { + const sockets: FakeSocket[] = [] + const delays: number[] = [] + const seen: Seen[] = [] + let fate: Fate = options.fate ?? 'hang' + let probeAnswer = options.probe ?? false + let probes = 0 + + const transport = new WsTransport({ + url: () => 'ws://gateway.test/rpc', + makeSocket: () => { + const socket = new FakeSocket() + sockets.push(socket) + /* A socket settles on a later job, the way a real one does: `connect()` + has not assigned its handlers yet when this returns. */ + if (fate !== 'hang') { + void Promise.resolve().then(() => (fate === 'open' ? socket.opened() : socket.closed())) + } + return socket + }, + probe: async () => { + probes += 1 + return probeAnswer + }, + now: options.now, + timers: { + setTimeout: (fn, ms) => { + delays.push(ms) + return globalThis.setTimeout(fn, ms) + }, + clearTimeout: (id) => { + globalThis.clearTimeout(id) + }, + }, + }) + transport.onState((state, info) => seen.push({ state, ...(info ?? {}) })) + return { + transport, + sockets, + delays, + seen, + probes: () => probes, + setFate: (next) => { + fate = next + }, + setProbe: (answer) => { + probeAnswer = answer + }, + } +} + +const socketAt = (h: Harness, index: number): FakeSocket => { + const socket = h.sockets[index] + if (!socket) throw new Error(`no socket ${index}`) + return socket +} + +/** A transport mid-handshake, the way the page makes one. */ +function connecting(options: Parameters[0] = {}): { + h: Harness + ws: FakeSocket + joined: Promise +} { + const h = harness(options) + const joined = h.transport.connect() + return { h, ws: socketAt(h, 0), joined } +} + +/** A transport whose first socket is open. */ +async function connected(options: Parameters[0] = {}): Promise<{ + h: Harness + ws: FakeSocket +}> { + const { h, ws, joined } = connecting(options) + ws.opened() + await joined + return { h, ws } +} + +const states = (h: Harness): ConnectionState[] => h.seen.map((s) => s.state) + +const attempts = (h: Harness, state: ConnectionState): Array => + h.seen.filter((s) => s.state === state).map((s) => s.attempt) + +const failure = async (answer: Promise): Promise => { + const error: unknown = await answer.catch((e: unknown) => e) + expect(error).toBeInstanceOf(RpcError) + return error as RpcError +} + +beforeEach(() => { + vi.useFakeTimers() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('a call made before the socket is open', () => { + it('is sent once the handshake finishes', async () => { + const { h, ws } = connecting() + + const answer = h.transport.call('session.list', { limit: 3 }) + /* Nothing on the wire yet, and nothing reported to the reader either. */ + expect(ws.sent).toEqual([]) + + ws.opened() + await Promise.resolve() + expect(ws.sent).toEqual([ + { jsonrpc: '2.0', id: 1, method: 'session.list', params: { limit: 3 } }, + ]) + + ws.receive(JSON.stringify({ jsonrpc: '2.0', id: 1, result: { sessions: [] } })) + await expect(answer).resolves.toEqual({ sessions: [] }) + }) + + it('fails when that socket closes without ever opening', async () => { + /* The gateway is genuinely absent. A held call must not hang for the life + of the page waiting for a handshake that is not coming. */ + const { h, ws } = connecting() + + const answer = h.transport.call('session.list', {}) + ws.closed() + + expect((await failure(answer)).message).toBe('not connected') + }) + + it('still fails fast when there is no socket at all', async () => { + const h = harness() + + const error = await failure(h.transport.call('session.list', {})) + expect(error.message).toBe('not connected') + expect(error.code).toBe(-1) + }) + + it('still fails fast on a socket that has closed', async () => { + const { h, ws } = connecting() + ws.closed() + + expect((await failure(h.transport.call('session.list', {}))).message).toBe('not connected') + }) + + it('holds each caller separately', async () => { + const { h, ws } = connecting() + + void h.transport.call('ext.list', {}) + void h.transport.call('session.list', {}) + ws.opened() + await Promise.resolve() + + expect(ws.sent.map((f) => f.method)).toEqual(['ext.list', 'session.list']) + }) +}) + +describe('connect', () => { + it('reports a socket that closed before it ever opened, without interpreting it', async () => { + const { ws, joined, h } = connecting() + ws.closed() + + await expect(joined).resolves.toBe(false) + /* No rejoin: only the caller knows whether this is a refusal or an + absence, so nothing was scheduled here. */ + expect(h.delays).toEqual([]) + }) + + it('resolves true on open and says so once', async () => { + const { h, ws, joined } = connecting() + ws.opened() + + await expect(joined).resolves.toBe(true) + expect(states(h)).toEqual(['closed', 'connecting', 'open']) + }) +}) + +describe('a call on an open socket', () => { + it('resolves with the frame result', async () => { + const { h, ws } = await connected() + + const answer = h.transport.call('cron.list', {}) + expect(ws.sent).toEqual([{ jsonrpc: '2.0', id: 1, method: 'cron.list', params: {} }]) + ws.receive(JSON.stringify({ jsonrpc: '2.0', id: 1, result: { jobs: [] } })) + + await expect(answer).resolves.toEqual({ jobs: [] }) + }) + + it('sends an undeclared name as given, and keeps its -32601', async () => { + const { h, ws } = await connected() + + const answer = h.transport.callUnchecked('raven.mcp.list', {}) + expect(ws.sent.map((f) => f.method)).toEqual(['raven.mcp.list']) + ws.receive( + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + error: { code: -32601, message: 'method not found' }, + }) + ) + + expect((await failure(answer)).code).toBe(-32601) + }) + + it('rejects every pending call when the connection drops', async () => { + const { h, ws } = await connected() + + const answer = h.transport.call('cron.list', {}) + ws.closed() + + const error = await failure(answer) + expect(error.code).toBe(-1) + expect(error.message).toBe('connection closed') + }) +}) + +describe('a rejected call', () => { + const reject = async (h: Harness, ws: FakeSocket, error: unknown): Promise => { + const answer = h.transport.call('cron.list', {}) + ws.receive(JSON.stringify({ jsonrpc: '2.0', id: 1, error })) + return await failure(answer) + } + + it('reads the sentence a person is meant to see, not the machine code', async () => { + const { h, ws } = await connected() + + const error = await reject(h, ws, { + code: -32602, + message: 'config_validation_error', + data: { detail: ' a base named notes already exists ' }, + }) + + expect(error.message).toBe('a base named notes already exists') + expect(error.code).toBe(-32602) + expect(error.data).toEqual({ detail: ' a base named notes already exists ' }) + expect(error.rpc).toEqual({ + code: -32602, + message: 'config_validation_error', + data: { detail: ' a base named notes already exists ' }, + }) + }) + + it('falls back to the message when the detail is not a sentence', async () => { + const { h, ws } = await connected() + + const error = await reject(h, ws, { code: -32602, message: 'session_not_found', data: { detail: 7 } }) + + expect(error.message).toBe('session_not_found') + }) + + it('has something to say even for a frame carrying neither', async () => { + const { h, ws } = await connected() + + expect((await reject(h, ws, { code: -32603 })).message).toBe('rpc error') + }) +}) + +describe('notifications', () => { + it('fan out to every handler, and detach removes one', async () => { + const { h, ws } = await connected() + const got: unknown[] = [] + const detach = h.transport.on('turn.delta', (p) => got.push(p)) + h.transport.on('turn.delta', (p) => got.push(p)) + + ws.receive(JSON.stringify({ jsonrpc: '2.0', method: 'turn.delta', params: { text: 'x' } })) + detach() + ws.receive(JSON.stringify({ jsonrpc: '2.0', method: 'turn.delta' })) + + expect(got).toEqual([{ text: 'x' }, { text: 'x' }, {}]) + }) +}) + +describe('binary frames', () => { + it('go to the binary handlers only, until detached', async () => { + const { h, ws } = await connected() + const got: number[] = [] + const detach = h.transport.binary((buf) => got.push(buf.byteLength)) + + ws.receive(new ArrayBuffer(4)) + detach() + ws.receive(new ArrayBuffer(8)) + + expect(got).toEqual([4]) + }) +}) + +describe('onState', () => { + it('reports the current state immediately', () => { + const h = harness() + + expect(h.seen).toEqual([{ state: 'closed' }]) + }) +}) + +describe('rejoin', () => { + /* The page has to survive an upgrade replacing the gateway under it, which + is minutes, so the backoff and the ceiling are the behaviour -- not an + implementation detail of a retry. */ + it('backs off 1.6x per attempt up to 8s', async () => { + const { h, ws } = await connected() + h.setFate('fail') + + ws.closed() + expect(h.delays).toEqual([1500]) + for (const slept of [1500, 2400, 3840, 6144, 8000]) { + await vi.advanceTimersByTimeAsync(slept) + } + + expect(h.delays).toEqual([1500, 2400, 3840, 6144, 8000, 8000]) + }) + + it('counts the failed attempts it reports', async () => { + const { h, ws } = await connected() + h.setFate('fail') + + ws.closed() + expect(attempts(h, 'reconnecting')).toEqual([0]) + await vi.advanceTimersByTimeAsync(1500) + + expect(attempts(h, 'reconnecting')).toEqual([0, 1]) + }) + + it('stops hoping after twenty minutes', async () => { + let clock = 0 + const { h, ws } = await connected({ now: () => clock }) + h.setFate('fail') + + ws.closed() + clock = 1_200_001 + await vi.advanceTimersByTimeAsync(1500) + + expect(h.seen.at(-1)).toEqual({ state: 'auth-failed', attempt: 0 }) + /* Gave up before opening another socket, and scheduled nothing more. */ + expect(h.sockets.length).toBe(1) + expect(h.delays).toEqual([1500]) + }) + + it('calls a refusal a refusal when the gateway answers over HTTP', async () => { + const { h, ws } = await connected() + h.setFate('fail') + h.setProbe(true) + + ws.closed() + await vi.advanceTimersByTimeAsync(1500) + expect(h.seen.at(-1)).toEqual({ state: 'auth-failed', attempt: 1 }) + + await vi.advanceTimersByTimeAsync(60_000) + expect(h.probes()).toBe(1) + expect(h.delays).toEqual([1500]) + expect(h.sockets.length).toBe(2) + }) + + it('reports a reconnect and is ready for the next drop', async () => { + const { h, ws } = await connected() + h.setFate('open') + + ws.closed() + await vi.advanceTimersByTimeAsync(1500) + + expect(states(h)).toEqual([ + 'closed', + 'connecting', + 'open', + 'reconnecting', + 'open', + 'reconnected', + ]) + + /* The rejoin flag is clear, so a second drop starts its own rejoin. */ + socketAt(h, 1).closed() + expect(h.delays).toEqual([1500, 1500]) + }) + + it('is cancelled by close', async () => { + const { h, ws } = await connected() + h.setFate('fail') + + ws.closed() + expect(h.delays).toEqual([1500]) + h.transport.close() + await vi.advanceTimersByTimeAsync(60_000) + + expect(h.sockets.length).toBe(1) + expect(states(h).at(-1)).toBe('closed') + }) +}) diff --git a/ui-web/src/rpc/wsTransport.ts b/ui-web/src/rpc/wsTransport.ts new file mode 100644 index 000000000..09f7be5be --- /dev/null +++ b/ui-web/src/rpc/wsTransport.ts @@ -0,0 +1,379 @@ +import type { ParamsOf, ResultOf, RpcMethod } from './generated' +import type { + BinaryHandler, + ConnectionState, + NotificationHandler, + RpcTransport, + StateInfo, + StateListener, +} from './transport' + +import { RpcError } from './transport' + +/* The live end of the DataSource seam: JSON-RPC 2.0 over one WebSocket to + /rpc, with the rejoin policy the page runs today. Every behaviour here is + ported from ui-web/src/live/020-rpc.js. + + What paints stays out of it: the reconnect status line, the upgrade shade, + the auth banner and the desktop shell's reauth handshake all belong to the + caller, which drives them off `onState`. */ + +/* How long a rejoin keeps trying, and how long it ever sleeps between tries. + The ceiling matches the upgrade watcher's (20 minutes) on purpose: the + reason the gateway is away this long is almost always an upgrade, and the + two should not disagree about when to stop hoping. The 8s cap keeps a page + left open overnight from hammering a machine that is simply off. */ +const REJOIN_CEILING_MS = 1_200_000 +const REJOIN_MAX_WAIT_MS = 8_000 +const REJOIN_FIRST_WAIT_MS = 1_500 +const REJOIN_GROWTH = 1.6 + +/* Not `WebSocket.CONNECTING`: the transport must be constructible where no + global WebSocket exists, which is how its own tests drive it. */ +const CONNECTING = 0 + +/** The slice of a WebSocket this transport uses, so a test can supply one. */ +export interface WebSocketLike { + readonly readyState: number + binaryType: string + onopen: (() => void) | null + onclose: (() => void) | null + onmessage: ((ev: { data: unknown }) => void) | null + send(data: string): void + close(): void + addEventListener(type: 'open' | 'close', fn: () => void, options: { once: true }): void +} + +export interface Timers { + setTimeout: (fn: () => void, ms: number) => number + clearTimeout: (id: number) => void +} + +export interface WsTransportOptions { + url?: () => string + makeSocket?: (url: string) => WebSocketLike + probe?: () => Promise + now?: () => number + timers?: Timers +} + +interface Pending { + resolve: (value: unknown) => void + reject: (error: unknown) => void +} + +interface WireError { + code: number + message?: string + data?: unknown +} + +interface WireFrame { + id?: number | null + method?: string + params?: unknown + result?: unknown + error?: WireError +} + +const defaultUrl = (): string => + `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/rpc` + +/* The cast is the one place the browser's own type meets the slice above. + A real WebSocket satisfies it at runtime but not structurally: its handler + properties are typed against the full event objects, and a property's + parameter types are checked contravariantly, so a narrower `{ data }` event + is rejected however it is written. */ +const defaultSocket = (url: string): WebSocketLike => + new globalThis.WebSocket(url) as unknown as WebSocketLike + +/* What separates "the gateway is not there" from "the gateway refused this + session": /health is unauthenticated precisely so it can answer a page whose + cookie the gateway has already forgotten, so an answer means the process is + back and a socket closing anyway is a real auth refusal. No answer means + keep waiting. (020-rpc.js probed '/' with HEAD; the two are equivalent, and + the dev server owns '/'.) */ +const probeHealth = async (): Promise => { + try { + const r = await fetch('/health', { cache: 'no-store' }) + return r.status !== 401 && r.status !== 403 + } catch { + return false + } +} + +/* Read through globalThis on each call, so a test that installs fake timers + after the transport was built still gets them. */ +const defaultTimers: Timers = { + setTimeout: (fn, ms) => globalThis.setTimeout(fn, ms), + clearTimeout: (id) => { + globalThis.clearTimeout(id) + }, +} + +const detailOf = (data: unknown): string => { + if (typeof data !== 'object' || data === null) return '' + const detail = (data as { detail?: unknown }).detail + return typeof detail === 'string' ? detail.trim() : '' +} + +/* What a rejected call carries. + + The gateway sends two different things: `message` is a machine code the + client matches on -- `config_validation_error`, `session_not_found` -- and + `data.detail` is the sentence a person is meant to read. Rejecting with the + frame as it stands meant every `toast(e.message)` on the page showed the + code, so naming a knowledge base that already existed reported + "config_validation_error" and nothing about the name. + + So the rejection is an Error whose message is the sentence when there is + one, with the code and the original frame kept on it: two callers match on + `e.code` for a method the gateway does not have, and they must go on + working. */ +const rpcFailure = (error: WireError): RpcError => + new RpcError(error.code, detailOf(error.data) || error.message || 'rpc error', error.data, error) + +/** A transport over one live WebSocket, rejoining on its own when it drops. */ +export class WsTransport implements RpcTransport { + private readonly url: () => string + private readonly makeSocket: (url: string) => WebSocketLike + private readonly probe: () => Promise + private readonly now: () => number + private readonly timers: Timers + + private readonly pending = new Map() + private readonly handlers = new Map>() + private readonly binaryHandlers = new Set() + private readonly stateListeners = new Set() + + private ws: WebSocketLike | null = null + private next = 1 + private opened = false + private rejoining = false + private timer: number | null = null + private state: ConnectionState = 'closed' + + constructor(options: WsTransportOptions = {}) { + this.url = options.url ?? defaultUrl + this.makeSocket = options.makeSocket ?? defaultSocket + this.probe = options.probe ?? probeHealth + this.now = options.now ?? (() => Date.now()) + this.timers = options.timers ?? defaultTimers + } + + connect(): Promise { + return new Promise((resolve) => { + const ws = this.makeSocket(this.url()) + ws.binaryType = 'arraybuffer' + this.ws = ws + // A rejoin already said what it is doing, once, and its ticks must not + // overwrite that with a 'connecting' per attempt. + if (!this.rejoining) this.setState('connecting') + ws.onopen = () => { + this.opened = true + this.setState('open') + resolve(true) + } + ws.onmessage = (ev: { data: unknown }) => { + this.receive(ev.data) + } + ws.onclose = () => { + const was = this.opened + this.opened = false + for (const p of this.pending.values()) { + p.reject(new RpcError(-1, 'connection closed')) + } + this.pending.clear() + /* A socket that never opened is reported, not interpreted. It means + one of two very different things -- the gateway refused this + session, or there is no gateway right now -- and only the caller has + the context to tell them apart. Deciding here is what made an + upgrade look like a sign-in failure. */ + if (!was) { + resolve(false) + return + } + this.rejoin() + } + }) + } + + async call(method: M, params: ParamsOf): Promise> { + return (await this.send(method, params)) as ResultOf + } + + /** + * @deprecated Only for `raven.mcp.list` and `raven.mcp.set`, which the + * contract does not declare. Both answer -32601 today; the escape hatch + * exists so that stays true instead of changing shape on the way out. + */ + callUnchecked(method: string, params: Record): Promise { + return this.send(method, params) + } + + on(method: string, handler: NotificationHandler): () => void { + const set = this.handlers.get(method) ?? new Set() + set.add(handler) + this.handlers.set(method, set) + return () => set.delete(handler) + } + + binary(handler: BinaryHandler): () => void { + this.binaryHandlers.add(handler) + return () => this.binaryHandlers.delete(handler) + } + + onState(listener: StateListener): () => void { + this.stateListeners.add(listener) + listener(this.state) + return () => this.stateListeners.delete(listener) + } + + close(): void { + this.rejoining = false + this.cancelTick() + // Cleared before the socket is told to close, so the close we asked for + // does not read as a drop and start a rejoin. + this.opened = false + this.ws?.close() + this.setState('closed') + } + + private send(method: string, params: unknown): Promise { + const ws = this.ws + if (!this.opened || !ws) { + /* A socket still shaking hands is not a missing gateway. The page's + first paint schedules its own loads, so those land in the window + between `connect()` being called and the socket opening, and the + reader was told "load failed: not connected" on every single reload. + Waiting for THIS connect to settle is the whole fix: a socket that is + closed, closing, or absent still fails fast, and a socket that never + opens rejects when it closes rather than leaving the caller hanging. */ + if (ws?.readyState === CONNECTING) return this.whenOpen(ws, method, params) + return Promise.reject(new RpcError(-1, 'not connected')) + } + const id = this.next++ + ws.send(JSON.stringify({ jsonrpc: '2.0', id, method, params: params ?? {} })) + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }) + }) + } + + /* One call, held until the socket it was made on opens. Registered with + `addEventListener` rather than by assigning the handlers, because + `connect()` owns `onopen`/`onclose` and overwriting either would take the + connection's own bookkeeping with it. `once`, so a call cannot be sent + twice and a rejected one cannot be settled again. */ + private whenOpen(ws: WebSocketLike, method: string, params: unknown): Promise { + return new Promise((resolve, reject) => { + ws.addEventListener( + 'open', + () => { + this.send(method, params).then(resolve, reject) + }, + { once: true } + ) + ws.addEventListener('close', () => reject(new RpcError(-1, 'not connected')), { once: true }) + }) + } + + private receive(data: unknown): void { + if (data instanceof ArrayBuffer) { + for (const h of this.binaryHandlers) { + h(data) + } + return + } + if (typeof data !== 'string') return + let frame: WireFrame + try { + frame = JSON.parse(data) as WireFrame + } catch { + return + } + const id = frame.id + if (id != null) { + const p = this.pending.get(id) + if (p) { + this.pending.delete(id) + if (frame.error) p.reject(rpcFailure(frame.error)) + else p.resolve(frame.result) + return + } + } + if (frame.method) { + for (const h of this.handlers.get(frame.method) ?? []) { + h(frame.params ?? {}) + } + } + } + + /* Keep trying, with backoff, instead of one 1.5s attempt. The one thing + that reliably takes the gateway away is an upgrade replacing the + installation under it, and an upgrade is minutes -- a cold one measured + nine. So a single retry was guaranteed to fire while the backend was + still absent, fail, and report an expired sign-in that had not expired. + + `probe()` is what separates absent from refused, and only when it says + refused does this give up and say so. */ + private rejoin(): void { + if (this.rejoining) return + this.rejoining = true + const t0 = this.now() + let wait = REJOIN_FIRST_WAIT_MS + let attempt = 0 + const tick = async (): Promise => { + if (this.now() - t0 > REJOIN_CEILING_MS) { + this.rejoining = false + this.setState('auth-failed', { attempt }) + return + } + if (await this.connect()) { + this.rejoining = false + /* The gateway that came back may be serving a different build than + the one this page was loaded from -- that is the upgrade case, and + reloading onto it is the caller's decision, not the transport's. */ + this.emit('reconnected', { attempt }) + return + } + attempt += 1 + if (await this.probe()) { + this.rejoining = false + this.setState('auth-failed', { attempt }) + return + } + this.setState('reconnecting', { attempt }) + wait = Math.min(Math.round(wait * REJOIN_GROWTH), REJOIN_MAX_WAIT_MS) + this.schedule(tick, wait) + } + this.setState('reconnecting', { attempt }) + this.schedule(tick, wait) + } + + private schedule(tick: () => Promise, wait: number): void { + this.timer = this.timers.setTimeout(() => { + this.timer = null + void tick() + }, wait) + } + + private cancelTick(): void { + if (this.timer !== null) { + this.timers.clearTimeout(this.timer) + this.timer = null + } + } + + private setState(state: ConnectionState, info?: StateInfo): void { + this.state = state + this.emit(state, info) + } + + /* 'reconnected' is an edge, not a resting state: the socket is open again, + so a listener attached after the fact must be told 'open'. */ + private emit(state: ConnectionState, info?: StateInfo): void { + for (const l of this.stateListeners) { + l(state, info) + } + } +} diff --git a/ui-web/src/state/gateway.test.ts b/ui-web/src/state/gateway.test.ts new file mode 100644 index 000000000..6f338a341 --- /dev/null +++ b/ui-web/src/state/gateway.test.ts @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, it } from 'vitest' + +import { FixtureTransport } from '../rpc/fixtureTransport' +import { gateway, setGateway } from './gateway' + +afterEach(() => { + setGateway(null) +}) + +describe('the installed gateway', () => { + it('refuses to invent one when nothing is installed', () => { + expect(() => gateway()).toThrow('no gateway installed') + }) + + it('hands back the transport that was installed', () => { + const transport = new FixtureTransport({}) + setGateway(transport) + + expect(gateway()).toBe(transport) + }) + + it('can be taken back out, so a test leaves nothing behind', () => { + setGateway(new FixtureTransport({})) + setGateway(null) + + expect(() => gateway()).toThrow('no gateway installed') + }) +}) diff --git a/ui-web/src/state/gateway.ts b/ui-web/src/state/gateway.ts new file mode 100644 index 000000000..992d1ecc7 --- /dev/null +++ b/ui-web/src/state/gateway.ts @@ -0,0 +1,16 @@ +import type { RpcTransport } from '../rpc/transport' + +/* The one data entry point. Nothing builds its own transport: the page + installs the live one, a test or the demo installs a FixtureTransport, and + every feature reads whichever is in place through `gateway()`. */ + +let current: RpcTransport | null = null + +export function gateway(): RpcTransport { + if (!current) throw new Error('no gateway installed') + return current +} + +export function setGateway(transport: RpcTransport | null): void { + current = transport +} From 580ad9ef39ec5ae2d3fbec46af45b5c3c0733d53 Mon Sep 17 00:00:00 2001 From: KT Date: Wed, 16 Sep 2026 23:24:32 +0800 Subject: [PATCH 03/83] refactor(ui-web): register decorators and own shared state instead of reassigning across files The concatenated page script reassigned 28 bindings declared in other parts, the one thing an ES module graph cannot express: an imported binding is read-only. Two shapes, two answers. Four names are function declarations another part replaced wholesale (extSet, showPage, closeDetail in demo/120-capabilities.js; drawCaps in demo/152-skills.js). Each becomes a function-declaration entry point that stays callable by its old name, a `var Decorators` registry, a `decorate(wrap)` registrar and the old body as `Base`, with a shared applyDecorators() in demo/010-kernel.js reducing in registration order so the last registrar wraps every earlier one -- the order the capture-and-replace chain produced. The registry is a `var` with no initialiser because demo/120 -> demo/152 is an edge of a 14-file cycle, so 152 can register before 120's statement has run and an initialiser would discard it. Nine `let` names whose writers are all in their own layer move onto an object the declaring file owns: runState.use, capFilter.kind/.query, park.turnOwner/ .lastAsk, staged.model/.tier/.perm, setupState.providerConfigured. APP_VERSION and HOST_PLATFORM are written from the OTHER layer, and a field the live layer writes is a strand count-shared-globals.mjs counts. They keep their `let` and gain a setter verb in the declaring file, which is the shape demo/010-kernel.js already uses for LANG. Five sandbox tests inject some of these names into a Function harness and follow the rename. The analysis scripts the change was driven from are kept under scripts/codemod/ with their baseline boot snapshot and dist digest. Verification: `npm run --prefix ui-web build` + `python3 ui-web/build.py` + `npm test --prefix ui-web` (108 files, 1836 cases, 0 failures, same as before); boot snapshot of dist/index.html at ?stub=1 byte-identical; `node ui-web/scripts/count-shared-globals.mjs` still 0/0/18; `node ui-web/scripts/codemod/writes.mjs` now reports 0 cross-file assignments; the island call order of all four decorated verbs recorded before and after is identical. Co-authored-by: Claude (claude-fable-5-1) --- ui-web/scripts/boot-order.test.mjs | 2 +- ui-web/scripts/codemod/a1-own-state.mjs | 142 ++++++ ui-web/scripts/codemod/baseline-boot.txt | 428 ++++++++++++++++++ .../scripts/codemod/baseline-dist-digest.txt | 1 + ui-web/scripts/codemod/boot-snapshot.mjs | 62 +++ ui-web/scripts/codemod/census.mjs | 128 ++++++ ui-web/scripts/codemod/deps5.mjs | 94 ++++ ui-web/scripts/codemod/freeglobals.mjs | 58 +++ ui-web/scripts/codemod/names.mjs | 45 ++ ui-web/scripts/codemod/order.mjs | 87 ++++ ui-web/scripts/codemod/sideeffects.mjs | 32 ++ ui-web/scripts/codemod/writes.mjs | 58 +++ ui-web/scripts/model-refresh-live.test.mjs | 15 +- ui-web/scripts/page-switch-live.test.mjs | 4 +- ui-web/src/demo/010-kernel.js | 13 +- ui-web/src/demo/040-state.js | 5 +- ui-web/src/demo/060-conversation.js | 4 +- ui-web/src/demo/080-replay.js | 2 +- ui-web/src/demo/090-composer.js | 6 +- ui-web/src/demo/120-capabilities.js | 29 +- ui-web/src/demo/130-settings.js | 1 + ui-web/src/demo/150-chrome.js | 6 +- ui-web/src/demo/152-skills.js | 23 +- ui-web/src/demo/153-plugins.js | 25 +- .../composer/open-conversation.test.ts | 4 +- .../src/features/composer/turn-park.test.ts | 2 +- ui-web/src/live/050-turn.js | 6 +- ui-web/src/live/060-parked.js | 29 +- ui-web/src/live/080-overrides.js | 35 +- ui-web/src/live/120-settings.js | 30 +- ui-web/src/live/200-boot.js | 6 +- 31 files changed, 1274 insertions(+), 108 deletions(-) create mode 100644 ui-web/scripts/codemod/a1-own-state.mjs create mode 100644 ui-web/scripts/codemod/baseline-boot.txt create mode 100644 ui-web/scripts/codemod/baseline-dist-digest.txt create mode 100644 ui-web/scripts/codemod/boot-snapshot.mjs create mode 100644 ui-web/scripts/codemod/census.mjs create mode 100644 ui-web/scripts/codemod/deps5.mjs create mode 100644 ui-web/scripts/codemod/freeglobals.mjs create mode 100644 ui-web/scripts/codemod/names.mjs create mode 100644 ui-web/scripts/codemod/order.mjs create mode 100644 ui-web/scripts/codemod/sideeffects.mjs create mode 100644 ui-web/scripts/codemod/writes.mjs diff --git a/ui-web/scripts/boot-order.test.mjs b/ui-web/scripts/boot-order.test.mjs index 269bd5450..4e5771793 100644 --- a/ui-web/scripts/boot-order.test.mjs +++ b/ui-web/scripts/boot-order.test.mjs @@ -81,7 +81,7 @@ describe('the assembled page boot order', () => { describe('first-run model setup', () => { it('records missing-provider state without opening onboarding automatically', () => { - expect(live).toContain('providerConfiguredLive = setup.provider_configured !== false;') + expect(live).toContain('setupState.providerConfigured = setup.provider_configured !== false;') expect(live).not.toMatch(/setup\.provider_configured === false\s*\|\|/) }) diff --git a/ui-web/scripts/codemod/a1-own-state.mjs b/ui-web/scripts/codemod/a1-own-state.mjs new file mode 100644 index 000000000..b95bec050 --- /dev/null +++ b/ui-web/scripts/codemod/a1-own-state.mjs @@ -0,0 +1,142 @@ +// A1 codemod: rewrite every cross-file-assigned top-level `let` into a field +// on a container object owned by its declaring file. +// +// Scoping is resolved on the CONCATENATED layer text, because a single file's +// AST cannot tell a reference to another file's top-level name from a free +// global. Declaration sites are reported, not rewritten -- those are edited by +// hand, since the declarations are interleaved with names that stay put. +// +// Usage: node scripts/codemod/a1-own-state.mjs [--write] +import { readFileSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import ts from 'typescript' + +const UIWEB = join(dirname(fileURLToPath(import.meta.url)), '..', '..') +const SRC = join(UIWEB, 'src') +const py = readFileSync(join(UIWEB, 'build.py'), 'utf8') +const manifest = (n) => [...py.match(new RegExp(`_${n}_PARTS = \\[([\\s\\S]*?)\\]`))[1].matchAll(/"([^"]+)"/g)].map((x) => x[1]) + +// old identifier -> replacement member expression +export const MAP = { + HOST_PLATFORM: 'host.platform', + use: 'runState.use', + cKind: 'capFilter.kind', + cQuery: 'capFilter.query', + APP_VERSION: 'appInfo.version', + turnOwner: 'park.turnOwner', + lastAsk: 'park.lastAsk', + pendingPerm: 'staged.perm', + pendingModel: 'staged.model', + pendingTier: 'staged.tier', + providerConfiguredLive: 'setupState.providerConfigured', +} + +function concat(dir, parts) { + let text = '' + const spans = [] + for (const p of parts) { + const t = readFileSync(join(SRC, dir, p), 'utf8') + spans.push({ file: `${dir}/${p}`, start: text.length, end: text.length + t.length }) + text += t + } + if (text.endsWith('\n')) { + text = text.slice(0, -1) + spans[spans.length - 1].end -= 1 + } + return { text, spans } +} +const seam = concat('seam', manifest('SEAM')) +const demo = concat('demo', manifest('DEMO')) +const live = concat('live', manifest('LIVE')) +const off = seam.text.length + 1 +const layers = { + demo: { text: seam.text + '\n' + demo.text, spans: [...seam.spans, ...demo.spans.map((s) => ({ file: s.file, start: s.start + off, end: s.end + off }))] }, + live: { text: live.text, spans: live.spans }, +} +function bn(nm, out) { + if (!nm) return + if (ts.isIdentifier(nm)) out.add(nm.text) + else if (nm.elements) for (const e of nm.elements) { if (!ts.isOmittedExpression(e)) bn(e.name, out) } +} +function declaredIn(scope) { + const out = new Set() + if (ts.isFunctionLike(scope)) { + for (const p of scope.parameters || []) bn(p.name, out) + if (scope.name && ts.isIdentifier(scope.name) && (ts.isFunctionExpression(scope) || ts.isFunctionDeclaration(scope))) out.add(scope.name.text) + } + if (ts.isCatchClause(scope) && scope.variableDeclaration) bn(scope.variableDeclaration.name, out) + const stmts = ts.isBlock(scope) || ts.isSourceFile(scope) ? scope.statements : ts.isFunctionLike(scope) && scope.body && ts.isBlock(scope.body) ? scope.body.statements : ts.isCaseClause(scope) || ts.isDefaultClause(scope) ? scope.statements : null + if (stmts) for (const st of stmts) { + if (ts.isVariableStatement(st)) for (const d of st.declarationList.declarations) bn(d.name, out) + else if ((ts.isFunctionDeclaration(st) || ts.isClassDeclaration(st)) && st.name) out.add(st.name.text) + } + const i = scope.initializer + if ((ts.isForStatement(scope) || ts.isForOfStatement(scope) || ts.isForInStatement(scope)) && i && ts.isVariableDeclarationList(i)) for (const d of i.declarations) bn(d.name, out) + return out +} +const isScope = (n) => + ts.isFunctionLike(n) || ts.isBlock(n) || ts.isSourceFile(n) || ts.isCatchClause(n) || ts.isForStatement(n) || ts.isForOfStatement(n) || ts.isForInStatement(n) || ts.isCaseClause(n) || ts.isDefaultClause(n) + +const edits = new Map() +const decls = [] +const skipped = [] +for (const label of ['demo', 'live']) { + const { text, spans } = layers[label] + const sf = ts.createSourceFile(label, text, ts.ScriptTarget.ES2022, true, ts.ScriptKind.JS) + const spanOf = (pos) => spans.find((s) => pos >= s.start && pos < s.end) + let topNode = sf + if (label === 'live') { let a = null; const f = (n) => { if (!a && ts.isArrowFunction(n)) a = n; else ts.forEachChild(n, f) }; f(sf.statements[0]); topNode = a.body } + const topStatements = label === 'live' ? topNode.statements : sf.statements + const declNodes = new Set() + for (const st of topStatements) { + if (ts.isVariableStatement(st)) for (const d of st.declarationList.declarations) { + const mark = (nm) => { if (ts.isIdentifier(nm)) declNodes.add(nm); else if (nm.elements) for (const e of nm.elements) e.name && mark(e.name) } + mark(d.name) + } else if ((ts.isFunctionDeclaration(st) || ts.isClassDeclaration(st)) && st.name) declNodes.add(st.name) + } + const cache = new Map() + const scopeNames = (n) => { if (!cache.has(n)) cache.set(n, declaredIn(n)); return cache.get(n) } + const local = (id) => { for (let p = id.parent; p; p = p.parent) { if (p === topNode || (label === 'demo' && ts.isSourceFile(p))) return false; if (isScope(p) && scopeNames(p).has(id.text)) return true } return false } + const walk = (n) => { + if (ts.isIdentifier(n) && Object.hasOwn(MAP, n.text)) { + const p = n.parent + const isProp = + (ts.isPropertyAccessExpression(p) && p.name === n) || + (ts.isPropertyAssignment(p) && p.name === n) || + (ts.isMethodDeclaration(p) && p.name === n) || + (ts.isBindingElement(p) && p.propertyName === n) || + (ts.isQualifiedName(p) && p.right === n) || + (ts.isGetAccessorDeclaration(p) && p.name === n) || + (ts.isSetAccessorDeclaration(p) && p.name === n) || + (ts.isLabeledStatement(p) && p.label === n) + const pos = n.getStart(sf) + const span = spanOf(pos) + if (ts.isShorthandPropertyAssignment(p)) { skipped.push(`SHORTHAND ${span.file} ${n.text}`); return } + if (isProp || local(n)) return + if (declNodes.has(n)) { decls.push(`${span.file} ${n.text}`); return } + if (!edits.has(span.file)) edits.set(span.file, []) + edits.get(span.file).push({ at: pos - span.start, len: n.text.length, from: n.text, to: MAP[n.text] }) + } + ts.forEachChild(n, walk) + } + walk(sf) +} +const write = process.argv.includes('--write') +let total = 0 +for (const [file, list] of [...edits].sort()) { + const path = join(SRC, file) + let text = readFileSync(path, 'utf8') + list.sort((a, b) => b.at - a.at) + for (const e of list) { + if (text.slice(e.at, e.at + e.len) !== e.from) throw new Error(`${file}@${e.at}: expected ${e.from}, found ${JSON.stringify(text.slice(e.at, e.at + e.len))}`) + text = text.slice(0, e.at) + e.to + text.slice(e.at + e.len) + } + total += list.length + console.log(`${String(list.length).padStart(3)} ${file}`) + if (write) writeFileSync(path, text) +} +console.log(`\n${total} references rewritten across ${edits.size} files${write ? '' : ' (dry run; pass --write)'}`) +console.log(`declaration sites left for hand edits: ${decls.length}`) +for (const d of decls) console.log(` ${d}`) +if (skipped.length) console.log(`skipped: ${JSON.stringify(skipped)}`) diff --git a/ui-web/scripts/codemod/baseline-boot.txt b/ui-web/scripts/codemod/baseline-boot.txt new file mode 100644 index 000000000..bd50fd023 --- /dev/null +++ b/ui-web/scripts/codemod/baseline-boot.txt @@ -0,0 +1,428 @@ +body + div#onb + div.app[data-rail=on] + aside.rail + div.railtop + button#railBtn.ghost-ic.tipdn[data-i18n-tip=gui.collapse_rail][data-i18n-aria=gui.collapse_rail] + svg + rect + path + button#findBtn.ghost-ic.tipdn[data-i18n-tip=gui.search_sessions][data-i18n-aria=gui.search_sessions] + svg + circle + path + nav.rail-nav + button#newBtn.navi.newrun + svg + path + span[data-i18n=gui.new_task] + button#skillBtn.navi + svg + path + span[data-i18n=gui.tab.skills] + button#plugBtn.navi + svg + path + span[data-i18n=gui.tab.plugins] + button#pbBtn.navi + svg + circle + circle + circle + path + span[data-i18n=gui.nav.pb] + button#kbBtn.navi + svg + path + span[data-i18n=gui.nav.kb] + button#memBtn.navi + svg + path + span[data-i18n=gui.nav.mem] + div#moreFly.moresub[data-open=false][data-i18n-aria=gui.nav.more] + button#moreBtn.navi.more + svg.chev + path + span.l-more[data-i18n=gui.nav.more] + span.l-less[data-i18n=gui.nav.less] + div#findBox.find + svg.ic + circle + path + input#sfind[data-i18n-ph=gui.search_sessions][data-i18n-aria=gui.search_sessions] + button#sclr.clr[data-i18n-aria=gui.clear_search] + div#list.list + div.rail-foot + button#upnote.upnote + span.pip + span.t[data-i18n=gui.update.note] + span.rl[data-i18n=gui.update.reload] + button#meBtn.me[data-i18n-aria=gui.nav.set] + span.av.anon + svg + circle + path + span.who + span.n[data-i18n=gui.nav.set] + span#meSub.s + span#meKbd.kbd + div#railGrip.grip[data-i18n-title=gui.resize_rail][data-i18n-aria=gui.resize_rail] + div.main + div#split.split[data-full=false][data-open=false] + div.chat + div.top + h1#title + button#renameBtn.ghost-ic.tipdn[data-i18n-tip=gui.rename_session][data-i18n-aria=gui.rename_session] + svg + path + path + span.spacer + button#wsBtn.ghost-ic.wstog.tipdn[data-i18n-tip=gui.expand_ws][data-i18n-aria=gui.expand_ws][data-tip=Expand workspace] + svg + rect + path + span#wsBdg.bdg + div#scroll.scroll + div#bannerHost + div.banner + b + span + button + button.x + div#flash.flash + div#stage.col + div[data-tsl=1] + div.ask.in + div.b + div.ansfoot + div.acts + button[data-tip=Copy][data-label=Copy] + svg + rect + path + span.turnmeta + div.tfold + button.tfh + span.lb + span.tm + svg.cv + path + div.tfb + div.answer-turn + div.answer.in + div.prose + h2 + p + strong + div.tw + table + thead + tr + th + th + th + th + tbody + tr + td + td + td + td + tr + td + td + td + td + tr + td + td + td + td + h3 + ul + li + strong + li + strong + li + strong + h3 + p + strong + div.arts + section.asec.deliveries + div.ahd + span.ahm + span.lb + span.n + div.atiles + div.atile.missing + span.pic.none + svg.fi + path + span.ft + span.cap + span.nm + span.mt + button.hit + div.atile.missing + span.pic.none + svg.fi + path + span.ft + span.cap + span.nm + span.mt + button.hit + div.atile.missing + span.pic.none + svg.fi + path + span.ft + span.cap + span.nm + span.mt + button.hit + button.amore + section.asec.changes + div.ahd + span.ahm + span.lb + span.n + div.achanges + button.achange + span.ck.new + span.cn + span.ct + span.ca + span.cd + button.achange + span.ck.edit + span.cn + span.ct + span.ca + span.cd + button.achange + span.ck.edit + span.cn + span.ct + span.ca + span.cd + button.achange + span.ck.new + span.cn + span.ct + span.ca + span.cd + button.amore + div.ansfoot + div.acts + button[data-tip=Copy][data-label=Copy] + svg + rect + path + button[data-tip=Branch into a new session][data-label=Branch into a new session] + svg + circle + circle + circle + path + path + span.turnmeta + button#backpill.backpill + svg + path + div#wsGrip.grip[data-i18n-title=gui.resize_ws][data-i18n-aria=gui.resize_ws] + div#brand + span.mk + span.wl + div.dock + div.crew.crew-back + img.rv.rv-content + div#sheetRack.sheets + div.dock-in + div#queued.queued + div#atts.atts + div.field + textarea#ta[data-i18n-ph=gui.composer_ph] + div.under + button#attBtn.tool-btn[data-i18n-tip=gui.attach][data-i18n-aria=gui.attach] + svg + path + button#permChip.chip + svg.pico + path + path + path + span#permName + span#envChip.chip + span.led + span#envName + span#meter.meter + button#ctxChip.chip.ctx[data-tip=context 17k/200k 9%] + svg.ring + circle.bg + circle.fg + button#tierChip.chip + svg.pico + path + path + span#tierName + button#modelChip.chip + span#modelName + button#go.go[data-i18n-aria=gui.send] + svg + path + div#slashPop.pop.slash[data-open=false][data-i18n-aria=gui.commands] + div.hd + span.lab[data-i18n=gui.session_commands] + div#slashList + div#permPop.pop[data-open=false][data-i18n-aria=gui.perm.title] + div.hd + span.lab[data-i18n=gui.perm.title] + div#permList + div.note[data-i18n=gui.perm.note] + div#tierPop.pop[data-open=false] + div.hd + span#tierPopLab.lab + div#tierList + div.note + div.crew.crew-front + img.rv.rv-main + img.rv.rv-research + img.rv.rv-coding + img.rv.rv-data + aside#ws.ws[data-i18n-aria=gui.workspace] + div.ws-top + div#wsTabs.wseg + button[data-w=diff] + svg + rect + path + span.lb[data-i18n=gui.ws.changes] + span#wsUnseen.bdg + button[data-w=browser] + svg + circle + path + span.lb[data-i18n=gui.ws.browser] + button[data-w=agents] + svg + rect + path + span.lb[data-i18n=gui.ws.agents] + span#wsAgentRun.rundot + div.ws-acts + button#wsWide.ghost-ic.wsfull.tipdn[data-i18n-tip=gui.ws.expand_panel][data-i18n-aria=gui.ws.expand_panel][data-tip=Expand to full window] + svg.ex + path + svg.in + path + button#wsClose.ghost-ic.wstog.tipdn[data-i18n-tip=gui.collapse_ws][data-i18n-aria=gui.collapse_ws] + svg + rect + path + div#wsBody.ws-body + button#railShow.ghost-ic.tipdn[data-i18n-tip=gui.expand_rail][data-i18n-aria=gui.expand_rail] + svg + rect + path + section#capsPage.page[data-open=false] + header + h2#capsTitle[data-i18n=gui.tab.skills] + div.work + div.wrap + div#pageHero.pmhero + h3 + div.cbar + div.cfind + svg.ic + circle + path + input#cq[data-i18n-aria=gui.search] + div#cKind.pills[data-i18n-aria=gui.filter_status] + button.pill[data-k=all][data-i18n=gui.filter.all] + button.pill[data-k=on][data-i18n=gui.filter.on] + button.pill[data-k=attn][data-i18n=gui.filter.todo] + button.pill[data-k=add][data-i18n=gui.filter.add] + button.pminstbtn + span + button.pminstbtn + span + div#capsBody + div + details#advAdd.adv + summary[data-i18n=gui.adv_add] + p[data-i18n=gui.adv_hint] + div.row + input#mName[data-i18n-ph=gui.adv_name_ph] + input#mAddr[data-i18n-ph=gui.adv_addr_ph] + button#mAdd.mini[data-i18n=gui.add] + section#xaPage.page[data-open=false][data-i18n-aria=gui.page.agents] + header + h2[data-i18n=gui.page.agents] + div.work + div#xaBody.wrap + section#connPage.page[data-open=false][data-i18n-aria=gui.page.conn] + header + h2[data-i18n=gui.page.conn] + div.work + div#connBody.wrap + section#memPage.page[data-open=false][data-i18n-aria=gui.mem.hero] + header + h2[data-i18n=gui.nav.mem] + div.work + div#memBody.wrap + section#pbPage.page[data-open=false][data-i18n-aria=gui.nav.pb] + header + h2[data-i18n=gui.nav.pb] + div.work + div#pbBody.wrap + section#kbPage.page[data-open=false][data-i18n-aria=gui.nav.kb] + header + h2[data-i18n=gui.nav.kb] + div.work + div#kbBody.wrap + section#cronPage.page[data-open=false][data-i18n-aria=gui.page.cron] + header + h2[data-i18n=gui.page.cron] + div.work + div#cronBody.wrap + div#jobVeil.veil[data-open=false] + aside#detail.detail[data-open=false][data-i18n-aria=gui.cap_detail] + div.dpanel + header + b#dTitle + button#dClose.dx[data-i18n-aria=gui.close] + svg + path + div#dBody.body + div#setVeil.veil.setveil[data-open=false] + div#setModal.smodal[data-i18n-aria=gui.page.set] + nav#snav.snav + div.brandrow + span.wm[data-i18n=gui.page.set] + div#snavList.snavlist + div.sbody + header.shd + div.ttl + h3#setTitle + p#setSub.sub + button#setClose.icb[data-i18n-tip=gui.close][data-i18n-aria=gui.close] + svg + path + div#spanels.spanels + div#veil.veil[data-open=false] + div.sheet + header#cfTitle + div#cfBody.body + footer + button#cfNo.btn[data-i18n=gui.cancel] + button#cfYes.btn.bad + div#connVeil.veil[data-open=false] + div#menu.menu[data-open=false] + div#toasts.toasts + div.sbars + div + div#deskHost + div.tipp diff --git a/ui-web/scripts/codemod/baseline-dist-digest.txt b/ui-web/scripts/codemod/baseline-dist-digest.txt new file mode 100644 index 000000000..8a87d80a6 --- /dev/null +++ b/ui-web/scripts/codemod/baseline-dist-digest.txt @@ -0,0 +1 @@ +edc2c58fabb79dbd492cd5994ddab0e7aab088266ec082f4326d80ce769d1b88 dist/index.html diff --git a/ui-web/scripts/codemod/boot-snapshot.mjs b/ui-web/scripts/codemod/boot-snapshot.mjs new file mode 100644 index 000000000..29756ab33 --- /dev/null +++ b/ui-web/scripts/codemod/boot-snapshot.mjs @@ -0,0 +1,62 @@ +// Boots a built dist/index.html in happy-dom and prints the settled body +// class tree: tag, #id, .classes and [data-*] per element, nesting by +// indentation, no text. script and style elements are skipped so a change in +// how the page ships its script blocks cannot move the snapshot. +// +// Usage: node scripts/codemod/boot-snapshot.mjs [url] +import { readFileSync } from 'node:fs' +import { Window } from 'happy-dom' + +const TICKS = 40 +const dist = process.argv[2] +const url = process.argv[3] || 'http://127.0.0.1:18792/?stub=1' +if (!dist) { + console.error('usage: boot-snapshot.mjs [url]') + process.exit(2) +} +const html = readFileSync(dist, 'utf8') +const logs = [] +const win = new Window({ + url, + console: { + ...console, + log: (...a) => logs.push('log: ' + a.join(' ')), + warn: (...a) => logs.push('warn: ' + a.join(' ')), + error: (...a) => logs.push('error: ' + a.join(' ')), + info: () => {}, + debug: () => {}, + }, + settings: { + enableJavaScriptEvaluation: true, + suppressInsecureJavaScriptEnvironmentWarning: true, + disableJavaScriptFileLoading: true, + disableCSSFileLoading: true, + enableImageFileLoading: false, + disableComputedStyleRendering: true, + handleDisabledFileLoadingAsSuccess: true, + timer: { maxTimeout: 50, maxIntervalTime: 50, maxIntervalIterations: 2, preventTimerLoops: true }, + }, +}) +win.document.write(html) +for (let i = 0; i < TICKS; i++) await new Promise((r) => setTimeout(r, 25)) + +const SKIP = new Set(['script', 'style']) +const lines = [] +const walk = (n, d) => { + if (n.nodeType !== 1) return + const tag = n.tagName.toLowerCase() + if (SKIP.has(tag)) return + const attrs = [] + if (n.id) attrs.push('#' + n.id) + const cn = typeof n.className === 'string' ? n.className.trim() : '' + if (cn) attrs.push('.' + cn.split(/\s+/).join('.')) + for (const a of n.getAttributeNames ? n.getAttributeNames() : []) { + if (a.startsWith('data-')) attrs.push(`[${a}=${n.getAttribute(a)}]`) + } + lines.push(' '.repeat(d) + tag + attrs.join('')) + for (const c of n.children) walk(c, d + 1) +} +walk(win.document.body, 0) +process.stdout.write(lines.join('\n') + '\n') +if (process.env.BOOT_SNAPSHOT_LOG) process.stderr.write(logs.join('\n') + '\n') +process.exit(0) diff --git a/ui-web/scripts/codemod/census.mjs b/ui-web/scripts/codemod/census.mjs new file mode 100644 index 000000000..13e2217e6 --- /dev/null +++ b/ui-web/scripts/codemod/census.mjs @@ -0,0 +1,128 @@ +// Per-file census of every top-level-scope reference to a given set of names +// across the concatenated legacy layers. Reports read/write and whether the +// reference sits at module-evaluation time or inside a function. +// +// Usage: node scripts/codemod/census.mjs name1 name2 ... +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import ts from 'typescript' + +const UIWEB = join(dirname(fileURLToPath(import.meta.url)), '..', '..') +const SRC = join(UIWEB, 'src') +const py = readFileSync(join(UIWEB, 'build.py'), 'utf8') +const manifest = (n) => [...py.match(new RegExp(`_${n}_PARTS = \\[([\\s\\S]*?)\\]`))[1].matchAll(/"([^"]+)"/g)].map((x) => x[1]) +const LAYER_DIR = { seam: 'seam', demo: 'demo', live: 'live' } +function concat(dir, parts) { + let text = '' + const spans = [] + for (const p of parts) { + const t = readFileSync(join(SRC, LAYER_DIR[dir], p), 'utf8') + spans.push({ file: `${dir}/${p}`, start: text.length, end: text.length + t.length }) + text += t + } + if (text.endsWith('\n')) { + text = text.slice(0, -1) + spans[spans.length - 1].end -= 1 + } + return { text, spans } +} +const seam = concat('seam', manifest('SEAM')) +const demo = concat('demo', manifest('DEMO')) +const live = concat('live', manifest('LIVE')) +const off = seam.text.length + 1 +const layers = { + demo: { text: seam.text + '\n' + demo.text, spans: [...seam.spans, ...demo.spans.map((s) => ({ file: s.file, start: s.start + off, end: s.end + off }))] }, + live: { text: live.text, spans: live.spans }, +} +function bn(nm, out) { + if (!nm) return + if (ts.isIdentifier(nm)) out.add(nm.text) + else if (nm.elements) for (const e of nm.elements) { if (!ts.isOmittedExpression(e)) bn(e.name, out) } +} +function declaredIn(scope) { + const out = new Set() + if (ts.isFunctionLike(scope)) { + for (const p of scope.parameters || []) bn(p.name, out) + if (scope.name && ts.isIdentifier(scope.name) && (ts.isFunctionExpression(scope) || ts.isFunctionDeclaration(scope))) out.add(scope.name.text) + } + if (ts.isCatchClause(scope) && scope.variableDeclaration) bn(scope.variableDeclaration.name, out) + const stmts = ts.isBlock(scope) || ts.isSourceFile(scope) ? scope.statements : ts.isFunctionLike(scope) && scope.body && ts.isBlock(scope.body) ? scope.body.statements : ts.isCaseClause(scope) || ts.isDefaultClause(scope) ? scope.statements : null + if (stmts) for (const st of stmts) { + if (ts.isVariableStatement(st)) for (const d of st.declarationList.declarations) bn(d.name, out) + else if ((ts.isFunctionDeclaration(st) || ts.isClassDeclaration(st)) && st.name) out.add(st.name.text) + } + const i = scope.initializer + if ((ts.isForStatement(scope) || ts.isForOfStatement(scope) || ts.isForInStatement(scope)) && i && ts.isVariableDeclarationList(i)) for (const d of i.declarations) bn(d.name, out) + return out +} +const isScope = (n) => + ts.isFunctionLike(n) || ts.isBlock(n) || ts.isSourceFile(n) || ts.isCatchClause(n) || ts.isForStatement(n) || ts.isForOfStatement(n) || ts.isForInStatement(n) || ts.isCaseClause(n) || ts.isDefaultClause(n) + +const want = new Set(process.argv.slice(2)) +const rows = [] +for (const label of ['demo', 'live']) { + const { text, spans } = layers[label] + const sf = ts.createSourceFile(label, text, ts.ScriptTarget.ES2022, true, ts.ScriptKind.JS) + const fileOf = (n) => { const pos = n.getStart(sf); for (const s of spans) if (pos >= s.start && pos < s.end) return s.file; return '?' } + const lineIn = (n) => { const pos = n.getStart(sf); for (const s of spans) if (pos >= s.start && pos < s.end) { let c = 1; for (let i = s.start; i < pos; i++) if (text[i] === '\n') c++; return c } return 0 } + // For the live layer the whole concatenation is one IIFE, so its arrow body + // block IS the top scope. Taking the arrow instead (as the original reviewer + // scripts do) makes local() treat every top-level live name as shadowed and + // silently drop all live-layer references. + let topNode = sf + if (label === 'live') { let a = null; const f = (n) => { if (!a && ts.isArrowFunction(n)) a = n; else ts.forEachChild(n, f) }; f(sf.statements[0]); topNode = a.body } + const topStatements = label === 'live' ? topNode.statements : sf.statements + const declNodes = new Set() + for (const st of topStatements) { + if (ts.isVariableStatement(st)) for (const d of st.declarationList.declarations) { + const mark = (nm) => { if (ts.isIdentifier(nm)) declNodes.add(nm); else if (nm.elements) for (const e of nm.elements) e.name && mark(e.name) } + mark(d.name) + } else if ((ts.isFunctionDeclaration(st) || ts.isClassDeclaration(st)) && st.name) declNodes.add(st.name) + } + const cache = new Map() + const scopeNames = (n) => { if (!cache.has(n)) cache.set(n, declaredIn(n)); return cache.get(n) } + const local = (id) => { for (let p = id.parent; p; p = p.parent) { if (p === topNode || (label === 'demo' && ts.isSourceFile(p))) return false; if (isScope(p) && scopeNames(p).has(id.text)) return true } return false } + const inFn = (id) => { for (let p = id.parent; p && p !== topNode; p = p.parent) if (ts.isFunctionLike(p)) return true; return false } + const walk = (n) => { + if (ts.isIdentifier(n) && want.has(n.text)) { + const p = n.parent + const isProp = + (ts.isPropertyAccessExpression(p) && p.name === n) || + (ts.isPropertyAssignment(p) && p.name === n) || + (ts.isMethodDeclaration(p) && p.name === n) || + (ts.isBindingElement(p) && p.propertyName === n) || + (ts.isQualifiedName(p) && p.right === n) || + (ts.isGetAccessorDeclaration(p) && p.name === n) || + (ts.isSetAccessorDeclaration(p) && p.name === n) || + (ts.isLabeledStatement(p) && p.label === n) + const shorthand = ts.isShorthandPropertyAssignment(p) + if (!isProp && !local(n)) { + const decl = declNodes.has(n) + const ASSIGN = new Set([ts.SyntaxKind.EqualsToken, ts.SyntaxKind.PlusEqualsToken, ts.SyntaxKind.MinusEqualsToken, ts.SyntaxKind.QuestionQuestionEqualsToken, ts.SyntaxKind.BarBarEqualsToken, ts.SyntaxKind.AmpersandAmpersandEqualsToken]) + const write = ts.isBinaryExpression(p) && p.left === n && ASSIGN.has(p.operatorToken.kind) + const UPD = new Set([ts.SyntaxKind.PlusPlusToken, ts.SyntaxKind.MinusMinusToken]) + const update = (ts.isPrefixUnaryExpression(p) || ts.isPostfixUnaryExpression(p)) && p.operand === n && UPD.has(p.operator) + rows.push({ + file: fileOf(n), + line: lineIn(n), + name: n.text, + kind: decl ? 'DECL' : write ? 'write' : update ? 'update' : shorthand ? 'SHORTHAND' : 'read', + load: !inFn(n), + src: text.slice(text.lastIndexOf('\n', n.getStart(sf)) + 1, text.indexOf('\n', n.getStart(sf))).trim().slice(0, 120), + }) + } + } + ts.forEachChild(n, walk) + } + walk(sf) +} +const byName = new Map() +for (const r of rows) { if (!byName.has(r.name)) byName.set(r.name, []); byName.get(r.name).push(r) } +for (const [name, rs] of byName) { + console.log(`\n=== ${name}: ${rs.length} references ===`) + for (const r of rs) console.log(` ${r.file}:${r.line} ${r.kind}${r.load ? ' [eval-time]' : ''} ${r.src}`) +} +console.log(`\ntotal references: ${rows.length}`) +const missing = [...want].filter((w) => !byName.has(w)) +if (missing.length) console.log(`names with no reference found: ${JSON.stringify(missing)}`) diff --git a/ui-web/scripts/codemod/deps5.mjs b/ui-web/scripts/codemod/deps5.mjs new file mode 100644 index 000000000..b1049ac28 --- /dev/null +++ b/ui-web/scripts/codemod/deps5.mjs @@ -0,0 +1,94 @@ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import ts from 'typescript' +const UIWEB = join(dirname(fileURLToPath(import.meta.url)), '..', '..') +const SRC = join(UIWEB, 'src') +const py = readFileSync(join(UIWEB, 'build.py'), 'utf8') +const manifest = (n) => [...py.match(new RegExp(`_${n}_PARTS = \\[([\\s\\S]*?)\\]`))[1].matchAll(/"([^"]+)"/g)].map((x) => x[1]) +// exact reproduction of build.py _concat: join texts, strip ONE trailing newline +function concat(dir, parts) { + let text = ''; const spans = [] + for (const p of parts) { const t = readFileSync(join(SRC, dir, p), 'utf8'); spans.push({ file: `${dir}/${p}`, start: text.length, end: text.length + t.length }); text += t } + if (text.endsWith('\n')) { text = text.slice(0, -1); spans[spans.length - 1].end -= 1 } + return { text, spans } +} +const seam = concat('seam', manifest('SEAM')), demo = concat('demo', manifest('DEMO')), live = concat('live', manifest('LIVE')) +const demoText = seam.text + '\n' + demo.text +const off = seam.text.length + 1 +const demoSpans = [...seam.spans, ...demo.spans.map((s) => ({ file: s.file, start: s.start + off, end: s.end + off }))] +const layers = { demo: { text: demoText, spans: demoSpans }, live: { text: live.text, spans: live.spans } } +function bindingNames(nm, out) { if (!nm) return; if (ts.isIdentifier(nm)) out.add(nm.text); else if (nm.elements) for (const e of nm.elements) { if (ts.isOmittedExpression(e)) continue; bindingNames(e.name, out) } } +function declaredIn(scope) { const out = new Set() + if (ts.isFunctionLike(scope)) { for (const p of scope.parameters || []) bindingNames(p.name, out); if (scope.name && ts.isIdentifier(scope.name) && (ts.isFunctionExpression(scope) || ts.isFunctionDeclaration(scope))) out.add(scope.name.text) } + if (ts.isCatchClause(scope) && scope.variableDeclaration) bindingNames(scope.variableDeclaration.name, out) + const stmts = ts.isBlock(scope) || ts.isSourceFile(scope) ? scope.statements : (ts.isFunctionLike(scope) && scope.body && ts.isBlock(scope.body)) ? scope.body.statements : (ts.isCaseClause(scope) || ts.isDefaultClause(scope)) ? scope.statements : null + if (stmts) for (const st of stmts) { if (ts.isVariableStatement(st)) for (const d of st.declarationList.declarations) bindingNames(d.name, out); else if ((ts.isFunctionDeclaration(st) || ts.isClassDeclaration(st)) && st.name) out.add(st.name.text) } + const i = scope.initializer + if ((ts.isForStatement(scope) || ts.isForOfStatement(scope) || ts.isForInStatement(scope)) && i && ts.isVariableDeclarationList(i)) for (const d of i.declarations) bindingNames(d.name, out) + return out } +const isScope = (n) => ts.isFunctionLike(n) || ts.isBlock(n) || ts.isSourceFile(n) || ts.isCatchClause(n) || ts.isForStatement(n) || ts.isForOfStatement(n) || ts.isForInStatement(n) || ts.isCaseClause(n) || ts.isDefaultClause(n) +function analyse(label) { const { text, spans } = layers[label] + const sf = ts.createSourceFile(label, text, ts.ScriptTarget.ES2022, true, ts.ScriptKind.JS) + const fileOf = (n) => { const pos = n.getStart(sf); for (const s of spans) if (pos >= s.start && pos < s.end) return s.file; return '?' } + const lineOf = (n) => sf.getLineAndCharacterOfPosition(n.getStart(sf)).line + 1 + const lineInFile = (n) => { const pos = n.getStart(sf); for (const s of spans) if (pos >= s.start && pos < s.end) { let c = 1; for (let i = s.start; i < pos; i++) if (text[i] === '\n') c++; return c } return 0 } + let topNode = sf + // The live layer is one IIFE, so its arrow BODY BLOCK is the top scope. + // Stopping at the arrow leaves that block in local()'s walk, which then + // reports every top-level live name as shadowed and drops all live-layer + // references (the original reviewer copy had this bug: 411 -> 693 pairs). + if (label === 'live') { let a = null; const f = (n) => { if (!a && ts.isArrowFunction(n)) a = n; else ts.forEachChild(n, f) }; f(sf.statements[0]); topNode = a.body } + const topStatements = label === 'live' ? topNode.statements : sf.statements + const decls = new Map(); const declNodes = new Set() + for (const st of topStatements) { if (ts.isVariableStatement(st)) for (const d of st.declarationList.declarations) { + const s = new Set(); bindingNames(d.name, s) + const mark = (nm) => { if (ts.isIdentifier(nm)) declNodes.add(nm); else if (nm.elements) for (const e of nm.elements) e.name && mark(e.name) } + mark(d.name); for (const n of s) decls.set(n, fileOf(d)) + } else if ((ts.isFunctionDeclaration(st) || ts.isClassDeclaration(st)) && st.name) { decls.set(st.name.text, fileOf(st.name)); declNodes.add(st.name) } } + const cache = new Map(); const scopeNames = (n) => { if (!cache.has(n)) cache.set(n, declaredIn(n)); return cache.get(n) } + const local = (id) => { for (let p = id.parent; p; p = p.parent) { if (p === topNode || (label === 'demo' && ts.isSourceFile(p))) return false; if (isScope(p) && scopeNames(p).has(id.text)) return true } return false } + const inFn = (id) => { for (let p = id.parent; p && p !== topNode; p = p.parent) if (ts.isFunctionLike(p)) return true; return false } + const refs = [] + const walk = (n) => { if (ts.isIdentifier(n)) { const p = n.parent + const isProp = (ts.isPropertyAccessExpression(p) && p.name === n) || (ts.isPropertyAssignment(p) && p.name === n) || (ts.isMethodDeclaration(p) && p.name === n) || (ts.isBindingElement(p) && p.propertyName === n) || (ts.isQualifiedName(p) && p.right === n) || (ts.isGetAccessorDeclaration(p) && p.name === n) || (ts.isSetAccessorDeclaration(p) && p.name === n) || (ts.isLabeledStatement(p) && p.label === n) + if (!isProp && !declNodes.has(n) && !local(n)) refs.push({ name: n.text, file: fileOf(n), line: lineInFile(n), load: !inFn(n) }) } + ts.forEachChild(n, walk) } + walk(sf); return { decls, refs } } +const D = analyse('demo'), L = analyse('live') +const all = new Map([...D.decls, ...[...L.decls].filter(([k]) => !D.decls.has(k))]) +const order = [...manifest('SEAM').map((p)=>'seam/'+p), ...manifest('DEMO').map((p)=>'demo/'+p), ...manifest('LIVE').map((p)=>'live/'+p)] +const idx = new Map(order.map((f, i) => [f, i])) +const perFile = new Map(); const edges = new Map(); let cross = 0; const loadRefs = [] +const borrowed = new Set(); const backRefs = [] +for (const [lbl, A] of [['demo', D], ['live', L]]) for (const r of A.refs) { + const to = all.get(r.name); if (!to || to === r.file) continue + cross++ + if (!perFile.has(r.file)) perFile.set(r.file, new Set()); perFile.get(r.file).add(r.name) + if (!edges.has(r.file)) edges.set(r.file, new Set()); edges.get(r.file).add(to) + if (lbl === 'live' && !to.startsWith('live/')) borrowed.add(r.name) + if (lbl === 'demo' && to.startsWith('live/')) backRefs.push(`${r.name} @ ${r.file}:${r.line}`) + if (r.load) loadRefs.push({ ...r, to }) +} +let total = 0; for (const [, s] of perFile) total += s.size +console.log(`declarations: demo layer ${D.decls.size}, live layer ${L.decls.size}, overlap ${[...L.decls.keys()].filter((k)=>D.decls.has(k)).length}`) +console.log(`cross-file identifier references: ${cross}`) +console.log(`distinct (file, foreign name) pairs = explicit import bindings needed: ${total} across ${perFile.size} files`) +console.log(`live-layer names borrowed from the demo/seam layer: ${borrowed.size}`) +console.log(`demo-layer reads of live-declared names: ${backRefs.length} ${JSON.stringify(backRefs)}`) +console.log(`file-level edges: ${[...edges.values()].reduce((a,s)=>a+s.size,0)}`) +const back = []; for (const [f, tos] of edges) for (const t of tos) if (idx.get(t) > idx.get(f)) back.push(`${f} -> ${t}`) +console.log(`forward edges (file uses a name declared LATER in manifest order): ${back.length}`) +console.log(back.map((x)=>' '+x).join('\n')) +const ids = new Map(); const low = new Map(); const on = new Set(); const stk = []; let c = 0; const sccs = [] +const strong = (v) => { ids.set(v,c); low.set(v,c); c++; stk.push(v); on.add(v) + for (const w of edges.get(v) || []) { if (!ids.has(w)) { strong(w); low.set(v, Math.min(low.get(v), low.get(w))) } else if (on.has(w)) low.set(v, Math.min(low.get(v), ids.get(w))) } + if (low.get(v) === ids.get(v)) { const comp = []; let w; do { w = stk.pop(); on.delete(w); comp.push(w) } while (w !== v); if (comp.length > 1) sccs.push(comp) } } +for (const f of order) if (!ids.has(f)) strong(f) +console.log(`file-level cycles: ${sccs.length}`); for (const s of sccs) console.log(` [${s.length}] ${s.sort().join(', ')}`) +const trivial = new Set(['$','mk','esc','DS']) +const hard = loadRefs.filter((r) => !trivial.has(r.name)) +console.log(`\nmodule-evaluation-time cross-file reads: ${loadRefs.length} total, ${hard.length} outside {$, mk, esc, DS}`) +for (const r of hard) console.log(` ${r.file}:${r.line} ${r.name} <- ${r.to}`) +console.log('\n-- files by import bindings needed --') +for (const [f, s] of [...perFile].sort((a,b)=>b[1].size-a[1].size)) console.log(`${String(s.size).padStart(4)} ${f}`) diff --git a/ui-web/scripts/codemod/freeglobals.mjs b/ui-web/scripts/codemod/freeglobals.mjs new file mode 100644 index 000000000..ef35dcec0 --- /dev/null +++ b/ui-web/scripts/codemod/freeglobals.mjs @@ -0,0 +1,58 @@ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import ts from 'typescript' +const UIWEB = join(dirname(fileURLToPath(import.meta.url)), '..', '..') +const SRC = join(UIWEB, 'src') +const py = readFileSync(join(UIWEB, 'build.py'), 'utf8') +const manifest = (n) => [...py.match(new RegExp(`_${n}_PARTS = \\[([\\s\\S]*?)\\]`))[1].matchAll(/"([^"]+)"/g)].map((x) => x[1]) +function concat(dir, parts) { let text=''; const spans=[] + for (const p of parts) { const t=readFileSync(join(SRC,dir,p),'utf8'); spans.push({file:`${dir}/${p}`,start:text.length,end:text.length+t.length}); text+=t } + if (text.endsWith('\n')) { text=text.slice(0,-1); spans[spans.length-1].end-=1 } + return {text,spans} } +const seam=concat('seam',manifest('SEAM')), demo=concat('demo',manifest('DEMO')), live=concat('live',manifest('LIVE')) +const off=seam.text.length+1 +const layers={ demo:{text:seam.text+'\n'+demo.text,spans:[...seam.spans,...demo.spans.map(s=>({file:s.file,start:s.start+off,end:s.end+off}))]}, live:{text:live.text,spans:live.spans} } +function bn(nm,out){ if(!nm)return; if(ts.isIdentifier(nm))out.add(nm.text); else if(nm.elements)for(const e of nm.elements){if(ts.isOmittedExpression(e))continue;bn(e.name,out)} } +function declaredIn(scope){ const out=new Set() + if(ts.isFunctionLike(scope)){for(const p of scope.parameters||[])bn(p.name,out); if(scope.name&&ts.isIdentifier(scope.name)&&(ts.isFunctionExpression(scope)||ts.isFunctionDeclaration(scope)))out.add(scope.name.text)} + if(ts.isCatchClause(scope)&&scope.variableDeclaration)bn(scope.variableDeclaration.name,out) + const stmts=ts.isBlock(scope)||ts.isSourceFile(scope)?scope.statements:(ts.isFunctionLike(scope)&&scope.body&&ts.isBlock(scope.body))?scope.body.statements:(ts.isCaseClause(scope)||ts.isDefaultClause(scope))?scope.statements:null + if(stmts)for(const st of stmts){if(ts.isVariableStatement(st))for(const d of st.declarationList.declarations)bn(d.name,out); else if((ts.isFunctionDeclaration(st)||ts.isClassDeclaration(st))&&st.name)out.add(st.name.text)} + const i=scope.initializer + if((ts.isForStatement(scope)||ts.isForOfStatement(scope)||ts.isForInStatement(scope))&&i&&ts.isVariableDeclarationList(i))for(const d of i.declarations)bn(d.name,out) + return out } +const isScope=(n)=>ts.isFunctionLike(n)||ts.isBlock(n)||ts.isSourceFile(n)||ts.isCatchClause(n)||ts.isForStatement(n)||ts.isForOfStatement(n)||ts.isForInStatement(n)||ts.isCaseClause(n)||ts.isDefaultClause(n) +const STD = new Set(['window','document','console','Math','JSON','Object','Array','String','Number','Boolean','Date','Promise','Set','Map','WeakMap','WeakSet','Error','TypeError','RangeError','Symbol','RegExp','Intl','URL','URLSearchParams','navigator','location','history','localStorage','sessionStorage','fetch','setTimeout','clearTimeout','setInterval','clearInterval','queueMicrotask','requestAnimationFrame','cancelAnimationFrame','MutationObserver','ResizeObserver','IntersectionObserver','Event','CustomEvent','MouseEvent','KeyboardEvent','Blob','File','FileReader','FormData','Headers','Request','Response','WebSocket','TextEncoder','TextDecoder','atob','btoa','matchMedia','getComputedStyle','alert','confirm','prompt','undefined','NaN','Infinity','parseInt','parseFloat','isNaN','isFinite','encodeURIComponent','decodeURIComponent','structuredClone','AbortController','Notification','crypto','performance','Uint8Array','ArrayBuffer','DataView','Image','HTMLElement','Node','NodeList','Element','DocumentFragment','globalThis','self','top','addEventListener','removeEventListener','scrollTo','open','close','DOMParser','XMLHttpRequest','CSS','Intl','ClipboardItem','requestIdleCallback','reportError','WeakRef','Proxy','Reflect','BigInt','eval','arguments','SVGElement','customElements','IntersectionObserverEntry','CSSStyleSheet','getSelection','visualViewport','screen','devicePixelRatio','innerWidth','innerHeight','scrollX','scrollY','frameElement','parent','postMessage','onerror','onunhandledrejection','import','meta','require','module','exports','process']) +const info={} +for (const label of ['demo','live']) { + const {text,spans}=layers[label] + const sf=ts.createSourceFile(label,text,ts.ScriptTarget.ES2022,true,ts.ScriptKind.JS) + const fileOf=(n)=>{const pos=n.getStart(sf);for(const s of spans)if(pos>=s.start&&pos{const pos=n.getStart(sf);for(const s of spans)if(pos>=s.start&&pos{if(!a&&ts.isArrowFunction(n))a=n;else ts.forEachChild(n,f)};f(sf.statements[0]);topNode=a} + const topStatements=label==='live'?topNode.body.statements:sf.statements + const decls=new Set(); const declNodes=new Set() + for(const st of topStatements){ if(ts.isVariableStatement(st))for(const d of st.declarationList.declarations){const s=new Set();bn(d.name,s);const mark=(nm)=>{if(ts.isIdentifier(nm))declNodes.add(nm);else if(nm.elements)for(const e of nm.elements)e.name&&mark(e.name)};mark(d.name);for(const n of s)decls.add(n)} + else if((ts.isFunctionDeclaration(st)||ts.isClassDeclaration(st))&&st.name){decls.add(st.name.text);declNodes.add(st.name)} } + info[label]={sf,fileOf,lineIn,decls,declNodes,topNode,isScope} +} +const allDecls=new Set([...info.demo.decls,...info.live.decls]) +const results=[] +for (const label of ['demo','live']) { + const {sf,fileOf,lineIn,declNodes,topNode}=info[label] + const cache=new Map(); const scopeNames=(n)=>{if(!cache.has(n))cache.set(n,declaredIn(n));return cache.get(n)} + const local=(id)=>{for(let p=id.parent;p;p=p.parent){if(p===topNode||(label==='demo'&&ts.isSourceFile(p)))return false;if(isScope(p)&&scopeNames(p).has(id.text))return true}return false} + const inFn=(id)=>{for(let p=id.parent;p&&p!==topNode;p=p.parent)if(ts.isFunctionLike(p))return true;return false} + const walk=(n)=>{ if(ts.isIdentifier(n)){const p=n.parent + const isProp=(ts.isPropertyAccessExpression(p)&&p.name===n)||(ts.isPropertyAssignment(p)&&p.name===n)||(ts.isMethodDeclaration(p)&&p.name===n)||(ts.isBindingElement(p)&&p.propertyName===n)||(ts.isQualifiedName(p)&&p.right===n)||(ts.isGetAccessorDeclaration(p)&&p.name===n)||(ts.isSetAccessorDeclaration(p)&&p.name===n)||(ts.isLabeledStatement(p)&&p.label===n)||ts.isShorthandPropertyAssignment(p) + const isWriteTarget=(ts.isBinaryExpression(p)&&p.left===n&&p.operatorToken.kind===ts.SyntaxKind.EqualsToken) + if(!isProp&&!declNodes.has(n)&&!local(n)&&!allDecls.has(n.text)&&!STD.has(n.text)&&!isWriteTarget&&!inFn(n)) results.push({label,file:fileOf(n),line:lineIn(n),name:n.text}) } + ts.forEachChild(n,walk) } + walk(sf) +} +console.log('=== eval-time reads of names declared NOWHERE in the legacy layers (i.e. page globals: main.tsx window.X, RavenIslands, etc.) ===') +for(const r of results) console.log(` ${r.file}:${r.line} ${r.name}`) +const names=[...new Set(results.map(r=>r.name))].sort() +console.log(`\ndistinct: ${names.length} ${JSON.stringify(names)}`) diff --git a/ui-web/scripts/codemod/names.mjs b/ui-web/scripts/codemod/names.mjs new file mode 100644 index 000000000..8182ba537 --- /dev/null +++ b/ui-web/scripts/codemod/names.mjs @@ -0,0 +1,45 @@ +// Prints every top-level declared name in the legacy layers, with its owner +// file. Used to pick collision-free names for new shared-state containers. +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import ts from 'typescript' + +const UIWEB = join(dirname(fileURLToPath(import.meta.url)), '..', '..') +const SRC = join(UIWEB, 'src') +const py = readFileSync(join(UIWEB, 'build.py'), 'utf8') +const manifest = (n) => [...py.match(new RegExp(`_${n}_PARTS = \\[([\\s\\S]*?)\\]`))[1].matchAll(/"([^"]+)"/g)].map((x) => x[1]) +const files = [...manifest('SEAM').map((p) => ['seam', p]), ...manifest('DEMO').map((p) => ['demo', p]), ...manifest('LIVE').map((p) => ['live', p])] +function bn(nm, out) { + if (!nm) return + if (ts.isIdentifier(nm)) out.add(nm.text) + else if (nm.elements) for (const e of nm.elements) { if (!ts.isOmittedExpression(e)) bn(e.name, out) } +} +const owner = new Map() +for (const [dir, name] of files) { + const text = readFileSync(join(SRC, dir, name), 'utf8') + const sf = ts.createSourceFile(name, text, ts.ScriptTarget.ES2022, true, ts.ScriptKind.JS) + let stmts = sf.statements + if (dir === 'live' && stmts.length && ts.isExpressionStatement(stmts[0])) { + let a = null + const f = (n) => { if (!a && ts.isArrowFunction(n)) a = n; else ts.forEachChild(n, f) } + f(stmts[0]) + if (a && a.body && ts.isBlock(a.body)) stmts = [...a.body.statements, ...stmts.slice(1)] + } + const out = new Set() + for (const st of stmts) { + if (ts.isVariableStatement(st)) for (const d of st.declarationList.declarations) bn(d.name, out) + else if ((ts.isFunctionDeclaration(st) || ts.isClassDeclaration(st)) && st.name) out.add(st.name.text) + } + for (const n of out) { + if (owner.has(n)) console.log(`DUPLICATE ${n}: ${owner.get(n)} and ${dir}/${name}`) + else owner.set(n, `${dir}/${name}`) + } +} +const probe = process.argv.slice(2) +if (probe.length) { + for (const p of probe) console.log(owner.has(p) ? `TAKEN ${p} -> ${owner.get(p)}` : `free ${p}`) +} else { + console.log(`top-level declared names: ${owner.size}`) + for (const [n, f] of [...owner].sort()) console.log(` ${n} ${f}`) +} diff --git a/ui-web/scripts/codemod/order.mjs b/ui-web/scripts/codemod/order.mjs new file mode 100644 index 000000000..66ff6985f --- /dev/null +++ b/ui-web/scripts/codemod/order.mjs @@ -0,0 +1,87 @@ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import ts from 'typescript' +const UIWEB = join(dirname(fileURLToPath(import.meta.url)), '..', '..') +const SRC = join(UIWEB, 'src') +const py = readFileSync(join(UIWEB, 'build.py'), 'utf8') +const manifest = (n) => [...py.match(new RegExp(`_${n}_PARTS = \\[([\\s\\S]*?)\\]`))[1].matchAll(/"([^"]+)"/g)].map((x) => x[1]) +function concat(dir, parts) { + let text = ''; const spans = [] + for (const p of parts) { const t = readFileSync(join(SRC, dir, p), 'utf8'); spans.push({ file: `${dir}/${p}`, start: text.length, end: text.length + t.length }); text += t } + if (text.endsWith('\n')) { text = text.slice(0, -1); spans[spans.length - 1].end -= 1 } + return { text, spans } +} +const seam = concat('seam', manifest('SEAM')), demo = concat('demo', manifest('DEMO')), live = concat('live', manifest('LIVE')) +const off = seam.text.length + 1 +const layers = { + demo: { text: seam.text + '\n' + demo.text, spans: [...seam.spans, ...demo.spans.map((s) => ({ file: s.file, start: s.start + off, end: s.end + off }))] }, + live: { text: live.text, spans: live.spans }, +} +function bindingNames(nm, out) { if (!nm) return; if (ts.isIdentifier(nm)) out.add(nm.text); else if (nm.elements) for (const e of nm.elements) { if (ts.isOmittedExpression(e)) continue; bindingNames(e.name, out) } } +function declaredIn(scope) { const out = new Set() + if (ts.isFunctionLike(scope)) { for (const p of scope.parameters || []) bindingNames(p.name, out); if (scope.name && ts.isIdentifier(scope.name) && (ts.isFunctionExpression(scope) || ts.isFunctionDeclaration(scope))) out.add(scope.name.text) } + if (ts.isCatchClause(scope) && scope.variableDeclaration) bindingNames(scope.variableDeclaration.name, out) + const stmts = ts.isBlock(scope) || ts.isSourceFile(scope) ? scope.statements : (ts.isFunctionLike(scope) && scope.body && ts.isBlock(scope.body)) ? scope.body.statements : (ts.isCaseClause(scope) || ts.isDefaultClause(scope)) ? scope.statements : null + if (stmts) for (const st of stmts) { if (ts.isVariableStatement(st)) for (const d of st.declarationList.declarations) bindingNames(d.name, out); else if ((ts.isFunctionDeclaration(st) || ts.isClassDeclaration(st)) && st.name) out.add(st.name.text) } + const i = scope.initializer + if ((ts.isForStatement(scope) || ts.isForOfStatement(scope) || ts.isForInStatement(scope)) && i && ts.isVariableDeclarationList(i)) for (const d of i.declarations) bindingNames(d.name, out) + return out } +const isScope = (n) => ts.isFunctionLike(n) || ts.isBlock(n) || ts.isSourceFile(n) || ts.isCatchClause(n) || ts.isForStatement(n) || ts.isForOfStatement(n) || ts.isForInStatement(n) || ts.isCaseClause(n) || ts.isDefaultClause(n) +function analyse(label) { const { text, spans } = layers[label] + const sf = ts.createSourceFile(label, text, ts.ScriptTarget.ES2022, true, ts.ScriptKind.JS) + const fileOf = (n) => { const pos = n.getStart(sf); for (const s of spans) if (pos >= s.start && pos < s.end) return s.file; return '?' } + const lineInFile = (n) => { const pos = n.getStart(sf); for (const s of spans) if (pos >= s.start && pos < s.end) { let c = 1; for (let i = s.start; i < pos; i++) if (text[i] === '\n') c++; return c } return 0 } + let topNode = sf + // See deps5.mjs: the live IIFE's arrow BODY BLOCK is the top scope. + if (label === 'live') { let a = null; const f = (n) => { if (!a && ts.isArrowFunction(n)) a = n; else ts.forEachChild(n, f) }; f(sf.statements[0]); topNode = a.body } + const topStatements = label === 'live' ? topNode.statements : sf.statements + const decls = new Map(); const declNodes = new Set() + for (const st of topStatements) { if (ts.isVariableStatement(st)) for (const d of st.declarationList.declarations) { + const s = new Set(); bindingNames(d.name, s) + const mark = (nm) => { if (ts.isIdentifier(nm)) declNodes.add(nm); else if (nm.elements) for (const e of nm.elements) e.name && mark(e.name) } + mark(d.name); for (const n of s) decls.set(n, fileOf(d)) + } else if ((ts.isFunctionDeclaration(st) || ts.isClassDeclaration(st)) && st.name) { decls.set(st.name.text, fileOf(st.name)); declNodes.add(st.name) } } + const cache = new Map(); const scopeNames = (n) => { if (!cache.has(n)) cache.set(n, declaredIn(n)); return cache.get(n) } + const local = (id) => { for (let p = id.parent; p; p = p.parent) { if (p === topNode || (label === 'demo' && ts.isSourceFile(p))) return false; if (isScope(p) && scopeNames(p).has(id.text)) return true } return false } + const inFn = (id) => { for (let p = id.parent; p && p !== topNode; p = p.parent) if (ts.isFunctionLike(p)) return true; return false } + const refs = [] + const walk = (n) => { if (ts.isIdentifier(n)) { const p = n.parent + const isProp = (ts.isPropertyAccessExpression(p) && p.name === n) || (ts.isPropertyAssignment(p) && p.name === n) || (ts.isMethodDeclaration(p) && p.name === n) || (ts.isBindingElement(p) && p.propertyName === n) || (ts.isQualifiedName(p) && p.right === n) || (ts.isGetAccessorDeclaration(p) && p.name === n) || (ts.isSetAccessorDeclaration(p) && p.name === n) || (ts.isLabeledStatement(p) && p.label === n) || ts.isShorthandPropertyAssignment(p) && false + if (!isProp && !declNodes.has(n) && !local(n)) refs.push({ name: n.text, file: fileOf(n), line: lineInFile(n), load: !inFn(n) }) } + ts.forEachChild(n, walk) } + walk(sf); return { decls, refs } } +const D = analyse('demo'), L = analyse('live') +const all = new Map([...D.decls, ...[...L.decls].filter(([k]) => !D.decls.has(k))]) +const order = [...manifest('SEAM').map((p)=>'seam/'+p), ...manifest('DEMO').map((p)=>'demo/'+p), ...manifest('LIVE').map((p)=>'live/'+p)] +// ---------- build import graph: file -> set of owner files it needs ---------- +const edges = new Map(order.map((f)=>[f, new Set()])) +for (const A of [D, L]) for (const r of A.refs) { const to = all.get(r.name); if (!to || to === r.file) continue; edges.get(r.file).add(to) } +// ---------- simulate ES module evaluation: root index.js imports order[] ---------- +const state = new Map(); const evalOrder = [] +function evaluate(f) { + if (state.get(f)) return + state.set(f, 'evaluating') + for (const dep of [...edges.get(f)].sort((a,b)=>order.indexOf(a)-order.indexOf(b))) if (!state.get(dep)) evaluate(dep) + state.set(f, 'done'); evalOrder.push(f) +} +for (const f of order) evaluate(f) +console.log('=== simulated ES module evaluation order (deps in manifest order inside each file) ===') +evalOrder.forEach((f,i)=>console.log(String(i+1).padStart(3)+' '+f+(order.indexOf(f)===i?'':' [manifest pos '+(order.indexOf(f)+1)+']'))) +let moved = 0 +for (let i=0;i[f,i])) +const inversions = [] +for (let i=0;ipos.get(order[j])) inversions.push([order[i],order[j]]) +console.log(`ordered pairs that invert (A before B today, B before A under ESM): ${inversions.length}`) +console.log(inversions.slice(0,400).map(([a,b])=>` ${a} <-> ${b}`).join('\n')) +console.log('\n=== edges out of demo/130-settings.js ===') +console.log([...edges.get('demo/130-settings.js')].join(', ')) +console.log('=== who imports demo/050-rail.js ===') +for (const [f,s] of edges) if (s.has('demo/050-rail.js')) console.log(' '+f) +console.log('=== edges out of demo/152-skills.js ===') +console.log([...edges.get('demo/152-skills.js')].join(', ')) +console.log('=== edges out of demo/120-capabilities.js ===') +console.log([...edges.get('demo/120-capabilities.js')].join(', ')) diff --git a/ui-web/scripts/codemod/sideeffects.mjs b/ui-web/scripts/codemod/sideeffects.mjs new file mode 100644 index 000000000..d5045dc5a --- /dev/null +++ b/ui-web/scripts/codemod/sideeffects.mjs @@ -0,0 +1,32 @@ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import ts from 'typescript' +const UIWEB = join(dirname(fileURLToPath(import.meta.url)), '..', '..') +const SRC = join(UIWEB, 'src') +const py = readFileSync(join(UIWEB, 'build.py'), 'utf8') +const manifest = (n) => [...py.match(new RegExp(`_${n}_PARTS = \\[([\\s\\S]*?)\\]`))[1].matchAll(/"([^"]+)"/g)].map((x) => x[1]) +const files = [...manifest('SEAM').map((p)=>['seam',p]), ...manifest('DEMO').map((p)=>['demo',p]), ...manifest('LIVE').map((p)=>['live',p])] +for (const [dir,name] of files) { + const text = readFileSync(join(SRC,dir,name),'utf8') + const sf = ts.createSourceFile(name, text, ts.ScriptTarget.ES2022, true, ts.ScriptKind.JS) + // for live parts, the file is a fragment of one IIFE; parse anyway and take + // statements at depth 0 (fragments parse as statements after error recovery) + const out = [] + const line = (n)=>sf.getLineAndCharacterOfPosition(n.getStart(sf)).line+1 + for (const st of sf.statements) { + if (ts.isFunctionDeclaration(st) || ts.isClassDeclaration(st)) continue + if (ts.isVariableStatement(st)) { + // only flag initialisers that call something / are IIFEs + for (const d of st.declarationList.declarations) { + const i = d.initializer + if (i && (ts.isCallExpression(i) || (ts.isParenthesizedExpression(i)))) out.push(`${line(d)} var-init call: ${text.slice(d.getStart(sf), Math.min(d.end, d.getStart(sf)+90)).replace(/\s+/g,' ')}`) + } + continue + } + if (ts.isExpressionStatement(st) || ts.isBlock(st) || ts.isIfStatement(st) || ts.isForStatement(st) || ts.isForOfStatement(st) || ts.isTryStatement(st) || ts.isSwitchStatement(st) || ts.isLabeledStatement(st)) { + out.push(`${line(st)} ${text.slice(st.getStart(sf), Math.min(st.end, st.getStart(sf)+110)).replace(/\s+/g,' ')}`) + } + } + if (out.length) { console.log(`\n--- ${dir}/${name} : ${out.length} eval-time statements ---`); for (const o of out) console.log(' '+o) } +} diff --git a/ui-web/scripts/codemod/writes.mjs b/ui-web/scripts/codemod/writes.mjs new file mode 100644 index 000000000..1d0a65414 --- /dev/null +++ b/ui-web/scripts/codemod/writes.mjs @@ -0,0 +1,58 @@ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import ts from 'typescript' +const UIWEB = join(dirname(fileURLToPath(import.meta.url)), '..', '..') +const SRC = join(UIWEB, 'src') +const py = readFileSync(join(UIWEB, 'build.py'), 'utf8') +const manifest = (n) => [...py.match(new RegExp(`_${n}_PARTS = \\[([\\s\\S]*?)\\]`))[1].matchAll(/"([^"]+)"/g)].map((x) => x[1]) +function concat(dir, parts) { let text = ''; const spans = [] + for (const p of parts) { const t = readFileSync(join(SRC, dir, p), 'utf8'); spans.push({ file: `${dir}/${p}`, start: text.length, end: text.length + t.length }); text += t } + if (text.endsWith('\n')) { text = text.slice(0, -1); spans[spans.length-1].end -= 1 } + return { text, spans } } +const seam = concat('seam', manifest('SEAM')), demo = concat('demo', manifest('DEMO')), live = concat('live', manifest('LIVE')) +const off = seam.text.length + 1 +const layers = { + demo: { text: seam.text + '\n' + demo.text, spans: [...seam.spans, ...demo.spans.map((s)=>({file:s.file,start:s.start+off,end:s.end+off}))] }, + live: { text: live.text, spans: live.spans }, +} +function bn(nm, out) { if (!nm) return; if (ts.isIdentifier(nm)) out.add(nm.text); else if (nm.elements) for (const e of nm.elements) { if (ts.isOmittedExpression(e)) continue; bn(e.name, out) } } +const info = {} +for (const label of ['demo','live']) { + const { text, spans } = layers[label] + const sf = ts.createSourceFile(label, text, ts.ScriptTarget.ES2022, true, ts.ScriptKind.JS) + const fileOf = (pos) => { for (const s of spans) if (pos >= s.start && pos < s.end) return s.file; return '?' } + const lineIn = (pos) => { for (const s of spans) if (pos >= s.start && pos < s.end) { let c=1; for (let i=s.start;i{ if(!a && ts.isArrowFunction(n)) a=n; else ts.forEachChild(n,f) }; f(sf.statements[0]); topNode=a } + const topStatements = label === 'live' ? topNode.body.statements : sf.statements + const decls = new Map() + const kinds = new Map() + for (const st of topStatements) { + if (ts.isVariableStatement(st)) { const k = st.declarationList.flags & ts.NodeFlags.Const ? 'const' : st.declarationList.flags & ts.NodeFlags.Let ? 'let' : 'var' + for (const d of st.declarationList.declarations) { const s=new Set(); bn(d.name,s); for (const n of s) { decls.set(n, fileOf(d.getStart(sf))); kinds.set(n, k) } } } + else if (ts.isFunctionDeclaration(st) && st.name) { decls.set(st.name.text, fileOf(st.name.getStart(sf))); kinds.set(st.name.text, 'function') } + else if (ts.isClassDeclaration(st) && st.name) { decls.set(st.name.text, fileOf(st.name.getStart(sf))); kinds.set(st.name.text, 'class') } + } + info[label] = { sf, fileOf, lineIn, decls, kinds } +} +const all = new Map([...info.demo.decls, ...[...info.live.decls].filter(([k])=>!info.demo.decls.has(k))]) +const kindOf = new Map([...info.demo.kinds, ...[...info.live.kinds].filter(([k])=>!info.demo.kinds.has(k))]) +const out = [] +for (const label of ['demo','live']) { + const { sf, fileOf, lineIn } = info[label] + const walk = (n) => { + if (ts.isBinaryExpression(n) && n.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isIdentifier(n.left)) { + const name = n.left.text, to = all.get(name) + if (to) { const from = fileOf(n.left.getStart(sf)); if (from !== to) out.push({ from, line: lineIn(n.left.getStart(sf)), name, to, kind: kindOf.get(name) }) } + } + ts.forEachChild(n, walk) + } + walk(sf) +} +console.log(`cross-file assignments to a binding declared in another concat file: ${out.length}`) +for (const w of out) console.log(` ${w.from}:${w.line} ${w.name} = ... (declared ${w.kind} in ${w.to})`) +const names = new Set(out.map((w)=>w.name)) +console.log(`\ndistinct names reassigned across files: ${names.size} ${JSON.stringify([...names])}`) +const fnNames = [...names].filter((n)=>kindOf.get(n)==='function') +console.log(`of which declared with 'function' (illegal to reassign via ESM import, and not even assignable in the SAME module from another module): ${fnNames.length} ${JSON.stringify(fnNames)}`) diff --git a/ui-web/scripts/model-refresh-live.test.mjs b/ui-web/scripts/model-refresh-live.test.mjs index ac2f218da..d79cd861b 100644 --- a/ui-web/scripts/model-refresh-live.test.mjs +++ b/ui-web/scripts/model-refresh-live.test.mjs @@ -25,7 +25,7 @@ function providerGuardHarness({ configured = null, providers = [] } = {}) { let opened = 0 const build = Function( 'deps', - `let providerConfiguredLive = deps.configured; + `const setupState = { providerConfigured: deps.configured }; let providersLive = deps.providers; const RavenIslands = { settings: { openModels: () => { deps.opened() } } }; ${providerGuardSrc[0]} @@ -143,14 +143,15 @@ function persistHarness({ session = 'sess-1', answer = {}, reject = null } = {}) const calls = [] const build = Function( 'deps', - `let defaultModelLive = '', defaultProviderLive = '', pendingModel = null, viewGen = 0; + `let defaultModelLive = '', defaultProviderLive = '', viewGen = 0; + const staged = { model: null, tier: null, perm: null }; const { rpc, sessionCurrent, loadProviders, modelSet, setModelLabel } = deps; ${persistSrc[0]} return { persistModel, defaults: () => ({ model: defaultModelLive, provider: defaultProviderLive }), - pending: () => pendingModel, - stage: (v) => { pendingModel = v; }, + pending: () => staged.model, + stage: (v) => { staged.model = v; }, };`, ) const api = build({ @@ -305,7 +306,8 @@ function combinedHarness({ session = 'a' } = {}) { const pending = [] const build = Function( 'deps', - `let viewGen = 0, providersLive = [], defaultModelLive = '', defaultProviderLive = '', pendingModel = null; + `let viewGen = 0, providersLive = [], defaultModelLive = '', defaultProviderLive = ''; + const staged = { model: null, tier: null, perm: null }; const { rpc, sessionCurrent, modelSet, setModelLabel } = deps; ${loadProvidersSrc} ${persistSrc[0]} @@ -381,7 +383,8 @@ function stagedHarness({ reject = null } = {}) { const build = Function( 'deps', 'T', - `let viewGen = 0, providersLive = [], pendingModel = { model: 'm2', provider: 'minimax' }; + `let viewGen = 0, providersLive = []; + const staged = { model: { model: 'm2', provider: 'minimax' }, tier: null, perm: null }; const { rpc, sessionCurrent, modelSet, setModelLabel, toast } = deps; ${loadProvidersSrc} ${stagedSrc[0]} diff --git a/ui-web/scripts/page-switch-live.test.mjs b/ui-web/scripts/page-switch-live.test.mjs index 322afff7f..6d7c18495 100644 --- a/ui-web/scripts/page-switch-live.test.mjs +++ b/ui-web/scripts/page-switch-live.test.mjs @@ -157,8 +157,8 @@ function harness({ rows, deferSubscribe } = {}) { return { subscribe, startDraft, openLiveSession, onReconnect: rpc.onReconnect, /* Test-only reach into the staged tier: it is written from the tier source in 120, which this harness does not compile. */ - stageTier: (m) => { pendingTier = m }, stagedTier: () => pendingTier, - stagePerm: (m) => { pendingPerm = m }, stagedPerm: () => pendingPerm };`, + stageTier: (m) => { staged.tier = m }, stagedTier: () => staged.tier, + stagePerm: (m) => { staged.perm = m }, stagedPerm: () => staged.perm };`, ) const api = install(env) return { diff --git a/ui-web/src/demo/010-kernel.js b/ui-web/src/demo/010-kernel.js index b505cb648..c1af1285b 100644 --- a/ui-web/src/demo/010-kernel.js +++ b/ui-web/src/demo/010-kernel.js @@ -26,9 +26,20 @@ let LANG = 'en'; function langSet(v) { LANG = v; applyI18n(); } /* The GATEWAY host's OS family -- host-side actions (reveal in Finder) happen there, not in this browser. The UA is only the prior for the usual localhost - case; system.hello corrects it. */ + case; system.hello corrects it. The live layer calls the setter rather than + assigning, the way it does for LANG: an imported binding is read-only, and a + field the other layer writes is a strand count-shared-globals.mjs counts. */ let HOST_PLATFORM = /Mac/.test(navigator.platform) ? 'mac' : /Win/.test(navigator.platform) ? 'windows' : 'linux'; +function hostPlatformSet(v) { HOST_PLATFORM = v; } + +/* Later layers decorate a handful of this shell's verbs rather than reassigning + them. Registration order is application order from the inside out, so the + last registrar wraps every earlier one -- the order the reassignment chain + produced when each layer captured the then-current value. */ +function applyDecorators(list, base) { + return (list ?? []).reduce((f, wrap) => wrap(f), base); +} const fillVars = (s, vars) => vars ? String(s).replace(/\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m)) : String(s); diff --git a/ui-web/src/demo/040-state.js b/ui-web/src/demo/040-state.js index cb8e7e262..cc95ecbe6 100644 --- a/ui-web/src/demo/040-state.js +++ b/ui-web/src/demo/040-state.js @@ -1,6 +1,9 @@ /* ══ app state ════════════════════════════════════════════════════ */ sessionSet('a'); -let timers = [], use = null; +let timers = []; +/* The running turn's usage totals. On an object because the conversation, + replay and composer layers all write it. */ +const runState = { use: null }; let rt = 'local', undoBin = null; const stop_ = () => { timers.forEach(clearTimeout); timers = []; }; diff --git a/ui-web/src/demo/060-conversation.js b/ui-web/src/demo/060-conversation.js index 429329ca0..765339840 100644 --- a/ui-web/src/demo/060-conversation.js +++ b/ui-web/src/demo/060-conversation.js @@ -5,7 +5,7 @@ function openDemoSession(s) { // row goes back to carrying its timestamp. if (s.status === 'done') s.status = null; markNewCurrent(); - stop_(); turn.dispatch({ type: 'idle' }); queueClear(); use = null; + stop_(); turn.dispatch({ type: 'idle' }); queueClear(); runState.use = null; wsReset(); setWs(false); $('#title').textContent = plainTitle(s.title); @@ -14,7 +14,7 @@ function openDemoSession(s) { drawMeter(); goState(); drawBanner(); if (s.run) { const r = RUNS[s.run]; - use = r.use; + runState.use = r.use; setCtx(((r.use && r.use.in) || 0) + ((r.use && r.use.out) || 0), 200000); ask(r.ask); replay(r, true); } else if (s.status === 'err') { diff --git a/ui-web/src/demo/080-replay.js b/ui-web/src/demo/080-replay.js index 4a6e4c464..6e165ebc9 100644 --- a/ui-web/src/demo/080-replay.js +++ b/ui-web/src/demo/080-replay.js @@ -68,7 +68,7 @@ function replay(run, instant) { collapseTurn(null); /* The turn's products close it, exactly as in live mode. */ RavenIslands.transcript.artifacts(WS.turn); - turn.dispatch({ type: 'idle' }); use = run.use; + turn.dispatch({ type: 'idle' }); runState.use = run.use; setCtx(((run.use && run.use.in) || 0) + ((run.use && run.use.out) || 0), 200000); const s = sess(sessionCurrent()); if (s) { s.last = run.key === 'gtm' ? '抓取了官网,出了对比表' : '3 runs, 0 failures'; s.status = null; } diff --git a/ui-web/src/demo/090-composer.js b/ui-web/src/demo/090-composer.js index 961651c10..d78f3ab02 100644 --- a/ui-web/src/demo/090-composer.js +++ b/ui-web/src/demo/090-composer.js @@ -30,7 +30,7 @@ const SLASH = [ const s = sess(sessionCurrent()); $('#stage').innerHTML = ''; pitch(); if (s) { s.run = null; s.last = T('gui.sess.not_started'); } - use = null; drawMeter(); sessionDraw(); + runState.use = null; drawMeter(); sessionDraw(); }) } ]; @@ -38,7 +38,7 @@ const SLASH = [ live mode installs its own meter wording and upload transport over this. */ DS.composer ??= { meter: () => (turn.busy() ? T('gui.meter.running') - : use ? T('gui.meter.usage', { calls: use.calls, in: (use.in / 1000).toFixed(1), out: (use.out / 1000).toFixed(1) }) + : runState.use ? T('gui.meter.usage', { calls: runState.use.calls, in: (runState.use.in / 1000).toFixed(1), out: (runState.use.out / 1000).toFixed(1) }) : ''), slash: SLASH, slashName: (id) => slashName(id), @@ -54,7 +54,7 @@ function send(text) { if (turn.busy()) { queuePush(text); toast('已排队,本轮结束后发出'); return; } const p = $('#stage').querySelector('.pitch'); if (p) p.remove(); ask(text); - turn.dispatch({ type: 'send' }); use = null; + turn.dispatch({ type: 'send' }); runState.use = null; drawMeter(); goState(); sessionDraw(); const run = pickRun(text); const s = sess(sessionCurrent()); diff --git a/ui-web/src/demo/120-capabilities.js b/ui-web/src/demo/120-capabilities.js index aac246e87..7c9a8a738 100644 --- a/ui-web/src/demo/120-capabilities.js +++ b/ui-web/src/demo/120-capabilities.js @@ -1,5 +1,8 @@ /* ══ module 2: capabilities page ══════════════════════════════════ */ -let extTab = 'skill', cKind = 'all', cQuery = ''; +let extTab = 'skill'; +/* The filter bar's state. On an object because demo/150-chrome.js writes both + fields from the pill row and the search field. */ +const capFilter = { kind: 'all', query: '' }; /* Only one module page at a time. They used to cover the whole window, so two open at once was invisible; now that the rail stays put, the one behind @@ -14,7 +17,15 @@ const NAV_OF = { cronPage: 'moreBtn', }; -function showPage(id) { +/* The skill and plugin layers wrap this verb. The registry is a `var` with no + initialiser on purpose: demo/152-skills.js is a cycle-mate of this file, so + it can register before this statement has run, and an initialiser would + discard what it registered. */ +var showPageDecorators; +function showPage(id) { return applyDecorators(showPageDecorators, showPageBase)(id); } +function decorateShowPage(wrap) { (showPageDecorators ??= []).push(wrap); } + +function showPageBase(id) { Object.keys(NAV_OF).forEach((p) => { $('#' + p).dataset.open = String(p === id); }); /* From the top, every time: the scroller keeps its position across a close and reopen, so a page could greet the reader halfway down its own list. */ @@ -37,9 +48,13 @@ function showPage(id) { /* Switching module resets the filters: a query typed while browsing skills is not a question about plugins. */ -function extSet(tab) { +var extSetDecorators; +function extSet(tab) { return applyDecorators(extSetDecorators, extSetBase)(tab); } +function decorateExtSet(wrap) { (extSetDecorators ??= []).push(wrap); } + +function extSetBase(tab) { if (!tab || tab === extTab) return; - extTab = tab; cKind = 'all'; cQuery = ''; + extTab = tab; capFilter.kind = 'all'; capFilter.query = ''; $('#cq').value = ''; [...$('#cKind').children].forEach((c, i) => c.setAttribute('aria-pressed', String(i === 0))); closeDetail(); @@ -84,7 +99,11 @@ function closeSet() { markNewCurrent(); } -function closeDetail() { $('#detail').dataset.open = 'false'; } +var closeDetailDecorators; +function closeDetail() { return applyDecorators(closeDetailDecorators, closeDetailBase)(); } +function decorateCloseDetail(wrap) { (closeDetailDecorators ??= []).push(wrap); } + +function closeDetailBase() { $('#detail').dataset.open = 'false'; } /* ══ module 2b: external agents ════════════════════════════════════ The renderer is the xa island (ui-web/src/features/xa/); what remains here diff --git a/ui-web/src/demo/130-settings.js b/ui-web/src/demo/130-settings.js index bc88d2813..827567a4e 100644 --- a/ui-web/src/demo/130-settings.js +++ b/ui-web/src/demo/130-settings.js @@ -28,6 +28,7 @@ window.sTab = 'usage'; // the running install is the only thing that knows its version. The rail foot // and the About card both render this as "--" rather than as a guess. let APP_VERSION = null; +function appVersionSet(v) { APP_VERSION = v; } /* Wiping the list is a session operation, so it goes on the session source rather than staying a name the live layer overwrites. It has to live in this diff --git a/ui-web/src/demo/150-chrome.js b/ui-web/src/demo/150-chrome.js index 0a1464a43..8c57f2dd3 100644 --- a/ui-web/src/demo/150-chrome.js +++ b/ui-web/src/demo/150-chrome.js @@ -138,13 +138,13 @@ document.addEventListener('keydown', (e) => { $('#setClose').onclick = () => closeSet(); $('#setVeil').onclick = (e) => { if (e.target === $('#setVeil')) closeSet(); }; $('#dClose').onclick = closeDetail; -/* The scrim closes the sheet; calls through the name so late rebinds win. */ +/* The scrim closes the sheet; calls through the name so later decorators win. */ $('#detail').addEventListener('click', (e) => { if (e.target === $('#detail')) closeDetail(); }); -$('#cq').oninput = () => { cQuery = $('#cq').value.trim().toLowerCase(); drawCaps(); }; +$('#cq').oninput = () => { capFilter.query = $('#cq').value.trim().toLowerCase(); drawCaps(); }; $('#cKind').onclick = (e) => { const b = e.target.closest('button'); if (!b) return; - cKind = b.dataset.k; + capFilter.kind = b.dataset.k; [...$('#cKind').children].forEach((c) => c.setAttribute('aria-pressed', String(c === b))); drawCaps(); }; diff --git a/ui-web/src/demo/152-skills.js b/ui-web/src/demo/152-skills.js index 150259d3a..48b28352c 100644 --- a/ui-web/src/demo/152-skills.js +++ b/ui-web/src/demo/152-skills.js @@ -39,7 +39,11 @@ const skInstBtn = (() => { box, and the chrome above it is still the page's, set here on every draw. demo/153-plugins.js wraps this name for the plugin tab, so a call that arrives here is always a skill draw. */ -function drawCaps() { +var drawCapsDecorators; +function drawCaps() { return applyDecorators(drawCapsDecorators, drawCapsBase)(); } +function decorateDrawCaps(wrap) { (drawCapsDecorators ??= []).push(wrap); } + +function drawCapsBase() { const box = $('#capsBody'); box.innerHTML = ''; const title = T('gui.tab.skills'); const installed = RavenIslands.skills.view() === 'installed'; @@ -71,21 +75,18 @@ function drawCaps() { RavenIslands.skills.searchNow($('#cq').value.trim()); }; - const prevExtSet = extSet; - extSet = function (tab) { + decorateExtSet((prev) => (tab) => { const was = extTab; - prevExtSet(tab); + prev(tab); if (extTab !== was) RavenIslands.skills.reset(); - }; + }); - const prevShowPage = showPage; - showPage = function (id) { - prevShowPage(id); + decorateShowPage((prev) => (id) => { + prev(id); if (id !== 'capsPage') RavenIslands.skills.dropDrawer(); - }; + }); - const prevCloseDetail = closeDetail; - closeDetail = function () { RavenIslands.skills.dropDrawer(); prevCloseDetail(); }; + decorateCloseDetail((prev) => () => { RavenIslands.skills.dropDrawer(); prev(); }); $('#dClose').onclick = () => closeDetail(); /* The island owns the view; the chrome follows it from out here. A view diff --git a/ui-web/src/demo/153-plugins.js b/ui-web/src/demo/153-plugins.js index 4feec22d2..4d1b6d109 100644 --- a/ui-web/src/demo/153-plugins.js +++ b/ui-web/src/demo/153-plugins.js @@ -86,8 +86,7 @@ function drawPlugTab() { if (title) h.appendChild(mk('h3', null, title)); }; - const prevDrawCaps = drawCaps; - drawCaps = function () { + decorateDrawCaps((prev) => () => { if (extTab === 'plugin') { skInstBtn.sync(); // the skills "installed" button must not linger on this tab drawPlugTab(); @@ -98,27 +97,23 @@ function drawPlugTab() { // status pills, and the skill view re-hides them for itself. $('#cKind').hidden = false; $('.cbar').style.display = ''; - prevDrawCaps(); + prev(); pmInstBtn.sync(); syncHero(); - }; + }); - const prevExtSet = extSet; - extSet = function (tab) { + decorateExtSet((prev) => (tab) => { const was = extTab; - prevExtSet(tab); + prev(tab); if (extTab !== was) { RavenIslands.plugins.reset(); $('.cbar').style.display = ''; } - }; + }); - const prevShowPage = showPage; - showPage = function (id) { - prevShowPage(id); + decorateShowPage((prev) => (id) => { + prev(id); if (id !== 'capsPage') { RavenIslands.plugins.drawerClosed(); $('.cbar').style.display = ''; } - }; + }); - const prevCloseDetail = closeDetail; - closeDetail = function () { RavenIslands.plugins.drawerClosed(); prevCloseDetail(); }; - // The X button captured the previous closeDetail reference at bind time. + decorateCloseDetail((prev) => () => { RavenIslands.plugins.drawerClosed(); prev(); }); $('#dClose').onclick = () => closeDetail(); const prevInput = $('#cq').oninput; diff --git a/ui-web/src/features/composer/open-conversation.test.ts b/ui-web/src/features/composer/open-conversation.test.ts index 9577447ef..501ef3de0 100644 --- a/ui-web/src/features/composer/open-conversation.test.ts +++ b/ui-web/src/features/composer/open-conversation.test.ts @@ -59,9 +59,9 @@ function harness(startAsDraft: boolean) { 'applyStagedModel', 'applyStagedTier', 'applyStagedPerm', 'sessionDraw', 'subscribe', 'wsSetRoot', 'startAsDraft', 'touchSession', 'beginNaming', 'mediaOf', 'namingDeclined', 'setSessionWorkdir', - `let draft = startAsDraft; let viewGen = 7; let turnOwner = null; let pendingWorkdir = null;\n${fnSource}\n` + `let draft = startAsDraft; let viewGen = 7; const park = { turnOwner: null, lastAsk: '' }; let pendingWorkdir = null;\n${fnSource}\n` + 'return { openConversation, sendOnSession, isDraft: () => draft, ' - + 'turnOwner: () => turnOwner, setDraft: (on) => { draft = on; }, ' + + 'turnOwner: () => park.turnOwner, setDraft: (on) => { draft = on; }, ' + 'stageWorkdir: (dir) => { pendingWorkdir = dir; }, stagedWorkdir: () => pendingWorkdir };', ) as (...args: unknown[]) => { openConversation: (preview?: string, atPointer?: (id: string) => void) => Promise diff --git a/ui-web/src/features/composer/turn-park.test.ts b/ui-web/src/features/composer/turn-park.test.ts index 903a731b7..9262c185d 100644 --- a/ui-web/src/features/composer/turn-park.test.ts +++ b/ui-web/src/features/composer/turn-park.test.ts @@ -54,7 +54,7 @@ function harness(): { ] const build = new Function(...names, `${source}\nreturn { parkTurn, restoreTurn, transitionTurn, parkedTurns, - setOwner(value) { turnOwner = value; }, + setOwner(value) { park.turnOwner = value; }, };`) as (...args: unknown[]) => ParkedHarness return { api: build(...values), queueRestore, workspaceRestore, drainQueue, diff --git a/ui-web/src/live/050-turn.js b/ui-web/src/live/050-turn.js index 8b56e3c13..cee23066b 100644 --- a/ui-web/src/live/050-turn.js +++ b/ui-web/src/live/050-turn.js @@ -67,7 +67,7 @@ function onEvent(ev) { drawn the question; a window that is only watching has not. */ if (!turn.busy() && p.content) ask(p.content); if (p.content) touchSession(sessionCurrent(), p.content); - turnOwner = sessionCurrent(); + park.turnOwner = sessionCurrent(); turn.dispatch({ type: 'stream', cancellable: true }); goState(); drawMeter(); RavenIslands.workspace.advanceTurn(); } else if (ev.type === 'turn.started') { @@ -102,7 +102,7 @@ function onEvent(ev) { to timing it here, a stop being the one that always does. */ live.startedAt = Date.now(); live.answerAt = 0; - turnOwner = sessionCurrent(); + park.turnOwner = sessionCurrent(); turn.dispatch({ type: 'stream', cancellable: false }); goState(); drawMeter(); } else if (ev.type === 'episode.start') { if (live.st) { live.st.seal(); } @@ -183,7 +183,7 @@ function onEvent(ev) { } turn.dispatch({ type: 'idle' }); noteRow(p.message || 'error', p.detail || p.reason || '', - lastAsk ? { retry: () => liveSend(lastAsk) } : null); + park.lastAsk ? { retry: () => liveSend(park.lastAsk) } : null); goState(); drawMeter(); sessionDraw(); } else if (ev.type === 'cron.delivered') { toast(T('gui.cron.new_output', { name: p.name })); diff --git a/ui-web/src/live/060-parked.js b/ui-web/src/live/060-parked.js index b871f4fef..aa093c5a7 100644 --- a/ui-web/src/live/060-parked.js +++ b/ui-web/src/live/060-parked.js @@ -9,22 +9,23 @@ const parkedTurns = new Map(); // session_key -> parked turn snapshot const subBySession = {}; // session_key -> subscription_id const subSession = {}; // subscription_id -> session_key const PARK_EVENT_CAP = 4000; -/* The session the running turn belongs to. parkTurn must NOT key by the current - pointer: every rail click moves it before sessionOpen(s), so by the time the - old turn is parked it already names the TARGET session. Parking under that - would file the old transcript in the wrong drawer and immediately hand it - back as the new session's content. */ -let turnOwner = null; -/* The message a retry would re-send. Held here rather than read back off the - last `.ask` bubble, which is markup and may belong to another session. */ -let lastAsk = ''; +/* turnOwner: the session the running turn belongs to. parkTurn must NOT key by + the current pointer: every rail click moves it before sessionOpen(s), so by + the time the old turn is parked it already names the TARGET session. Parking + under that would file the old transcript in the wrong drawer and immediately + hand it back as the new session's content. + lastAsk: the message a retry would re-send. Held here rather than read back + off the last `.ask` bubble, which is markup and may belong to another + session. + Both on an object because the turn and override layers write them. */ +const park = { turnOwner: null, lastAsk: '' }; function parkTurn() { - if (!turnOwner || !turn.busy()) return; + if (!park.turnOwner || !turn.busy()) return; stopSayPaint(); - const s = sess(turnOwner); + const s = sess(park.turnOwner); if (s) s.status = 'run'; - parkedTurns.set(turnOwner, { + parkedTurns.set(park.turnOwner, { nodes: [...$('#stage').childNodes], turn: { st: live.st, steps: live.steps, say: live.say, open: new Map(live.open), sawEpisode: live.sawEpisode, startedAt: live.startedAt, answerAt: live.answerAt }, @@ -52,7 +53,7 @@ DS.transcript.parked = (node) => { }; function restoreTurn(pk) { - turnOwner = sessionCurrent(); + park.turnOwner = sessionCurrent(); const stage = $('#stage'); stage.innerHTML = ''; pk.nodes.forEach((n) => stage.appendChild(n)); @@ -77,7 +78,7 @@ function restoreTurn(pk) { Apply the same reducer to the visible phase or to the saved copy, never to whichever conversation merely happens to be open when the frame lands. */ function transitionTurn(owner, event) { - if (owner === turnOwner && owner === sessionCurrent()) { + if (owner === park.turnOwner && owner === sessionCurrent()) { turn.dispatch(event); return; } diff --git a/ui-web/src/live/080-overrides.js b/ui-web/src/live/080-overrides.js index 5c8b05624..05e8c1a53 100644 --- a/ui-web/src/live/080-overrides.js +++ b/ui-web/src/live/080-overrides.js @@ -102,15 +102,10 @@ let draft = false; session to scope the switch to, and writing it would change the global default instead. It is applied to the session the first message mints, then forgotten. Cleared on any leave of the draft so a stale pick cannot land on - the next conversation. */ -let pendingModel = null; -/* The tier picked before the conversation exists. Declared beside `pendingModel` - and reset on exactly the same two paths, because it is the same problem: a - draft has no session_key, and writing under the empty one lands on a policy - the first turn will not read. Assigned from the tier source in 120. */ -let pendingTier = null; -/* The permission mode picked while still a draft: the same pair, the same two resets. */ -let pendingPerm = null; + the next conversation. The tier and the permission mode are the same problem + and reset on exactly the same two paths, which is why the three share one + object; the settings layer in 120 assigns all three. */ +const staged = { model: null, tier: null, perm: null }; /* The working directory picked while still a draft. Not a fourth staged write: the engine takes it on `session.create` itself, so `promote` hands it over with the mint rather than writing it afterwards. Reset on the same two paths @@ -130,8 +125,8 @@ let pendingWorkdir = null; Its own function so the refusal path is reachable from a test without driving the whole send. */ async function applyStagedModel(sessionId, gen) { - if (!pendingModel) return; - const pm = pendingModel; pendingModel = null; + if (!staged.model) return; + const pm = staged.model; staged.model = null; try { await rpc.call('config.set', { key: 'model', value: pm.model, provider: pm.provider, session_id: sessionId }); } catch (e) { @@ -215,7 +210,7 @@ function startDraft() { live.subId = null; parkDraft(); loadDraft('new'); resetView(); - draft = true; sessionSet(null); pendingModel = null; + draft = true; sessionSet(null); staged.model = null; // The other half of the pair with openLiveSession: a draft runs the // configured default, so leaving a conversation for one has to read that // default back or the chip keeps claiming the model of the conversation just @@ -225,8 +220,8 @@ function startDraft() { so leaving a conversation for one has to read that default back -- and a tier staged for a draft that was never sent belongs to nothing, so it goes rather than waiting to be spent by whichever conversation is sent next. */ - pendingTier = null; void loadTier(); - pendingPerm = null; void loadPermMode(null, gen); + staged.tier = null; void loadTier(); + staged.perm = null; void loadPermMode(null, gen); pendingWorkdir = null; setDraftWorkdir(); $('#title').textContent = T('gui.new_task'); pitch(); sessionDraw(); ta.focus(); @@ -242,7 +237,7 @@ async function openLiveSession(s) { // into the newly opened stage. Route them to the parked buffer instead. live.subId = null; parkDraft(); loadDraft(s.id); - draft = false; pendingModel = null; + draft = false; staged.model = null; // The model is per conversation now, so the chip must follow the one being // opened -- otherwise it keeps the model of the session left behind. Keyed to // s.id rather than sessionCurrent(): the current session is not switched over @@ -256,8 +251,8 @@ async function openLiveSession(s) { in memory (`_session_policies`, no persistence), so a gateway restart puts every session back on the catalogue default -- and the chip went on naming the tier from before the gap. */ - pendingTier = null; void loadTier(); - pendingPerm = null; void loadPermMode(s.id, gen); + staged.tier = null; void loadTier(); + staged.perm = null; void loadPermMode(s.id, gen); // Opening it IS reading it. ``s`` can be a bare {id, title} from the // reconnect path, so clear the flag on the row in sessionRows(), not on the arg. const row = sess(s.id); @@ -488,7 +483,7 @@ function sendOnSession(text, failed) { /* What `liveSend` has always run here, unchanged. */ function dispatchSend(text, failed) { const current = sessionCurrent(); - turnOwner = current; + park.turnOwner = current; touchSession(current, text); beginNaming(text); /* `=== false`, not falsy: a server too old to carry the field says nothing @@ -504,7 +499,7 @@ function liveSend(text) { const p = $('#stage').querySelector('.pitch'); if (p) p.remove(); /* What a retry re-sends. Recorded after the attachment note is folded in, so the second attempt carries the same message as the first. */ - lastAsk = text; + park.lastAsk = text; ask(text); turn.dispatch({ type: 'send' }); resetTurnState(); @@ -522,7 +517,7 @@ function liveSend(text) { } // The draft becomes a real session here, on its first message. (async () => { - const id = await openConversation(rowPreview(text), () => { turnOwner = sessionCurrent(); }); + const id = await openConversation(rowPreview(text), () => { park.turnOwner = sessionCurrent(); }); beginNaming(text); /* `sessionCurrent()` rather than `id`, which is what this has always sent: the two differ only when the reader opened another conversation inside diff --git a/ui-web/src/live/120-settings.js b/ui-web/src/live/120-settings.js index 684c32aa0..c22a9dabc 100644 --- a/ui-web/src/live/120-settings.js +++ b/ui-web/src/live/120-settings.js @@ -33,7 +33,7 @@ let everosLive = null; calls it late-bound. */ window.persistPermMode = (m) => { const sid = sessionCurrent(); - if (!sid) { pendingPerm = m; return true; } + if (!sid) { staged.perm = m; return true; } return rpc.call('config.set', { key: 'permissions.mode', value: m, scope: 'session', session_id: sid }) .then((r) => { if (r && r.applied) return true; @@ -43,10 +43,10 @@ window.persistPermMode = (m) => { .catch(() => { toast(T('gui.perm.save_failed')); return false; }); }; -/* Read by the send path, next to the staged model and tier. `pendingPerm` is - declared beside them in 080, where all three are reset on the two paths +/* Read by the send path, next to the staged model and tier. `staged` is + declared in 080, where all three of its fields are reset on the two paths that abandon a draft. */ -function stagedPerm() { const p = pendingPerm; pendingPerm = null; return p; } +function stagedPerm() { const p = staged.perm; staged.perm = null; return p; } async function loadEveros() { try { @@ -111,11 +111,13 @@ const HIDDEN_PROVIDERS = new Set(['hosted_vllm', 'custom']); let providersLive = []; let defaultModelLive = ''; let defaultProviderLive = ''; -let providerConfiguredLive = null; +/* Whether first-run setup reported a configured provider. On an object because + the boot in 200 is what learns the answer. */ +const setupState = { providerConfigured: null }; function openModelsForMissingProvider() { const connected = providersLive.some((p) => p.on); - const knownMissing = providerConfiguredLive === false || providersLive.length > 0; + const knownMissing = setupState.providerConfigured === false || providersLive.length > 0; if (connected || !knownMissing) return false; void RavenIslands.settings.openModels(); return true; @@ -243,7 +245,7 @@ DS.settings = { /* check:true = fetch now, not the daily cache: the button says check for updates, and a person who just clicked it is asking about now. */ const v = await rpc.call('system.version', { check: true }); - if (v.raven_version) APP_VERSION = v.raven_version; + if (v.raven_version) appVersionSet(v.raven_version); if (v.update_available) { showUpNote('ver', v.latest_version); drawSettings(); @@ -420,14 +422,14 @@ async function persistModel(m, provider, scope) { // leaving the draft for a conversation of its own advances the generation // (every view switch does) -- so without this the resolved draft write // repaints a chip that has since been loaded correctly for someone else. - if (!sid && !pendingModel && gen === viewGen) { modelSet(m); setModelLabel(); } + if (!sid && !staged.model && gen === viewGen) { modelSet(m); setModelLabel(); } return; } if (sid) { await rpc.call('config.set', { key: 'model', value: m, provider, session_id: sid }); return; } - pendingModel = { model: m, provider }; + staged.model = { model: m, provider }; return 'staged'; } @@ -440,10 +442,10 @@ async function persistModel(m, provider, scope) { leaves the chip hidden on the refusal rather than inventing one. */ let tierMenu = []; -/* Read by the send path, which applies it next to the staged model. - `pendingTier` itself is declared beside `pendingModel` in 080, where both are - reset on the two paths that abandon a draft. */ -function stagedTier() { const t = pendingTier; pendingTier = null; return t; } +/* Read by the send path, which applies it next to the staged model. `staged` + itself is declared in 080, where every field is reset on the two paths that + abandon a draft. */ +function stagedTier() { const t = staged.tier; staged.tier = null; return t; } DS.tier = { read: async () => { @@ -460,7 +462,7 @@ DS.tier = { /* Staged, and echoed back as though written: there is no server state to contradict it yet, and the chip has to show the reader what their next turn will run at. */ - pendingTier = mode; + staged.tier = mode; return { mode, availableModes: tierMenu }; } const r = await rpc.call('session.set_mode', { session_key: sid, mode }); diff --git a/ui-web/src/live/200-boot.js b/ui-web/src/live/200-boot.js index 93ec4ebd2..e15f01a36 100644 --- a/ui-web/src/live/200-boot.js +++ b/ui-web/src/live/200-boot.js @@ -26,12 +26,12 @@ DS.onboard = { if (!(await rpc.connect())) { authFail(); return; } try { const hello = await rpc.call('system.hello', { client_version: '0.1.0', surface: SURFACE }); - if (hello && hello.platform) HOST_PLATFORM = hello.platform; + if (hello && hello.platform) hostPlatformSet(hello.platform); // Before the first paint of anything data-driven: config.language decides // what every label below says. await loadLang(); const v = await rpc.call('system.version', {}); - if (v.raven_version) APP_VERSION = v.raven_version; + if (v.raven_version) appVersionSet(v.raven_version); drawFoot(); /* Absent until system.version carries them; the row simply stays hidden, so an older server degrades to no notice rather than a broken one. */ @@ -67,7 +67,7 @@ DS.onboard = { standalone onboarding flow for an explicit design or support pass. */ try { const setup = await rpc.call('setup.status', {}); - providerConfiguredLive = setup.provider_configured !== false; + setupState.providerConfigured = setup.provider_configured !== false; /* ?onboard=demo asked for the canned flow, which the demo shell has already put on screen. Both write into #onb, so opening this one would replace it -- and the reader who asked for the version that writes From 6f108fded1267f1c09c0603337f362664900eef8 Mon Sep 17 00:00:00 2001 From: KT Date: Wed, 16 Sep 2026 23:27:19 +0800 Subject: [PATCH 04/83] refactor(ui-web): move the concatenated layers under src/legacy src/seam, src/demo and src/live become src/legacy/{seam,demo,live}. The 49 files are unchanged, so dist/index.html is byte-identical. The layer names "seam", "demo" and "live" stay the argument every caller passes: build.py's _concat maps them to legacy/ internally, which is what keeps the manifests and the two Python tests outside this directory working untouched. count-shared-globals.mjs gains a `legacy` base for the layer reads while shell/bridge.ts stays under src/. Twenty test files read the layer sources by path: sixteen scripts/*.test.mjs and four src/features/**/*.test.ts (the plan counted only the .mjs ones). Eleven more files name a moved file in prose and follow it. Verification: `python3 ui-web/build.py` then `shasum -a 256 ui-web/dist/index.html` gives 7bee82640cef7ac90f367309a606e6f462f46b62b2abb740f312bc22b166fd76, identical to the pre-move build; `npm test --prefix ui-web` 108 files / 1836 cases / 0 failures; `node ui-web/scripts/count-shared-globals.mjs` 0/0/18; boot snapshot identical; `uv run pytest tests/test_ui_language_repaint.py -q` 11 passed. Co-authored-by: Claude (claude-fable-5-1) --- ui-web/build.py | 23 +++++++++++-------- ui-web/scripts/agents-roster-live.test.mjs | 2 +- ui-web/scripts/approval-lapse-live.test.mjs | 2 +- ui-web/scripts/boot-order.test.mjs | 6 ++--- ui-web/scripts/codemod/a1-own-state.mjs | 2 +- ui-web/scripts/codemod/census.mjs | 2 +- ui-web/scripts/codemod/deps5.mjs | 2 +- ui-web/scripts/codemod/freeglobals.mjs | 2 +- ui-web/scripts/codemod/names.mjs | 2 +- ui-web/scripts/codemod/order.mjs | 2 +- ui-web/scripts/codemod/sideeffects.mjs | 2 +- ui-web/scripts/codemod/writes.mjs | 2 +- ui-web/scripts/count-shared-globals.mjs | 10 ++++---- ui-web/scripts/dag-live-path.test.mjs | 2 +- ui-web/scripts/dag-node-open-live.test.mjs | 2 +- ui-web/scripts/dag-sheet-live.test.mjs | 2 +- ui-web/scripts/desk-page-gate.test.mjs | 2 +- .../scripts/direct-chat-media-live.test.mjs | 4 ++-- ui-web/scripts/model-refresh-live.test.mjs | 4 ++-- ui-web/scripts/page-switch-live.test.mjs | 2 +- ui-web/scripts/playbooks-demo-shape.test.mjs | 2 +- ui-web/scripts/playbooks-live.test.mjs | 2 +- ui-web/scripts/rail-nav-registry.test.mjs | 2 +- ui-web/scripts/rpc-connect.test.mjs | 2 +- ui-web/scripts/session-actions-live.test.mjs | 2 +- ui-web/scripts/session-naming-live.test.mjs | 2 +- ui-web/scripts/turn-clock-live.test.mjs | 2 +- ui-web/src/features/browser/BrowserPage.tsx | 2 +- ui-web/src/features/browser/store.ts | 2 +- .../composer/open-conversation.test.ts | 2 +- .../src/features/composer/turn-park.test.ts | 2 +- ui-web/src/features/composer/types.ts | 2 +- ui-web/src/features/connections/types.ts | 2 +- ui-web/src/features/cron/CronPage.tsx | 2 +- .../plugins/capabilities-source.test.ts | 12 +++++----- .../src/features/rail/session-delete.test.ts | 4 ++-- ui-web/src/features/settings/store.ts | 2 +- .../src/features/subagents/SubagentsPage.tsx | 2 +- ui-web/src/features/subagents/store.ts | 2 +- ui-web/src/{ => legacy}/demo/010-kernel.js | 0 ui-web/src/{ => legacy}/demo/020-prose.js | 0 ui-web/src/{ => legacy}/demo/030-fixtures.js | 0 ui-web/src/{ => legacy}/demo/040-state.js | 0 ui-web/src/{ => legacy}/demo/050-rail.js | 0 .../src/{ => legacy}/demo/060-conversation.js | 0 .../src/{ => legacy}/demo/070-transcript.js | 0 ui-web/src/{ => legacy}/demo/080-replay.js | 0 ui-web/src/{ => legacy}/demo/090-composer.js | 0 ui-web/src/{ => legacy}/demo/100-workspace.js | 0 ui-web/src/{ => legacy}/demo/110-subagents.js | 0 ui-web/src/{ => legacy}/demo/112-browser.js | 0 .../src/{ => legacy}/demo/120-capabilities.js | 0 ui-web/src/{ => legacy}/demo/130-settings.js | 0 ui-web/src/{ => legacy}/demo/140-schedule.js | 0 .../src/{ => legacy}/demo/145-connections.js | 0 ui-web/src/{ => legacy}/demo/150-chrome.js | 0 ui-web/src/{ => legacy}/demo/152-skills.js | 0 ui-web/src/{ => legacy}/demo/153-plugins.js | 0 ui-web/src/{ => legacy}/demo/154-playbooks.js | 0 ui-web/src/{ => legacy}/demo/155-bridge.js | 0 ui-web/src/{ => legacy}/demo/160-boot.js | 0 .../src/{ => legacy}/live/010-boot-guard.js | 0 ui-web/src/{ => legacy}/live/020-rpc.js | 0 ui-web/src/{ => legacy}/live/030-sessions.js | 0 ui-web/src/{ => legacy}/live/040-history.js | 0 ui-web/src/{ => legacy}/live/050-turn.js | 0 ui-web/src/{ => legacy}/live/060-parked.js | 0 ui-web/src/{ => legacy}/live/070-notify.js | 0 ui-web/src/{ => legacy}/live/080-overrides.js | 0 .../src/{ => legacy}/live/090-extensions.js | 0 ui-web/src/{ => legacy}/live/100-schedules.js | 0 .../src/{ => legacy}/live/110-connections.js | 0 ui-web/src/{ => legacy}/live/120-settings.js | 0 ui-web/src/{ => legacy}/live/130-writes.js | 0 ui-web/src/{ => legacy}/live/140-skills.js | 0 ui-web/src/{ => legacy}/live/150-plugins.js | 0 ui-web/src/{ => legacy}/live/160-memory.js | 0 ui-web/src/{ => legacy}/live/165-knowledge.js | 0 ui-web/src/{ => legacy}/live/167-playbooks.js | 0 ui-web/src/{ => legacy}/live/170-workspace.js | 0 .../src/{ => legacy}/live/180-attachments.js | 0 .../{ => legacy}/live/190-session-actions.js | 0 ui-web/src/{ => legacy}/live/200-boot.js | 0 .../{ => legacy}/live/210-update-notice.js | 0 ui-web/src/{ => legacy}/live/220-browser.js | 0 ui-web/src/{ => legacy}/live/230-tabs.js | 0 .../{ => legacy}/live/240-external-agents.js | 0 .../src/{ => legacy}/seam/000-datasource.js | 0 ui-web/src/rpc/transport.ts | 2 +- ui-web/src/shell/bridge.ts | 6 ++--- 90 files changed, 70 insertions(+), 65 deletions(-) rename ui-web/src/{ => legacy}/demo/010-kernel.js (100%) rename ui-web/src/{ => legacy}/demo/020-prose.js (100%) rename ui-web/src/{ => legacy}/demo/030-fixtures.js (100%) rename ui-web/src/{ => legacy}/demo/040-state.js (100%) rename ui-web/src/{ => legacy}/demo/050-rail.js (100%) rename ui-web/src/{ => legacy}/demo/060-conversation.js (100%) rename ui-web/src/{ => legacy}/demo/070-transcript.js (100%) rename ui-web/src/{ => legacy}/demo/080-replay.js (100%) rename ui-web/src/{ => legacy}/demo/090-composer.js (100%) rename ui-web/src/{ => legacy}/demo/100-workspace.js (100%) rename ui-web/src/{ => legacy}/demo/110-subagents.js (100%) rename ui-web/src/{ => legacy}/demo/112-browser.js (100%) rename ui-web/src/{ => legacy}/demo/120-capabilities.js (100%) rename ui-web/src/{ => legacy}/demo/130-settings.js (100%) rename ui-web/src/{ => legacy}/demo/140-schedule.js (100%) rename ui-web/src/{ => legacy}/demo/145-connections.js (100%) rename ui-web/src/{ => legacy}/demo/150-chrome.js (100%) rename ui-web/src/{ => legacy}/demo/152-skills.js (100%) rename ui-web/src/{ => legacy}/demo/153-plugins.js (100%) rename ui-web/src/{ => legacy}/demo/154-playbooks.js (100%) rename ui-web/src/{ => legacy}/demo/155-bridge.js (100%) rename ui-web/src/{ => legacy}/demo/160-boot.js (100%) rename ui-web/src/{ => legacy}/live/010-boot-guard.js (100%) rename ui-web/src/{ => legacy}/live/020-rpc.js (100%) rename ui-web/src/{ => legacy}/live/030-sessions.js (100%) rename ui-web/src/{ => legacy}/live/040-history.js (100%) rename ui-web/src/{ => legacy}/live/050-turn.js (100%) rename ui-web/src/{ => legacy}/live/060-parked.js (100%) rename ui-web/src/{ => legacy}/live/070-notify.js (100%) rename ui-web/src/{ => legacy}/live/080-overrides.js (100%) rename ui-web/src/{ => legacy}/live/090-extensions.js (100%) rename ui-web/src/{ => legacy}/live/100-schedules.js (100%) rename ui-web/src/{ => legacy}/live/110-connections.js (100%) rename ui-web/src/{ => legacy}/live/120-settings.js (100%) rename ui-web/src/{ => legacy}/live/130-writes.js (100%) rename ui-web/src/{ => legacy}/live/140-skills.js (100%) rename ui-web/src/{ => legacy}/live/150-plugins.js (100%) rename ui-web/src/{ => legacy}/live/160-memory.js (100%) rename ui-web/src/{ => legacy}/live/165-knowledge.js (100%) rename ui-web/src/{ => legacy}/live/167-playbooks.js (100%) rename ui-web/src/{ => legacy}/live/170-workspace.js (100%) rename ui-web/src/{ => legacy}/live/180-attachments.js (100%) rename ui-web/src/{ => legacy}/live/190-session-actions.js (100%) rename ui-web/src/{ => legacy}/live/200-boot.js (100%) rename ui-web/src/{ => legacy}/live/210-update-notice.js (100%) rename ui-web/src/{ => legacy}/live/220-browser.js (100%) rename ui-web/src/{ => legacy}/live/230-tabs.js (100%) rename ui-web/src/{ => legacy}/live/240-external-agents.js (100%) rename ui-web/src/{ => legacy}/seam/000-datasource.js (100%) diff --git a/ui-web/build.py b/ui-web/build.py index 800c7e9f6..d65e43318 100644 --- a/ui-web/build.py +++ b/ui-web/build.py @@ -6,13 +6,13 @@ ``/*__STYLE__*/`` inside its ``' +const SCRIPT_BLOCK = '' + +/* page.html is written for build.py: it carries markers where the stylesheet + and the bundle get spliced in. The dev server splices different things into + the same two places -- a link to the stylesheet Vite watches, and the real + module entry -- so one page skeleton serves both paths and neither owns a + copy of the other's markup. */ +const devPage = (): Plugin => ({ + name: 'raven-dev-page', + apply: 'serve', + transformIndexHtml: { + order: 'pre', + handler(html) { + for (const block of [STYLE_BLOCK, SCRIPT_BLOCK]) { + if (!html.includes(block)) throw new Error(`vite dev: src/page.html no longer carries ${block}`) + } + return html + .replace(STYLE_BLOCK, '') + .replace('"__ASSETV__"', '"dev"') + .replace(SCRIPT_BLOCK, '') }, - outDir: '.modern', - emptyOutDir: true, - sourcemap: false, - target: 'es2020', }, -})) + configureServer(server) { + /* Asset URLs are relative (`assets/providers/x.svg`), so from /src/page.html + they land on /src/assets -- where they really are, except for the one the + build copies in from ui-web/icon. */ + server.middlewares.use((req, _res, next) => { + const mark = '/src/assets/raven.svg' + if (req.url?.startsWith(mark)) req.url = `/icon/raven.svg${req.url.slice(mark.length)}` + next() + }) + }, +}) + +export default defineConfig(({ command, mode }) => { + const dev = command === 'serve' && mode !== 'test' + return { + plugins: dev ? [react(), devPage()] : [react()], + // Lib mode does not substitute NODE_ENV on its own; without this the + // bundle carries React's development build, three times the size. Scoped + // to build: vitest shares this config, and tests need the development + // React (act() only exists there). + define: command === 'build' ? { 'process.env.NODE_ENV': JSON.stringify('production') } : undefined, + // `..` is the repo root: the message catalogue the demo layer imports is + // i18n/messages.json, outside this directory on purpose. + server: dev + ? { host: '127.0.0.1', open: '/src/page.html', fs: { allow: ['..'] }, proxy: gatewayProxy() } + : undefined, + build: { + lib: { + entry: 'src/main.tsx', + name: 'RavenModern', + formats: ['iife'], + fileName: () => 'modern.iife.js', + }, + outDir: '.modern', + emptyOutDir: true, + sourcemap: false, + target: 'es2020', + }, + } +}) From 400022ac446bad09f66a7c6c27870662eadc84e1 Mon Sep 17 00:00:00 2001 From: KT Date: Thu, 17 Sep 2026 02:08:18 +0800 Subject: [PATCH 12/83] chore(ui-web): retire the conversion scaffolding and pin the live boot shape The boot snapshot took one mode. A live-only boot break -- the SURFACE/SHELL regression A3 introduced was one -- passes an untouched stub snapshot, so boot-snapshot.mjs now takes --url and --golden and build.py runs it twice: the demo shell on its fixtures against boot-stub.txt (427 nodes), and live mode with no gateway answering against the new boot-live-noserver.txt (285 nodes). Offline the refused connection is the expected condition, so only a broken program fails that run: a ReferenceError, TypeError or SyntaxError that is not itself a network failure. build.py writes dist/index.html with newline="" so a Windows checkout emits the same bytes as CI and the wheel, matching the same fix on the other line. The codemods and analysis scripts that did the conversion are spent and gone. The one generator that is not -- the index that pins the install order -- moves to scripts/legacy-index.mjs and keeps regenerating src/legacy/index.js byte-identically; build.py and the shape gate point at it instead of the deleted directory. Co-authored-by: Claude (claude-fable-5-1) --- ui-web/README.md | 11 +- ui-web/build.py | 40 +- .../scripts/__golden__/boot-live-noserver.txt | 285 ++++++++++++ ui-web/scripts/boot-snapshot.mjs | 75 ++- ui-web/scripts/codemod/a1-own-state.mjs | 145 ------ ui-web/scripts/codemod/a3-modules.mjs | 439 ------------------ ui-web/scripts/codemod/a3b-indent-install.mjs | 121 ----- ui-web/scripts/codemod/baseline-boot.txt | 428 ----------------- .../scripts/codemod/baseline-dist-digest.txt | 1 - ui-web/scripts/codemod/census.mjs | 131 ------ ui-web/scripts/codemod/concat-era.mjs | 27 -- ui-web/scripts/codemod/deps5.mjs | 97 ---- ui-web/scripts/codemod/freeglobals.mjs | 61 --- ui-web/scripts/codemod/names.mjs | 48 -- ui-web/scripts/codemod/order.mjs | 90 ---- ui-web/scripts/codemod/sideeffects.mjs | 32 -- ui-web/scripts/codemod/writes.mjs | 61 --- .../a3-index.mjs => legacy-index.mjs} | 6 +- ui-web/scripts/legacy-shape.test.mjs | 18 +- ui-web/src/legacy/index.js | 2 +- 20 files changed, 386 insertions(+), 1732 deletions(-) create mode 100644 ui-web/scripts/__golden__/boot-live-noserver.txt delete mode 100644 ui-web/scripts/codemod/a1-own-state.mjs delete mode 100644 ui-web/scripts/codemod/a3-modules.mjs delete mode 100644 ui-web/scripts/codemod/a3b-indent-install.mjs delete mode 100644 ui-web/scripts/codemod/baseline-boot.txt delete mode 100644 ui-web/scripts/codemod/baseline-dist-digest.txt delete mode 100644 ui-web/scripts/codemod/census.mjs delete mode 100644 ui-web/scripts/codemod/concat-era.mjs delete mode 100644 ui-web/scripts/codemod/deps5.mjs delete mode 100644 ui-web/scripts/codemod/freeglobals.mjs delete mode 100644 ui-web/scripts/codemod/names.mjs delete mode 100644 ui-web/scripts/codemod/order.mjs delete mode 100644 ui-web/scripts/codemod/sideeffects.mjs delete mode 100644 ui-web/scripts/codemod/writes.mjs rename ui-web/scripts/{codemod/a3-index.mjs => legacy-index.mjs} (94%) diff --git a/ui-web/README.md b/ui-web/README.md index 77b4d0672..4bf3caac2 100644 --- a/ui-web/README.md +++ b/ui-web/README.md @@ -30,9 +30,10 @@ python3 build.py # splice into src/page.html -> dist/index.html, copy asse They are separate because `build.py` must run where npm may not be on PATH: the installer adds npm inside a subshell, and the python step is what the release wheel and `make build-ui` call. `build.py` ends by booting the artifact -in happy-dom and comparing its DOM shape against a golden under -`scripts/__golden__/`, so a -structural regression fails the build rather than the browser. +in happy-dom twice -- once on its fixtures (`?stub=1`), once in live mode with +no gateway answering -- and comparing each DOM shape against its golden under +`scripts/__golden__/`, so a structural regression fails the build rather than +the browser. ## Develop @@ -63,5 +64,5 @@ node scripts/check-class-namespace.mjs node scripts/count-shared-globals.mjs ``` -The page checks read `dist/`, so build before running them. The boot snapshot -runs on its own, from `build.py`. +The page checks read `dist/`, so build before running them. The two boot +snapshots run on their own, from `build.py`. diff --git a/ui-web/build.py b/ui-web/build.py index d29c1c597..32787fec2 100644 --- a/ui-web/build.py +++ b/ui-web/build.py @@ -45,7 +45,7 @@ # Install order is semantics: each part's install() does what the part used to # do while the concatenated script ran, and several of them read what an earlier # one wrote. src/legacy/index.js calls them in exactly this order -- regenerate -# it (scripts/codemod/a3-index.mjs) after renaming, adding or removing a part. +# it (scripts/legacy-index.mjs) after renaming, adding or removing a part. _DEMO_PARTS = [ "010-kernel.js", "020-prose.js", @@ -139,6 +139,16 @@ def _assets_stamp() -> str: return digest.hexdigest()[:10] +#: The two load modes, each with its own golden. Stub mode is the demo shell on +#: its fixtures; live mode is the page a reader gets when the gateway is not +#: there, which settles into a different tree and would otherwise be unguarded +#: -- a live-only boot break passes the stub snapshot untouched. +_BOOT_SNAPSHOTS = ( + ("http://127.0.0.1:18792/?stub=1", "boot-stub.txt"), + ("http://127.0.0.1:18792/", "boot-live-noserver.txt"), +) + + def main() -> None: page = (ROOT / "src" / "page.html").read_text(encoding="utf-8") style = (ROOT / "src" / "styles" / "page.css").read_text(encoding="utf-8") @@ -162,11 +172,9 @@ def main() -> None: out = page.replace(ASSETV_MARK, _assets_stamp(), 1) dist = ROOT / "dist" dist.mkdir(exist_ok=True) - # newline="" so the bytes are the string: the default translates every \n - # to os.linesep, which on Windows emits a CRLF page whose script payloads - # check-page.mjs can no longer extract (its `