From ea29c06762a08ab6c19a8910ff54da18398fb4de Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 5 Sep 2026 11:27:32 +0800 Subject: [PATCH 1/8] test(desktop): retire warm transcript fixture controls Generated-by: Codex --- apps/desktop/e2e/fixtures.ts | 71 ++-------------- apps/desktop/renderer-architecture.json | 5 +- .../runtime-host-session-observer.test.ts | 83 ------------------- .../__tests__/scroll-motion-policy.test.ts | 14 +--- apps/desktop/src/main/e2e-fixture.ts | 13 +-- apps/desktop/src/main/runtime-host-boot.ts | 2 - .../main/runtime-host-desktop-candidate.ts | 2 - ...runtime-host-session-execution-ipc-main.ts | 7 -- ...ntime-host-session-observation-registry.ts | 29 ------- apps/desktop/src/preload/preload.ts | 3 - .../src/renderer/app-shell-e2e-fixture.ts | 6 -- .../src/renderer/scroll-motion-policy.ts | 20 ----- packages/core/src/e2e-fixture.ts | 7 -- scripts/fixture-env.mjs | 7 -- 14 files changed, 10 insertions(+), 259 deletions(-) diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index c62b9d4ae0..4836073573 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -416,7 +416,6 @@ async function withE2eWindow( locale, platform, showWindow, - scrollMotion, invocableSkills, gitReviewExtraFiles, parentRemovalSessions, @@ -427,8 +426,6 @@ async function withE2eWindow( readinessSelector: string; e2eFixtureScenario?: string; locale?: 'zh-CN' | 'zh-TW' | 'en'; - /** Opt this window back into animated scrolling; see `scroll-motion-policy`. */ - scrollMotion?: 'auto' | 'smooth'; /** #1312: force app:info's platform so the window boots natively into that platform's `data-os` cascade. */ platform?: 'darwin' | 'win32' | 'linux'; /** Show fixtures whose contract depends on compositor-paced frames. */ @@ -473,7 +470,6 @@ async function withE2eWindow( scenario: e2eFixtureScenario, locale, platform, - scrollMotion, showWindow: visibleWindow, }), }); @@ -519,47 +515,6 @@ async function withE2eWindow( } } -interface PromptRailWorker { - app: ElectronApplication; - page: Page; - viewport: { width: number; height: number }; -} - -async function setPromptRailWindowVisible( - worker: PromptRailWorker, - visible: boolean, -): Promise { - await worker.app.evaluate(({ BrowserWindow }, shouldShow) => { - const window = BrowserWindow.getAllWindows()[0]; - if (!window) throw new Error('the prompt-rail BrowserWindow is missing'); - // showInactive, not show: this worker window is re-revealed between every - // test in the file, and show() activates the app each time — a suite run - // would yank the developer's foreground away a dozen times over. The - // window still needs to be on screen for the compositor. - if (shouldShow) window.showInactive(); - else window.hide(); - }, visible); -} - -async function resetPromptRailWindow(worker: PromptRailWorker): Promise { - await worker.page.evaluate(async () => { - const controls = ( - window as typeof window & { - makaE2eLatch?: { - releaseRendererObservations(): Promise; - }; - } - ).makaE2eLatch; - if (!controls) throw new Error('the isolated E2E controls are unavailable'); - await controls.releaseRendererObservations(); - localStorage.clear(); - sessionStorage.clear(); - }); - await worker.page.setViewportSize(worker.viewport); - await worker.page.reload(); - await worker.page.waitForSelector('[data-turn-id]', { timeout: 20_000 }); -} - type E2eTestFixtures = { window: Page; agentGraphWindow: Page; @@ -582,7 +537,6 @@ type E2eTestFixtures = { type E2eWorkerFixtures = { isolatedDisplay: void; - promptRailWorker: PromptRailWorker; }; export const test = base.extend({ @@ -720,10 +674,10 @@ export const test = base.extend({ use, ); }, - // Keep this scenario's real Electron + Host composition warm for the worker, - // while the test-scoped wrapper below restores Host and renderer state - // between tests. Tests on it may run a Turn, so the reset is not read-only. - promptRailWorker: [async ({}, use) => { + // A multi-prompt transcript. Each cost assertion gets an isolated Host and + // renderer so observation state cannot bleed between tests. The window is + // shown because these cases drive the real compositor through CDP. + promptRailWindow: async ({}, use) => { await withE2eWindow({ seed: false, // A rendered turn, deliberately not the rail: Playwright treats a @@ -737,22 +691,7 @@ export const test = base.extend({ // passes on a Chinese desktop and cannot find it on an English CI runner. locale: 'zh-CN', showWindow: true, - }, async (page, { app }) => { - const viewport = await page.evaluate(() => ({ width: innerWidth, height: innerHeight })); - await use({ app, page, viewport }); - }); - }, { scope: 'worker' }], - // A multi-prompt transcript. Shown, because the perf suite that measures it - // reads real frame pacing, and a throttled compositor paces nothing a user - // would see. - promptRailWindow: async ({ promptRailWorker }, use) => { - await setPromptRailWindowVisible(promptRailWorker, true); - try { - await resetPromptRailWindow(promptRailWorker); - await use(promptRailWorker.page); - } finally { - await setPromptRailWindowVisible(promptRailWorker, false); - } + }, use); }, // The same seeded transcript, on a window of its own. Search reads the Host // through the bridge and renders nothing, so it needs neither the warm diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 68445223b9..280af69dca 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -441,7 +441,7 @@ "window.maka.e2eFixture.getState": 1 }, "environmentCapabilities": { - "document.documentElement.setAttribute": 4 + "document.documentElement.setAttribute": 3 }, "hookCalls": {}, "lifecycleMethods": {}, @@ -453,7 +453,7 @@ "./theme": 1 }, "importSpecifiers": 1, - "nonTriviaTokens": 666 + "nonTriviaTokens": 642 }, "src/renderer/app-shell-effects.ts": { "importDeclarations": 11, @@ -2115,7 +2115,6 @@ "document.documentElement": 2, "document.documentElement.dataset.makaE2eFixture": 1, "document.documentElement.dataset.makaReducedMotion": 1, - "document.documentElement.dataset.makaScrollMotion": 5, "window.matchMedia": 2 }, "hookCalls": {}, diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts index ea76a37f39..852b89f2bd 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts @@ -577,89 +577,6 @@ test('does not hold Host observation recovery on transcript replay', async () => await observations.close(); }); -test('releases one renderer target before reload without restoring its observations', async () => { - const observations = new RuntimeHostSessionObservationRegistry(); - const sessionCleanup = deferred(); - const transcriptCleanup = deferred(); - const unobserved: string[] = []; - const closedTranscripts: string[] = []; - const firstSource = { - async observe() {}, - async unobserve(observerId: string) { - unobserved.push(observerId); - await sessionCleanup.promise; - }, - async openTranscript(sessionId: string) { - return { - sessionId, - generation: 'first', - hostEpoch: 'host-first', - readThroughMessageId: null, - }; - }, - async loadTranscriptBefore() {}, - async loadTranscriptAround() {}, - async closeTranscript(consumerId: string) { - closedTranscripts.push(consumerId); - await transcriptCleanup.promise; - }, - }; - const targetA = { - id: 31, - send() {}, - once() {}, - off() {}, - } satisfies RuntimeHostSessionObserverTarget & RuntimeHostTranscriptTarget; - const targetB = { - id: 32, - send() {}, - once() {}, - off() {}, - } satisfies RuntimeHostSessionObserverTarget; - - await observations.attach(firstSource); - await observations.observe('session-a', 'observer-a', targetA); - await observations.openTranscript('session-a', 'consumer-a', targetA); - await observations.observe('session-b', 'observer-b', targetB); - - let released = false; - const releasing = observations.releaseTarget(targetA.id).then(() => { - released = true; - }); - assert.deepEqual(unobserved, ['observer-a']); - assert.deepEqual(closedTranscripts, ['consumer-a']); - assert.deepEqual(observations.trackedSessionIds(), ['session-b']); - assert.equal(released, false); - - sessionCleanup.resolve(); - await Promise.resolve(); - assert.equal(released, false); - transcriptCleanup.resolve(); - await releasing; - assert.equal(released, true); - - observations.detach(firstSource); - const restoredObservers: string[] = []; - const restoredTranscripts: string[] = []; - const secondSource = { - async observe(_sessionId: string, observerId: string) { - restoredObservers.push(observerId); - }, - async unobserve() {}, - async openTranscript(_sessionId: string, consumerId: string) { - restoredTranscripts.push(consumerId); - throw new Error('released transcript was restored'); - }, - async loadTranscriptBefore() {}, - async loadTranscriptAround() {}, - async closeTranscript() {}, - }; - assert.deepEqual(await observations.attach(secondSource), ['session-b']); - assert.deepEqual(restoredObservers, ['observer-b']); - assert.deepEqual(restoredTranscripts, []); - await observations.close(); -}); - test('fences transcript range failures to the current registration and Host source', async () => { const observations = new RuntimeHostSessionObservationRegistry(); const target: RuntimeHostTranscriptTarget = { diff --git a/apps/desktop/src/main/__tests__/scroll-motion-policy.test.ts b/apps/desktop/src/main/__tests__/scroll-motion-policy.test.ts index 19f69aa98e..6f4664f710 100644 --- a/apps/desktop/src/main/__tests__/scroll-motion-policy.test.ts +++ b/apps/desktop/src/main/__tests__/scroll-motion-policy.test.ts @@ -27,21 +27,16 @@ const base = { prefersReducedMotion: false, }; -test('captures collapse scroll motion, and a fixture can ask for it back', () => { +test('captures collapse scroll motion', () => { assert.equal(resolveScrollMotionBehavior(base), 'smooth'); assert.equal( resolveScrollMotionBehavior({ ...base, e2eFixtureAttr: true }), 'auto', 'a capture is deterministic by default', ); - assert.equal( - resolveScrollMotionBehavior({ ...base, e2eFixtureAttr: true, scrollMotionAttr: 'smooth' }), - 'smooth', - 'a fixture whose subject is scrolling opts back in', - ); }); -test('no fixture request outranks a preference for less motion', () => { +test('a preference for less motion collapses scrolling', () => { for (const reduced of [ { reducedMotionAttr: true }, { prefersReducedMotion: true }, @@ -51,13 +46,8 @@ test('no fixture request outranks a preference for less motion', () => { ...base, ...reduced, e2eFixtureAttr: true, - scrollMotionAttr: 'smooth', }), 'auto', ); } }); - -test('a fixture can also ask for no motion outside a capture', () => { - assert.equal(resolveScrollMotionBehavior({ ...base, scrollMotionAttr: 'auto' }), 'auto'); -}); diff --git a/apps/desktop/src/main/e2e-fixture.ts b/apps/desktop/src/main/e2e-fixture.ts index 5b64d73e1d..154e57ca64 100644 --- a/apps/desktop/src/main/e2e-fixture.ts +++ b/apps/desktop/src/main/e2e-fixture.ts @@ -92,7 +92,6 @@ export interface E2eFixture { locale: UiLocale | null; timezone: string | null; platform: 'darwin' | 'win32' | 'linux' | null; - scrollMotion: 'auto' | 'smooth' | null; } export function resolveE2eFixture( @@ -103,7 +102,6 @@ export function resolveE2eFixture( rawLocale: string | undefined = undefined, rawTimezone: string | undefined = undefined, rawPlatform: string | undefined = undefined, - rawScrollMotion: string | undefined = undefined, ): E2eFixture | null { if (!rawScenario) return null; if (isPackaged) throw new Error('MAKA_E2E_FIXTURE is only available in dev/test builds.'); @@ -119,15 +117,9 @@ export function resolveE2eFixture( locale: parseLocaleFlag(rawLocale), timezone: parseTimezoneFlag(rawTimezone), platform: parsePlatformFlag(rawPlatform), - scrollMotion: parseScrollMotionFlag(rawScrollMotion), }; } -function parseScrollMotionFlag(raw: string | undefined): 'auto' | 'smooth' | null { - const normalized = raw?.trim().toLowerCase(); - return normalized === 'auto' || normalized === 'smooth' ? normalized : null; -} - function parseThemeFlag(raw: string | undefined): 'light' | 'dark' | 'auto' | null { const normalized = raw?.trim().toLowerCase(); return normalized === 'light' || normalized === 'dark' || normalized === 'auto' @@ -174,7 +166,6 @@ export function getE2eFixtureState(fixture: E2eFixture | null): E2eFixtureState ...(fixture.theme ? { theme: fixture.theme } : {}), ...(fixture.locale ? { locale: fixture.locale } : {}), ...(fixture.timezone ? { timezone: fixture.timezone } : {}), - ...(fixture.scrollMotion ? { scrollMotion: fixture.scrollMotion } : {}), }; switch (fixture.scenario) { case 'settings-models': @@ -188,9 +179,7 @@ export function getE2eFixtureState(fixture: E2eFixture | null): E2eFixtureState return { ...state, activeSessionId: TURN_SESSION_ID, workbarCollapsed: false, workbarTab: 'browser' }; case 'chat-prompt-rail': // Workbar collapsed: the rail lives on the chat scrollport's right edge, - // and the panel would take the width the measurements are about. Whether - // this window scrolls smoothly is a per-launch choice (`scrollMotion`), - // because only the jump case needs it and it costs seconds of settling. + // and the panel would take the width the measurements are about. return { ...state, activeSessionId: PROMPT_RAIL_SESSION_ID, workbarCollapsed: true }; case 'chat-partial-history': return { ...state, activeSessionId: PARTIAL_HISTORY_SESSION_ID, workbarCollapsed: true }; diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 192e465f8f..ef0005c406 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -1099,7 +1099,6 @@ const startLocalRuntimeHostManager = () => startRuntimeHostDesktopManager( }, emitSessionsChanged, completeComputerUseTurn, - enableE2eControls: isE2e, createSessionCopyCleanup: ({ removeSession, resumeSessionCopy }) => createSessionCopyCleanupAuthority({ workspaceRoot, @@ -2055,7 +2054,6 @@ function resolveDesktopE2eFixture(): ReturnType { process.env.MAKA_E2E_FIXTURE_LOCALE, process.env.MAKA_E2E_FIXTURE_TIMEZONE, process.env.MAKA_E2E_FIXTURE_PLATFORM, - process.env.MAKA_E2E_FIXTURE_SCROLL_MOTION, ); } catch (error) { if (!process.env.MAKA_E2E_FIXTURE) throw error; diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts index 81dd3bffbc..e323bf6c4a 100644 --- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts +++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts @@ -137,7 +137,6 @@ export interface DesktopRuntimeHostCandidateDeps { readonly completeComputerUseTurn: ( sessionId: string, ) => void | Promise; - readonly enableE2eControls?: boolean; readonly e2eInteractions?: RuntimeHostSessionExecutionIpcDeps["e2eInteractions"]; readonly renderer?: { send(channel: string, scope: DesktopTargetScope, payload: unknown): void; @@ -672,7 +671,6 @@ export async function createDesktopRuntimeHostCandidate( }, }, ipc, - deps.enableE2eControls === true, ); if (target.access === 'session_guest') { const trackedSessionIds = sessionObservations.trackedSessionIds(); diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts index 7cf76169bd..3175fb3c4a 100644 --- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts @@ -178,7 +178,6 @@ export interface RuntimeHostSessionObservationIpcDeps { | 'loadTranscriptBefore' | 'observe' | 'openTranscript' - | 'releaseTarget' >; resolveSideConversation(sessionId: string): Promise; } @@ -187,7 +186,6 @@ export interface RuntimeHostSessionObservationIpcDeps { export function registerRuntimeHostSessionObservationIpc( deps: RuntimeHostSessionObservationIpcDeps, ipcMain: ReconnectableReadIpcMain, - enableE2eControls = false, ): void { handleReconnectableRead( ipcMain, @@ -223,11 +221,6 @@ export function registerRuntimeHostSessionObservationIpc( event.sender.id, ); }); - if (enableE2eControls) { - ipcMain.handle('sessions:e2e:release-renderer-observations', (event) => - deps.observations.releaseTarget(event.sender.id), - ); - } } /** diff --git a/apps/desktop/src/main/runtime-host-session-observation-registry.ts b/apps/desktop/src/main/runtime-host-session-observation-registry.ts index 4e1a6754fc..c8bb8f9db9 100644 --- a/apps/desktop/src/main/runtime-host-session-observation-registry.ts +++ b/apps/desktop/src/main/runtime-host-session-observation-registry.ts @@ -248,35 +248,6 @@ export class RuntimeHostSessionObservationRegistry { } } - async releaseTarget(targetId: number): Promise { - const source = this.#source; - const observations = [...this.#registrations].filter( - ([, registration]) => registration.target.id === targetId, - ); - const transcripts = [...this.#transcripts].filter( - ([, registration]) => registration.target.id === targetId, - ); - for (const [observerId, registration] of observations) { - this.#deleteRegistration(observerId, registration); - } - for (const [consumerId, registration] of transcripts) { - this.#deleteTranscript(consumerId, registration); - } - if (!source) return; - - const cleanup = await Promise.allSettled([ - ...observations.map(([observerId]) => source.unobserve(observerId)), - ...transcripts.map(([consumerId]) => source.closeTranscript?.(consumerId, targetId)), - ]); - const errors = cleanup - .filter((result): result is PromiseRejectedResult => result.status === 'rejected') - .map((result) => result.reason); - if (errors.length === 1) throw errors[0]; - if (errors.length > 1) { - throw new AggregateError(errors, 'Failed to release renderer Session observations'); - } - } - detach(source: SessionObservationSource): void { if (this.#source === source) { this.#source = undefined; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 7041eda546..1f7e01f312 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -3865,9 +3865,6 @@ if (process.env.MAKA_E2E === '1' && process.env.MAKA_E2E_USER_DATA_DIR) { invocableSkillsWaiters.set(sessionId, waiters); }); }, - releaseRendererObservations() { - return invokeActiveRuntimeHost('sessions:e2e:release-renderer-observations'); - }, rejectNextSessionObservation(message: string) { nextSessionObservationError = new Error(message); }, diff --git a/apps/desktop/src/renderer/app-shell-e2e-fixture.ts b/apps/desktop/src/renderer/app-shell-e2e-fixture.ts index 102b9e4ade..af7eabc496 100644 --- a/apps/desktop/src/renderer/app-shell-e2e-fixture.ts +++ b/apps/desktop/src/renderer/app-shell-e2e-fixture.ts @@ -69,12 +69,6 @@ export function createAppShellE2eFixtureActions(options: { Date.now = () => state.now!; } document.documentElement.setAttribute('data-maka-e2e-fixture', 'true'); - // Read by `scroll-motion-policy`: a fixture whose subject is scrolling - // asks for the production behavior back, since the blanket collapse would - // finish every scroll in one frame and hide what it is testing. - if (state.scrollMotion) { - document.documentElement.setAttribute('data-maka-scroll-motion', state.scrollMotion); - } // PR-IR-01b: theme override applied BEFORE the persisted user pref so // the rendered fixture matches the `--` variant // exactly. `applyTheme` writes both the React state + the `.dark` class diff --git a/apps/desktop/src/renderer/scroll-motion-policy.ts b/apps/desktop/src/renderer/scroll-motion-policy.ts index 74cf336533..4212fba37c 100644 --- a/apps/desktop/src/renderer/scroll-motion-policy.ts +++ b/apps/desktop/src/renderer/scroll-motion-policy.ts @@ -47,21 +47,6 @@ export interface ScrollMotionPolicyInputs { e2eFixtureAttr: boolean; /** `window.matchMedia('(prefers-reduced-motion: reduce)').matches` */ prefersReducedMotion: boolean; - /** - * `document.documentElement.dataset.makaScrollMotion`, set by a fixture that - * asks for a specific behavior. - * - * Collapsing motion for every capture is right for a screenshot and wrong - * for a test about scrolling: a scroll that finishes in one frame cannot - * collide with anything, and Astryx's auto-follow lock only contends with a - * scroll still in flight. This lets one fixture opt back in without - * loosening the default for the rest. - * - * Deliberately below the reduced-motion triggers: a fixture may ask for - * motion the capture would otherwise skip, but nothing may override a - * stated preference for less of it. - */ - scrollMotionAttr?: ScrollMotionBehavior | undefined; } /** @@ -75,9 +60,6 @@ export function resolveScrollMotionBehavior(inputs: ScrollMotionPolicyInputs): S if (inputs.reducedMotionAttr || inputs.prefersReducedMotion) { return 'auto'; } - if (inputs.scrollMotionAttr) { - return inputs.scrollMotionAttr; - } if (inputs.e2eFixtureAttr) { return 'auto'; } @@ -94,11 +76,9 @@ export function readScrollMotionBehavior(): ScrollMotionBehavior { const prefersReducedMotion = typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; - const requested = root.dataset.makaScrollMotion; return resolveScrollMotionBehavior({ reducedMotionAttr: root.dataset.makaReducedMotion === 'true', e2eFixtureAttr: root.dataset.makaE2eFixture === 'true', prefersReducedMotion, - scrollMotionAttr: requested === 'smooth' || requested === 'auto' ? requested : undefined, }); } diff --git a/packages/core/src/e2e-fixture.ts b/packages/core/src/e2e-fixture.ts index 574e60548c..5647a162b0 100644 --- a/packages/core/src/e2e-fixture.ts +++ b/packages/core/src/e2e-fixture.ts @@ -46,13 +46,6 @@ export interface E2eFixtureState { activeSessionId?: string; openSettingsSection?: SettingsSection; reducedMotion?: boolean; - /** - * Opt a fixture back into animated scrolling. Captures collapse scroll - * motion so a screenshot never depends on when it settles, which also means - * no fixture can exercise a scroll that is still in flight — and that is - * precisely what the prompt rail's jump has to survive. - */ - scrollMotion?: 'auto' | 'smooth'; theme?: 'light' | 'dark' | 'auto'; locale?: UiLocale; timezone?: string; diff --git a/scripts/fixture-env.mjs b/scripts/fixture-env.mjs index 3d9dc4ee67..fff66e506e 100644 --- a/scripts/fixture-env.mjs +++ b/scripts/fixture-env.mjs @@ -59,7 +59,6 @@ function isDeniedEnvKey(key) { * locale?: 'zh-CN' | 'zh-TW' | 'en', * platform?: 'darwin' | 'win32' | 'linux', * theme?: 'light' | 'dark', - * scrollMotion?: 'auto' | 'smooth', * timezone?: string, * showWindow?: boolean, * }} [options] @@ -104,12 +103,6 @@ export function buildFixtureEnv(userDataDir, homeDir, options = {}) { // explicitly. The decision stays with the caller: this builder is a pure // function of its arguments, so a test asserting "hidden run stays hidden" // means the same thing on a laptop and on a CI runner. - // Captures collapse scroll motion so their state never depends on when a - // scroll settles. A fixture whose subject IS the scrolling asks for the - // production behavior back — see `scroll-motion-policy`. Per launch rather - // than per scenario: it costs several seconds of settling per window, and - // only the case that needs it should pay. - if (options.scrollMotion) env.MAKA_E2E_FIXTURE_SCROLL_MOTION = options.scrollMotion; if (options.showWindow) env.MAKA_E2E_SHOW_WINDOW = '1'; return env; } From 3d9d4a2348a8bda49df9c5484a1a6ac6c85bcb0b Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 5 Sep 2026 11:34:41 +0800 Subject: [PATCH 2/8] test(desktop): move graph and onboarding layout to stories Generated-by: Codex --- apps/desktop/e2e/agent-graph-layout.spec.ts | 87 ------------- apps/desktop/e2e/fixtures.ts | 22 ---- apps/desktop/e2e/onboarding-viewport.spec.ts | 119 ------------------ apps/desktop/src/main/e2e-fixture.ts | 98 +-------------- .../src/main/e2e-fixture/scenarios-chat.ts | 10 -- .../src/main/e2e-fixture/seed-helpers.ts | 1 - .../stories/agent-graph-panel.stories.tsx | 36 +++++- apps/desktop/stories/onboarding.stories.tsx | 80 +++++++++++- packages/core/src/e2e-fixture.ts | 1 - 9 files changed, 110 insertions(+), 344 deletions(-) delete mode 100644 apps/desktop/e2e/agent-graph-layout.spec.ts delete mode 100644 apps/desktop/e2e/onboarding-viewport.spec.ts diff --git a/apps/desktop/e2e/agent-graph-layout.spec.ts b/apps/desktop/e2e/agent-graph-layout.spec.ts deleted file mode 100644 index 8739520196..0000000000 --- a/apps/desktop/e2e/agent-graph-layout.spec.ts +++ /dev/null @@ -1,87 +0,0 @@ -/* - * 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. - */ - -import { expect, test } from './fixtures'; - -test('production AgentGraphPanel keeps its heading visible without covering scrolled content', async ({ agentGraphWindow: page }) => { - const panel = page.getByRole('region', { name: 'Agent Graph', exact: true }); - await expect(panel).toBeVisible(); - await expect(panel.locator('.maka-agent-graph-operators > li')).toHaveCount(24); - const content = panel.locator('.maka-agent-graph-content'); - const before = await panel.evaluate((element) => { - const heading = element.querySelector('.maka-agent-graph-heading'); - const content = element.querySelector('.maka-agent-graph-content'); - if (!(heading instanceof HTMLElement) || !(content instanceof HTMLElement)) { - throw new Error('Agent Graph production panel structure is incomplete'); - } - const panelRect = element.getBoundingClientRect(); - const headingRect = heading.getBoundingClientRect(); - const contentRect = content.getBoundingClientRect(); - return { - panelHeight: panelRect.height, - headingTop: headingRect.top, - headingBottom: headingRect.bottom, - contentTop: contentRect.top, - contentScrollHeight: content.scrollHeight, - contentClientHeight: content.clientHeight, - contentScrollTop: content.scrollTop, - }; - }); - expect(before.contentScrollHeight).toBeGreaterThan(before.contentClientHeight); - await content.evaluate((element) => { - element.scrollTop = element.scrollHeight; - }); - const after = await panel.evaluate((element) => { - const heading = element.querySelector('.maka-agent-graph-heading'); - const content = element.querySelector('.maka-agent-graph-content'); - if (!(heading instanceof HTMLElement) || !(content instanceof HTMLElement)) { - throw new Error('Agent Graph production panel structure is incomplete'); - } - const headingRect = heading.getBoundingClientRect(); - const contentRect = content.getBoundingClientRect(); - return { - headingTop: headingRect.top, - headingBottom: headingRect.bottom, - contentTop: contentRect.top, - contentScrollTop: content.scrollTop, - }; - }); - expect(after.contentScrollTop).toBeGreaterThan(0); - expect(Math.abs(after.headingTop - before.headingTop)).toBeLessThanOrEqual(1); - expect(Math.abs(after.contentTop - before.contentTop)).toBeLessThanOrEqual(1); - expect(after.contentTop).toBeGreaterThanOrEqual(after.headingBottom); - const collapse = page.getByRole('button', { name: '收起 Agent Graph', exact: true }); - await collapse.click(); - await expect(panel).toHaveAttribute('data-collapsed', 'true'); - // The data attribute is committed before a newly loaded stylesheet has - // necessarily completed its first style recalculation. Wait for the - // collapsed contract rather than sampling that transient pre-CSS value. - await expect - .poll(async () => panel.locator('.maka-agent-graph-heading').evaluate((element) => { - const style = getComputedStyle(element); - return { paddingBottom: style.paddingBottom, borderBottomWidth: style.borderBottomWidth }; - })) - .toEqual({ paddingBottom: '0px', borderBottomWidth: '0px' }); - const collapsedHeading = await panel.locator('.maka-agent-graph-heading').evaluate((element) => { - const style = getComputedStyle(element); - return { paddingBottom: style.paddingBottom, borderBottomWidth: style.borderBottomWidth }; - }); - expect(collapsedHeading.paddingBottom).toBe('0px'); - expect(collapsedHeading.borderBottomWidth).toBe('0px'); -}); diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index 4836073573..312486d283 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -517,8 +517,6 @@ async function withE2eWindow( type E2eTestFixtures = { window: Page; - agentGraphWindow: Page; - onboardingWindow: Page; gitReviewWindow: { page: Page; projectRoot: string }; invocableSkillsWindow: Page; linkColorWindow: Page; @@ -577,26 +575,6 @@ export const test = base.extend({ window: async ({}, use) => { await withE2eWindow({ seed: true, readinessSelector: COMPOSER_INPUT, locale: 'zh-CN' }, use); }, - agentGraphWindow: async ({}, use) => { - await withE2eWindow( - { - seed: false, - readinessSelector: '.maka-agent-graph-panel', - e2eFixtureScenario: 'agent-graph-layout', - locale: 'zh-CN', - showWindow: true, - }, - use, - ); - }, - onboardingWindow: async ({}, use) => { - await withE2eWindow({ - seed: false, - readinessSelector: '[data-maka-contract="onboarding-card"]', - locale: 'zh-CN', - showWindow: true, - }, use); - }, gitReviewWindow: async ({}, use) => { await withE2eWindow( { diff --git a/apps/desktop/e2e/onboarding-viewport.spec.ts b/apps/desktop/e2e/onboarding-viewport.spec.ts deleted file mode 100644 index 0605dd3b49..0000000000 --- a/apps/desktop/e2e/onboarding-viewport.spec.ts +++ /dev/null @@ -1,119 +0,0 @@ -/* - * 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. - */ - -import { expect, test } from './fixtures'; - -test('first-run onboarding stays within the chat viewport', async ({ onboardingWindow }) => { - const geometry = await onboardingWindow.evaluate(() => { - const pageRoot = document.scrollingElement; - const scrollContainer = document.querySelector('[data-chat-scroll-container="true"]'); - const surface = document.querySelector('[data-maka-contract="onboarding-surface"]'); - const card = document.querySelector('[data-maka-contract="onboarding-card"]'); - if (!pageRoot || !scrollContainer || !surface || !card) { - throw new Error('Onboarding geometry is unavailable'); - } - - const scrollRect = scrollContainer.getBoundingClientRect(); - const surfaceRect = surface.getBoundingClientRect(); - const cardRect = card.getBoundingClientRect(); - return { - pageClientHeight: pageRoot.clientHeight, - pageScrollHeight: pageRoot.scrollHeight, - clientHeight: scrollContainer.clientHeight, - scrollHeight: scrollContainer.scrollHeight, - surfaceTop: surfaceRect.top, - surfaceBottom: surfaceRect.bottom, - viewportTop: scrollRect.top, - viewportBottom: scrollRect.bottom, - onboardingLayout: scrollContainer.dataset.makaOnboarding, - cardTop: cardRect.top, - cardBottom: cardRect.bottom, - }; - }); - - expect(geometry.pageScrollHeight, JSON.stringify(geometry, null, 2)).toBe( - geometry.pageClientHeight, - ); - expect(geometry.scrollHeight, JSON.stringify(geometry, null, 2)).toBe(geometry.clientHeight); - expect(geometry.onboardingLayout).toBe('true'); - expect(geometry.surfaceTop).toBeGreaterThanOrEqual(geometry.viewportTop); - expect(geometry.surfaceBottom).toBeLessThanOrEqual(geometry.viewportBottom); - expect(geometry.cardTop).toBeGreaterThanOrEqual(geometry.viewportTop); - expect(geometry.cardBottom).toBeLessThanOrEqual(geometry.viewportBottom); -}); - -test('first-run onboarding remains reachable at the minimum window size', async ({ - onboardingWindow, -}) => { - await onboardingWindow.setViewportSize({ width: 480, height: 320 }); - - const surface = onboardingWindow.locator('[data-maka-contract="onboarding-surface"]'); - const initialScrollTop = await surface.evaluate((element) => element.scrollTop); - await surface.hover(); - await onboardingWindow.mouse.wheel(0, 10_000); - await expect.poll(() => surface.evaluate((element) => element.scrollTop)).toBeGreaterThan( - initialScrollTop, - ); - - const geometry = await onboardingWindow.evaluate(() => { - const pageRoot = document.scrollingElement; - const scrollContainer = document.querySelector('[data-chat-scroll-container="true"]'); - const surface = document.querySelector('[data-maka-contract="onboarding-surface"]'); - const content = surface?.querySelector('.maka-onboarding-center'); - if (!pageRoot || !scrollContainer || !surface || !content) { - throw new Error('Onboarding geometry is unavailable'); - } - - const scrollRect = scrollContainer.getBoundingClientRect(); - const surfaceRect = surface.getBoundingClientRect(); - const contentRect = content.getBoundingClientRect(); - - return { - windowInnerWidth: window.innerWidth, - windowInnerHeight: window.innerHeight, - pageClientHeight: pageRoot.clientHeight, - pageScrollHeight: pageRoot.scrollHeight, - viewportClientHeight: scrollContainer.clientHeight, - viewportScrollHeight: scrollContainer.scrollHeight, - viewportTop: scrollRect.top, - viewportBottom: scrollRect.bottom, - surfaceClientHeight: surface.clientHeight, - surfaceScrollHeight: surface.scrollHeight, - surfaceTop: surfaceRect.top, - surfaceBottom: surfaceRect.bottom, - finalScrollTop: surface.scrollTop, - contentBottom: contentRect.bottom, - }; - }); - - expect(geometry.windowInnerWidth).toBe(480); - expect(geometry.windowInnerHeight).toBe(320); - expect(geometry.pageScrollHeight, JSON.stringify(geometry, null, 2)).toBe( - geometry.pageClientHeight, - ); - expect(geometry.viewportScrollHeight, JSON.stringify(geometry, null, 2)).toBe( - geometry.viewportClientHeight, - ); - expect(geometry.surfaceScrollHeight).toBeGreaterThan(geometry.surfaceClientHeight); - expect(initialScrollTop).toBe(0); - expect(geometry.finalScrollTop).toBeGreaterThan(initialScrollTop); - expect(geometry.surfaceTop).toBeGreaterThanOrEqual(geometry.viewportTop); - expect(geometry.surfaceBottom).toBeLessThanOrEqual(geometry.viewportBottom); - expect(geometry.contentBottom).toBeLessThanOrEqual(geometry.surfaceBottom); -}); diff --git a/apps/desktop/src/main/e2e-fixture.ts b/apps/desktop/src/main/e2e-fixture.ts index 154e57ca64..5be7c0ed42 100644 --- a/apps/desktop/src/main/e2e-fixture.ts +++ b/apps/desktop/src/main/e2e-fixture.ts @@ -20,10 +20,6 @@ import { mkdir, rm } from 'node:fs/promises'; import { join } from 'node:path'; import type { E2eFixtureScenario, E2eFixtureState } from '@maka/core/e2e-fixture'; -import type { AgentGraphClientSnapshot } from '@maka/runtime/stream-graph-read-model'; -import { agentGraphIdForRootSession } from '@maka/runtime/stream-graph-coordinator'; -import { createAgentGraphControlStore } from '@maka/storage/agent-graph-control-store'; -import { AGENT_GRAPH_CLIENT_PROJECTION_SCHEMA_VERSION } from '@maka/core/agent-graph-client-projection'; import { MODEL_CALL_ATTEMPT_EVENT_TYPE } from '@maka/core/model-call-attempt'; import type { UiLocale } from '@maka/core/ui-locale'; import { createSqliteAgentRunStore } from '@maka/storage/agent-run-store'; @@ -33,7 +29,6 @@ import { resolveStorageRoot, tryAcquireInteractiveRootOwner, } from '@maka/storage/root-authority'; -import { openInteractiveSessionTodoStoreForWrite } from '@maka/storage/session-todo-authority'; import { openInteractiveUsageStoresForWrite } from '@maka/storage/usage-stores'; import { E2E_FIXTURE_NOW, @@ -42,7 +37,6 @@ import { LONG_SIDEBAR_SESSION_PREFIX, PARTIAL_HISTORY_SESSION_ID, PROMPT_RAIL_SESSION_ID, - AGENT_GRAPH_SESSION_ID, TURN_SESSION_ID, writeSession, } from './e2e-fixture/seed-helpers.js'; @@ -53,7 +47,6 @@ import { promptRailSession, turnMessages, turnSession, - agentGraphSession, } from './e2e-fixture/scenarios-chat.js'; import { seedMcpFixture, seedSkillsMarketFixture } from './e2e-fixture/scenarios-modules.js'; import { longSidebarSessions } from './e2e-fixture/scenarios-sessions.js'; @@ -80,7 +73,6 @@ const E2E_FIXTURE_SCENARIOS = new Set([ 'module-mcp', 'module-daily-review', 'scheduled-tasks', - 'agent-graph-layout', 'sidebar-search-modal-open', ]); @@ -206,8 +198,6 @@ export function getE2eFixtureState(fixture: E2eFixture | null): E2eFixtureState return { ...state, activeSessionId: TURN_SESSION_ID, sidebarSection: 'daily-review', sidebarCollapsed: false }; case 'scheduled-tasks': return { ...state, activeSessionId: TURN_SESSION_ID, sidebarSection: 'automations', sidebarCollapsed: false }; - case 'agent-graph-layout': - return { ...state, activeSessionId: AGENT_GRAPH_SESSION_ID }; case 'sidebar-search-modal-open': return { ...state, @@ -233,13 +223,10 @@ export async function seedE2eFixture(input: { await writeConnections(input.workspaceRoot, now, scenario); await writeSession( input.workspaceRoot, - scenario === 'agent-graph-layout' ? agentGraphSession(now) : turnSession(now), + turnSession(now), turnMessages(now), ); - if (scenario === 'agent-graph-layout') await seedAgentGraphLayout(input.workspaceRoot, now); - - if (scenario === 'chat-prompt-rail') { await writeSession(input.workspaceRoot, promptRailSession(now), promptRailMessages(now)); } @@ -313,86 +300,3 @@ export async function seedE2eFixture(input: { } } } - -async function seedAgentGraphLayout(workspaceRoot: string, now: number): Promise { - const graphId = agentGraphIdForRootSession(AGENT_GRAPH_SESSION_ID); - const operators: AgentGraphClientSnapshot['operators'] = Array.from( - { length: 24 }, - (_, index) => ({ - operatorId: `operator-${index + 1}`, - childSessionId: `child-session-${index + 1}`, - provisionId: `provision-${index + 1}`, - agentId: `agent-${index + 1}`, - provisionedAt: now - (24 - index) * 1_000, - status: 'completed' as const, - inboundEdgeIds: [], - outboundEdgeIds: [], - scheduledWorkIds: [`work-${index + 1}`], - readiness: [], - omitted: { - inboundEdgeIds: 0, - outboundEdgeIds: 0, - scheduledWorkIds: 0, - readiness: 0, - readinessWaits: 0, - }, - }), - ); - const snapshot: AgentGraphClientSnapshot = { - schemaVersion: 1, - rootSessionId: AGENT_GRAPH_SESSION_ID, - graphId, - orchestrationMode: 'graph', - snapshotVersion: `sha256:${'a'.repeat(64)}`, - status: 'active', - scheduleRevision: 1, - topologyFingerprint: `sha256:${'b'.repeat(64)}`, - closed: false, - latestEventTime: now, - operators, - edges: [], - work: operators.map((operator, index) => ({ - workId: `work-${index + 1}`, - target: { kind: 'agent' as const, agentId: operator.agentId }, - inputIds: [], - status: 'requested' as const, - instructionPreview: `布局回归 operator ${index + 1}`, - instructionTruncated: false, - revision: 1, - committedAt: now - (24 - index) * 1_000, - })), - reconciliationFailures: [], - stoppedTargets: [], - claims: [], - recentControlDecisions: [], - recentActivity: [], - terminalHistory: { records: [] }, - omitted: { - operators: 0, - edges: 0, - work: 0, - reconciliationFailures: 0, - stoppedTargets: 0, - claims: 0, - controlDecisions: 0, - recentActivity: 0, - }, - }; - const controlStore = createAgentGraphControlStore(workspaceRoot); - try { - await controlStore.commitAgentGraphClientProjection({ - schemaVersion: AGENT_GRAPH_CLIENT_PROJECTION_SCHEMA_VERSION, - graphId, - rootSessionId: AGENT_GRAPH_SESSION_ID, - expectedSnapshotVersion: null, - snapshotVersion: snapshot.snapshotVersion, - snapshot, - replaceOperators: true, - operators: [], - terminalActivities: [], - activityRecords: [], - }); - } finally { - controlStore.close(); - } -} diff --git a/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts b/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts index fb26506f70..a62da0d421 100644 --- a/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts +++ b/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts @@ -20,22 +20,12 @@ import type { SessionHeader, StoredMessage } from '@maka/core/session'; import { header, - AGENT_GRAPH_SESSION_ID, PARTIAL_HISTORY_SESSION_ID, PROMPT_RAIL_PROMPT_COUNT, PROMPT_RAIL_SESSION_ID, TURN_SESSION_ID, } from './seed-helpers.js'; -export function agentGraphSession(now: number): SessionHeader { - return { - ...turnSession(now), - id: AGENT_GRAPH_SESSION_ID, - name: 'Agent Graph 布局示例', - orchestrationMode: 'graph', - }; -} - export function turnSession(now: number): SessionHeader { return header({ id: TURN_SESSION_ID, diff --git a/apps/desktop/src/main/e2e-fixture/seed-helpers.ts b/apps/desktop/src/main/e2e-fixture/seed-helpers.ts index 02255173ea..1e1cd6a535 100644 --- a/apps/desktop/src/main/e2e-fixture/seed-helpers.ts +++ b/apps/desktop/src/main/e2e-fixture/seed-helpers.ts @@ -33,7 +33,6 @@ import { createSqliteSessionMetadataStore } from '@maka/storage/sqlite-session-m export const E2E_FIXTURE_NOW = Date.UTC(2026, 4, 22, 3, 0, 0); export const TURN_SESSION_ID = 'e2e-fixture-turn'; -export const AGENT_GRAPH_SESSION_ID = 'e2e-fixture-agent-graph'; export const PROMPT_RAIL_SESSION_ID = 'e2e-fixture-prompt-rail'; export const PARTIAL_HISTORY_SESSION_ID = 'e2e-fixture-partial-history'; /** Exceeds both the 64-tick rail and the bounded active transcript range. */ diff --git a/apps/desktop/stories/agent-graph-panel.stories.tsx b/apps/desktop/stories/agent-graph-panel.stories.tsx index de038fb7f9..f9de92b649 100644 --- a/apps/desktop/stories/agent-graph-panel.stories.tsx +++ b/apps/desktop/stories/agent-graph-panel.stories.tsx @@ -260,10 +260,40 @@ export const ManyOperators: Story = { ], render: panel, play: async ({ canvasElement }) => { - // The full list renders (its last operator is reachable). An exact row - // count and the omitted "+N" line are read-model contracts, covered by the - // read-model's own tests rather than asserted here (review feedback). await waitFor(() => expect(canvasElement.textContent).toContain('op-27')); + const panelElement = canvasElement.querySelector('.maka-agent-graph-panel'); + const heading = panelElement?.querySelector('.maka-agent-graph-heading'); + const content = panelElement?.querySelector('.maka-agent-graph-content'); + const collapse = panelElement?.querySelector( + '.maka-agent-graph-collapse-toggle', + ); + if (!panelElement || !heading || !content || !collapse) { + throw new Error('Agent Graph production panel structure is incomplete'); + } + expect(panelElement.querySelectorAll('.maka-agent-graph-operators > li')).toHaveLength(28); + + const before = { + headingTop: heading.getBoundingClientRect().top, + contentTop: content.getBoundingClientRect().top, + contentScrollHeight: content.scrollHeight, + contentClientHeight: content.clientHeight, + }; + expect(before.contentScrollHeight).toBeGreaterThan(before.contentClientHeight); + content.scrollTop = content.scrollHeight; + await waitFor(() => expect(content.scrollTop).toBeGreaterThan(0)); + + const headingRect = heading.getBoundingClientRect(); + expect(Math.abs(headingRect.top - before.headingTop)).toBeLessThanOrEqual(1); + expect(Math.abs(content.getBoundingClientRect().top - before.contentTop)).toBeLessThanOrEqual(1); + expect(content.getBoundingClientRect().top).toBeGreaterThanOrEqual(headingRect.bottom); + + collapse.click(); + await waitFor(() => expect(panelElement.dataset.collapsed).toBe('true')); + await waitFor(() => { + const style = getComputedStyle(heading); + expect(style.paddingBottom).toBe('0px'); + expect(style.borderBottomWidth).toBe('0px'); + }); }, }; diff --git a/apps/desktop/stories/onboarding.stories.tsx b/apps/desktop/stories/onboarding.stories.tsx index 3fd821ab3c..0905029f1d 100644 --- a/apps/desktop/stories/onboarding.stories.tsx +++ b/apps/desktop/stories/onboarding.stories.tsx @@ -23,6 +23,7 @@ import type { LlmConnection, ProviderType } from '@maka/core/llm-connections'; import type { OnboardingState } from '@maka/core/onboarding'; import type { SettingsSection } from '@maka/core/settings'; import { ChatSurfaceLayout, ChatView } from '@maka/ui'; +import { expect, waitFor } from 'storybook/test'; import { OnboardingHero } from '../src/renderer/onboarding-hero'; const meta = { @@ -36,7 +37,7 @@ type Story = StoryObj; const MAKA_WINDOW_FLOOR_VIEWPORT = { makaWindowFloor: { name: 'Maka window floor', - styles: { width: '480px', height: '900px' }, + styles: { width: '480px', height: '320px' }, type: 'desktop' as const, }, }; @@ -68,7 +69,12 @@ const connections: LlmConnection[] = [ * three wrappers outside it mirror the app shell because app-shell.tsx itself * cannot be mounted as a story without its main-process orchestration. */ -function DetailPane(props: { children?: ReactNode }) { +function DetailPane(props: { + children?: ReactNode; + height?: number | string; + minHeight?: number; + width?: number | string; +}) { const emptyOverride = props.children === undefined ? undefined :
{props.children}
; @@ -76,7 +82,12 @@ function DetailPane(props: { children?: ReactNode }) {
( + '[data-chat-scroll-container="true"]', + ); + const surface = canvasElement.querySelector('.maka-onboarding-surface'); + const card = canvasElement.querySelector('[data-maka-contract="onboarding-card"]'); + if (!pageRoot || !scrollContainer || !surface || !card) { + throw new Error('Onboarding geometry is unavailable'); + } + const scrollRect = scrollContainer.getBoundingClientRect(); + const surfaceRect = surface.getBoundingClientRect(); + const cardRect = card.getBoundingClientRect(); + return { + pageClientHeight: pageRoot.clientHeight, + pageScrollHeight: pageRoot.scrollHeight, + viewportClientHeight: scrollContainer.clientHeight, + viewportScrollHeight: scrollContainer.scrollHeight, + viewportTop: scrollRect.top, + viewportBottom: scrollRect.bottom, + surfaceClientHeight: surface.clientHeight, + surfaceScrollHeight: surface.scrollHeight, + surfaceTop: surfaceRect.top, + surfaceBottom: surfaceRect.bottom, + cardTop: cardRect.top, + cardBottom: cardRect.bottom, + onboardingLayout: scrollContainer.dataset.makaOnboarding, + }; +} + function heroProps( state: OnboardingState, overrides: Partial> = {}, @@ -122,6 +163,16 @@ export const NeedsConnection: Story = { ), + play: async ({ canvasElement }) => { + const geometry = onboardingGeometry(canvasElement); + expect(geometry.pageScrollHeight).toBe(geometry.pageClientHeight); + expect(geometry.viewportScrollHeight).toBe(geometry.viewportClientHeight); + expect(geometry.onboardingLayout).toBe('true'); + expect(geometry.surfaceTop).toBeGreaterThanOrEqual(geometry.viewportTop); + expect(geometry.surfaceBottom).toBeLessThanOrEqual(geometry.viewportBottom); + expect(geometry.cardTop).toBeGreaterThanOrEqual(geometry.viewportTop); + expect(geometry.cardBottom).toBeLessThanOrEqual(geometry.viewportBottom); + }, }; // Real path: a configured connection exists but its credential is unavailable. @@ -181,8 +232,29 @@ export const NarrowWindow: Story = { parameters: { viewport: { options: MAKA_WINDOW_FLOOR_VIEWPORT } }, globals: { viewport: { value: 'makaWindowFloor', isRotated: false } }, render: () => ( - + ), + play: async ({ canvasElement }) => { + const surface = canvasElement.querySelector('.maka-onboarding-surface'); + if (!surface) throw new Error('Onboarding surface is unavailable'); + const initialScrollTop = surface.scrollTop; + surface.scrollTop = surface.scrollHeight; + await waitFor(() => expect(surface.scrollTop).toBeGreaterThan(initialScrollTop)); + + const geometry = onboardingGeometry(canvasElement); + const content = surface.querySelector('.maka-onboarding-center'); + if (!content) throw new Error('Onboarding content is unavailable'); + const app = canvasElement.querySelector('.app'); + if (!app) throw new Error('Onboarding app shell is unavailable'); + expect(app.getBoundingClientRect().width).toBe(480); + expect(app.getBoundingClientRect().height).toBe(320); + expect(geometry.pageScrollHeight).toBe(geometry.pageClientHeight); + expect(geometry.viewportScrollHeight).toBe(geometry.viewportClientHeight); + expect(geometry.surfaceScrollHeight).toBeGreaterThan(geometry.surfaceClientHeight); + expect(geometry.surfaceTop).toBeGreaterThanOrEqual(geometry.viewportTop); + expect(geometry.surfaceBottom).toBeLessThanOrEqual(geometry.viewportBottom); + expect(content.getBoundingClientRect().bottom).toBeLessThanOrEqual(geometry.surfaceBottom); + }, }; diff --git a/packages/core/src/e2e-fixture.ts b/packages/core/src/e2e-fixture.ts index 5647a162b0..0099c188ee 100644 --- a/packages/core/src/e2e-fixture.ts +++ b/packages/core/src/e2e-fixture.ts @@ -37,7 +37,6 @@ export type E2eFixtureScenario = | 'module-mcp' | 'module-daily-review' | 'scheduled-tasks' - | 'agent-graph-layout' | 'sidebar-search-modal-open'; export interface E2eFixtureState { From 2ed264f14339b5c5bae69ad56b9afc31c79fafa8 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 5 Sep 2026 11:45:26 +0800 Subject: [PATCH 3/8] test(desktop): move settings layout contracts to stories Generated-by: Codex --- apps/desktop/e2e/fixtures.ts | 18 --- apps/desktop/e2e/link-color-contract.spec.ts | 88 ------------ .../permission-center-metadata-layout.spec.ts | 86 ----------- .../e2e/request-header-row-contract.spec.ts | 78 ---------- apps/desktop/e2e/settings.spec.ts | 77 ---------- .../main/permission-snapshot-e2e-fixture.ts | 20 +-- .../settings/provider-settings.stories.tsx | 26 ++++ .../settings/settings-pages.stories.tsx | 133 +++++++++++++++++- scripts/storybook-visual-smoke.mjs | 1 + 9 files changed, 168 insertions(+), 359 deletions(-) delete mode 100644 apps/desktop/e2e/link-color-contract.spec.ts delete mode 100644 apps/desktop/e2e/permission-center-metadata-layout.spec.ts delete mode 100644 apps/desktop/e2e/request-header-row-contract.spec.ts diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index 312486d283..d5a2b5de1e 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -519,7 +519,6 @@ type E2eTestFixtures = { window: Page; gitReviewWindow: { page: Page; projectRoot: string }; invocableSkillsWindow: Page; - linkColorWindow: Page; projectSidebarWindow: Page; parentRemovalWindow: Page; railRenderWindow: Page; @@ -527,7 +526,6 @@ type E2eTestFixtures = { threadSearchWindow: Page; partialHistoryWindow: Page; requestHeaderRowWindow: Page; - permissionCenterWindow: Page; newTaskTargetWindow: Page; directoryReferenceWindow: { page: Page; folder: string }; accessibilityNarrativeWindow: Page; @@ -600,13 +598,6 @@ export const test = base.extend({ invocableSkills: true, }, use); }, - linkColorWindow: async ({}, use) => { - await withE2eWindow({ - seed: false, - readinessSelector: '.settingsBotConfigDocLink', - e2eFixtureScenario: 'settings-bots-onboarding', - }, use); - }, // Seeded connection so the composer is ready, plus one registered Project so // the workspace picker under it has a second target to move to. newTaskTargetWindow: async ({}, use) => { @@ -707,15 +698,6 @@ export const test = base.extend({ showWindow: true, }, use); }, - permissionCenterWindow: async ({}, use) => { - await withE2eWindow({ - seed: false, - readinessSelector: '.settingsCapabilityGroup', - e2eFixtureScenario: 'settings-permissions', - locale: 'zh-CN', - showWindow: true, - }, use); - }, // A data-backed conversation with settled tool evidence and the workbar open // beside it. Shown because the accessibility journey follows real native // focus order through the transcript into the composer controls. diff --git a/apps/desktop/e2e/link-color-contract.spec.ts b/apps/desktop/e2e/link-color-contract.spec.ts deleted file mode 100644 index 16cd371faa..0000000000 --- a/apps/desktop/e2e/link-color-contract.spec.ts +++ /dev/null @@ -1,88 +0,0 @@ -/* - * 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. - */ - -import type { Page } from '@playwright/test'; -import { test, expect } from './fixtures'; - -async function renderedLinkColors(page: Page, dark: boolean) { - return page.evaluate(async (isDark) => { - const root = document.documentElement; - const renderedLink = document.querySelector('.settingsBotConfigDocLink')!; - renderedLink.style.setProperty('transition', 'none', 'important'); - root.setAttribute('data-maka-theme', 'tokyo-night'); - // Both halves, exactly as theme.ts setDarkClass does it: the class carries - // the mode to Astryx, and color-scheme is what resolves the palette's - // light-dark() pairs (DESIGN.md §8). Toggling the class alone leaves every - // colour on its light value. - root.classList.toggle('dark', isDark); - root.style.colorScheme = isDark ? 'dark' : 'light'; - - // Astryx's re-declares color-scheme on its own wrapper from React - // state that follows the class through a MutationObserver, so the app's - // subtree lands a commit after the root does. Wait for the mode to reach - // the content rather than for a frame: a rAF is not always enough, and a - // fixed delay would be a race with a number on it. - const settled = () => getComputedStyle(renderedLink).colorScheme === (isDark ? 'dark' : 'light'); - for (let attempt = 0; attempt < 120 && !settled(); attempt += 1) { - await new Promise((resolve) => requestAnimationFrame(() => resolve())); - } - - const resolve = (value: string) => { - const probe = document.createElement('span'); - probe.style.setProperty('color', value, 'important'); - // Inside the themed subtree, next to the link it is compared against — - // document.body sits OUTSIDE Astryx's wrapper and so resolves its - // light-dark() pairs against the root's color-scheme instead. - renderedLink.parentElement!.appendChild(probe); - const color = getComputedStyle(probe).color; - probe.remove(); - return color; - }; - const canvas = document.createElement('canvas'); - canvas.width = 1; - canvas.height = 1; - const context = canvas.getContext('2d', { willReadFrequently: true })!; - const rgba = (value: string) => { - context.clearRect(0, 0, 1, 1); - context.fillStyle = value; - context.fillRect(0, 0, 1, 1); - return [...context.getImageData(0, 0, 1, 1).data]; - }; - const colors = { - link: rgba(resolve('var(--link)')), - solid: rgba(resolve('var(--accent-solid)')), - accent: rgba(resolve('var(--accent)')), - rendered: rgba(getComputedStyle(renderedLink).color), - }; - return colors; - }, dark); -} - -test('link text follows the solid accent tier in light and dark palettes', async ({ - linkColorWindow: page, -}) => { - const light = await renderedLinkColors(page, false); - const dark = await renderedLinkColors(page, true); - for (const colors of [light, dark]) { - expect(colors.link).toEqual(colors.solid); - expect(colors.link).not.toEqual(colors.accent); - expect(colors.rendered).toEqual(colors.link); - } - expect(dark.link).not.toEqual(light.link); -}); diff --git a/apps/desktop/e2e/permission-center-metadata-layout.spec.ts b/apps/desktop/e2e/permission-center-metadata-layout.spec.ts deleted file mode 100644 index 9309073c1d..0000000000 --- a/apps/desktop/e2e/permission-center-metadata-layout.spec.ts +++ /dev/null @@ -1,86 +0,0 @@ -/* - * 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. - */ - -import type { Locator, Page } from '@playwright/test'; -import { expect, test } from './fixtures'; - -async function expandComputerUse(page: Page): Promise { - const row = page.locator('[data-readiness]').first(); - const trigger = row.locator('button[aria-expanded]').first(); - await trigger.click(); - await expect(trigger).toHaveAttribute('aria-expanded', 'true'); - return row; -} - -async function metadataTextRows(row: Locator) { - return row.evaluate((element) => { - const firstTextMetrics = (root: HTMLElement) => { - const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); - let node = walker.nextNode(); - while (node && !node.textContent?.trim()) node = walker.nextNode(); - if (!node?.parentElement) throw new Error('Metadata cell has no text'); - const range = document.createRange(); - range.selectNodeContents(node); - return { - bottom: range.getBoundingClientRect().bottom, - fontSize: getComputedStyle(node.parentElement).fontSize, - }; - }; - - return Array.from( - element.querySelectorAll('.settingsCapabilityMetadata > dl'), - ).flatMap((grid) => { - const terms = Array.from(grid.querySelectorAll(':scope > dt')); - const values = Array.from(grid.querySelectorAll(':scope > dd')); - if (terms.length !== values.length) throw new Error('Metadata pairs are incomplete'); - return terms.map((term, index) => { - const value = values[index]!; - const labelMetrics = firstTextMetrics(term); - const valueMetrics = firstTextMetrics(value); - return { - label: term.textContent?.trim() ?? '', - value: value.textContent?.trim() ?? '', - labelTextBottom: labelMetrics.bottom, - valueTextBottom: valueMetrics.bottom, - labelFontSize: labelMetrics.fontSize, - valueFontSize: valueMetrics.fontSize, - }; - }); - }); - }); -} - -test('Permission Center gives each metadata label and value consistent body typography', async ({ - permissionCenterWindow: page, -}) => { - await page.setViewportSize({ width: 1490, height: 900 }); - const rows = await metadataTextRows(await expandComputerUse(page)); - - expect(rows.length).toBeGreaterThan(0); - for (const row of rows) { - expect( - Math.abs(row.labelTextBottom - row.valueTextBottom), - `${row.label} and ${row.value} should share a text baseline`, - ).toBeLessThanOrEqual(1); - expect( - row.valueFontSize, - `${row.label} and ${row.value} should use the same body text size`, - ).toBe(row.labelFontSize); - } -}); diff --git a/apps/desktop/e2e/request-header-row-contract.spec.ts b/apps/desktop/e2e/request-header-row-contract.spec.ts deleted file mode 100644 index 772d5f5ae9..0000000000 --- a/apps/desktop/e2e/request-header-row-contract.spec.ts +++ /dev/null @@ -1,78 +0,0 @@ -/* - * 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. - */ - -import { expect, test } from './fixtures'; -import { getProviderSettingsCopy } from '../src/renderer/features/connection-settings'; - -/** - * The custom request header row's remove button centres on the FIELD, not on - * whatever box its own cell happens to declare. - * - * `.requestHeaderRow` is `align-items: start` on purpose: the value input can - * grow an inline error underneath itself and the trash icon must not ride down - * with it. That makes `.requestHeaderRemove` responsible for stating the height - * it centres its button inside, and a wrong height there is invisible in a - * diff — the rule looks deliberate either way. It regressed exactly once, as a - * bare `min-height: 2.5rem` against 32px inputs, which parked the icon 4px low. - * - * `scripts/audit-alignment.mjs` cannot cover this. It clusters controls by - * `parentElement`, and this row wraps every cell in its own div, so the inputs - * and the button are never in the same cluster to compare. It also only sees - * surfaces as they first render, and this editor is three clicks deep. - */ - -const copy = getProviderSettingsCopy('zh-CN').detail; - -test('the request header remove button centres on its field', async ({ - requestHeaderRowWindow: page, -}) => { - // `no-models` is the seeded openai-compatible relay — the custom relay whose - // detail page carries the advanced request editor. - await page.locator('[data-connection-slug="no-models"] button').first().click(); - await page.locator(`button[aria-label="${copy.edit}: ${copy.requestHeaders}"]`).click(); - await page.getByRole('button', { name: copy.addHeader }).click(); - await expect(page.locator('.requestHeaderRow')).toHaveCount(1); - - const geometry = await page.evaluate(() => { - const row = document.querySelector('.requestHeaderRow')!; - // Select the actual field wrapper directly so this contract test measures - // the visual field box, not a DOM relationship that may change. - const field = row.querySelector('.requestHeaderName')!; - const cell = row.querySelector('.requestHeaderRemove')!; - const button = cell.querySelector('button')!; - const centre = (element: Element) => { - const box = element.getBoundingClientRect(); - return box.top + box.height / 2; - }; - return { - drift: centre(button) - centre(field), - fieldHeight: field.getBoundingClientRect().height, - cellHeight: cell.getBoundingClientRect().height, - }; - }); - - // Allow a small amount of cross-platform/sub-pixel variance here: a - // fractional device pixel ratio can shift centres slightly, but the 4px - // regression this pins is still far outside this tolerance. - expect(Math.abs(geometry.drift)).toBeLessThanOrEqual(1); - // The mechanism, named separately so a failure says which half broke: the - // cell must not declare a meaningfully taller box than the field it centres - // against. Allow a small epsilon for DOMRect/font-rendering variance. - expect(geometry.cellHeight).toBeLessThanOrEqual(geometry.fieldHeight + 1); -}); diff --git a/apps/desktop/e2e/settings.spec.ts b/apps/desktop/e2e/settings.spec.ts index c54dfdbe0f..46b6e9bbbb 100644 --- a/apps/desktop/e2e/settings.spec.ts +++ b/apps/desktop/e2e/settings.spec.ts @@ -26,23 +26,6 @@ interface SettingsChunkLatchWindow extends Window { }; } -async function choiceContentGeometry(card: import('@playwright/test').Locator) { - return card.evaluate((element) => { - const content = Array.from(element.children).find((child) => child.tagName !== 'INPUT'); - if (!(content instanceof HTMLElement)) { - throw new Error('SelectableCard content is missing'); - } - const cardRect = element.getBoundingClientRect(); - const contentRect = content.getBoundingClientRect(); - return { - cardHeight: cardRect.height, - contentHeight: contentRect.height, - topGap: contentRect.top - cardRect.top, - bottomGap: cardRect.bottom - contentRect.bottom, - }; - }); -} - test('Settings loading surface owns unmodified Escape', async ({ window: page }) => { const latchInstalled = await page.evaluate(() => { const e2eLatch = (window as unknown as SettingsChunkLatchWindow).makaE2eLatch; @@ -143,66 +126,6 @@ test('settings hides expanded workbar chrome and restores it on close', async ({ await expect(openFaceTab).toBeVisible(); }); -test('wide settings gutters scroll the whole main pane', async ({ window: page }) => { - await page.setViewportSize({ width: 1600, height: 520 }); - await ensureSidebarExpanded(page); - await page.getByRole('button', { name: '设置' }).click(); - await page.getByRole('button', { name: '通用', exact: true }).click(); - await expect(page.getByRole('textbox', { name: '助手语气偏好' })).toBeEnabled(); - - const pane = page.locator('.settingsMainPane'); - const content = pane.locator('.settingsPageStack').first(); - const geometry = await pane.evaluate((element) => { - const paneRect = element.getBoundingClientRect(); - const contentElement = element.querySelector('.settingsPageStack'); - const contentRect = contentElement?.getBoundingClientRect(); - const layoutContent = element.querySelector('.astryx-layout-content'); - if (!contentRect || !layoutContent) throw new Error('Settings layout is incomplete'); - return { - blankRight: paneRect.right - contentRect.right, - clientHeight: element.clientHeight, - contentOverflowY: getComputedStyle(layoutContent).overflowY, - paneOverflowY: getComputedStyle(element).overflowY, - scrollHeight: element.scrollHeight, - wheelPoint: { - x: Math.floor((contentRect.right + paneRect.right) / 2), - y: Math.floor(Math.min(contentRect.top + 120, paneRect.bottom - 40)), - }, - }; - }); - - expect(geometry.blankRight).toBeGreaterThan(40); - expect(geometry.scrollHeight).toBeGreaterThan(geometry.clientHeight); - expect(geometry.contentOverflowY).not.toBe('auto'); - expect(geometry.paneOverflowY).toBe('auto'); - - await page.mouse.move(geometry.wheelPoint.x, geometry.wheelPoint.y); - await page.mouse.wheel(0, 600); - await expect.poll(() => pane.evaluate((element) => element.scrollTop)).toBeGreaterThan(0); - await expect(content).toBeVisible(); -}); - -test('appearance choice content stays vertically centered in stretched grid rows', async ({ window: page }) => { - await page.evaluate(async () => { - await window.maka.settings.update({ personalization: { uiLocale: 'en' } }); - }); - await page.reload(); - await page.waitForSelector(COMPOSER_INPUT); - await page.setViewportSize({ width: 1650, height: 992 }); - await ensureSidebarExpanded(page); - await page.getByRole('button', { name: 'Settings' }).click(); - await page.getByRole('button', { name: 'Appearance', exact: true }).click(); - await expect(page.getByRole('heading', { name: 'App icon' })).toBeVisible(); - - for (const name of ['Azure', 'Classic']) { - const card = page.getByRole('checkbox', { name, exact: true }).locator('..'); - await expect(card).toBeVisible(); - const geometry = await choiceContentGeometry(card); - expect(geometry.cardHeight).toBeGreaterThan(geometry.contentHeight); - expect(Math.abs(geometry.topGap - geometry.bottomGap)).toBeLessThanOrEqual(1); - } -}); - test('reopening settings keeps the last-ready General page stable while refreshing', async ({ window: page, }) => { diff --git a/apps/desktop/src/main/permission-snapshot-e2e-fixture.ts b/apps/desktop/src/main/permission-snapshot-e2e-fixture.ts index 79484d70cb..436962739c 100644 --- a/apps/desktop/src/main/permission-snapshot-e2e-fixture.ts +++ b/apps/desktop/src/main/permission-snapshot-e2e-fixture.ts @@ -21,15 +21,14 @@ import type { OsPermissionSnapshot, OsPermissionState, PermissionSnapshot } from /** * #1361: a typed OS-permission snapshot for the `settings-permissions` e2e - * fixture. + * fixture, which the alignment audit renders. * - * The Permission Center's narrow-layout contract is about what happens when a - * row carries several grant buttons: the row grid's `auto` actions track used to beat - * the body's `minmax(0, 1fr)` and squeeze it to 0px, hiding which permission - * the row was even about. Reading the host's real TCC state cannot exercise - * that — a fully-granted dev machine renders no buttons at all, and Linux CI - * reports most permissions as `unsupported`, so the assertion would pass - * without testing anything. + * The page is worth auditing for what happens when a row carries several grant + * buttons: the row grid's `auto` actions track used to beat the body's + * `minmax(0, 1fr)` and squeeze it to 0px, hiding which permission the row was + * even about. Reading the host's real TCC state cannot produce that — a + * fully-granted dev machine renders no buttons at all, and Linux CI reports + * most permissions as `unsupported`, so the audit would measure an empty page. * * This fixture pins the states that matter instead: * - `screen_recording` — `not_determined` + requestable + openable, the @@ -39,8 +38,9 @@ import type { OsPermissionSnapshot, OsPermissionState, PermissionSnapshot } from * ("当前平台不支持", ~101px intrinsic and `whitespace-nowrap` by primitive * contract) and so sets the row's minimum readable width. * - * Mirrors the Storybook fixture in `stories/settings/settings-pages.stories.tsx` - * so the story baseline and the E2E contract describe the same page. + * Mirrors the Storybook fixture in `stories/settings/settings-pages.stories.tsx`, + * which is where the row's layout contract is asserted, so the audited page and + * the story baseline describe the same states. * * Production is untouched: this returns null unless the fixture scenario is * active, and `registerPermissionsIpc` falls back to `buildPermissionSnapshot`. diff --git a/apps/desktop/stories/settings/provider-settings.stories.tsx b/apps/desktop/stories/settings/provider-settings.stories.tsx index e663b33b0d..ec6a0f37b3 100644 --- a/apps/desktop/stories/settings/provider-settings.stories.tsx +++ b/apps/desktop/stories/settings/provider-settings.stories.tsx @@ -38,8 +38,10 @@ import type { ApiKeyOnboardingBridge, ConnectionOAuthBridge, } from '../../src/renderer/features/connection-settings'; +import { getProviderSettingsCopy } from '../../src/renderer/features/connection-settings'; const NOW = Date.parse('2026-07-01T08:00:00Z'); +const detailCopy = getProviderSettingsCopy('zh-CN').detail; // Fidelity convention (#1433): every story below names the real app path // that reaches it. See apps/desktop/stories/FIDELITY.md. @@ -841,6 +843,30 @@ export const RelayConnectionDetail: Story = { await expect(none).toHaveAttribute('aria-checked', 'false'); await expect(partial).toHaveAttribute('aria-description', '1/4 个模型'); await expect(none).toHaveAttribute('aria-description', '全部未声明'); + + await userEvent.keyboard('{Escape}'); + await userEvent.click( + body.getByRole('button', { + name: `${detailCopy.edit}: ${detailCopy.requestHeaders}`, + }), + ); + await userEvent.click(body.getByRole('button', { name: detailCopy.addHeader })); + await waitFor(() => { + expect(canvasElement.querySelectorAll('.requestHeaderRow')).toHaveLength(1); + }); + const row = canvasElement.querySelector('.requestHeaderRow'); + const field = row?.querySelector('.requestHeaderName'); + const cell = row?.querySelector('.requestHeaderRemove'); + const button = cell?.querySelector('button'); + if (!field || !cell || !button) throw new Error('Request header row is incomplete'); + const centre = (element: Element) => { + const box = element.getBoundingClientRect(); + return box.top + box.height / 2; + }; + expect(Math.abs(centre(button) - centre(field))).toBeLessThanOrEqual(1); + expect(cell.getBoundingClientRect().height).toBeLessThanOrEqual( + field.getBoundingClientRect().height + 1, + ); }, }; diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx index 2e0c647255..f586eb2524 100644 --- a/apps/desktop/stories/settings/settings-pages.stories.tsx +++ b/apps/desktop/stories/settings/settings-pages.stories.tsx @@ -1614,6 +1614,36 @@ function makeBotAttentionBridge(settings: AppSettings) { const withBotAttentionBridge = withScopedMakaBridge(makeBotAttentionBridge(botAttentionSettings)); +function renderedLinkColors(renderedLink: HTMLElement) { + const root = document.documentElement; + renderedLink.style.setProperty('transition', 'none', 'important'); + root.setAttribute('data-maka-theme', 'tokyo-night'); + + const resolve = (value: string) => { + const probe = document.createElement('span'); + probe.style.setProperty('color', value, 'important'); + renderedLink.parentElement?.appendChild(probe); + const color = getComputedStyle(probe).color; + probe.remove(); + return color; + }; + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d', { willReadFrequently: true }); + if (!context) throw new Error('Link color canvas is unavailable'); + const rgba = (value: string) => { + context.clearRect(0, 0, 1, 1); + context.fillStyle = value; + context.fillRect(0, 0, 1, 1); + return [...context.getImageData(0, 0, 1, 1).data]; + }; + return { + link: rgba(resolve('var(--link)')), + solid: rgba(resolve('var(--accent-solid)')), + accent: rgba(resolve('var(--accent)')), + rendered: rgba(getComputedStyle(renderedLink).color), + }; +} + type SettingsStoryProps = { section: SettingsSection; connections?: LlmConnection[]; @@ -1623,6 +1653,9 @@ type SettingsStoryProps = { /** Seeds 已归档任务. Empty for every story that is not about that page. */ archivedTaskSessions?: readonly SessionSummary[]; seedSnapshotCache?(cache: SettingsSnapshotCache): void; + frameHeight?: number | string; + frameMinHeight?: number; + frameWidth?: number | string; }; /** @@ -1672,8 +1705,9 @@ function SettingsStoryFrame(props: SettingsStoryProps) { data-maka-e2e-fixture="true" style={{ background: 'var(--surface-canvas)', - height: '100dvh', - minHeight: 640, + height: props.frameHeight ?? '100dvh', + minHeight: props.frameMinHeight ?? 640, + width: props.frameWidth ?? '100%', }} > @@ -1777,6 +1811,36 @@ export const General: Story = { decorators: [withSettingsBridge], render: () => , }; +// Real path: 设置 → 通用 in a wide, short Desktop window. The main pane owns +// overflow even when the pointer is over its blank right gutter. +export const GeneralWideShort: Story = { + decorators: [withSettingsBridge], + render: () => ( + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByRole('textbox', { name: '助手语气偏好' }); + const pane = canvasElement.querySelector('.settingsMainPane'); + const content = pane?.querySelector('.settingsPageStack'); + const layoutContent = pane?.querySelector('.astryx-layout-content'); + if (!pane || !content || !layoutContent) throw new Error('Settings layout is incomplete'); + const paneRect = pane.getBoundingClientRect(); + const contentRect = content.getBoundingClientRect(); + expect(paneRect.right - contentRect.right).toBeGreaterThan(40); + expect(pane.scrollHeight).toBeGreaterThan(pane.clientHeight); + expect(getComputedStyle(layoutContent).overflowY).not.toBe('auto'); + expect(getComputedStyle(pane).overflowY).toBe('auto'); + pane.scrollTop = 600; + await waitFor(() => expect(pane.scrollTop).toBeGreaterThan(0)); + expect(content.getBoundingClientRect().width).toBeGreaterThan(0); + }, +}; // Cold path: Desktop-owned preferences are ready while the selected Runtime // Host settings read is still pending. The complete page topology stays // visible as neutral row placeholders, without treating hydration as a warning. @@ -2039,7 +2103,26 @@ export const GeneralGitBash: Story = { // Real path: 设置 → 外观. export const Appearance: Story = { decorators: [withSettingsBridge], + globals: { locale: 'en' }, render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByRole('heading', { name: 'App icon' }); + for (const name of ['Azure', 'Classic']) { + const input = await canvas.findByRole('checkbox', { name }); + const card = input.parentElement; + const content = card ? [...card.children].find((child) => child.tagName !== 'INPUT') : null; + if (!(card instanceof HTMLElement) || !(content instanceof HTMLElement)) { + throw new Error(`Appearance card ${name} is incomplete`); + } + const cardRect = card.getBoundingClientRect(); + const contentRect = content.getBoundingClientRect(); + expect(cardRect.height).toBeGreaterThan(contentRect.height); + expect( + Math.abs((contentRect.top - cardRect.top) - (cardRect.bottom - contentRect.bottom)), + ).toBeLessThanOrEqual(1); + } + }, }; /** #1362: proxy + auth enabled so the full form-grid stack renders. */ // Real path: 设置 → 使用统计 → 供应商统计, before any usage has been recorded. @@ -2207,6 +2290,23 @@ export const WebSearch: Story = { export const BotChatNeedsAttention: Story = { decorators: [withBotAttentionBridge], render: () => , + play: async ({ canvasElement }) => { + const dingtalk = await waitForStoryButton( + canvasElement, + (button) => button.closest('.settingsRemoteAccessCatalogRow')?.textContent?.includes('钉钉') === true, + ); + await userEvent.click(dingtalk); + await waitForStoryCondition( + () => canvasElement.querySelector('.settingsBotConfigDocLink') !== null, + 'Bot configuration documentation link did not render', + ); + const link = canvasElement.querySelector('.settingsBotConfigDocLink'); + if (!link) throw new Error('Bot configuration documentation link did not render'); + const colors = renderedLinkColors(link); + expect(colors.link).toEqual(colors.solid); + expect(colors.link).not.toEqual(colors.accent); + expect(colors.rendered).toEqual(colors.link); + }, }; // Real path: 设置 → 每日回顾. export const DailyReview: Story = { @@ -2589,6 +2689,35 @@ export const PermissionCenterDiagnosticsExpanded: Story = { canvasElement.querySelector('[data-readiness] button[aria-expanded="true"]') !== null, 'Permission Center story did not expand a capability row', ); + const row = canvasElement.querySelector('[data-readiness]'); + if (!row) throw new Error('Permission Center capability row did not render'); + const firstTextMetrics = (root: HTMLElement) => { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let node = walker.nextNode(); + while (node && !node.textContent?.trim()) node = walker.nextNode(); + if (!node?.parentElement) throw new Error('Metadata cell has no text'); + const range = document.createRange(); + range.selectNodeContents(node); + return { + bottom: range.getBoundingClientRect().bottom, + fontSize: getComputedStyle(node.parentElement).fontSize, + }; + }; + const grids = row.querySelectorAll('.settingsCapabilityMetadata > dl'); + expect(grids.length).toBeGreaterThan(0); + for (const grid of grids) { + const terms = [...grid.querySelectorAll(':scope > dt')]; + const values = [...grid.querySelectorAll(':scope > dd')]; + expect(values).toHaveLength(terms.length); + for (const [index, term] of terms.entries()) { + const value = values[index]; + if (!value) throw new Error('Permission metadata value is missing'); + const labelMetrics = firstTextMetrics(term); + const valueMetrics = firstTextMetrics(value); + expect(Math.abs(labelMetrics.bottom - valueMetrics.bottom)).toBeLessThanOrEqual(1); + expect(valueMetrics.fontSize).toBe(labelMetrics.fontSize); + } + } }, }; // Real path: 设置 → 健康 (also reachable from the topbar health action), with probes diff --git a/scripts/storybook-visual-smoke.mjs b/scripts/storybook-visual-smoke.mjs index 6b4b697685..1153621593 100644 --- a/scripts/storybook-visual-smoke.mjs +++ b/scripts/storybook-visual-smoke.mjs @@ -69,6 +69,7 @@ const DARK_THEME_SENTINEL_STORY_IDS = new Set([ 'product-accessibility-dialogs--rename-conversation', 'product-markdown--rich-assistant-answer', 'product-settings-pages--appearance', + 'product-settings-pages--bot-chat-needs-attention', 'product-shell-official-appshell--default-layout', ]); From 80ab6ec09cb089822b08f2c78e6b9ed82c51f183 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 5 Sep 2026 11:55:27 +0800 Subject: [PATCH 4/8] test(desktop): move shell layout contracts to stories Generated-by: Codex --- apps/desktop/e2e/fixtures.ts | 12 - .../e2e/partial-history-notice.spec.ts | 137 ---------- apps/desktop/e2e/sidebar-geometry.spec.ts | 73 ------ .../e2e/sidebar-titlebar-actions.spec.ts | 59 ----- apps/desktop/e2e/transcript-measure.spec.ts | 48 ---- apps/desktop/src/main/e2e-fixture.ts | 13 - .../src/main/e2e-fixture/scenarios-chat.ts | 43 ---- .../src/main/e2e-fixture/seed-helpers.ts | 1 - .../src/renderer/styles/shell-layout.css | 2 +- apps/desktop/stories/app-shell.stories.tsx | 236 +++++++++++++++++- packages/core/src/e2e-fixture.ts | 1 - 11 files changed, 235 insertions(+), 390 deletions(-) delete mode 100644 apps/desktop/e2e/partial-history-notice.spec.ts delete mode 100644 apps/desktop/e2e/sidebar-geometry.spec.ts delete mode 100644 apps/desktop/e2e/sidebar-titlebar-actions.spec.ts delete mode 100644 apps/desktop/e2e/transcript-measure.spec.ts diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index d5a2b5de1e..aa5f733143 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -524,7 +524,6 @@ type E2eTestFixtures = { railRenderWindow: Page; promptRailWindow: Page; threadSearchWindow: Page; - partialHistoryWindow: Page; requestHeaderRowWindow: Page; newTaskTargetWindow: Page; directoryReferenceWindow: { page: Page; folder: string }; @@ -674,17 +673,6 @@ export const test = base.extend({ locale: 'zh-CN', }, use); }, - // A transcript larger than the bounded Desktop range. Clicking an unloaded - // prompt exercises the real load-around path and its partial-history UI. - partialHistoryWindow: async ({}, use) => { - await withE2eWindow({ - seed: false, - readinessSelector: '[data-turn-id]', - e2eFixtureScenario: 'chat-partial-history', - locale: 'zh-CN', - showWindow: true, - }, use); - }, // Settings → 模型, where `no-models` is the seeded openai-compatible relay — // the connection type whose detail page owns the custom request headers // editor. Shown, because what this window is for is a rendered box diff --git a/apps/desktop/e2e/partial-history-notice.spec.ts b/apps/desktop/e2e/partial-history-notice.spec.ts deleted file mode 100644 index 9b70c12b6a..0000000000 --- a/apps/desktop/e2e/partial-history-notice.spec.ts +++ /dev/null @@ -1,137 +0,0 @@ -/* - * 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. - */ - -import type { Page } from '@playwright/test'; -import { expect, test } from './fixtures'; - -const NOTICE = '.maka-transcript-history-controls'; - -async function waitForPaint(page: Page): Promise { - await page.evaluate(() => new Promise((resolve) => { - requestAnimationFrame(() => requestAnimationFrame(() => resolve())); - })); -} - -async function noticePresentation(page: Page) { - return page.locator(NOTICE).evaluate((notice) => { - const style = getComputedStyle(notice); - const box = notice.getBoundingClientRect(); - const composer = document.querySelector('.maka-composer-astryx'); - if (!composer) throw new Error('the composer is missing'); - const composerBox = composer.getBoundingClientRect(); - return { - backgroundColor: style.backgroundColor, - borderWidths: [ - style.borderTopWidth, - style.borderRightWidth, - style.borderBottomWidth, - style.borderLeftWidth, - ], - display: style.display, - flexWrap: style.flexWrap, - justifyContent: style.justifyContent, - widthDelta: Math.abs(box.width - composerBox.width), - centerDelta: Math.abs( - (box.left + box.right) / 2 - (composerBox.left + composerBox.right) / 2, - ), - fitsViewport: box.left >= 0 && box.right <= document.documentElement.clientWidth, - hasHorizontalOverflow: notice.scrollWidth > notice.clientWidth, - }; - }); -} - -test('partial history is a quiet reading-column control with neutral rail ticks', async ({ - partialHistoryWindow: page, -}) => { - await page.setViewportSize({ width: 1_400, height: 800 }); - await expect(page.locator(NOTICE)).toHaveCount(0); - - const firstPrompt = page.locator( - '.maka-prompt-rail-tick[data-prompt-turn-id="turn-partial-history-1"]', - ); - await expect(firstPrompt).toBeVisible(); - await firstPrompt.click(); - - const notice = page.locator(NOTICE); - await expect(notice).toBeVisible(); - await expect(notice).toContainText('正在查看较早的消息'); - await expect(notice.getByRole('button', { name: '返回最新消息' })).toBeVisible(); - await expect(notice).not.toContainText(/保存|加载/); - - const regular = await noticePresentation(page); - expect(regular).toEqual({ - backgroundColor: 'rgba(0, 0, 0, 0)', - borderWidths: ['0px', '0px', '0px', '0px'], - display: 'flex', - flexWrap: 'wrap', - justifyContent: 'center', - widthDelta: expect.any(Number), - centerDelta: expect.any(Number), - fitsViewport: true, - hasHorizontalOverflow: false, - }); - expect(regular.widthDelta).toBeLessThanOrEqual(1); - expect(regular.centerDelta).toBeLessThanOrEqual(1); - - await page.mouse.move(0, 0); - await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()); - const railPresentation = await page.evaluate(() => { - const ticks = [...document.querySelectorAll('.maka-prompt-rail-tick')]; - const presentation = (tick: HTMLElement) => { - const bar = tick.querySelector('.maka-prompt-rail-tick-bar'); - if (!bar) throw new Error('a prompt rail tick is missing its bar'); - const style = getComputedStyle(bar); - return { - backgroundColor: style.backgroundColor, - borderStyle: style.borderStyle, - borderWidth: style.borderWidth, - boxShadow: style.boxShadow, - }; - }; - const neutralPaint = ticks - .filter((tick) => tick.dataset.active !== 'true' && !tick.matches(':hover')) - .map(presentation); - const residentStyleRules = [...document.styleSheets].flatMap((sheet) => - [...sheet.cssRules].filter((rule) => rule.cssText.includes('data-resident')) - ); - return { - residentAttributeCount: document.querySelectorAll('[data-resident]').length, - residentStyleRuleCount: residentStyleRules.length, - neutralTickCount: neutralPaint.length, - neutralPaintCount: new Set(neutralPaint.map((paint) => JSON.stringify(paint))).size, - }; - }); - expect(railPresentation.residentAttributeCount).toBe(0); - expect(railPresentation.residentStyleRuleCount).toBe(0); - expect(railPresentation.neutralTickCount).toBeGreaterThan(1); - expect(railPresentation.neutralPaintCount).toBe(1); - - await page.setViewportSize({ width: 520, height: 720 }); - await waitForPaint(page); - const narrow = await noticePresentation(page); - expect(narrow.centerDelta).toBeLessThanOrEqual(1); - expect(narrow.fitsViewport).toBe(true); - expect(narrow.hasHorizontalOverflow).toBe(false); - - await notice.getByRole('button', { name: '返回最新消息' }).click(); - await expect(notice).toHaveCount(0); - await expect( - page.locator('[data-turn-id="turn-partial-history-8"]'), - ).toBeVisible(); -}); diff --git a/apps/desktop/e2e/sidebar-geometry.spec.ts b/apps/desktop/e2e/sidebar-geometry.spec.ts deleted file mode 100644 index a0e52cd609..0000000000 --- a/apps/desktop/e2e/sidebar-geometry.spec.ts +++ /dev/null @@ -1,73 +0,0 @@ -/* - * 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. - */ - -/* - * Locks `.maka-sidenav-motion { height: 100% }` in shell-layout.css. Without a - * definite height, the sidenav grows to its unclipped content and pushes the - * footer below the window. `projectSidebarWindow` supplies the overflowing - * session list needed to expose that regression. - */ - -import { expect, test } from './fixtures'; -import type { Page } from '@playwright/test'; - -async function revealPopulatedSidebar(page: Page): Promise { - await page.keyboard.press('Escape'); - await expect(page.locator('[data-maka-contract="search-modal"]')).not.toBeVisible(); - const sidebar = page.getByRole('navigation', { name: '任务列表' }); - await expect(sidebar).toBeVisible(); -} - -test('sidenav footer stays inside the window under an overflowing list', async ({ - projectSidebarWindow: page, -}) => { - await revealPopulatedSidebar(page); - - // Precondition: the list genuinely overflows its scrollport. Without this the - // footer-in-window assertion below would pass even if `height: 100%` were - // dropped, because short content never grows past the wrapper's parent. - const listOverflows = await page.evaluate(() => { - const nav = document.querySelector('nav.maka-session-panel'); - if (!nav) return false; - return [nav, ...nav.querySelectorAll('*')].some( - (element) => - element.scrollHeight - element.clientHeight > 4 && - getComputedStyle(element).overflowY !== 'visible', - ); - }); - expect(listOverflows).toBe(true); - - const wrapper = page.locator('.maka-sidenav-motion'); - const footer = page.locator('.maka-session-panel-footer'); - await expect(footer).toBeVisible(); - - const innerHeight = await page.evaluate(() => window.innerHeight); - const [wrapperBox, footerBox] = await Promise.all([ - wrapper.boundingBox(), - footer.boundingBox(), - ]); - expect(wrapperBox).not.toBeNull(); - expect(footerBox).not.toBeNull(); - - // The definite height caps the wrapper at the window; the footer rides its - // bottom edge. Drop `height: 100%` and the wrapper grows to ~60 rows tall, - // taking the footer far below the fold — both bottoms then exceed innerHeight. - expect(wrapperBox!.y + wrapperBox!.height).toBeLessThanOrEqual(innerHeight + 1); - expect(footerBox!.y + footerBox!.height).toBeLessThanOrEqual(innerHeight + 1); -}); diff --git a/apps/desktop/e2e/sidebar-titlebar-actions.spec.ts b/apps/desktop/e2e/sidebar-titlebar-actions.spec.ts deleted file mode 100644 index 4996b30fab..0000000000 --- a/apps/desktop/e2e/sidebar-titlebar-actions.spec.ts +++ /dev/null @@ -1,59 +0,0 @@ -/* - * 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. - */ - -import { expect, test } from './fixtures'; - -test('expanded sidebar chrome follows Astryx toolbar geometry', async ({ window }) => { - const sidebar = window.getByRole('navigation', { name: '任务列表' }); - const actions = window.locator('[data-maka-contract="shell-topbar-rail"]'); - const expandSidebar = window.getByRole('button', { name: '展开侧边栏' }); - - if (await expandSidebar.isVisible()) await expandSidebar.click(); - await expect(window.getByRole('button', { name: '收起侧边栏' })).toBeVisible(); - await expect(sidebar).toBeVisible(); - await expect(actions).toBeVisible(); - - const sidebarBox = await sidebar.boundingBox(); - const actionsBox = await actions.boundingBox(); - expect(sidebarBox).not.toBeNull(); - expect(actionsBox).not.toBeNull(); - - const trailingInset = sidebarBox!.x + sidebarBox!.width - (actionsBox!.x + actionsBox!.width); - expect(trailingInset).toBeGreaterThanOrEqual(0); - expect(trailingInset).toBeLessThanOrEqual(16); - await expect(actions).toHaveCSS('column-gap', '4px'); -}); - -test('collapsed sidebar removes its icon rail but keeps the titlebar restore action', async ({ - window, -}) => { - const sidebar = window.getByRole('navigation', { name: '任务列表' }); - const collapseSidebar = window.getByRole('button', { name: '收起侧边栏' }); - - if (await collapseSidebar.isVisible()) await collapseSidebar.click(); - - await expect(sidebar).toBeHidden(); - await expect(window.locator('.maka-sidenav-motion')).toHaveCSS('width', '0px'); - const expandSidebar = window.getByRole('button', { name: '展开侧边栏' }); - await expect(expandSidebar).toBeVisible(); - - await expandSidebar.click(); - await expect(sidebar).toBeVisible(); - await expect(window.getByRole('button', { name: '收起侧边栏' })).toBeVisible(); -}); diff --git a/apps/desktop/e2e/transcript-measure.spec.ts b/apps/desktop/e2e/transcript-measure.spec.ts deleted file mode 100644 index b8d6bd196a..0000000000 --- a/apps/desktop/e2e/transcript-measure.spec.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* - * 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. - */ - -import { expect, test, COMPOSER_INPUT } from './fixtures'; - -test('assistant prose reaches the transcript column edge on a wide window', async ({ - window: page, -}) => { - await page.setViewportSize({ width: 1400, height: 800 }); - const composer = page.locator(COMPOSER_INPUT); - await composer.fill('measure'); - await composer.press('Enter'); - - const answer = page.getByRole('article', { name: 'Maka 的回答' }).last(); - const paragraph = answer.locator('[role="paragraph"]').first(); - await expect(paragraph).toBeVisible(); - - const edges = await paragraph.evaluate((element) => { - const turn = element.closest('.maka-turn'); - if (!turn) throw new Error('assistant paragraph is not inside a turn'); - const turnRect = turn.getBoundingClientRect(); - return { - turnWidth: turnRect.width, - rightGap: turnRect.right - element.getBoundingClientRect().right, - }; - }); - - // Guard against a false pass when both boxes are viewport-constrained: - // a turn wider than Astryx's own 680px cap is the case that regressed. - expect(edges.turnWidth).toBeGreaterThan(680); - expect(edges.rightGap).toBeLessThanOrEqual(1); -}); diff --git a/apps/desktop/src/main/e2e-fixture.ts b/apps/desktop/src/main/e2e-fixture.ts index 5be7c0ed42..d253fd472b 100644 --- a/apps/desktop/src/main/e2e-fixture.ts +++ b/apps/desktop/src/main/e2e-fixture.ts @@ -35,14 +35,11 @@ import { LONG_SIDEBAR_PROJECT_ID, LONG_SIDEBAR_PROJECT_NAME, LONG_SIDEBAR_SESSION_PREFIX, - PARTIAL_HISTORY_SESSION_ID, PROMPT_RAIL_SESSION_ID, TURN_SESSION_ID, writeSession, } from './e2e-fixture/seed-helpers.js'; import { - partialHistoryMessages, - partialHistorySession, promptRailMessages, promptRailSession, turnMessages, @@ -63,7 +60,6 @@ const E2E_FIXTURE_SCENARIOS = new Set([ 'turn-narrative', 'turn-narrative-browser', 'chat-prompt-rail', - 'chat-partial-history', 'settings-data', 'settings-bots-onboarding', 'settings-general', @@ -173,8 +169,6 @@ export function getE2eFixtureState(fixture: E2eFixture | null): E2eFixtureState // Workbar collapsed: the rail lives on the chat scrollport's right edge, // and the panel would take the width the measurements are about. return { ...state, activeSessionId: PROMPT_RAIL_SESSION_ID, workbarCollapsed: true }; - case 'chat-partial-history': - return { ...state, activeSessionId: PARTIAL_HISTORY_SESSION_ID, workbarCollapsed: true }; case 'settings-data': return { ...state, activeSessionId: TURN_SESSION_ID, openSettingsSection: 'data' }; case 'settings-bots-onboarding': @@ -230,13 +224,6 @@ export async function seedE2eFixture(input: { if (scenario === 'chat-prompt-rail') { await writeSession(input.workspaceRoot, promptRailSession(now), promptRailMessages(now)); } - if (scenario === 'chat-partial-history') { - await writeSession( - input.workspaceRoot, - partialHistorySession(now), - partialHistoryMessages(now), - ); - } if (scenario === 'sidebar-search-modal-open') { for (const seed of longSidebarSessions(now)) { await writeSession(input.workspaceRoot, seed.header, seed.messages); diff --git a/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts b/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts index a62da0d421..dce4fb2b3b 100644 --- a/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts +++ b/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts @@ -20,7 +20,6 @@ import type { SessionHeader, StoredMessage } from '@maka/core/session'; import { header, - PARTIAL_HISTORY_SESSION_ID, PROMPT_RAIL_PROMPT_COUNT, PROMPT_RAIL_SESSION_ID, TURN_SESSION_ID, @@ -149,45 +148,3 @@ export function promptRailMessages(now: number): StoredMessage[] { } return messages; } - -export function partialHistorySession(now: number): SessionHeader { - return header({ - id: PARTIAL_HISTORY_SESSION_ID, - name: '超长对话历史范围示例', - connection: 'zai-live', - model: 'glm-5.1', - now, - lastMessageAt: now - 60_000, - }); -} - -/** - * Eight turns whose durable transcript is well over the Desktop range budget. - * The whitespace is stored but collapses when rendered, keeping this a useful - * visual fixture while forcing the initial open to contain only the latest - * contiguous range. - */ -export function partialHistoryMessages(now: number): StoredMessage[] { - const messages: StoredMessage[] = []; - const rangePadding = ' '.repeat(180 * 1024); - for (let index = 1; index <= 8; index += 1) { - const turnId = `turn-partial-history-${index}`; - const ts = now - (9 - index) * 60_000; - messages.push({ - type: 'user', - id: `msg-partial-history-user-${index}`, - turnId, - ts, - text: `第 ${index} 个问题:请概括这一阶段的实现进展。`, - }); - messages.push({ - type: 'assistant', - id: `msg-partial-history-assistant-${index}`, - turnId, - ts: ts + 1_000, - text: `第 ${index} 阶段已经完成关键实现,并通过了对应验证。${rangePadding}`, - modelId: 'glm-5.1', - }); - } - return messages; -} diff --git a/apps/desktop/src/main/e2e-fixture/seed-helpers.ts b/apps/desktop/src/main/e2e-fixture/seed-helpers.ts index 1e1cd6a535..6367161d6d 100644 --- a/apps/desktop/src/main/e2e-fixture/seed-helpers.ts +++ b/apps/desktop/src/main/e2e-fixture/seed-helpers.ts @@ -34,7 +34,6 @@ export const E2E_FIXTURE_NOW = Date.UTC(2026, 4, 22, 3, 0, 0); export const TURN_SESSION_ID = 'e2e-fixture-turn'; export const PROMPT_RAIL_SESSION_ID = 'e2e-fixture-prompt-rail'; -export const PARTIAL_HISTORY_SESSION_ID = 'e2e-fixture-partial-history'; /** Exceeds both the 64-tick rail and the bounded active transcript range. */ export const PROMPT_RAIL_PROMPT_COUNT = 120; export const LONG_SIDEBAR_SESSION_PREFIX = 'e2e-fixture-sidebar-long-'; diff --git a/apps/desktop/src/renderer/styles/shell-layout.css b/apps/desktop/src/renderer/styles/shell-layout.css index 1ce69237d2..ce6ea21dd8 100644 --- a/apps/desktop/src/renderer/styles/shell-layout.css +++ b/apps/desktop/src/renderer/styles/shell-layout.css @@ -173,7 +173,7 @@ /* The nav sizes itself against a definite height (`.maka-session-panel` is `height: 100%`); without one here the wrapper grows to the unclipped content and the footer leaves the window — the geometry - e2e/sidebar-geometry.spec.ts locks. */ + the OverflowingSidebar shell story locks. */ height: 100%; width: var(--maka-sidenav-width); /* Clip the expanded content while the sidebar width catches up. */ diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index bc49fb088d..e8a2d3b37f 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -18,7 +18,7 @@ */ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { expect, waitFor } from 'storybook/test'; +import { expect, waitFor, within } from 'storybook/test'; import { useEffect, useState, type CSSProperties, type ReactNode } from 'react'; import type { ComponentProps } from 'react'; import type { ProjectRecord } from '@maka/core/project'; @@ -271,6 +271,7 @@ const baseComposerProps: ComposerProps = { function ShellFrame(props: { children: ReactNode; + height?: number | string; motionEnabled?: boolean; sidebarCollapsed?: boolean; }) { @@ -287,6 +288,7 @@ function ShellFrame(props: { style={ { minHeight: 640, + height: props.height, /* Same publication point as production, for the same reason as `data-sidebar-state` above: the titlebar's first grid track is a `calc()` on this variable, and an unset variable makes the whole @@ -336,6 +338,7 @@ function ComposedShell(props: { * supplying the relatives, not by hand-writing what the helpers would return. */ relatedSessions?: SessionSummary[]; + frameHeight?: number | string; /** Drives the footer's update action; `undefined` is the silent phase. */ updateReminder?: SessionListPanelProps['updateReminder']; }) { @@ -380,7 +383,11 @@ function ComposedShell(props: { })); return ( - +
, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const sidebar = canvas.getByRole('navigation', { name: '任务列表' }); + const actions = canvasElement.querySelector( + '[data-maka-contract="shell-topbar-rail"]', + ); + if (!actions) throw new Error('Shell topbar rail did not render'); + await expect(sidebar).toBeVisible(); + await expect(actions).toBeVisible(); + const sidebarBox = sidebar.getBoundingClientRect(); + const actionsBox = actions.getBoundingClientRect(); + const trailingInset = sidebarBox.right - actionsBox.right; + expect(trailingInset).toBeGreaterThanOrEqual(0); + expect(trailingInset).toBeLessThanOrEqual(16); + expect(getComputedStyle(actions).columnGap).toBe('4px'); + }, }; // Real path: the updater finishes downloading in the background (autoDownload @@ -512,6 +535,21 @@ export const UpdateDownloadedCollapsed: Story = { render: () => ( ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const sidebar = canvasElement.querySelector('nav.maka-session-panel'); + const motion = canvasElement.querySelector('.maka-sidenav-motion'); + if (!sidebar || !motion) throw new Error('Collapsed sidebar did not render'); + await expect(sidebar).not.toBeVisible(); + expect(getComputedStyle(motion).width).toBe('0px'); + const expand = canvas.getByRole('button', { name: '展开侧边栏' }); + await expect(expand).toBeVisible(); + expand.click(); + await waitFor(() => { + expect(canvas.getByRole('navigation', { name: '任务列表' })).toBeVisible(); + }); + expect(canvas.getByRole('button', { name: '收起侧边栏' })).toBeVisible(); + }, }; // Real path: send a message → the turn is streaming (composer shows the @@ -850,6 +888,72 @@ export const ManyTurns: Story = { ), }; +// 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 = { + render: () => ( + + makeSession({ + id: `session-overflow-${index}`, + name: `历史任务 ${String(index + 1).padStart(2, '0')}`, + lastMessageAt: NOW - (index + 20) * 60_000, + projectId: 'project-maka', + cwd: '/workspace/maka-agent', + }))} + /> + ), + play: async ({ canvasElement }) => { + const nav = canvasElement.querySelector('nav.maka-session-panel'); + const wrapper = canvasElement.querySelector('.maka-sidenav-motion'); + const footer = canvasElement.querySelector('.maka-session-panel-footer'); + if (!nav || !wrapper || !footer) throw new Error('Overflowing sidebar is incomplete'); + const scrollOwner = [nav, ...nav.querySelectorAll('*')].find( + (element) => + element.scrollHeight - element.clientHeight > 4 && + getComputedStyle(element).overflowY !== 'visible', + ); + expect(scrollOwner).toBeDefined(); + const frameBottom = canvasElement.querySelector('.appFrame')?.getBoundingClientRect().bottom; + if (frameBottom === undefined) throw new Error('Shell frame did not render'); + expect(wrapper.getBoundingClientRect().bottom).toBeLessThanOrEqual(frameBottom + 1); + expect(footer.getBoundingClientRect().bottom).toBeLessThanOrEqual(frameBottom + 1); + }, +}; + +// Real path: an assistant response in a wide conversation. Maka's prose owns +// the full turn column instead of inheriting Astryx's 680px text cap. +export const WideAssistantProse: Story = { + render: () => ( + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const answers = await canvas.findAllByRole('article', { name: 'Maka 的回答' }); + const answer = answers.at(-1); + if (!answer) throw new Error('Wide assistant answer did not render'); + const paragraph = await within(answer).findByRole('paragraph'); + const turn = paragraph.closest('.maka-turn'); + if (!turn) throw new Error('Wide assistant paragraph did not render inside a turn'); + const turnRect = turn.getBoundingClientRect(); + expect(turnRect.width).toBeGreaterThan(680); + expect(turnRect.right - paragraph.getBoundingClientRect().right).toBeLessThanOrEqual(1); + }, +}; + // Real path: Desktop Computer Use is exposed through the Runtime Host Client // Capability bridge. The settled observation establishes the confirmed target; // the following sequence inherits it while live progress replaces the generic @@ -1596,6 +1700,134 @@ function transcriptTurns(from: number, count: number): StoredMessage[] { }).flat(); } +const PARTIAL_HISTORY_INDEX = Array.from({ length: 8 }, (_, index) => ({ + turnId: `turn-scroll-${index + 1}`, + sequence: index + 1, + label: `第 ${index + 1} 个问题`, +})); + +function PartialHistoryHarness() { + const [readingEarlier, setReadingEarlier] = useState(false); + return ( + setReadingEarlier(true), + returnToLatest: readingEarlier + ? { + title: '正在查看较早的消息', + label: '返回最新消息', + isPending: false, + onClick: () => setReadingEarlier(false), + } + : undefined, + }} + /> + ); +} + +function historyNoticePresentation(notice: HTMLElement) { + const style = getComputedStyle(notice); + const box = notice.getBoundingClientRect(); + const composer = document.querySelector('.maka-composer-astryx'); + const frame = notice.closest('.appFrame'); + if (!composer || !frame) throw new Error('The shell geometry is incomplete'); + const composerBox = composer.getBoundingClientRect(); + const frameBox = frame.getBoundingClientRect(); + return { + backgroundColor: style.backgroundColor, + borderWidths: [ + style.borderTopWidth, + style.borderRightWidth, + style.borderBottomWidth, + style.borderLeftWidth, + ], + display: style.display, + flexWrap: style.flexWrap, + justifyContent: style.justifyContent, + widthDelta: Math.abs(box.width - composerBox.width), + centerDelta: Math.abs( + (box.left + box.right) / 2 - (composerBox.left + composerBox.right) / 2, + ), + fitsFrame: box.left >= frameBox.left && box.right <= frameBox.right, + hasHorizontalOverflow: notice.scrollWidth > notice.clientWidth, + }; +} + +// Real path: selecting a prompt outside the loaded transcript range, then +// returning to the latest range. The notice stays a quiet reading-column +// control and every inactive prompt-rail tick uses one neutral treatment. +export const PartialHistoryNotice: Story = { + render: () => , + play: async ({ canvasElement }) => { + expect(canvasElement.querySelector('.maka-transcript-history-controls')).toBeNull(); + const firstPrompt = canvasElement.querySelector( + '.maka-prompt-rail-tick[data-prompt-turn-id="turn-scroll-1"]', + ); + if (!firstPrompt) throw new Error('The first historical prompt tick did not render'); + firstPrompt.click(); + + await waitFor(() => { + expect(canvasElement.querySelector('.maka-transcript-history-controls')).not.toBeNull(); + }); + const notice = canvasElement.querySelector('.maka-transcript-history-controls'); + if (!notice) throw new Error('The partial-history notice did not render'); + expect(notice.textContent).toContain('正在查看较早的消息'); + expect(notice.textContent).not.toMatch(/保存|加载/); + + const regular = historyNoticePresentation(notice); + expect(regular.backgroundColor).toBe('rgba(0, 0, 0, 0)'); + expect(regular.borderWidths).toEqual(['0px', '0px', '0px', '0px']); + expect(regular.display).toBe('flex'); + expect(regular.flexWrap).toBe('wrap'); + expect(regular.justifyContent).toBe('center'); + expect(regular.widthDelta).toBeLessThanOrEqual(1); + expect(regular.centerDelta).toBeLessThanOrEqual(1); + expect(regular.hasHorizontalOverflow).toBe(false); + + const neutralPaint = [ + ...canvasElement.querySelectorAll('.maka-prompt-rail-tick'), + ] + .filter((tick) => tick.dataset.active !== 'true' && !tick.matches(':hover')) + .map((tick) => { + const bar = tick.querySelector('.maka-prompt-rail-tick-bar'); + if (!bar) throw new Error('A prompt rail tick is missing its bar'); + const barStyle = getComputedStyle(bar); + return JSON.stringify({ + backgroundColor: barStyle.backgroundColor, + borderStyle: barStyle.borderStyle, + borderWidth: barStyle.borderWidth, + boxShadow: barStyle.boxShadow, + }); + }); + expect(neutralPaint.length).toBeGreaterThan(1); + expect(new Set(neutralPaint).size).toBe(1); + expect(canvasElement.querySelectorAll('[data-resident]')).toHaveLength(0); + expect( + [...document.styleSheets].flatMap((sheet) => + [...sheet.cssRules].filter((rule) => rule.cssText.includes('data-resident'))), + ).toHaveLength(0); + + const frame = canvasElement.querySelector('.appFrame'); + if (!frame) throw new Error('Shell frame did not render'); + frame.style.width = '520px'; + await painted(2); + const narrow = historyNoticePresentation(notice); + expect(narrow.centerDelta).toBeLessThanOrEqual(1); + expect(narrow.fitsFrame).toBe(true); + expect(narrow.hasHorizontalOverflow).toBe(false); + + const returnButton = within(notice).getByRole('button', { name: '返回最新消息' }); + returnButton.click(); + await waitFor(() => { + expect(canvasElement.querySelector('.maka-transcript-history-controls')).toBeNull(); + expect(canvasElement.querySelector('[data-turn-id="turn-scroll-8"]')).not.toBeNull(); + }); + }, +}; + /** Stops the harness below, so the tail can be read against a settled transcript. */ let stopTailStream: (() => void) | undefined; diff --git a/packages/core/src/e2e-fixture.ts b/packages/core/src/e2e-fixture.ts index 0099c188ee..2961cca642 100644 --- a/packages/core/src/e2e-fixture.ts +++ b/packages/core/src/e2e-fixture.ts @@ -27,7 +27,6 @@ export type E2eFixtureScenario = | 'turn-narrative' | 'turn-narrative-browser' | 'chat-prompt-rail' - | 'chat-partial-history' | 'settings-data' | 'settings-bots-onboarding' | 'settings-general' From 625f5abc4bf183a2a940db5f9d067c3bb9efc3fb Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 5 Sep 2026 12:04:32 +0800 Subject: [PATCH 5/8] test(desktop): move settings focus rings to stories Generated-by: Codex --- .../e2e/settings-row-focus-ring.spec.ts | 176 ------------------ .../settings/settings-pages.stories.tsx | 99 ++++++++++ scripts/storybook-visual-smoke.mjs | 10 +- scripts/storybook-visual-smoke.test.mjs | 19 +- 4 files changed, 122 insertions(+), 182 deletions(-) delete mode 100644 apps/desktop/e2e/settings-row-focus-ring.spec.ts diff --git a/apps/desktop/e2e/settings-row-focus-ring.spec.ts b/apps/desktop/e2e/settings-row-focus-ring.spec.ts deleted file mode 100644 index 76200d2849..0000000000 --- a/apps/desktop/e2e/settings-row-focus-ring.spec.ts +++ /dev/null @@ -1,176 +0,0 @@ -/* - * 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. - */ - -import { ensureSidebarExpanded, expect, test } from './fixtures'; -import type { Locator, Page } from '@playwright/test'; - -/** - * A settings row rings its OWN tab stop and nothing else. - * - * Astryx's Item draws `outline: 2px solid accent` at `:has(:focus-visible)` - * unconditionally. That is right only for a row whose click target is the - * invisible ` +
+ + + +
+
+ ); +} + const meta = { title: 'Product/Composer Slash Menu', component: SlashMenuHarness, @@ -378,3 +416,42 @@ export const SurvivesASameContentProjectionRefresh: Story = { await expect(skillsGroup.isConnected).toBe(true); }, }; + +// Real path: leaving an existing Session for a new-task composer. The previous +// Session's populated Skill projection must stop being actionable in the same +// render; the new surface stays busy until its own projection resolves. +export const ContextSwitchStartsWithALoadingCatalog: Story = { + render: () => , + play: async ({ canvasElement }) => { + const page = overlay(); + await waitFor(() => expect(projectionLoads).toBeGreaterThan(0)); + await userEvent.click(within(canvasElement).getByRole('button', { + name: 'Switch to new task', + })); + await userEvent.click(page.getByRole('button', { name: '添加上下文' })); + const menu = page.getByRole('menu', { name: '添加上下文' }); + const skillsRow = within(menu).getByRole('menuitem', { name: /选择技能/ }); + + await waitFor(() => expect(skillsRow).toHaveAttribute('aria-busy', 'true')); + await expect(skillsRow).not.toHaveAttribute('aria-disabled', 'true'); + await userEvent.click(skillsRow); + await expect(menu).toBeVisible(); + await expect(editor(canvasElement)).toHaveTextContent(''); + await expect(page.queryByRole('listbox', { name: /技能/ })).not.toBeInTheDocument(); + + releaseHeldProjection?.(); + await waitFor(() => { + const settledRow = within( + page.getByRole('menu', { name: '添加上下文' }), + ).getByRole('menuitem', { name: /选择技能/ }); + expect(settledRow).not.toHaveAttribute('aria-busy'); + }); + const settledRow = within( + page.getByRole('menu', { name: '添加上下文' }), + ).getByRole('menuitem', { name: /选择技能/ }); + await userEvent.click(settledRow); + await expect(await page.findByRole('listbox', { name: /技能/ }, { + timeout: 5_000, + })).toBeVisible(); + }, +}; diff --git a/packages/ui/src/session-setting-intent.test.ts b/packages/ui/src/session-setting-intent.test.ts index 038f67f091..8d48fdc5c7 100644 --- a/packages/ui/src/session-setting-intent.test.ts +++ b/packages/ui/src/session-setting-intent.test.ts @@ -232,6 +232,163 @@ test('rapid revision-aware requests retire only after the last committed revisio assert.equal(controller!.overlayByChannel.model['session-1'], undefined); }); +test('rapid requests drain to the latest requested value', async () => { + const { controller, writes } = await mountIntentHarness(); + + let completion!: Promise; + await act(async () => { + completion = controller().request('model', 'session-1', 'plan'); + void controller().request('model', 'session-1', 'agent'); + }); + assert.deepEqual(writes.map(({ value }) => value), ['plan']); + assert.equal(controller().overlayByChannel.model['session-1'], 'agent'); + + await act(async () => { + writes[0]!.result.resolve(true); + await Promise.resolve(); + }); + assert.deepEqual(writes.map(({ value }) => value), ['plan', 'agent']); + + await act(async () => { + writes[1]!.result.resolve(true); + assert.equal(await completion, true); + }); + assert.equal(controller().overlayByChannel.model['session-1'], 'agent'); +}); + +test('a rejected catalog refresh does not roll back a committed value', async () => { + const { controller, writes } = await mountIntentHarness({ + refreshCatalog: async () => { + throw new Error('catalog unavailable'); + }, + }); + + let completion!: Promise; + await act(async () => { + completion = controller().request('model', 'session-1', 'plan'); + writes[0]!.result.resolve(true); + assert.equal(await completion, true); + }); + assert.equal(controller().overlayByChannel.model['session-1'], 'plan'); +}); + +test('a newer request still writes after the in-flight write fails', async () => { + const errors: Array<{ attempted: string; error: unknown }> = []; + const { controller, writes } = await mountIntentHarness({ errors }); + + let completion!: Promise; + await act(async () => { + completion = controller().request('model', 'session-1', 'plan'); + void controller().request('model', 'session-1', 'agent'); + writes[0]!.result.reject(new Error('first write failed')); + await Promise.resolve(); + }); + assert.deepEqual(writes.map(({ value }) => value), ['plan', 'agent']); + assert.deepEqual(errors, []); + + await act(async () => { + writes[1]!.result.resolve(true); + assert.equal(await completion, true); + }); + assert.equal(controller().overlayByChannel.model['session-1'], 'agent'); +}); + +test('clearing a session drops its pending and queued requests', async () => { + const { controller, writes } = await mountIntentHarness(); + + let completion!: Promise; + await act(async () => { + completion = controller().request('model', 'session-1', 'plan'); + void controller().request('model', 'session-1', 'agent'); + controller().clear('session-1'); + assert.equal(await completion, false); + }); + assert.equal(controller().overlayByChannel.model['session-1'], undefined); + + await act(async () => { + writes[0]!.result.resolve(true); + await Promise.resolve(); + }); + assert.deepEqual(writes.map(({ value }) => value), ['plan']); + assert.equal(controller().overlayByChannel.model['session-1'], undefined); +}); + +async function mountIntentHarness(options?: { + refreshCatalog?: () => Promise; + errors?: Array<{ attempted: string; error: unknown }>; +}) { + const { document, window } = parseHTML('
'); + Object.assign(globalThis, { + document, + window, + HTMLElement: window.HTMLElement, + Node: window.Node, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + mountedRoot = root; + + const writes: Array<{ + value: string; + result: ReturnType> & { reject(error: unknown): void }; + }> = []; + let captured: Controller | undefined; + await act(async () => { + root.render(createElement(IntentHarness, { + capture: (next) => { + captured = next; + }, + refreshCatalog: options?.refreshCatalog ?? (async () => {}), + modelWrite: async (_sessionId, value) => { + let reject!: (error: unknown) => void; + const pending = deferred(); + const promise = new Promise((resolve, rejectPromise) => { + reject = rejectPromise; + pending.promise.then(resolve, rejectPromise); + }); + const result = { ...pending, promise, reject }; + writes.push({ value, result }); + return promise; + }, + onModelWriteError: (_sessionId, error, attempted) => { + options?.errors?.push({ attempted, error }); + }, + })); + }); + return { + controller: () => { + assert.ok(captured); + return captured; + }, + writes, + }; +} + +function IntentHarness({ + capture, + refreshCatalog, + modelWrite, + onModelWriteError, +}: { + capture(controller: Controller): void; + refreshCatalog(): Promise; + modelWrite(sessionId: string, value: string): Promise; + onModelWriteError(sessionId: string, error: unknown, attempted: string): void; +}) { + const controller = useSessionSettingIntent({ + catalogRevision: 0, + refreshCatalog, + channels: { + model: { write: modelWrite, onWriteError: onModelWriteError }, + permission: { write: async () => true, onWriteError: () => {} }, + }, + }); + capture(controller); + return null; +} + function RapidRevisionHarness({ capture, catalogRevision, From 0269250827132abb969aaf56af2deec83031ac7c Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 5 Sep 2026 12:30:17 +0800 Subject: [PATCH 7/8] test(desktop): move sidebar row interactions to stories Generated-by: Codex --- .../e2e/sidebar-project-reload.spec.ts | 49 ++++ apps/desktop/e2e/sidebar-project-row.spec.ts | 232 ------------------ .../ui/stories/session-list-panel.stories.tsx | 143 ++++++++++- 3 files changed, 191 insertions(+), 233 deletions(-) create mode 100644 apps/desktop/e2e/sidebar-project-reload.spec.ts delete mode 100644 apps/desktop/e2e/sidebar-project-row.spec.ts diff --git a/apps/desktop/e2e/sidebar-project-reload.spec.ts b/apps/desktop/e2e/sidebar-project-reload.spec.ts new file mode 100644 index 0000000000..8ac1aba3d4 --- /dev/null +++ b/apps/desktop/e2e/sidebar-project-reload.spec.ts @@ -0,0 +1,49 @@ +/* + * 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. + */ + +import { expect, test } from './fixtures'; + +// This stays in Electron: the contract is persistence across a real renderer +// reload, not SessionRail's rendering or interaction behavior. Those three +// component-owned journeys live in Product/Sidebar Session List/Project Groups. +test('rail grouping survives a renderer reload', async ({ projectSidebarWindow: page }) => { + await page.keyboard.press('Escape'); + await expect(page.locator('[data-maka-contract="search-modal"]')).not.toBeVisible(); + + const sidebar = page.getByRole('navigation', { name: '任务列表' }); + const byTime = sidebar.getByRole('radio', { name: '按时间', exact: true }); + const byProject = sidebar.getByRole('radio', { name: '按项目', exact: true }); + + await expect(byTime).toBeChecked(); + await byProject.click(); + await expect(byProject).toBeChecked(); + await expect + .poll(() => page.evaluate(() => localStorage.getItem('maka-chat-list-view-mode-v1'))) + .toBe('project'); + + await page.reload(); + await expect(page.locator('[data-maka-contract="search-modal"][open]')).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(page.locator('[data-maka-contract="search-modal"]')).not.toBeVisible(); + + await expect(sidebar.getByRole('radio', { name: '按项目', exact: true })).toBeChecked(); + await expect + .poll(() => page.evaluate(() => localStorage.getItem('maka-chat-list-view-mode-v1'))) + .toBe('project'); +}); diff --git a/apps/desktop/e2e/sidebar-project-row.spec.ts b/apps/desktop/e2e/sidebar-project-row.spec.ts deleted file mode 100644 index e7262470a6..0000000000 --- a/apps/desktop/e2e/sidebar-project-row.spec.ts +++ /dev/null @@ -1,232 +0,0 @@ -/* - * 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. - */ - -import { - LONG_SIDEBAR_PROJECT_ID, - LONG_SIDEBAR_PROJECT_NAME, - LONG_SIDEBAR_SESSION_PREFIX, -} from '../src/main/e2e-fixture/seed-helpers'; -import type { Locator } from '@playwright/test'; -import { expect, test } from './fixtures'; - -function sessionRow(sidebar: Locator, sessionId: string): Locator { - return sidebar.locator(`[data-session-id*=${JSON.stringify(sessionId)}]`); -} - -test('project navigation and actions follow their visual keyboard order', async ({ - projectSidebarWindow: page, -}) => { - await page.keyboard.press('Escape'); - await expect(page.locator('[data-maka-contract="search-modal"]')).not.toBeVisible(); - - const sidebar = page.getByRole('navigation', { name: '任务列表' }); - await sidebar.getByRole('radio', { name: '按项目', exact: true }).click(); - - const projectRow = sidebar.locator( - `[data-project-id="project:${LONG_SIDEBAR_PROJECT_ID}"]`, - ); - const action = projectRow.getByRole('button', { - name: `${LONG_SIDEBAR_PROJECT_NAME} 项目操作`, - exact: true, - }); - const navigation = projectRow.locator( - 'button[aria-controls]:not([aria-haspopup="menu"])', - ); - const controlledGroupId = await navigation.getAttribute('aria-controls'); - expect(controlledGroupId).toBeTruthy(); - const controlledGroup = page.locator(`[id="${controlledGroupId}"]`); - const firstSessionControl = controlledGroup.locator('[data-session-id] button').first(); - - await expect(navigation).toBeVisible(); - await expect(firstSessionControl).toBeVisible(); - const projectTitle = navigation.getByText(LONG_SIDEBAR_PROJECT_NAME, { exact: true }); - const sessionTitle = firstSessionControl.getByText('任务 00', { exact: true }); - await expect(projectTitle).toBeVisible(); - await expect(sessionTitle).toBeVisible(); - const [projectNavigationBox, firstSessionBox, projectTitleBox, sessionTitleBox] = await Promise.all([ - navigation.boundingBox(), - firstSessionControl.boundingBox(), - projectTitle.boundingBox(), - sessionTitle.boundingBox(), - ]); - expect(projectNavigationBox).not.toBeNull(); - expect(firstSessionBox).not.toBeNull(); - expect(projectTitleBox).not.toBeNull(); - expect(sessionTitleBox).not.toBeNull(); - // Hierarchy is the session button sitting inside the project button. Title - // alignment is the product contract: leading content widths differ, so the - // inset is not the same as the title column. - const sessionInset = firstSessionBox!.x - projectNavigationBox!.x; - expect(sessionInset).toBeGreaterThanOrEqual(6); - expect(sessionInset).toBeLessThan(16); - expect(Math.abs(projectTitleBox!.x - sessionTitleBox!.x)).toBeLessThanOrEqual(2); - - await expect(projectRow.locator('button button')).toHaveCount(0); - await expect(navigation).toHaveAttribute('aria-expanded', 'true'); - - await navigation.focus(); - await page.keyboard.press('Tab'); - await expect(action).toBeFocused(); - await page.keyboard.press('Tab'); - await expect(firstSessionControl).toBeFocused(); - - await navigation.focus(); - await page.keyboard.press('Enter'); - await expect(navigation).toHaveAttribute('aria-expanded', 'false'); - await expect(controlledGroup).toHaveAttribute('aria-hidden', 'true'); - expect(await controlledGroup.getAttribute('inert')).not.toBeNull(); - await page.keyboard.press('Enter'); - await expect(navigation).toHaveAttribute('aria-expanded', 'true'); - await expect(controlledGroup).toHaveAttribute('aria-hidden', 'false'); - expect(await controlledGroup.getAttribute('inert')).toBeNull(); - - await action.focus(); - await page.keyboard.press('Enter'); - const newTaskItem = page.getByRole('menuitem', { name: '新建任务', exact: true }); - await expect(newTaskItem).toBeVisible(); - await expect(navigation).toHaveAttribute('aria-expanded', 'true'); - await page.keyboard.press('Escape'); - await expect(newTaskItem).not.toBeVisible(); - await expect(action).toBeFocused(); - - await page.keyboard.press('Enter'); - await page.getByRole('menuitem', { name: '重命名', exact: true }).click(); - await expect(page.getByRole('dialog', { name: '重命名项目' })).toBeVisible(); - await page.getByRole('button', { name: '关闭', exact: true }).click(); - await expect(action).toBeFocused(); -}); - -test('task row action menu accepts pointer selection', async ({ - projectSidebarWindow: page, -}) => { - await page.keyboard.press('Escape'); - await expect(page.locator('[data-maka-contract="search-modal"]')).not.toBeVisible(); - - const sidebar = page.getByRole('navigation', { name: '任务列表' }); - const taskSessionId = `${LONG_SIDEBAR_SESSION_PREFIX}00`; - const taskRow = sessionRow(sidebar, taskSessionId); - const actionMenu = taskRow.locator(':scope > .maka-session-row-action'); - const timestamp = taskRow.locator('.maka-session-row-time'); - await expect(timestamp).toHaveCSS('visibility', 'visible'); - await taskRow.hover(); - await taskRow.getByRole('button', { name: /任务操作$/ }).click(); - - const rename = page.getByRole('menuitem', { name: '重命名', exact: true }); - await expect(rename).toBeVisible(); - await expect(actionMenu).toHaveAttribute('data-menu-open', 'true'); - await rename.hover(); - await expect.poll(() => taskRow.evaluate((row) => row.matches(':hover'))).toBe(false); - await expect(timestamp).toHaveCSS('visibility', 'hidden'); - - await page.mouse.click(4, 4); - await expect(rename).not.toBeVisible(); - await expect(actionMenu).not.toHaveAttribute('data-menu-open', 'true'); - - await taskRow.hover(); - await taskRow.getByRole('button', { name: /任务操作$/ }).focus(); - await page.keyboard.press('Enter'); - await expect(rename).toBeVisible(); - await rename.click(); - - await expect(page.getByRole('dialog', { name: '重命名任务' })).toBeVisible(); -}); - -test('project and task rows show contextual hover details', async ({ - projectSidebarWindow: page, -}) => { - await page.keyboard.press('Escape'); - await expect(page.locator('[data-maka-contract="search-modal"]')).not.toBeVisible(); - - const sidebar = page.getByRole('navigation', { name: '任务列表' }); - const taskSessionId = `${LONG_SIDEBAR_SESSION_PREFIX}00`; - const taskRow = sessionRow(sidebar, taskSessionId); - const taskNavigation = taskRow.locator('.astryx-side-nav-item'); - await expect(taskNavigation).toHaveAccessibleDescription( - /已归档第 00 条研究记录。.*glm-5\.1/, - ); - // A new Electron window can open under the cursor left by the prior test; - // force a real leave → enter transition so the delayed hover trigger fires. - await page.mouse.move(800, 400); - await taskNavigation.hover(); - - const taskCard = page.locator('.maka-sidebar-hover-card[data-kind="session"]'); - await expect(taskCard).toBeVisible(); - await expect(taskCard.locator('.maka-sidebar-hover-card-title')).toHaveText('任务 00'); - await expect(taskCard.locator('.maka-sidebar-hover-card-preview')).toHaveText( - '已归档第 00 条研究记录。', - ); - const taskProject = taskCard.locator('.maka-sidebar-hover-card-project'); - await expect(taskProject).toHaveText(LONG_SIDEBAR_PROJECT_NAME); - await expect(taskProject).toHaveAttribute( - 'title', - /e2e-fixture-sidebar-search-modal-open/, - ); - await expect(taskCard.locator('.maka-sidebar-hover-card-meta')).toContainText('glm-5.1'); - - await sidebar.getByRole('radio', { name: '按项目', exact: true }).click(); - const projectRow = sidebar.locator( - `[data-project-id="project:${LONG_SIDEBAR_PROJECT_ID}"]`, - ); - const projectNavigation = projectRow.locator(':scope > div > .astryx-side-nav-item'); - await expect(projectNavigation.locator('.astryx-badge')).toHaveCount(0); - await expect(projectNavigation).toHaveAccessibleDescription(/3 个任务.*目录可用/); - await projectNavigation.hover(); - - const projectCard = page.locator('.maka-sidebar-hover-card[data-kind="project"]'); - await expect(projectCard).toBeVisible(); - await expect(projectCard.locator('.maka-sidebar-hover-card-title')).toHaveText( - LONG_SIDEBAR_PROJECT_NAME, - ); - await expect(projectCard.locator('.maka-sidebar-hover-card-meta')).toContainText('3 个任务'); - await expect(projectCard.locator('.maka-sidebar-hover-card-meta')).toContainText('目录可用'); - - const ungroupedRow = sidebar.locator('[data-project-id="__ungrouped__"]'); - const ungroupedNavigation = ungroupedRow.locator(':scope > div > .astryx-side-nav-item'); - await expect(ungroupedNavigation.locator('.astryx-badge')).toHaveCount(0); - await ungroupedNavigation.focus(); - await expect( - page.getByRole('dialog', { name: '未归属项目 分组详情', exact: true }), - ).toBeVisible(); -}); - -test('rail grouping survives a renderer reload', async ({ projectSidebarWindow: page }) => { - await page.keyboard.press('Escape'); - await expect(page.locator('[data-maka-contract="search-modal"]')).not.toBeVisible(); - - const sidebar = page.getByRole('navigation', { name: '任务列表' }); - const byTime = sidebar.getByRole('radio', { name: '按时间', exact: true }); - const byProject = sidebar.getByRole('radio', { name: '按项目', exact: true }); - - await expect(byTime).toBeChecked(); - await byProject.click(); - await expect(byProject).toBeChecked(); - await expect - .poll(() => page.evaluate(() => localStorage.getItem('maka-chat-list-view-mode-v1'))) - .toBe('project'); - - await page.reload(); - await expect(page.locator('[data-maka-contract="search-modal"][open]')).toBeVisible(); - await page.keyboard.press('Escape'); - await expect(page.locator('[data-maka-contract="search-modal"]')).not.toBeVisible(); - - await expect(sidebar.getByRole('radio', { name: '按项目', exact: true })).toBeChecked(); - await expect - .poll(() => page.evaluate(() => localStorage.getItem('maka-chat-list-view-mode-v1'))) - .toBe('project'); -}); diff --git a/packages/ui/stories/session-list-panel.stories.tsx b/packages/ui/stories/session-list-panel.stories.tsx index 5892728f1d..1b8f542bea 100644 --- a/packages/ui/stories/session-list-panel.stories.tsx +++ b/packages/ui/stories/session-list-panel.stories.tsx @@ -19,7 +19,7 @@ import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; -import { expect, waitFor, within } from 'storybook/test'; +import { expect, userEvent, waitFor, within } from 'storybook/test'; import type { ProjectRecord } from '@maka/core/project'; import type { SessionBlockedReason, SessionStatus, SessionSummary } from '@maka/core/session'; import { SessionRail, type SessionRailStoryProps } from './session-rail-harness.js'; @@ -54,6 +54,9 @@ function makeSession(input: { hasUnread?: boolean; backend?: SessionSummary['backend']; llmConnectionSlug?: string; + projectId?: string | null; + cwd?: string; + lastMessagePreview?: string; }): SessionSummary { const status = input.status ?? 'active'; return { @@ -71,6 +74,11 @@ function makeSession(input: { connectionLocked: false, model: 'glm-4.7', permissionMode: 'ask', + ...(input.projectId !== undefined ? { projectId: input.projectId } : {}), + ...(input.cwd !== undefined ? { cwd: input.cwd } : {}), + ...(input.lastMessagePreview !== undefined + ? { lastMessagePreview: input.lastMessagePreview } + : {}), }; } @@ -546,12 +554,21 @@ export const ProjectGroups: Story = { name: 'worktree 上的修复', status: 'running', lastMessageAt: NOW - 1 * 60 * 1000, + projectId: maka.id, + cwd: '/workspace/maka-agent/.worktree/sidebar', + lastMessagePreview: '正在把侧栏交互契约迁移到浏览器 story。', }), makeSession({ id: 'proj-docs', name: '文档站改版', lastMessageAt: NOW - 30 * 60 * 1000, }), + makeSession({ + id: 'proj-loose', + name: '未归属的临时任务', + projectId: null, + lastMessageAt: NOW - 45 * 60 * 1000, + }), ]; return ( @@ -581,6 +598,11 @@ export const ProjectGroups: Story = { project: missing, sessions: [], }, + { + id: '__ungrouped__', + label: '未归属项目', + sessions: [sessions[3]!], + }, ], projectActions: { onNew: noop, @@ -594,6 +616,125 @@ export const ProjectGroups: Story = { ); }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const page = within(canvasElement.ownerDocument.body); + const projectRow = canvasElement.querySelector( + '[data-project-id="project:project-maka"]', + ); + if (!projectRow) throw new Error('project row is missing'); + const navigation = projectRow.querySelector( + 'button[aria-controls]:not([aria-haspopup="menu"])', + ); + if (!navigation) throw new Error('project navigation is missing'); + const action = within(projectRow).getByRole('button', { + name: 'maka-agent 项目操作', + }); + const groupId = navigation.getAttribute('aria-controls'); + if (!groupId) throw new Error('project navigation does not own a group'); + const group = canvasElement.ownerDocument.getElementById(groupId); + if (!group) throw new Error('project task group is missing'); + const taskControl = group.querySelector('[data-session-id] button'); + if (!taskControl) throw new Error('nested task control is missing'); + const projectTitle = within(navigation).getByText('maka-agent', { exact: true }); + const taskTitle = within(taskControl).getByText('worktree 上的修复', { exact: true }); + + expect(projectRow.querySelectorAll('button button')).toHaveLength(0); + const projectBox = navigation.getBoundingClientRect(); + const taskBox = taskControl.getBoundingClientRect(); + const sessionInset = taskBox.x - projectBox.x; + expect(sessionInset).toBeGreaterThanOrEqual(6); + expect(sessionInset).toBeLessThan(16); + expect(Math.abs( + projectTitle.getBoundingClientRect().x - taskTitle.getBoundingClientRect().x, + )).toBeLessThanOrEqual(2); + + navigation.focus(); + await userEvent.tab(); + await expect(action).toHaveFocus(); + await userEvent.tab(); + await expect(taskControl).toHaveFocus(); + + navigation.focus(); + await userEvent.keyboard('{Enter}'); + await expect(navigation).toHaveAttribute('aria-expanded', 'false'); + await expect(group).toHaveAttribute('aria-hidden', 'true'); + expect(group.getAttribute('inert')).not.toBeNull(); + await userEvent.keyboard('{Enter}'); + await expect(navigation).toHaveAttribute('aria-expanded', 'true'); + await expect(group).toHaveAttribute('aria-hidden', 'false'); + expect(group.getAttribute('inert')).toBeNull(); + + action.focus(); + await userEvent.keyboard('{Enter}'); + await expect(page.getByRole('menuitem', { name: '新建任务' })).toBeVisible(); + await userEvent.keyboard('{Escape}'); + await expect(action).toHaveFocus(); + await userEvent.keyboard('{Enter}'); + await userEvent.click(page.getByRole('menuitem', { name: '重命名' })); + await expect(page.getByRole('dialog', { name: '重命名项目' })).toBeVisible(); + await userEvent.click(page.getByRole('button', { name: '关闭' })); + await expect(action).toHaveFocus(); + + await userEvent.hover(taskControl); + const taskCard = await page.findByText('正在把侧栏交互契约迁移到浏览器 story。'); + const taskHoverCard = taskCard.closest( + '.maka-sidebar-hover-card[data-kind="session"]', + ); + if (!taskHoverCard) throw new Error('task hover card is missing'); + await expect(within(taskHoverCard).getByText('worktree 上的修复')).toBeVisible(); + await expect(within(taskHoverCard).getByText(/glm-4\.7/)).toBeVisible(); + + await userEvent.hover(navigation); + await waitFor(() => expect( + canvasElement.ownerDocument.querySelector( + '.maka-sidebar-hover-card[data-kind="project"]', + ), + ).toBeVisible()); + const projectHoverCard = canvasElement.ownerDocument.querySelector( + '.maka-sidebar-hover-card[data-kind="project"]', + ); + if (!projectHoverCard) throw new Error('project hover card is missing'); + await expect(within(projectHoverCard).getByText('1 个任务')).toBeVisible(); + await expect(within(projectHoverCard).getByText(/目录可用/)).toBeVisible(); + await userEvent.unhover(navigation); + await waitFor(() => expect(projectHoverCard).not.toBeVisible()); + + const taskRow = taskControl.closest('[data-session-id]'); + if (!taskRow) throw new Error('task row is missing'); + const timestamp = taskRow.querySelector('.maka-session-row-time'); + if (!timestamp) throw new Error('task timestamp is missing'); + const taskActionButton = within(taskRow).getByRole('button', { name: /任务操作$/ }); + taskActionButton.focus(); + await userEvent.keyboard('{Enter}'); + const renameTask = page.getByRole('menuitem', { name: '重命名' }); + await expect(renameTask).toBeVisible(); + const taskAction = taskRow.querySelector('.maka-session-row-action'); + if (!taskAction) throw new Error('task action is missing'); + await expect(taskAction).toHaveAttribute( + 'data-menu-open', + 'true', + ); + await userEvent.hover(renameTask); + await expect(timestamp).toHaveStyle({ visibility: 'hidden' }); + await userEvent.click(renameTask); + await expect(await page.findByRole('dialog', { name: '重命名任务' }, { + timeout: 5_000, + })).toBeVisible(); + await userEvent.click(page.getByRole('button', { name: '关闭' })); + + const ungroupedRow = canvasElement.querySelector( + '[data-project-id="__ungrouped__"]', + ); + const ungroupedNavigation = ungroupedRow?.querySelector( + ':scope > div > .astryx-side-nav-item', + ); + if (!ungroupedNavigation) throw new Error('ungrouped project row is missing'); + ungroupedNavigation.focus(); + await expect(await page.findByRole('dialog', { + name: '未归属项目 分组详情', + })).toBeVisible(); + }, }; // Group-by-project where a project's only task is pinned, so the project row From a48cbdf8fbab0868d08a4b95d042872ab13f972b Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 5 Sep 2026 12:30:34 +0800 Subject: [PATCH 8/8] test(desktop): keep only native quote drag coverage Generated-by: Codex --- ....spec.ts => quote-window-boundary.spec.ts} | 59 +------------------ 1 file changed, 2 insertions(+), 57 deletions(-) rename apps/desktop/e2e/{quote-selection.spec.ts => quote-window-boundary.spec.ts} (59%) diff --git a/apps/desktop/e2e/quote-selection.spec.ts b/apps/desktop/e2e/quote-window-boundary.spec.ts similarity index 59% rename from apps/desktop/e2e/quote-selection.spec.ts rename to apps/desktop/e2e/quote-window-boundary.spec.ts index a89b6b158f..8fd5a516a8 100644 --- a/apps/desktop/e2e/quote-selection.spec.ts +++ b/apps/desktop/e2e/quote-window-boundary.spec.ts @@ -19,6 +19,8 @@ import { expect, test, COMPOSER_INPUT } from './fixtures'; +// This stays in Electron: the physical pointer leaves the renderer viewport, +// and Chromium pointer capture must route its release back to the owning Turn. test('a transcript drag releases outside the window through its owning Turn', async ({ window: page, }) => { @@ -80,15 +82,11 @@ test('a transcript drag releases outside the window through its owning Turn', as const startX = bounds.x + 2; const selectedX = bounds.x + bounds.width - 2; - // Ignore any collapsed selectionchange left by the Composer focus change; - // the marker below must come from this drag while capture is active. await turn.evaluate((element) => { const owner = element as HTMLElement; delete owner.dataset.e2eSelectionChanged; }); - // Pointer capture must route the physical release back to the owning Turn - // after the mouse leaves the renderer viewport. await page.mouse.move(startX, y); await page.mouse.down(); await page.mouse.move(selectedX, y, { steps: 5 }); @@ -97,62 +95,9 @@ test('a transcript drag releases outside the window through its owning Turn', as .poll(() => page.evaluate(() => window.getSelection()?.isCollapsed === false)) .toBe(true); await expect(turn).toHaveAttribute('data-e2e-selection-changed', 'true'); - // Leave through the transcript side of the viewport. Exiting through the - // left would drag across the navigation rail and correctly turn this into a - // cross-scope Selection, which the quote resolver must reject. await page.mouse.move(1220, y, { steps: 5 }); await page.mouse.up(); await expect(turn).toHaveAttribute('data-e2e-captured-pointer-up', 'true'); await expect(quoteLayer).toBeVisible(); }); - -// Known broken, kept visible rather than described in a PR nobody re-reads. -// Selecting inside a still-streaming answer loses the Selection when the -// stream closes: the markdown renderer rebuilds the paragraph's inline -// fragments, the browser discards the Selection those nodes held, and it does -// so without a `selectionchange` — so the quote hook, which re-reads the -// Selection 350ms after pointer release, finds nothing to offer. -// -// Not fixable from this repo as it stands: the rebuild is inside -// @astryxdesign/core, and pinning the `isStreaming` / `settledText` props that -// drive it still reproduces. Closing this needs an upstream fix, or a decision -// to snapshot the quote at pointerup — which would show a quote bar over text -// whose highlight the browser has already erased. -test.fixme('a drag begun while the answer streams still offers a quote', async ({ - window: page, -}) => { - const composer = page.locator(COMPOSER_INPUT); - await composer.fill('pointer capture source'); - await composer.press('Enter'); - - const reply = page.getByText(/Fake backend received: pointer capture source/); - await expect(reply).toBeVisible(); - const bounds = await reply.evaluate((element) => { - const range = document.createRange(); - range.selectNodeContents(element); - const rect = range.getBoundingClientRect(); - return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; - }); - const y = bounds.y + bounds.height / 2; - await page.mouse.move(bounds.x + 2, y); - await page.mouse.down(); - await page.mouse.move(bounds.x + bounds.width - 2, y, { steps: 5 }); - await page.mouse.up(); - - // Assert the contract that is actually broken, not the quote bar: the bar is - // also reachable while the gap is open, because the hook's 350ms read can win - // the race against the stream close and leave a bar standing over a Selection - // the browser has already erased. Wait for the close, then require the - // Selection to still be there — that is the state the quote hook needs and - // the one the fragment rebuild destroys. - await expect - .poll(() => page.evaluate(() => window.getSelection()?.isCollapsed === false)) - .toBe(true); - await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { - timeout: 20_000, - }); - await expect - .poll(() => page.evaluate(() => window.getSelection()?.isCollapsed === false)) - .toBe(true); -});