Files
fusion/packages/dashboard/vitest.setup.ts
gsxdsm 55745af80f Settings: one type scale, every setting searchable, nav grouped by topic (#2158)
Settings had three compounding problems: you couldn't find a setting,
and once you found it, it didn't look like its neighbour.

## Organization — the nav was grouped by the wrong thing

It was grouped by **scope** (Global / Runtimes / Project). Scope is an
*attribute* of a setting, not a category — nobody opens Settings
thinking "I need a project-authority setting." That single choice split
five concepts across two groups, including **two nav entries both
labelled "MCP Servers"**, distinguishable only by a small icon.

Now grouped by topic, with paired sections adjacent and stating their
own scope:

![Topic-first settings
navigation](https://raw.githubusercontent.com/Runfusion/Fusion/468f71dfa065be6f46e4fccfe09f18518fbf4e81/shots/nav-topic-groups.png)

```
PREFERENCES     Appearance · Keyboard Shortcuts · Notifications · General · Global
PROJECT         General · Project · Commands & Scripts · Worktrees · Merge
AI & MODELS     Models · Global · Models · Project · CLI Agents · Agents & Permissions · Prompts · Memory
AUTOMATION      Scheduling & Capacity · Scheduled Evals
INTEGRATIONS    Authentication · MCP Servers · Global · MCP Servers · Project · Plugins · Runtimes · Secrets
INFRASTRUCTURE  Node Sync · Node Routing · Remote Access · Backups
ADVANCED        Experimental Features
```

## Search — it now finds settings, not just sections

Search matched only hand-written `searchableText` keyword arrays per nav
entry, and those demonstrably rotted: Project Models accumulated 20
keywords across **two separate fixes** (FN-7907, then
title-summarization on 2026-07-14) because operators searched
"summarize" and got nothing.

Every setting is now indexed from **its real label and help text** — 123
settings across 18 sections, zero curated keywords. The same query that
was patched twice by hand:


![Search returns individual
settings](https://raw.githubusercontent.com/Runfusion/Fusion/468f71dfa065be6f46e4fccfe09f18518fbf4e81/shots/search-summarize.png)

Two of those four hits match on *help text*, which a keyword index
structurally cannot do. Picking a result jumps to the exact control,
**opens its collapsed disclosure**, and highlights it:

![Jump to
field](https://raw.githubusercontent.com/Runfusion/Fusion/468f71dfa065be6f46e4fccfe09f18518fbf4e81/shots/jump-highlight.png)


**This can't rot again.** `settings-search-index.test.ts` extracts every
`descriptor={{ key }}` from section sources and fails the build if one
isn't indexed. "All settings are searchable" is an enforced invariant,
not a promise.

## Consistency — one type scale

A text field's label and a toggle's label in the same section were
styled **three different ways**: `.form-group label`
(12px/600/uppercase/muted), a `.settings-content` override narrowing it
to 0.72rem, and `.checkbox-label` (13px/500/sentence-case) whose every
declaration carried `!important` purely to out-specify the other two.

| | Before | After |
|---|---|---|
| Sections on shared primitives | 1 | **18** |
| Indexed searchable settings | 0 | **123** |
| `form-group` in settings | 226 | **98** |
| `checkbox-label` in settings | 79 | **40** |


![Appearance](https://raw.githubusercontent.com/Runfusion/Fusion/468f71dfa065be6f46e4fccfe09f18518fbf4e81/shots/appearance.png)

![Scheduling](https://raw.githubusercontent.com/Runfusion/Fusion/468f71dfa065be6f46e4fccfe09f18518fbf4e81/shots/scheduling.png)

![Backups](https://raw.githubusercontent.com/Runfusion/Fusion/468f71dfa065be6f46e4fccfe09f18518fbf4e81/shots/backups.png)

![Worktrees](https://raw.githubusercontent.com/Runfusion/Fusion/468f71dfa065be6f46e4fccfe09f18518fbf4e81/shots/worktrees.png)

![Memory](https://raw.githubusercontent.com/Runfusion/Fusion/468f71dfa065be6f46e4fccfe09f18518fbf4e81/shots/memory.png)

![Merge](https://raw.githubusercontent.com/Runfusion/Fusion/468f71dfa065be6f46e4fccfe09f18518fbf4e81/shots/merge.png)

![Notifications](https://raw.githubusercontent.com/Runfusion/Fusion/468f71dfa065be6f46e4fccfe09f18518fbf4e81/shots/notifications.png)
![General ·
Project](https://raw.githubusercontent.com/Runfusion/Fusion/468f71dfa065be6f46e4fccfe09f18518fbf4e81/shots/general.png)

**Global `.form-group` is untouched** — 35 non-settings files style
forms with it, so settings migrate *off* it rather than restyle it
underneath the rest of the dashboard.

### A real latent bug this surfaced
`--font-size-sm` and `--font-size-md` were named by **12 declarations**
across AgentDetailView, DockTaskList, SetupWizardModal, and
ModelOnboardingModal — but **defined nowhere**. Those rules silently
no-op'd and inherited. The command-center token guard is the only thing
that catches this class of omission and it doesn't cover those files.
The scale is now complete (`2xs · xs · sm · base · md · lg`); `md` is
1.125rem to hold current rendering, since unstyled `h3` already inherits
the 1.17em UA default.

## What deliberately did *not* migrate

Some rows stay bespoke **on purpose**, not from incompleteness:

- **Password fields** (`ntfyAccessToken`, `githubAuthToken`,
`gitlabAuthToken`) — `SettingsTextRow` hardcodes `type="text"`, so
migrating would have **rendered stored tokens unmasked**.
- **Help text with embedded `<code>`/links** — `descriptor.help` is a
single string; flattening would drop markup or reword copy.
- **`<details>` progressive disclosure** in Merge — a descriptor's help
renders unconditionally, so migrating ~10 rows would delete the
disclosure and produce a wall of prose.
- **Dynamic flag lists** (Experimental) and bespoke editors/CRUD
(Prompts, Agents & Permissions, Plugins, KeyboardShortcuts' capture
widget) — no settings field name and no i18n key to anchor honestly.

## Data-model ambiguities surfaced (not papered over)

These need a human call and are **not** fixed here:

- **Five keys are declared in BOTH `DEFAULT_GLOBAL_SETTINGS` and
`DEFAULT_PROJECT_SETTINGS`**: `worktrunk`, `testMode`, `gitlabEnabled`,
`gitlabAuthToken`, `gitlabAuthTokenType`. No scope badge can be stamped
honestly, so those rows are left bespoke.
- **`globalMaxConcurrent` lives in the *project* blob** despite its
name, its dedicated global endpoint, and the "Global" header it renders
under. Its badge is omitted rather than assert a contradiction.
- **Two settings were editable from two screens**: `gitlabEnabled`
(General + Merge) and `githubTrackingDefaultRepo` (General + Global
General). Both are ambiguous-scope and custom widgets, so deduplication
is left as follow-up.


## Follow-ups from review

**`SettingsTextRow` gained `type`, so the token rows could migrate.** It
hardcoded `type="text"`, which is why every secret-bearing row (ntfy
access token, GitHub/GitLab tokens, the Cloudflare tunnel token) stayed
hand-rolled — migrating would have rendered stored secrets in plain
text. `password` rows now default to `autocomplete="off"` so a browser
never offers to save an API token, and masking is pinned by tests: a
regression there would not throw and would not look wrong in review, the
field would simply render the token.

That also made them findable. Searching "token" previously matched
nothing useful; it now returns 9 settings including *Access token* and
*Tunnel token*:


**Jump-to-field now reveals collapsed disclosures.** Rows inside a
closed `<details>` are in the DOM but invisible, so the jump scrolled to
and highlighted a control the operator could not see. The settings most
worth searching for are exactly the ones behind "Advanced".

**The scope banner is gone.** It claimed one scope for a whole section,
which was false wherever a section mixed them — Appearance is a "global"
nav entry whose task-presentation toggles are all project-scoped.
Per-row badges already say this accurately, so the banner and its dead
CSS are removed.

**Scheduling is split by scope.** `globalMaxConcurrent` moved to its own
`Scheduling · Global` section instead of sitting above the project
settings behind an in-section subheading. One section held two authority
levels, so "does this affect my other projects?" depended on which
subheading you had scrolled past — and a search result landing
mid-section shows no subheading at all.

![Scheduling ·
Project](https://raw.githubusercontent.com/Runfusion/Fusion/468f71dfa065be6f46e4fccfe09f18518fbf4e81/shots/scheduling.png)

**Text-entry padding is now genuinely uniform.** Settings shipped two
input treatments: `.input`/`.select` (6px 10px at 13px) and the global
`.form-group input` rule (8px 12px at 14px). Padding depended on whether
an ancestor happened to be a `.form-group`, and because `.form-group
input` (0,1,1) out-specifies `.input` (0,1,0), naming the standard class
on a nested control did nothing. Measured in-browser after the fix: **51
controls across four sections, one appearance, zero outliers.** The same
specificity trap was silently re-imposing the uppercase/muted label
treatment on migrated rows nested in a `.form-group`; fixed as an
invariant rather than per-row, since sections legitimately keep
`.form-group` around bespoke content.

## Reconciling with #2147 (please review this call)

PR #2147 landed while this branch was open and deliberately moved the
two import auto-translate controls **off** `SettingsToggleRow` onto
`checkbox-label`, pinning that markup with a test written to survive *"a
refactor back onto the primitive"*. Its objection was that the primitive
rendered a right-aligned toggle switch clashing with the section's
native checkboxes — two idioms in one section.

Both halves of that objection are now gone: the primitive renders a
native checkbox **before** its label, and every checkbox in that section
— including the neighbour the test asserts parity against — renders
through it. The idiom split is resolved by migrating all of them rather
than de-migrating these two, so the markup assertions now track the
primitive. **Every behavioural contract from #2147 is kept and still
pinned**, and one was a real bug on this branch: switching
auto-translate off wrote `false` where it must write `undefined`,
leaving an explicit opt-out in the settings blob instead of staying
unset.

#2147's curated translate keywords are preserved for the genuine
vocabulary gaps ("localize", "localization", "foreign language issues")
that appear in no copy. FN-8016's rewritten `taskPopupsBoardListOnly`
copy (default now enabled) is adopted into the migrated row and its
search entry.

Worth noting: #2147's own FNXC says the translate controls were
*"effectively unfindable"* because *"settings search only matches
curated terms plus advertised i18n keys"* — 25 keywords hand-added days
ago. That is precisely the rot this PR's derived index removes.


## Source Control — the duplicate had a cause

GitLab settings were split across **three** sections (General: enable +
URLs; Merge: auth token + type; Global General: all five at global
scope) and GitHub across two. That split is *why* `gitlabEnabled` ended
up writable from both General and Merge — two enable toggles for one
key, last-save-wins.

A `Source Control · Global` / `· Project` pair now owns all 17 keys,
adjacent under Integrations, with **one** GitLab disclosure and **one**
enable toggle:

![Source Control ·
Project](https://raw.githubusercontent.com/Runfusion/Fusion/468f71dfa065be6f46e4fccfe09f18518fbf4e81/shots/source-control.png)

Key ownership moved with them in `section-keys.ts` / `save-split.ts`, so
every key has exactly one owner (`section-keys.test.ts` enforces
disjointness).

**A latent bug surfaced by the move:** nine sites outside the registries
hardcode `"global-general"`/`"general"` and silently gate **scope
routing** — four in `save-split.ts`, five in `SettingsModal.tsx`. Left
stale, global GitLab edits would have been written as project overrides.
Also verified against the schema: **all five `gitlab*` keys AND
`githubTrackingDefaultRepo`** are declared in both defaults (more than
the four originally identified), so those rows carry no scope badge
rather than assert a scope the data model can't support.

## Help moved behind a "?" beside every label

Rendering every description inline turned dense sections into walls of
prose — median help is ~100 chars, some past 400. That pressure is what
made Merge invent its own "More details" disclosure, so one section
showed two idioms. Measured across sections, Merge's help was **not**
unusually long (median 103 vs Appearance's 168, which rendered inline),
so the disclosure wasn't earning its keep.

The copy is deferred **visually, not removed**: the bubble is always
rendered and only fades in, so it stays in the accessibility tree for
`aria-describedby`, stays findable with in-page find, and **the search
index keeps matching on help text**. Errors are never deferred — a
validation message you must hunt for is one you won't see.

Only ~24 of 136 `<small>` blocks were actually row help. The rest stay
inline on purpose and aren't help: validation errors, live status,
empty-state explanations, per-option descriptions inside a multi-select,
and copy explaining *why* a control is disabled.

`children: ReactNode` (not a string) is what let the `<code>`-bearing
and link-bearing rows migrate without rewording your copy — a string API
is precisely why they were hand-rolled before.

### Mobile, verified on a 390px viewport

![Help tip on
mobile](https://raw.githubusercontent.com/Runfusion/Fusion/468f71dfa065be6f46e4fccfe09f18518fbf4e81/shots/mobile-help-tip.png)

Driving a real phone-sized viewport caught two bugs that neither jsdom
nor code review did:

- **Bubble rendered off-screen.** The label line reads "Name [scope] ?",
so the "?" sits well right of centre; anchored to the trigger, the
bubble spanned x=338→658 against a 390px screen — 268px unreachable.
`max-width` clamps width but can't help when the *anchor* is near the
edge. It now anchors to the row (`inset-inline: 0`): re-measured at
x=20→370 inside 390. An earlier comment claimed this behaviour; only the
comment existed.
- **Two bubbles open at once.** Outside-`pointerdown` dismissal misses a
path: `click` fires with **no pointer event** when Enter/Space activates
a focused trigger, so a keyboard user opening a second tip left the
first open underneath. Tips now broadcast on open and close each other;
the regression test asserts the bare-click path specifically.

Also verified on touch: tapping outside dismisses, and opening a second
tip closes the first — no stranded bubbles.

**Checkbox wrapping.** On a 390px viewport a long label ("Keep task
popups on the view where they were opened") stranded its checkbox alone
on line 1, with the text on lines 2-3 and the badge on line 4. The head
was a flex row and the label was a flex ITEM, so once it no longer fit
beside the checkbox the whole label wrapped rather than its text. Label
+ badge + tip now form one group that absorbs the wrapping, leaving the
checkbox as the only sibling item; continuation lines align under the
first word.

Audited every checkbox and radio on all 31 screens at 390px afterwards:
**101 controls, all on the first line of their label text.** (The
audit's first pass flagged 41 — all false positives from measuring
`<label>` elements whose text is a bare text node, i.e. the label box
included the checkbox. Re-measuring the text nodes themselves via Range
cleared them.)



### Every row, including the ones that stayed bespoke

Merge was the last holdout — 18 `<details>` "More details" disclosures
plus 4 inline blocks, an idiom it invented and no other section used.
All 22 now use the same "?":


![Merge](https://raw.githubusercontent.com/Runfusion/Fusion/468f71dfa065be6f46e4fccfe09f18518fbf4e81/shots/merge.png)

Project Models likewise rendered lane help as prose while the global
lanes next door already used the tip; its help now hangs on the existing
lane label row beside the Override/Inherited badge (the badge is live
state and stays visible; the fallback chain behind it is what you open
deliberately).

**Audited all 31 screens programmatically** (label treatments, control
padding, row overflow, leftover banners, horizontal scroll):
- **one label treatment on every screen**, one control treatment, zero
overflowing rows, zero banners, no horizontal scroll
- the copy still rendered inline is deliberately not row help:
validation errors, live status, block descriptions, per-option text
inside multi-selects, and copy explaining *why* a control is disabled

**Known gap:** the three plugin runtime screens (Hermes / OpenClaw /
Paperclip) still render inline help. They delegate to
`HermesRuntimeCard` / `OpenClawRuntimeCard` / `PaperclipRuntimeCard` —
separate card components outside the settings tree — and their copy *is*
genuine row help ("Leave blank to resolve hermes from your PATH"). They
are advanced-only and were left out of this pass rather than swept in at
the end without review.

## Padding and label consistency (measured, not eyeballed)

Two idioms were still visible on one screen — Merge rendered "PLAN
APPROVAL MODE" in caps directly above "Auto-merge conflict retries" in
sentence case. Rows that deliberately stay bespoke inherited the global
`.form-group label` treatment.

| | Before | After |
|---|---|---|
| Label treatments | uppercase/muted/11.5px **and** sentence/14px | **70
labels, one treatment** |
| Control padding | `6px 10px` **and** `8px 12px` | **81 controls, one
treatment** |
| Row gaps | 12 / 16 / 20 by adjacency | **0** — every row owns its
space |

The cause was a specificity trap: `.form-group input` (0,1,1)
out-specifies `.input` (0,1,0), so naming the standard class on a nested
control did nothing. Fixed settings-scoped; the global `.form-group` is
untouched (35 non-settings files depend on it, where uppercase is that
context's convention).

## Advanced settings toggle — verified

Confirmed in-browser after the regroup: **OFF → 15 sections / 5 groups;
ON → 31 sections / 7 groups**, `data-show-advanced` flips, preference
persists. The now-empty **Infrastructure and Advanced group headers are
correctly hidden** when off — the case the regroup could have broken,
since those groups contain only advanced sections.

## Pre-existing failures found (verified NOT caused by this PR)

Each verified by running the identical file on the parent commit and
diffing the **failure sets**, not just the counts:

| Failure | Verified |
|---|---|
| `SettingsModal.scheduling-merge.test.tsx` — 55 failures | identical
set before/after |
| `SettingsModal.remote-notifications.test.tsx` — 16 failures |
identical set before/after |
| `settings-default-descriptions.test.tsx` — `sqliteMigrationNotice`,
`postgresMigrationInboxMessageSentAt` | fails on clean tree |
| i18n `parity.test.ts` — 12 violations
(`settings.general.autoTranslate*`, `taskDetail.plan.*`) | 12 before, 12
after |

**This PR adds zero new failures.**

Fixed along the way: a stale `AppearanceSection` assertion testing copy
FN-7945 deliberately rewrote (failing silently), the ungrammatical "1
matching sections", and `&ldquo;` rendering literally on screen.

## Verification

- `tsc --noEmit -p tsconfig.app.json` → **0 errors** (note: the default
`tsconfig.json` only covers `src/` and does **not** typecheck `app/`)
- `pnpm test:gate` → **63 passed**
- Settings surface → 68 failures, every one a verified subset of the
pre-existing baseline, diffed by failure SET not count (this branch
incidentally fixes 4)
- `pnpm test:gate` 63/63 · lint clean across 83 changed files · `tsc -p
tsconfig.app.json` 0 errors
- Rebased onto `main`: conflicts with #2147 and FN-8016 resolved
- Driven in a real browser at 1440px and 390px: search, jump-to-field,
help tips, advanced toggle, and every migrated section
- Driven in a real browser: search, jump-to-field, highlight, and every
migrated section

🤖 Generated with [Claude Code](https://claude.com/claude-code)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Settings search now indexes individual settings controls, ranks
matches, and navigates directly to the exact row with a temporary
highlight.
* **UI Improvements**
* Migrated settings sections to shared row primitives
(toggle/select/number/text/textarea) for consistent spacing and
touch-friendly controls.
* Updated typography to a complete tokenized type scale; added the “?”
help tip and tokenized row highlight/error styling.
  * Navigation and search labels now show clear Global vs Project scope.
* **Bug Fixes**
* Improved search accuracy using label/help/keywords and fixed settings
search counts/pluralization and MCP scope labels.
* **Tests**
* Added/updated checks to ensure the settings search index stays
consistent with rendered rows.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 22:00:27 -07:00

224 lines
7.6 KiB
TypeScript

import "@testing-library/jest-dom";
import { vi } from "vitest";
import i18next from "i18next";
import { initReactI18next } from "react-i18next";
// Initialize a minimal real i18next instance for component tests. Components
// call useTranslation() without a provider in tests; react-i18next's
// uninitialized fallback returns inline defaults WITHOUT interpolation
// (literal "{{count}}" in output). A backend-less en instance keeps t(key,
// default, options) returning the interpolated English default, so tests
// keep asserting the same strings as before the i18n migration. Tests that
// vi.mock("react-i18next") or "../i18n" are unaffected.
await i18next.use(initReactI18next).init({
lng: "en",
fallbackLng: "en",
// Each namespace present (empty) so hasLoadedNamespace() is true — an
// unloaded namespace makes useTranslation() suspend (no Suspense boundary
// in component tests) even with useSuspense disabled belt-and-braces below.
//
// FNXC:TestI18n 2026-06-22-21:40:
// Pluralized count keys must resolve from resources, not the singular inline
// default. t("taskChat.entryCount", "{{count}} entry", { count }) renders the
// singular default for ALL counts when the key is absent — so count=2 became
// "2 entry". Provide the _one/_other forms (as the real en locale does) so the
// correct plural ("2 entries", "7 tool calls") renders in tests too. Only these
// keys resolve from the bundle; every other key still falls back to its inline
// default, preserving existing assertions.
resources: {
en: {
common: {},
app: {
taskChat: {
entryCount_one: "{{count}} entry",
entryCount_other: "{{count}} entries",
toolCallCount_one: "{{count}} tool call",
toolCallCount_other: "{{count}} tool calls",
},
/*
FNXC:TestI18n 2026-07-15-17:35:
Settings search reports its counts through i18next plural resolution, and its call sites intentionally pass NO inline default — a literal default would out-rank the catalog's singular form and reinstate "1 matching settings".
That means these keys resolve from resources or not at all, so they are mirrored here (same `_one`/`_other` shape as the real en catalog) exactly as the taskChat counters above.
*/
settings: {
search: {
resultCount_one: "{{count}} matching section",
resultCount_other: "{{count}} matching sections",
settingResultCount_one: "{{count}} matching setting",
settingResultCount_other: "{{count}} matching settings",
},
},
},
errors: {},
},
},
ns: ["common", "app", "errors"],
defaultNS: "common",
interpolation: { escapeValue: false },
returnNull: false,
react: { useSuspense: false },
});
// Ensure dashboard route/server tests start in no-auth mode unless they
// explicitly opt in. CI/agent shells may export daemon tokens globally,
// which would otherwise force 401s across unrelated endpoint tests.
const clearDaemonAuthEnv = () => {
delete process.env.FUSION_DAEMON_TOKEN;
delete process.env.FUSION_BEARER_TOKEN;
};
clearDaemonAuthEnv();
const noisyOutputMarkers = [
"ExperimentalWarning: SQLite is an experimental feature",
"Subagent result watcher failed",
"pi-async-subagent-results",
"[pi] createFnAgent called",
"[pi] Session created successfully",
"[pi-claude-cli] Claude CLI is not authenticated",
"Terminal WebSocket server mounted at /api/terminal/ws",
"[api:error]",
"[models] Failed to load models:",
"[routes] failed to trigger",
];
function isNoisyTestOutput(value: unknown): boolean {
const text = typeof value === "string" || value instanceof Buffer ? String(value) : "";
return noisyOutputMarkers.some((marker) => text.includes(marker));
}
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
process.stdout.write = ((chunk: unknown, ...args: unknown[]) => {
if (isNoisyTestOutput(chunk)) {
return true;
}
return originalStdoutWrite(chunk as any, ...(args as any));
}) as typeof process.stdout.write;
const originalStderrWrite = process.stderr.write.bind(process.stderr);
process.stderr.write = ((chunk: unknown, ...args: unknown[]) => {
if (isNoisyTestOutput(chunk)) {
return true;
}
return originalStderrWrite(chunk as any, ...(args as any));
}) as typeof process.stderr.write;
const originalConsoleLog = console.log.bind(console);
console.log = (...args: unknown[]) => {
if (args.some(isNoisyTestOutput)) {
return;
}
originalConsoleLog(...args);
};
const originalConsoleError = console.error.bind(console);
console.error = (...args: unknown[]) => {
if (args.some(isNoisyTestOutput)) {
return;
}
originalConsoleError(...args);
};
// Mock localStorage
const localStorageMock: Record<string, string> = {};
if (typeof window !== "undefined") {
Object.defineProperty(window, "localStorage", {
value: {
getItem: (key: string) => localStorageMock[key] || null,
setItem: (key: string, value: string) => {
localStorageMock[key] = value;
},
removeItem: (key: string) => {
delete localStorageMock[key];
},
clear: () => {
Object.keys(localStorageMock).forEach((key) => delete localStorageMock[key]);
},
get length() {
return Object.keys(localStorageMock).length;
},
key: (index: number) => Object.keys(localStorageMock)[index] ?? null,
},
writable: true,
});
// Mock matchMedia
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: query === "(prefers-color-scheme: dark)" ? true : false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
class MockResizeObserver {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
}
(window as typeof window & { ResizeObserver?: typeof MockResizeObserver }).ResizeObserver = MockResizeObserver;
(globalThis as typeof globalThis & { ResizeObserver?: typeof MockResizeObserver }).ResizeObserver = MockResizeObserver;
}
// Global MockEventSource for tests
class MockEventSource {
static instances: MockEventSource[] = [];
static CONNECTING = 0;
static OPEN = 1;
static CLOSED = 2;
url: string;
listeners: Record<string, ((e: any) => void)[]> = {};
readyState = 0;
close = vi.fn(() => {
this.readyState = MockEventSource.CLOSED;
});
constructor(url: string) {
this.url = url;
this.readyState = MockEventSource.OPEN;
MockEventSource.instances.push(this);
}
addEventListener(event: string, fn: (e: any) => void) {
if (!this.listeners[event]) this.listeners[event] = [];
this.listeners[event].push(fn);
}
removeEventListener(event: string, fn: (e: any) => void) {
this.listeners[event] = (this.listeners[event] || []).filter((listener) => listener !== fn);
}
// Helper to simulate a server event
_emit(event: string, data?: unknown) {
for (const fn of this.listeners[event] || []) {
fn(data === undefined ? ({ } as { data: string }) : { data: JSON.stringify(data) });
}
}
}
// Set up before each test
beforeEach(() => {
clearDaemonAuthEnv();
MockEventSource.instances = [];
(globalThis as any).EventSource = MockEventSource;
});
// Clean up after each test
afterEach(() => {
// Close all lingering EventSource instances
for (const instance of MockEventSource.instances) {
instance.close();
}
MockEventSource.instances = [];
delete (globalThis as any).EventSource;
clearDaemonAuthEnv();
});
export { MockEventSource };