You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/09/12 10:54:56 UTC
(maka) branch main updated: test(desktop): reduce Electron coverage from 41 to 34 tests (#5228)
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git
The following commit(s) were added to refs/heads/main by this push:
new d92fed919a test(desktop): reduce Electron coverage from 41 to 34 tests (#5228)
d92fed919a is described below
commit d92fed919a0a63daa1e576102c723d882d249447
Author: AstroHan <le...@outlook.com>
AuthorDate: Sat Sep 12 18:54:51 2026 +0800
test(desktop): reduce Electron coverage from 41 to 34 tests (#5228)
* test(desktop): move renderer checks out of Electron
Generated-by: OpenAI Codex
* docs(desktop): define Electron test admission rules
Generated-by: OpenAI Codex
* docs(storybook): add scoped test tier guidance
Generated-by: OpenAI Codex
* fix(desktop): remove unused settings modal re-exports
Generated-by: OpenAI Codex
---
apps/desktop/e2e-budget.json | 12 +-
apps/desktop/e2e/AGENTS.md | 72 ++++++++
apps/desktop/e2e/session-workbar.spec.ts | 22 +--
apps/desktop/e2e/settings.spec.ts | 161 ++---------------
apps/desktop/e2e/transcript-scroll-cost.spec.ts | 199 ++-------------------
apps/desktop/renderer-architecture.json | 4 +-
apps/desktop/src/preload/preload.ts | 7 +-
apps/desktop/src/renderer/app-shell-overlays.tsx | 70 +++-----
.../src/renderer/settings/settings-modal.tsx | 3 -
apps/desktop/stories/AGENTS.md | 48 +++++
apps/desktop/stories/FIDELITY.md | 6 +-
apps/desktop/stories/app-shell.stories.tsx | 106 ++++++++++-
.../stories/settings/settings-pages.stories.tsx | 51 +++++-
packages/ui/stories/AGENTS.md | 41 +++++
14 files changed, 381 insertions(+), 421 deletions(-)
diff --git a/apps/desktop/e2e-budget.json b/apps/desktop/e2e-budget.json
index fa464f77f1..3d49a2a75d 100644
--- a/apps/desktop/e2e-budget.json
+++ b/apps/desktop/e2e-budget.json
@@ -42,12 +42,12 @@
"electron": "SQLite admission crosses preload and main, survives both renderer reload and complete Electron restart, and cached history must render when the live IPC endpoint is unavailable"
},
"session-workbar.spec.ts": {
- "tests": 6,
- "electron": "Git changes re-read on window focus, terminal PTY ownership across Sessions, Side Chat's fork lifecycle, a first send that has to reach the Host, and per-Session collapse persisted across a renderer reload; the composer-usage test is renderer-only and rides along on those windows until app-shell.tsx's composer-to-workbar wiring has a story host"
+ "tests": 5,
+ "electron": "Git changes re-read on native window focus; terminal PTY ownership changes across Sessions; Side Chat owns a Host fork lifecycle; first send reaches the Host; the composer usage entry opens a workbar whose per-Session visibility survives renderer reload."
},
"settings.spec.ts": {
- "tests": 5,
- "electron": "Code Mode persistence crosses renderer/preload/main/Host settings IPC; the preload makaE2eLatch holds the settings chunk mid-load, and the rename it commits is a Host write; the workbar-chrome test rides along on that window and would move to a story the day app-shell.tsx's settings wiring has one"
+ "tests": 2,
+ "electron": "Code Mode reads back Host settings through IPC; opening Settings commits a pending title rename to the Host, with workbar hide/restore asserted in that same Session."
},
"sidebar-project-reload.spec.ts": {
"tests": 1,
@@ -66,8 +66,8 @@
"electron": "observation seeding, reconnect and settle are Host subscriptions surviving a renderer remount"
},
"transcript-scroll-cost.spec.ts": {
- "tests": 4,
- "electron": "the perf budget is measured from CDP wheel input and the browser's own render skipping, and reader displacement only exists against a real layout"
+ "tests": 1,
+ "electron": "One traversal exercises real Host transcript paging through preload/IPC, bounded retained ranges, reader displacement when pages install, and a return-to-tail Host read. Chromium containment and fixture motion run in the TranscriptRenderCost story."
},
"workhub-layout.spec.ts": {
"tests": 2,
diff --git a/apps/desktop/e2e/AGENTS.md b/apps/desktop/e2e/AGENTS.md
new file mode 100644
index 0000000000..d919de0885
--- /dev/null
+++ b/apps/desktop/e2e/AGENTS.md
@@ -0,0 +1,72 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied. See the License for the
+ specific language governing permissions and limitations
+ under the License.
+-->
+
+# Electron test admission
+
+Before adding or extending a test here, choose the lowest tier that can expose
+the actual defect. Apply this to each behavioral assertion, not just each file.
+Existing tests and entries in `../e2e-budget.json` are not exemptions. See #4761,
+#4877 and #4892 for the migration history.
+
+1. Use existing unit, component or integration tests for state, event ordering,
+ request routing, storage and recovery. Desktop renderer tests already run
+ from `../src/main/__tests__` through `node --test`; React tests use the existing
+ fake DOM. The directory name does not restrict them to main-process code.
+ Shared UI tests live in `../../../packages/ui/src/__tests__`.
+2. Use Storybook or a focused browser test when the assertion needs Chromium:
+ layout, scrolling, selection, focus, animation, viewport or theme behavior.
+ Reuse `../stories`, `../../../packages/ui/stories` and
+ [the fidelity convention](../stories/FIDELITY.md).
+3. Use Electron only when a lower tier would miss a concrete Electron boundary:
+ native window/input behavior, Electron preload/main integration, or a
+ cross-process persistence or lifecycle failure requiring the actual app.
+
+## Prove the boundary
+
+- State beside each test which Electron-owned mechanism it verifies and what
+ defect a lower-tier test would miss. A call used only to prepare the scenario
+ is not that mechanism. Creating a Host Session does not make a focus test an
+ IPC test; reading a Host snapshot does not by itself prove Electron is needed.
+- Real wheel input, CDP, geometry, localStorage, page reload, or a preload test
+ latch alone do not establish an Electron requirement. Trace the owner of the
+ behavior being asserted. Node integration tests can also exercise real Host
+ and storage boundaries.
+- Do not justify a renderer-only assertion by another test in the same file,
+ an already-open window, or an unchanged test count. Keep Electron journeys
+ focused on their necessary boundaries; move independent renderer contracts.
+- Search existing lower-tier coverage before adding a replacement. Extend the
+ actual component/controller/service seam; do not copy product logic into a
+ second shell or introduce a global render-completion protocol for tests.
+
+## Migrate and verify
+
+- Establish that the replacement detects the original defect, preferably by
+ reverting the relevant behavior or a targeted mutation. Verify the behavioral
+ failure, not merely an import/type error or the absence of a new helper.
+- Delete replaced E2E cases, duplicate assertions and unreferenced fixture hooks
+ in the same PR. Preserve any independent native or cross-process protection.
+- Do not raise timeouts, add retries, or weaken assertions to make a migration
+ pass. Diagnose a red replacement before deciding it is a harness problem.
+- Update `../e2e-budget.json` with counts and concrete boundary reasons, then run
+ `npm run check:e2e-budget` from the repository root. This checks inventory
+ consistency; a green result is not proof of correct tier selection.
+- Build before running compiled tests or Storybook smoke. Run Electron tests
+ from `apps/desktop` because the fixtures use that working directory. Report
+ transferred/lost protection and fixture launches or traversals removed,
+ separately from the number of test declarations.
diff --git a/apps/desktop/e2e/session-workbar.spec.ts b/apps/desktop/e2e/session-workbar.spec.ts
index c7c2124b0b..8e9b909230 100644
--- a/apps/desktop/e2e/session-workbar.spec.ts
+++ b/apps/desktop/e2e/session-workbar.spec.ts
@@ -53,22 +53,6 @@ async function createSession(page: Page, prompt: string) {
return { composer, sessionId: sessionId!, sidebar };
}
-test('the composer usage action opens Task trace in the right workbar', async ({
- accessibilityNarrativeWindow: page,
-}) => {
- const action = page.getByRole('button', { name: '打开用量追踪' });
- await expect(action).toBeVisible();
-
- await action.click();
-
- const rightPanel = page.locator(
- '.maka-session-workbar-panel[data-overlay][data-placement="right"]',
- );
- await expect(
- rightPanel.locator('[data-maka-contract="session-inspector"]'),
- ).toBeVisible();
-});
-
test('right workbar visibility belongs to each Session and survives reload', async ({
window: page,
}) => {
@@ -79,8 +63,10 @@ test('right workbar visibility belongs to each Session and survives reload', asy
.getByRole('list', { name: '打开工具' })
.getByRole('button', { name: /变更.*查看当前 Git 工作区变化/ })
.click();
- await page.getByRole('button', { name: '打开或关闭工作栏的面' }).click();
- await page.getByRole('menu').getByRole('menuitem', { name: '追踪', exact: true }).click();
+ await page.getByRole('button', { name: '打开用量追踪' }).click();
+ await expect(page.locator(
+ '.maka-session-workbar-panel[data-overlay][data-placement="right"] [data-maka-contract="session-inspector"]',
+ )).toBeVisible();
await expect(panel).toBeVisible();
await first.sidebar.getByRole('button', { name: '新任务', exact: true }).click();
const second = await createSession(page, 'second workbar owner');
diff --git a/apps/desktop/e2e/settings.spec.ts b/apps/desktop/e2e/settings.spec.ts
index 412e035844..67f04c1fae 100644
--- a/apps/desktop/e2e/settings.spec.ts
+++ b/apps/desktop/e2e/settings.spec.ts
@@ -19,13 +19,6 @@
import { awaitSendReady, COMPOSER_INPUT, ensureSidebarExpanded, expect, test } from './fixtures';
-interface SettingsChunkLatchWindow extends Window {
- makaE2eLatch?: {
- arm(key: 'settings.chunk'): void;
- release(key: 'settings.chunk'): void;
- };
-}
-
test('Code Mode persists as a global setting after reopening settings', async ({ window: page }, testInfo) => {
await ensureSidebarExpanded(page);
await page.getByRole('button', { name: '设置' }).click();
@@ -48,84 +41,12 @@ test('Code Mode persists as a global setting after reopening settings', async ({
await expect.poll(() => page.evaluate(async () => (await window.maka.settings.get()).chatDefaults.codeModeEnabled === true)).toBe(false);
});
-test('Settings loading surface owns unmodified Escape', async ({ window: page }) => {
- const latchInstalled = await page.evaluate(() => {
- const e2eLatch = (window as unknown as SettingsChunkLatchWindow).makaE2eLatch;
- e2eLatch?.arm('settings.chunk');
- return e2eLatch !== undefined;
- });
- expect(latchInstalled, 'the preload E2E latch is installed').toBe(true);
-
- try {
- await ensureSidebarExpanded(page);
- await page.getByRole('button', { name: '设置' }).click();
-
- const loadingSurface = page.locator('.maka-lazy-fallback');
- await expect(loadingSurface).toBeVisible();
-
- for (const modifier of ['ctrlKey', 'metaKey', 'altKey'] as const) {
- const wasNotPrevented = await page.evaluate((key) =>
- window.dispatchEvent(
- new KeyboardEvent('keydown', {
- key: 'Escape',
- [key]: true,
- bubbles: true,
- cancelable: true,
- }),
- ), modifier);
- expect(wasNotPrevented).toBe(true);
- await expect(loadingSurface).toBeVisible();
- }
-
- const wasNotPrevented = await page.evaluate(() =>
- window.dispatchEvent(
- new KeyboardEvent('keydown', {
- key: 'Escape',
- bubbles: true,
- cancelable: true,
- }),
- ),
- );
- expect(wasNotPrevented).toBe(false);
- await expect(page.locator('.settingsModal')).toHaveCount(0);
- } finally {
- await page.evaluate(() =>
- (window as unknown as SettingsChunkLatchWindow).makaE2eLatch?.release(
- 'settings.chunk',
- ),
- );
- }
-});
-
test('opening settings commits an active titlebar rename', async ({ window: page }) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('create a session for settings rename');
await awaitSendReady(page);
await composer.press('Enter');
- const identity = page.locator('[data-maka-contract="titlebar-identity"]');
- await expect(identity).toBeVisible();
- await page.getByRole('button', { name: '展开侧边栏' }).click();
- await identity.getByRole('button', { name: /重命名任务/ }).click();
- await page.getByRole('textbox', { name: '重命名任务' }).fill('renamed before settings');
-
- // Programmatic activation preserves input focus, matching the macOS
- // application-menu command that opens Settings before Chromium can blur it.
- await page.getByRole('button', { name: '设置' }).evaluate((button) => button.click());
- await expect(page.getByRole('main', { name: '设置内容' })).toBeVisible();
- await page.keyboard.press('Escape');
-
- await expect(identity).toContainText('renamed before settings');
-});
-
-test('settings hides expanded workbar chrome and restores it on close', async ({
- window: page,
-}) => {
- const composer = page.locator(COMPOSER_INPUT);
- await composer.fill('create a session with an expanded workbar');
- await awaitSendReady(page);
- await composer.press('Enter');
-
await page.getByRole('button', { name: '展开任务工作栏' }).click();
const workbar = page.locator('.maka-session-workbar[data-placement="right"]');
const workbarToolbar = workbar.getByRole('toolbar', { name: '任务工作栏标签' });
@@ -140,79 +61,23 @@ test('settings hides expanded workbar chrome and restores it on close', async ({
const openFaceTab = workbarToolbar.getByRole('tab', { name: '变更' });
await expect(openFaceTab).toBeVisible();
- await ensureSidebarExpanded(page);
- await page.getByRole('button', { name: '设置' }).click();
+ const identity = page.locator('[data-maka-contract="titlebar-identity"]');
+ await expect(identity).toBeVisible();
+ await page.getByRole('button', { name: '展开侧边栏' }).click();
+ await identity.getByRole('button', { name: /重命名任务/ }).click();
+ await page.getByRole('textbox', { name: '重命名任务' }).fill('renamed before settings');
+
+ // Programmatic activation preserves input focus, matching the macOS
+ // application-menu command that opens Settings before Chromium can blur it.
+ await page.getByRole('button', { name: '设置' }).evaluate((button) => button.click());
await expect(page.getByRole('main', { name: '设置内容' })).toBeVisible();
await expect(workbar).not.toBeVisible();
-
await page.keyboard.press('Escape');
await expect(workbarToolbar).toBeVisible();
await expect(openFaceTab).toBeVisible();
-});
-
-test('reopening settings keeps the last-ready General page stable while refreshing', async ({
- window: page,
-}) => {
- await ensureSidebarExpanded(page);
- await page.getByRole('button', { name: '设置' }).click();
- const settings = page.locator('.settingsSurface');
- await page.getByRole('button', { name: '通用', exact: true }).click();
- await expect(settings.getByRole('textbox', { name: '助手语气偏好' })).toBeEnabled();
- await expect(settings.getByRole('button', { name: '默认模型' })).toBeEnabled();
-
- await page.keyboard.press('Escape');
- await expect(settings).toHaveCount(0);
- await expect(page.getByRole('button', { name: '设置' })).toBeVisible();
- await page.evaluate(() => {
- const state = {
- sawLoadingWarning: false,
- sawGeneralWithoutReadyHostControls: false,
- };
- const inspect = () => {
- const surface = document.querySelector('.settingsSurface');
- const main = surface?.querySelector('main, [role="main"]');
- if (!surface || !main) return;
- state.sawLoadingWarning ||= Array.from(
- surface.querySelectorAll('[role="alert"]'),
- ).some((banner) => banner.textContent?.includes('正在加载设置') === true);
- const defaultModelReady = Array.from(main.querySelectorAll('*')).some(
- (element) =>
- element.children.length === 0 &&
- element.textContent?.trim() === '默认模型' &&
- Boolean(element.closest('.astryx-item')?.querySelector('button')),
- );
- state.sawGeneralWithoutReadyHostControls ||=
- main.querySelector('textarea') === null || !defaultModelReady;
- };
- const observer = new MutationObserver(inspect);
- observer.observe(document.body, { childList: true, subtree: true, characterData: true });
- Object.assign(window, {
- __makaSettingsReopenProbe: {
- finish() {
- inspect();
- observer.disconnect();
- return state;
- },
- },
- });
- });
-
- await page.getByRole('button', { name: '设置' }).click();
- await expect(settings.getByRole('textbox', { name: '助手语气偏好' })).toBeEnabled();
- await expect(settings.getByRole('button', { name: '默认模型' })).toBeEnabled();
-
- const probe = await page.evaluate(() => {
- const target = window as typeof window & {
- __makaSettingsReopenProbe: {
- finish(): {
- sawLoadingWarning: boolean;
- sawGeneralWithoutReadyHostControls: boolean;
- };
- };
- };
- return target.__makaSettingsReopenProbe.finish();
- });
- expect(probe.sawLoadingWarning).toBe(false);
- expect(probe.sawGeneralWithoutReadyHostControls).toBe(false);
+ await expect(identity).toContainText('renamed before settings');
+ await expect.poll(() => page.evaluate(async () =>
+ (await window.maka.sessions.list()).some((session) => session.name === 'renamed before settings'),
+ )).toBe(true);
});
diff --git a/apps/desktop/e2e/transcript-scroll-cost.spec.ts b/apps/desktop/e2e/transcript-scroll-cost.spec.ts
index 388ad4546b..4b5efff725 100644
--- a/apps/desktop/e2e/transcript-scroll-cost.spec.ts
+++ b/apps/desktop/e2e/transcript-scroll-cost.spec.ts
@@ -62,12 +62,6 @@ const BOUNDARY_DISPLACEMENT_MAX_PX = 40;
declare global {
interface Window {
- __makaTranscriptCost?: {
- transitionRuns: number;
- animationStarts: number;
- skipped: WeakSet<Element>;
- skippedCount: number;
- };
__makaTranscriptDisplacement?: {
boundaries: TranscriptBoundary[];
record(on: boolean): void;
@@ -123,47 +117,6 @@ async function wheel(
));
}
-/**
- * Count every transition and animation the page starts, and track which Turns
- * the browser is currently skipping.
- *
- * `contentvisibilityautostatechange` rather than
- * `checkVisibility({ contentVisibilityAuto: true })`: the flag that method
- * reads is updated during rendering, so a synchronous call right after a
- * scroll reports every Turn visible even when the browser is skipping most of
- * them. Measured on this fixture, the method returned 0 skipped Turns in every
- * position the event reported between 1 and 8.
- */
-async function observe(page: Page): Promise<void> {
- await page.evaluate(() => {
- const state = {
- transitionRuns: 0,
- animationStarts: 0,
- skipped: new WeakSet<Element>(),
- skippedCount: 0,
- };
- window.__makaTranscriptCost = state;
- document.addEventListener('transitionrun', () => { state.transitionRuns += 1; }, true);
- document.addEventListener('animationstart', () => { state.animationStarts += 1; }, true);
- const bound = new WeakSet<Element>();
- const bind = (): void => {
- for (const turn of document.querySelectorAll('.maka-transcript-turn')) {
- if (bound.has(turn)) continue;
- bound.add(turn);
- turn.addEventListener('contentvisibilityautostatechange', (event) => {
- const skipped = (event as Event & { skipped: boolean }).skipped;
- if (skipped === state.skipped.has(turn)) return;
- if (skipped) state.skipped.add(turn);
- else state.skipped.delete(turn);
- state.skippedCount += skipped ? 1 : -1;
- });
- }
- };
- bind();
- new MutationObserver(bind).observe(document.body, { childList: true, subtree: true });
- });
-}
-
/**
* Watch every frame for a change in the mounted range, and measure what that
* change did to the reader.
@@ -289,30 +242,6 @@ async function displacement(page: Page): Promise<readonly TranscriptBoundary[]>
});
}
-interface CostSample {
- transitionRuns: number;
- animationStarts: number;
- unfinished: number;
- skippedTurns: number;
- mountedTurns: number;
-}
-
-async function sample(page: Page): Promise<CostSample> {
- return page.evaluate(() => {
- const state = window.__makaTranscriptCost;
- if (!state) throw new Error('the transcript cost observer is missing');
- return {
- transitionRuns: state.transitionRuns,
- animationStarts: state.animationStarts,
- unfinished: document.body
- .getAnimations({ subtree: true })
- .filter((animation) => animation.playState !== 'finished').length,
- skippedTurns: state.skippedCount,
- mountedTurns: document.querySelectorAll('[data-turn-id]').length,
- };
- });
-}
-
/**
* A transcript opened at its tail keeps fetching older history until two
* screens of it sit above the reader, and trims what falls outside the band it
@@ -363,124 +292,15 @@ async function returnToLatest(page: Page): Promise<void> {
await returnLatest.click();
}
-/**
- * The fixture's own motion contract, asserted as the count it is.
- *
- * `[data-maka-e2e-fixture]` collapses motion so a fixture render does not
- * depend on the millisecond it settles. It used to do that with
- * `transition-duration: 0.01ms`, which is not "no transition": the initial
- * `transition-property` is `all`, so every element kept a live transition on
- * every animatable property and fired transitionrun/start/end on every style
- * recalculation — measured here, ~1,200 transitions for one sweep over ten
- * mounted Turns, and tens of thousands over a long one. Every timing number
- * the replaced suite reported was mostly that.
- *
- * Nothing downstream can measure the product while the harness generates work
- * of its own, so the harness asserts zero.
- */
-test('a scroll through the fixture transcript starts no transitions', async ({
- promptRailWindow: page,
-}) => {
- await page.setViewportSize({ width: 1_000, height: 700 });
- await expect(page.locator(`[data-turn-id="turn-prompt-rail-${PROMPT_RAIL_PROMPT_COUNT}"]`))
- .toHaveCount(1);
- const cdp = await page.context().newCDPSession(page);
- await observe(page);
- await moveToTail(page);
- await wheel(page, cdp, { ticks: 40, deltaY: -120 });
- await wheel(page, cdp, { ticks: 40, deltaY: 120 });
-
- const cost = await sample(page);
- expect(cost.transitionRuns).toBe(0);
- expect(cost.animationStarts).toBe(0);
- // The reason the declaration exists: a fixture render is a settled state,
- // never an entry frame. `none` serves that strictly better than a near-zero
- // duration did — that one left transitions still running at sample time.
- expect(cost.unfinished).toBe(0);
-});
-
-/**
- * Containment is engaging at all. A `content-visibility: auto` that stops
- * skipping — a Turn that gains a property forcing layout, a container query,
- * an ancestor that breaks the containment chain — costs nothing that a timing
- * threshold would notice on a ten-Turn range, and everything on a long one.
- */
-test('the browser skips the Turns the reader has scrolled past', async ({
- promptRailWindow: page,
-}) => {
- await page.setViewportSize({ width: 1_000, height: 700 });
- await expect(page.locator(`[data-turn-id="turn-prompt-rail-${PROMPT_RAIL_PROMPT_COUNT}"]`))
- .toHaveCount(1);
- const cdp = await page.context().newCDPSession(page);
- await observe(page);
- await moveToTail(page);
- // Two viewports up and back: enough for the Turns at the far end of the
- // mounted range to leave the browser's relevance margin in both directions.
- await wheel(page, cdp, { ticks: 20, deltaY: -120 });
- await wheel(page, cdp, { ticks: 20, deltaY: 120 });
-
- expect((await sample(page)).skippedTurns).toBeGreaterThan(0);
-});
-
-/**
- * The bound the Desktop transcript is built on: paging back through a history
- * far longer than the retained band mounts a bounded number of Turns, not a
- * growing one. Sampled at every page rather than only at the end, because the
- * regression is a range that grows while the reader travels and is only trimmed
- * once they stop.
- */
-test('paging back through the whole history keeps the mounted range bounded', async ({
- promptRailWindow: page,
-}) => {
- test.setTimeout(120_000);
- await page.setViewportSize({ width: 1_000, height: 700 });
- await expect(page.locator(`[data-turn-id="turn-prompt-rail-${PROMPT_RAIL_PROMPT_COUNT}"]`))
- .toHaveCount(1);
- const cdp = await page.context().newCDPSession(page);
- const turns = page.locator('[data-turn-id]');
- let mountedMax = 0;
- let pages = 0;
-
- for (let iteration = 0; iteration < PROMPT_RAIL_PROMPT_COUNT; iteration += 1) {
- const firstBefore = await turns.first().getAttribute('data-turn-id');
- if (firstBefore === 'turn-prompt-rail-1') break;
- // The product asks for history on an upward wheel near the start, so the
- // gesture that pages is the gesture a reader makes. How many gestures it
- // takes is how tall the resident range happens to be, which is not what
- // this test is about — keep scrolling until the range moves.
- await expect
- .poll(async () => {
- await wheel(page, cdp, { ticks: 12, deltaY: -120 });
- return turns.first().getAttribute('data-turn-id');
- })
- .not.toBe(firstBefore);
- pages += 1;
- mountedMax = Math.max(mountedMax, await turns.count());
- }
-
- expect(pages).toBeGreaterThan(0);
- await expect(turns.first()).toHaveAttribute('data-turn-id', 'turn-prompt-rail-1');
- expect(mountedMax).toBeLessThanOrEqual(MOUNTED_TURNS_MAX);
-
- // Coming back from the far end reads the tail page and rebuilds the window
- // around it, so it is slower than the scrolling above. The suite's 10s expect
- // timeout is sized for UI that is already on screen, and this step measured
- // past it on a loaded CI runner.
- await returnToLatest(page);
- await expect(page.locator(`[data-turn-id="turn-prompt-rail-${PROMPT_RAIL_PROMPT_COUNT}"]`))
- .toHaveCount(1, { timeout: 30_000 });
- expect(await turns.count()).toBeLessThanOrEqual(MOUNTED_TURNS_MAX);
-});
-
/**
* The scenario #5163 was reported from: quit Desktop, start it again, open a
* long Session, and scroll upward through history without stopping. The reader
* perceives stalls or jumps around range boundaries.
*
- * The tests above establish that paging works and stays bounded. Neither says
+ * A mounted-range bound alone does not say
* where the reader ended up while a page was installing, which is the whole of
* what that report is about. This one measures it: every frame the mounted
- * range changes, whatever Turn the reader can still see must hold its document
+ * range changes, whatever Turn the reader can still see must hold its viewport
* position.
*
* Displacement in pixels rather than frame timings on purpose — see this file's
@@ -489,7 +309,7 @@ test('paging back through the whole history keeps the mounted range bounded', as
* content out from under the reader) and only one of them can be asserted
* without a clock.
*/
-test('paging back never moves the reader at a range boundary', async ({
+test('Host history paging stays bounded, preserves the reader and returns to latest', async ({
promptRailWindow: page,
}) => {
test.setTimeout(120_000);
@@ -500,6 +320,8 @@ test('paging back never moves the reader at a range boundary', async ({
const turns = page.locator('[data-turn-id]');
await moveToTail(page);
await observeDisplacement(page);
+ let pages = 0;
+ let mountedMax = await turns.count();
for (let iteration = 0; iteration < PROMPT_RAIL_PROMPT_COUNT; iteration += 1) {
const firstBefore = await turns.first().getAttribute('data-turn-id');
@@ -516,8 +338,14 @@ test('paging back never moves the reader at a range boundary', async ({
return turns.first().getAttribute('data-turn-id');
})
.not.toBe(firstBefore);
+ pages += 1;
+ mountedMax = Math.max(mountedMax, await turns.count());
}
+ expect(pages).toBeGreaterThan(0);
+ await expect(turns.first()).toHaveAttribute('data-turn-id', 'turn-prompt-rail-1');
+ expect(mountedMax).toBeLessThanOrEqual(MOUNTED_TURNS_MAX);
+
const boundaries = await displacement(page);
// The probe has to have seen the thing it measures: a run that paged nothing,
// or one where every boundary replaced the range wholesale and carried no
@@ -528,4 +356,9 @@ test('paging back never moves the reader at a range boundary', async ({
const displaced = boundaries.filter((boundary) => boundary.worstPx > BOUNDARY_DISPLACEMENT_MAX_PX);
expect(displaced, `range boundaries moved the reader: ${JSON.stringify(displaced)}`)
.toEqual([]);
+
+ await returnToLatest(page);
+ await expect(page.locator(`[data-turn-id="turn-prompt-rail-${PROMPT_RAIL_PROMPT_COUNT}"]`))
+ .toHaveCount(1, { timeout: 30_000 });
+ expect(await turns.count()).toBeLessThanOrEqual(MOUNTED_TURNS_MAX);
});
diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json
index e68255dab9..8918f334e9 100644
--- a/apps/desktop/renderer-architecture.json
+++ b/apps/desktop/renderer-architecture.json
@@ -502,7 +502,6 @@
"importDeclarations": 7,
"bridgePaths": {},
"environmentCapabilities": {
- "window": 2,
"window.addEventListener": 1,
"window.removeEventListener": 1
},
@@ -528,7 +527,7 @@
"react": 1
},
"importSpecifiers": 11,
- "nonTriviaTokens": 977
+ "nonTriviaTokens": 962
},
"src/renderer/app-shell-project-actions.ts": {
"importDeclarations": 4,
@@ -3457,7 +3456,6 @@
"actionFactories": [],
"dependencyPaths": {
"../locales/settings-shared-copy": 1,
- "./settings-nav": 1,
"./settings-surface": 1,
"@maka/ui": 1,
"react": 1
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index 04a4e1e3a7..81db4d9305 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -3913,14 +3913,14 @@ const makaBridge = {
// E2E-only async controls. Real users never get these: the preload mirrors the
// main process's isolated-E2E gate (startup-context.ts) — MAKA_E2E alone is
// not enough without the throwaway profile dir. An armed latch holds the next
-// bridge call or an explicitly gated renderer boundary until the test releases
+// bridge call until the test releases
// it, while a settled-call waiter exposes a deterministic completion boundary
// for work whose visible result may intentionally keep the same DOM identity.
// The wrappers must be installed BEFORE
// exposeInMainWorld: the bridge is cloned into the main world at expose time,
// and the exposed clone is sealed against later patching.
if (process.env.MAKA_E2E === '1' && process.env.MAKA_E2E_USER_DATA_DIR) {
- type LatchKey = 'newTasks.listInvocableSkills' | 'sessions.list' | 'sessions.observe' | 'settings.chunk';
+ type LatchKey = 'newTasks.listInvocableSkills' | 'sessions.list' | 'sessions.observe';
const gates = new Map<LatchKey, { promise: Promise<void>; oneShot: boolean }>();
const releases = new Map<LatchKey, { resolve: () => void; reject: (error: Error) => void }>();
let nextSessionObservationError: Error | undefined;
@@ -4009,9 +4009,6 @@ if (process.env.MAKA_E2E === '1' && process.env.MAKA_E2E_USER_DATA_DIR) {
gates.set(key, { promise, oneShot: options?.oneShot === true });
releases.set(key, { resolve, reject });
},
- wait(key: 'settings.chunk') {
- return waitForLatch(key);
- },
waitForInvocableSkillsCall(sessionId: string) {
return new Promise<void>((resolve) => {
const waiters = invocableSkillsWaiters.get(sessionId) ?? [];
diff --git a/apps/desktop/src/renderer/app-shell-overlays.tsx b/apps/desktop/src/renderer/app-shell-overlays.tsx
index d675b618e1..9a466e8272 100644
--- a/apps/desktop/src/renderer/app-shell-overlays.tsx
+++ b/apps/desktop/src/renderer/app-shell-overlays.tsx
@@ -17,7 +17,7 @@
* under the License.
*/
-import { lazy, Suspense, useLayoutEffect, useRef } from 'react';
+import { lazy, Suspense, useLayoutEffect, useRef, type ReactNode } from 'react';
import type { ChatDefaultPermissionMode, SettingsSection, ThemePalette, ThemePreference } from '@maka/core/settings';
import type { ProviderType } from '@maka/core/llm-connections';
import type { DesktopSessionSummary } from '../preload/bridge-contract.js';
@@ -32,15 +32,7 @@ import type { ArchivedTasksBridge } from './settings/tasks-settings-page';
import type { UiLocaleUpdateGate } from './settings/ui-locale-update-gate';
import { getShellRemainingCopy } from './locales/shell-remaining-copy.js';
-const SettingsModal = lazy(async () => {
- const e2eLatch = (
- window as typeof window & {
- makaE2eLatch?: { wait(key: 'settings.chunk'): Promise<void> };
- }
- ).makaE2eLatch;
- await e2eLatch?.wait('settings.chunk');
- return import('./settings/settings-modal');
-});
+const SettingsModal = lazy(() => import('./settings/settings-modal'));
type SearchModalProps = Parameters<typeof SearchModal>[0];
@@ -61,6 +53,30 @@ function SettingsModalFallback() {
);
}
+// Own dismissal outside the lazy chunk, including its Suspense fallback.
+export function SettingsOverlay({ onClose, children }: {
+ onClose(): void;
+ children: ReactNode;
+}) {
+ const closeRef = useRef(onClose);
+ useLayoutEffect(() => {
+ closeRef.current = onClose;
+ });
+ useLayoutEffect(() => {
+ function onKeyDown(event: KeyboardEvent) {
+ if (
+ event.key.toLowerCase() !== 'escape' || event.defaultPrevented ||
+ event.ctrlKey || event.metaKey || event.altKey
+ ) return;
+ event.preventDefault();
+ closeRef.current();
+ }
+ window.addEventListener('keydown', onKeyDown);
+ return () => window.removeEventListener('keydown', onKeyDown);
+ }, []);
+ return <Suspense fallback={<SettingsModalFallback />}>{children}</Suspense>;
+}
+
export function AppShellOverlays(props: {
settingsOpen: boolean;
closeSettings(): void;
@@ -126,36 +142,6 @@ export function AppShellOverlays(props: {
onExternalSessionImported,
} = props;
- const closeSettingsRef = useRef(closeSettings);
- useLayoutEffect(() => {
- closeSettingsRef.current = closeSettings;
- });
-
- // The overlay boundary, rather than the lazy Settings chunk, owns Escape.
- // That keeps one owner installed before paint for both the Suspense loading
- // surface and the resolved Settings surface. Keep the listener stable while
- // Settings is open, but read the latest shell callback after every commit.
- useLayoutEffect(() => {
- if (!settingsOpen) return;
-
- function onKeyDown(event: globalThis.KeyboardEvent) {
- if (
- event.key.toLowerCase() !== 'escape' ||
- event.defaultPrevented ||
- event.ctrlKey ||
- event.metaKey ||
- event.altKey
- ) {
- return;
- }
- event.preventDefault();
- closeSettingsRef.current();
- }
-
- window.addEventListener('keydown', onKeyDown);
- return () => window.removeEventListener('keydown', onKeyDown);
- }, [settingsOpen]);
-
// #1045: base commands freeze per open/close; session rows stay live on
// visibleSessions/activeId. run() closures read latest options via ref.
const commands = useAppShellCommands(paletteOpen, commandOptions);
@@ -169,7 +155,7 @@ export function AppShellOverlays(props: {
return (
<>
{settingsOpen && (
- <Suspense fallback={<SettingsModalFallback />}>
+ <SettingsOverlay onClose={closeSettings}>
<SettingsModal
onClose={closeSettings}
themePref={themePref}
@@ -192,7 +178,7 @@ export function AppShellOverlays(props: {
onRemoteHostAdded={props.onRemoteHostAdded}
onSelectedRuntimeHostProfileIdChange={props.onSelectedRuntimeHostProfileIdChange}
/>
- </Suspense>
+ </SettingsOverlay>
)}
<KeyboardHelpModal
isOpen={helpOpen}
diff --git a/apps/desktop/src/renderer/settings/settings-modal.tsx b/apps/desktop/src/renderer/settings/settings-modal.tsx
index 3cf5cce934..fff4b75115 100644
--- a/apps/desktop/src/renderer/settings/settings-modal.tsx
+++ b/apps/desktop/src/renderer/settings/settings-modal.tsx
@@ -28,9 +28,6 @@ import { SettingsSurface } from './settings-surface';
import type { ArchivedTasksBridge } from './tasks-settings-page';
import type { UiLocaleUpdateGate } from './ui-locale-update-gate';
-export { SETTINGS_NAV } from './settings-nav';
-export type { SettingsNavGroup } from './settings-nav';
-
export default function SettingsModal(props: {
onClose(): void;
themePref: ThemePreference;
diff --git a/apps/desktop/stories/AGENTS.md b/apps/desktop/stories/AGENTS.md
new file mode 100644
index 0000000000..cc802fbf99
--- /dev/null
+++ b/apps/desktop/stories/AGENTS.md
@@ -0,0 +1,48 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied. See the License for the
+ specific language governing permissions and limitations
+ under the License.
+-->
+
+# Desktop Storybook
+
+Before adding or changing stories, read [FIDELITY.md](FIDELITY.md), the shared
+convention for both Desktop and UI stories.
+
+- Prefer existing unit/component tests for state, event ordering, request
+ routing and persistence. Desktop renderer tests can live in
+ `../src/main/__tests__` using the existing Node/React test seam.
+- Use this browser tier for real layout, scrolling, selection, focus and
+ animation that a fake DOM cannot verify. Another viewport or theme is not a
+ reason to move the test to Electron; apply [Electron admission](../e2e/AGENTS.md)
+ before escalating.
+- Reuse the production component, controller and frame. Extend an existing
+ story when it already reaches the behavior; do not duplicate product logic or
+ build a second shell to make the test easy.
+- Check the smoke runner's actual viewport/theme jobs. Storybook toolbar
+ settings alone do not establish automated coverage. Follow FIDELITY for
+ reachable states, framing and assertion timing.
+- For migrated regression coverage, demonstrate that the assertion detects the
+ original defect, then remove the replaced E2E assertions and unused fixtures
+ in the same PR. Do not compensate for failures with longer timeouts, retries
+ or a global render-completion protocol.
+
+The Desktop Storybook host also discovers `packages/ui/stories`. From the
+repository root, run `npm --workspace @maka/desktop run typecheck:stories` and
+`npm --workspace @maka/desktop run build-storybook` before
+`npm --workspace @maka/desktop run smoke:storybook`. Smoke does not rebuild the
+catalog. For focused runs, verify that the relevant `play` functions finish;
+an image of the final screen alone does not prove their assertions passed.
diff --git a/apps/desktop/stories/FIDELITY.md b/apps/desktop/stories/FIDELITY.md
index 17bc65f42b..cba6648f2b 100644
--- a/apps/desktop/stories/FIDELITY.md
+++ b/apps/desktop/stories/FIDELITY.md
@@ -44,10 +44,10 @@ A story earns its place by rendering pixels no other story renders. A second lev
Two facts decide it, and both were guessed wrong once:
-- **Where a story renders.** CI mounts every story exactly once at 1280 wide in light. It does not maintain a viewport, theme or screenshot matrix. Responsive and theme behaviour belongs in a focused component contract or the real desktop E2E harness.
+- **Where a story renders.** The smoke runner owns the actual viewport and theme coverage; check `scripts/storybook-visual-smoke.mjs` rather than assuming Storybook toolbar parameters create CI jobs. It currently selects a narrow viewport for story IDs containing `narrow` and additional theme/palette/forced-color runs for selected sentinels. A responsive or theme contract needs an explicit browser scenario for its required conditions, not an Electron window merely because the default smo [...]
- **Whether `play` reaches the state.** `play` drives a story into the state a reviewer needs to see, and CI runs it — so the state it lands on is the state the smoke reads, and a story that only differs by a `play` step is a second state, not a variant.
-Extra stories still cost: a reviewer scanning the sidebar cannot tell which entry is the page, and duplicates re-render the same pixels every run while claiming coverage they do not add. Where a state matters but renders nothing new, pin it somewhere that runs — a `packages/ui` test or an e2e journey.
+Extra stories still cost: a reviewer scanning the sidebar cannot tell which entry is the page, and duplicates re-render the same pixels every run while claiming coverage they do not add. Where a state matters but renders nothing new, prefer an existing unit or component test. A browser or Electron test needs a boundary that the lower tier cannot verify; see [Electron admission](../e2e/AGENTS.md).
## The frame matters, not just the component
@@ -67,7 +67,7 @@ The render smoke waits for Storybook's `storyFinished` event before it reads the
That makes `play` the right home for a behavioural contract whose subject is the browser: a live Selection, a caret between text nodes, an undo transaction, a portal's identity across a re-render. None of those exist in a `packages/ui` DOM shim, and none of them need Electron.
-It is still not a place for geometry or theme matrices. CI mounts every story once, at 1280 wide, in light; a contract that depends on any other viewport or scheme belongs in a `packages/ui` test or the desktop E2E harness. And a rule that is pure state — which commands a Session offers, what a query parses to — belongs in a unit test, where it costs milliseconds instead of a browser.
+Real layout, browser scrolling, and responsive/theme behavior belong in this browser tier. Assert the relevant geometry against the production component and frame, with the viewport and theme explicitly exercised by the runner. A fake DOM cannot establish real layout, and needing another viewport is not a reason to launch Electron. Pure state — which commands a Session offers, what a query parses to — belongs in a unit test, where it costs milliseconds instead of a browser.
Write the assertion so it can only pass for the reason it names. A story that mounts the surface and then observes it cannot see anything that happened during the mount, so a probe that must be installed first (a constructor count, an event before the first paint) belongs in a test that owns the global.
diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx
index ee1388e81c..9555a69344 100644
--- a/apps/desktop/stories/app-shell.stories.tsx
+++ b/apps/desktop/stories/app-shell.stories.tsx
@@ -35,6 +35,7 @@ import {
import type { ChatModelChoice, SessionViewMode, TurnViewModel } from '@maka/ui';
import { SessionRail, type SessionRailStoryProps } from '../../../packages/ui/stories/session-rail-harness.js';
import { AppShellTopbarActions } from '../src/renderer/app-shell-chrome-actions';
+import { SettingsOverlay } from '../src/renderer/app-shell-overlays';
import {
WorkbarServicesProvider,
WorkbarTitlebarActions,
@@ -942,24 +943,113 @@ export const SafeResumeAfterRestart: Story = {
// Real path: a long session with 120 turns — past the transcript virtualizer's
// window, so it must stay correct and quiet where a handful of seeded turns
// would never trip the virtualization path.
+const manyTurnMessages = Array.from({ length: 120 }, (_, index) => {
+ const turnId = `turn-m-${index}`;
+ const minutesAgo = (120 - index) * 3;
+ return [
+ user(`msg-m-u-${index}`, turnId, minutesAgo, `第 ${index + 1} 轮:这个模块的边界条件该怎么覆盖?`),
+ assistant(`msg-m-a-${index}`, turnId, minutesAgo - 1, `第 ${index + 1} 轮回答:先列输入域,再对空、超长、并发三类分别加断言。`),
+ ];
+}).flat();
+
export const ManyTurns: Story = {
render: () => (
<ComposedShell
session={{ lastMessageAt: NOW - 60_000 }}
chat={{
- messages: Array.from({ length: 120 }, (_, index) => {
- const turnId = `turn-m-${index}`;
- const minutesAgo = (120 - index) * 3;
- return [
- user(`msg-m-u-${index}`, turnId, minutesAgo, `第 ${index + 1} 轮:这个模块的边界条件该怎么覆盖?`),
- assistant(`msg-m-a-${index}`, turnId, minutesAgo - 1, `第 ${index + 1} 轮回答:先列输入域,再对空、超长、并发三类分别加断言。`),
- ];
- }).flat(),
+ messages: manyTurnMessages,
}}
/>
),
};
+// The same transcript and fixture CSS, with enough resident Turns for native
+// render skipping but no need to mount the 120-Turn catalog demonstration.
+export const TranscriptRenderCost: Story = {
+ render: () => <ComposedShell frameHeight={700} chat={{ messages: manyTurnMessages.slice(-64) }} />,
+ play: async ({ canvasElement }) => {
+ const scroller = canvasElement.querySelector<HTMLElement>('[data-chat-scroll-container="true"]');
+ if (!scroller) throw new Error('Transcript scrollport is missing');
+ const frame = () => new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
+ await frame();
+ await frame();
+ let transitions = 0;
+ let animations = 0;
+ const skipped = new Set<Element>();
+ const turns = [...canvasElement.querySelectorAll<HTMLElement>('.maka-transcript-turn')];
+ expect(turns.length).toBeGreaterThan(0);
+ for (const pseudo of [null, '::before', '::after']) {
+ const style = getComputedStyle(turns[0], pseudo);
+ expect(style.transitionProperty).toBe('none');
+ expect(style.animationName).toBe('none');
+ }
+ const visibility = (event: Event) => {
+ if (event.target !== event.currentTarget) return;
+ const turn = event.currentTarget as Element;
+ if ((event as Event & { skipped: boolean }).skipped) skipped.add(turn);
+ else skipped.delete(turn);
+ };
+ for (const turn of turns) turn.addEventListener('contentvisibilityautostatechange', visibility);
+ const transition = () => { transitions += 1; };
+ const animation = () => { animations += 1; };
+ canvasElement.addEventListener('transitionrun', transition, true);
+ canvasElement.addEventListener('animationstart', animation, true);
+ try {
+ // No Host paging or wheel routing is under test: move the real Chromium
+ // scrollport to exercise the fixture CSS and browser render skipping.
+ // Two distant positions expose and skip resident Turns. Forty incremental
+ // paints added cost under CI contention without testing another contract.
+ for (const direction of [-1, 1]) {
+ scroller.dispatchEvent(new WheelEvent('wheel', { deltaY: direction * 120, bubbles: true }));
+ scroller.scrollTop = direction < 0 ? 0 : scroller.scrollHeight;
+ await frame();
+ await frame();
+ }
+ await waitFor(() => expect(skipped.size).toBeGreaterThan(0));
+ expect(transitions).toBe(0);
+ expect(animations).toBe(0);
+ expect(canvasElement.getAnimations({ subtree: true })
+ .filter((animation) => animation.playState !== 'finished')).toHaveLength(0);
+ } finally {
+ for (const turn of turns) turn.removeEventListener('contentvisibilityautostatechange', visibility);
+ canvasElement.removeEventListener('transitionrun', transition, true);
+ canvasElement.removeEventListener('animationstart', animation, true);
+ }
+ },
+};
+
+const pendingSettingsChunk = new Promise<never>(() => {});
+function PendingSettingsChunk(): never { throw pendingSettingsChunk; }
+function SettingsLoadingScene() {
+ const [open, setOpen] = useState(true);
+ return open ? (
+ <SettingsOverlay onClose={() => setOpen(false)}><PendingSettingsChunk /></SettingsOverlay>
+ ) : null;
+}
+
+// Real path: AppShellOverlays owns dismissal while its Settings chunk suspends.
+export const SettingsLoadingEscape: Story = {
+ render: () => <SettingsLoadingScene />,
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await canvas.findByRole('status', { name: '正在加载设置' });
+ for (const modifier of ['ctrlKey', 'metaKey', 'altKey'] as const) {
+ const event = new KeyboardEvent('keydown', {
+ key: 'Escape', [modifier]: true, bubbles: true, cancelable: true,
+ });
+ expect(window.dispatchEvent(event)).toBe(true);
+ expect(canvas.getByRole('status', { name: '正在加载设置' })).toBeVisible();
+ }
+ expect(window.dispatchEvent(new KeyboardEvent('keydown', {
+ key: 'Escape', bubbles: true, cancelable: true,
+ }))).toBe(false);
+ await waitFor(() => expect(canvas.queryByRole('status', { name: '正在加载设置' })).not.toBeInTheDocument());
+ expect(window.dispatchEvent(new KeyboardEvent('keydown', {
+ key: 'Escape', bubbles: true, cancelable: true,
+ }))).toBe(true);
+ },
+};
+
// Real path: enough task history to overflow the sidebar. The rail owns the
// scrollport while its footer remains inside the fixed shell frame.
export const OverflowingSidebar: Story = {
diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx
index 8064f31381..676e0ea180 100644
--- a/apps/desktop/stories/settings/settings-pages.stories.tsx
+++ b/apps/desktop/stories/settings/settings-pages.stories.tsx
@@ -1848,6 +1848,7 @@ function renderedLinkColors(renderedLink: HTMLElement) {
}
type SettingsStoryProps = {
+ reopenable?: boolean;
section: SettingsSection;
connections?: LlmConnection[];
defaultSlug?: string | null;
@@ -1895,6 +1896,7 @@ function SettingsStory(props: SettingsStoryProps) {
}
function SettingsStoryFrame(props: SettingsStoryProps) {
+ const [open, setOpen] = useState(true);
const archivedTasks = useArchivedTasksStoryBridge(props.archivedTaskSessions ?? []);
const initialFocusRef = useRef<HTMLButtonElement>(null);
const [uiLocaleUpdateGate] = useState(createUiLocaleUpdateGate);
@@ -1918,6 +1920,7 @@ function SettingsStoryFrame(props: SettingsStoryProps) {
return (
<>
+ {props.reopenable && <button onClick={() => setOpen(!open)}>{open ? 'Close settings' : 'Reopen settings'}</button>}
{/* `100dvh`, not `100%`: `SettingsSurface` is a `Layout height="fill"`,
which needs a bounded ancestor to hand its content pane a scroll
box. Under Storybook's fullscreen body a percentage height resolves
@@ -1936,7 +1939,7 @@ function SettingsStoryFrame(props: SettingsStoryProps) {
<ConnectionSettingsServicesProvider services={connectionSettingsServices}>
<RuntimeHostManagementServicesProvider services={runtimeHostManagementServices}>
<SessionBundleServicesProvider services={sessionBundleServices}>
- <SettingsSurface
+ {open && <SettingsSurface
onClose={noop}
themePref={themePref}
onThemeChange={setThemePref}
@@ -1957,7 +1960,7 @@ function SettingsStoryFrame(props: SettingsStoryProps) {
onRemoteHostAdded={noop}
onSelectedRuntimeHostProfileIdChange={noop}
snapshotCache={snapshotCache}
- />
+ />}
</SessionBundleServicesProvider>
</RuntimeHostManagementServicesProvider>
</ConnectionSettingsServicesProvider>
@@ -2212,6 +2215,50 @@ export const GeneralCachedRevalidation: Story = {
await expect(canvas.queryByRole('alert')).not.toBeInTheDocument();
},
};
+
+// Real path: unmount and reopen Settings with its renderer-owned snapshot cache.
+// Observe every DOM commit, not just the final ready screen after refresh.
+export const GeneralReopenKeepsReadyControls: Story = {
+ decorators: [withGeneralHostGenerationRevalidationBridge],
+ render: () => {
+ resetGenerationStoryBridge();
+ return <SettingsStory section="general" reopenable />;
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await waitFor(() => {
+ expect(canvas.getByRole('textbox', { name: '助手语气偏好' })).toBeEnabled();
+ expect(canvas.getByRole('button', { name: '默认模型' })).toBeEnabled();
+ });
+ await userEvent.click(canvas.getByRole('button', { name: 'Close settings' }));
+ expect(canvas.queryByRole('textbox', { name: '助手语气偏好' })).not.toBeInTheDocument();
+ let missingControls = false;
+ let loadingAlert = false;
+ const inspect = () => {
+ const surface = canvasElement.querySelector('.settingsSurface');
+ const main = surface?.querySelector('main, [role="main"]');
+ if (!surface || !main) return;
+ missingControls ||= main.querySelector('textarea') === null ||
+ within(main as HTMLElement).queryByRole('button', { name: '默认模型' }) === null;
+ loadingAlert ||= [...surface.querySelectorAll('[role="alert"]')]
+ .some((alert) => alert.textContent?.includes('正在加载设置'));
+ };
+ const observer = new MutationObserver(inspect);
+ observer.observe(canvasElement, { childList: true, subtree: true, characterData: true });
+ try {
+ await userEvent.click(canvas.getByRole('button', { name: 'Reopen settings' }));
+ await waitFor(() => {
+ expect(canvas.getByRole('textbox', { name: '助手语气偏好' })).toBeEnabled();
+ expect(canvas.getByRole('button', { name: '默认模型' })).toBeEnabled();
+ });
+ inspect();
+ expect(missingControls).toBe(false);
+ expect(loadingAlert).toBe(false);
+ } finally {
+ observer.disconnect();
+ }
+ },
+};
// A Runtime Host can be replaced without changing its renderer-facing
// profileId:hostId key. The lifecycle epoch is the generation boundary: keep
// cached Host values visible, revoke their write authority immediately, and
diff --git a/packages/ui/stories/AGENTS.md b/packages/ui/stories/AGENTS.md
new file mode 100644
index 0000000000..8c4399b55c
--- /dev/null
+++ b/packages/ui/stories/AGENTS.md
@@ -0,0 +1,41 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied. See the License for the
+ specific language governing permissions and limitations
+ under the License.
+-->
+
+# Shared UI Storybook
+
+Read [the shared fidelity convention](../../../apps/desktop/stories/FIDELITY.md)
+before adding or changing stories. Use its rules rather than maintaining a
+second version here.
+
+- Prefer `../src/__tests__` for pure state and component behavior that the
+ existing Node/React harness can verify. Do not create a browser story solely
+ because a test happens to render React.
+- Keep real Chromium layout, scrolling, selection, focus and animation checks
+ in the browser tier. Fake DOM geometry is not layout evidence; viewport,
+ theme or wheel requirements alone do not justify Electron. Follow
+ [Electron admission](../../../apps/desktop/e2e/AGENTS.md) before escalating.
+- Exercise the shared component through its production props and behavior.
+ Desktop-specific shell wiring belongs in the Desktop stories; do not copy
+ that shell into a UI fixture. Preserve the real owning frame for geometry.
+- Reuse existing scenarios and imports, and prove migrated regression
+ assertions can detect the defect before deleting their old coverage.
+
+These stories run in the Desktop Storybook host. Follow the
+[Desktop validation instructions](../../../apps/desktop/stories/AGENTS.md)
+for typecheck, build and smoke; there is no separate UI Storybook runner.
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org