Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions apps/desktop/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,28 @@ async function seedE2eLocale(userDataDir: string, locale: 'zh' | 'en'): Promise<
});
}

/** Rows for the rail-render contract: enough that a stray render is loud. */
export const RAIL_RENDER_SESSION_COUNT = 12;

async function seedRailRenderSessions(userDataDir: string): Promise<void> {
const workspaceRoot = path.join(userDataDir, 'workspaces', 'default');
const store = createSessionStore(workspaceRoot);
try {
for (let index = 0; index < RAIL_RENDER_SESSION_COUNT; index += 1) {
await store.create({
cwd: path.join(userDataDir, 'project'),
llmConnectionSlug: 'e2e',
model: 'claude-sonnet-4-5-20250929',
permissionMode: 'ask',
name: `Rail row ${index}`,
labels: [],
});
}
} finally {
await store.close?.();
}
}

async function seedParentRemovalSessions(userDataDir: string): Promise<void> {
const workspaceRoot = path.join(userDataDir, 'workspaces', 'default');
const store = createSessionStore(workspaceRoot);
Expand Down Expand Up @@ -354,6 +376,7 @@ async function withE2eWindow(
invocableSkills,
gitReviewExtraFiles,
parentRemovalSessions,
railRenderSessions,
newTaskProject,
}: {
seed: boolean;
Expand All @@ -369,6 +392,7 @@ async function withE2eWindow(
invocableSkills?: boolean;
gitReviewExtraFiles?: number;
parentRemovalSessions?: boolean;
railRenderSessions?: boolean;
newTaskProject?: boolean;
},
use: (page: Page, context: { userDataDir: string }) => Promise<void>,
Expand All @@ -384,6 +408,7 @@ async function withE2eWindow(
try {
if (seed) await seedE2eConnection(userDataDir);
if (parentRemovalSessions) await seedParentRemovalSessions(userDataDir);
if (railRenderSessions) await seedRailRenderSessions(userDataDir);
if (invocableSkills) await seedE2eInvocableSkills(userDataDir);
if (gitReviewExtraFiles !== undefined) {
await seedE2eGitReviewProject(userDataDir, gitReviewExtraFiles);
Expand Down Expand Up @@ -455,6 +480,7 @@ export const test = base.extend<{
linkColorWindow: Page;
projectSidebarWindow: Page;
parentRemovalWindow: Page;
railRenderWindow: Page;
promptRailWindow: Page;
promptRailMotionWindow: Page;
requestHeaderRowWindow: Page;
Expand Down Expand Up @@ -537,6 +563,17 @@ export const test = base.extend<{
use,
);
},
railRenderWindow: async ({}, use) => {
await withE2eWindow(
{
seed: true,
readinessSelector: COMPOSER_INPUT,
locale: 'zh',
railRenderSessions: true,
},
use,
);
},
// A multi-prompt transcript for the prompt anchor rail. Shown, because every
// assertion in prompt-rail.spec.ts is geometry the compositor has to settle.
promptRailWindow: async ({}, use) => {
Expand Down
180 changes: 180 additions & 0 deletions apps/desktop/e2e/session-rail-render-contract.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/*
* 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, type Page } from '@playwright/test';
import {
ensureSidebarExpanded,
RAIL_RENDER_SESSION_COUNT,
test,
} from './fixtures.js';

/**
* Switching a session moves one row's selection. What it must not do is rewrite
* the rest of the rail.
*
* Deliberately a budget on the OUTCOME rather than an assertion about
* identities or `memo`. The rail's cost has had several independent causes —
* `setActiveId` changing identity every AppShell render, `Intl` formatters
* rebuilt per row, catalog refreshes replacing unchanged row objects — and each
* was invisible to the others. A DOM-write budget catches all of them and the
* ones not yet found, including anything that raises the number of commits a
* switch produces (#4109).
*
* The counter reads inline `style` writes on rail buttons because that is the
* dominant term: every Astryx button removes and re-adds its `anchor-name` per
* render, so one wasted rail render is two style writes per button plus the
* style recalculation they force.
*
* The assertion that carries the contract is `rowsTouched`, not the total.
* Attributing each write to its row makes the budget independent of how many
* rows the fixture seeds, and closes the hole a total-only budget leaves: a
* regression that re-renders the whole rail exactly ONCE stays under any total
* generous enough not to flake, but it cannot touch two rows. That is also the
* missing middle of the fix's own claim — identity is fixed so `memo` holds, and
* `memo` holding means untouched rows produce no DOM work at all.
*
* `styleWrites > 0` is the counter's own liveness check. Every write counted
* here comes from an Astryx ref callback that is not wrapped in `useCallback`;
* if that upstream detail is ever memoised, both the healthy and the regressed
* reading collapse to zero and a one-sided budget would pass forever without
* ever failing again.
*/
const RAIL_ROWS_TOUCHED_BUDGET = 2;
/** The leaving row and the arriving row, two writes each, doubled for slack. */
const RAIL_STYLE_WRITE_BUDGET = 8;

interface RailCounters {
/** Cumulative since the last `resetRailCounters`; what the budgets read. */
styleWrites: number;
rowIds: string[];
rowRemounts: number;
/** Drained by every quiet poll, so it reports only the latest interval. */
delta: number;
}

type RailWindow = Window & { __railCounters: RailCounters };

/**
* Waits until the rail has been silent for ~300ms.
*
* A fixed `waitForTimeout` would be the only thing standing between a slow
* machine and a red run: `toHaveCount` proves the rows mounted, not that the
* commits behind them are done, and one late catalog refresh writes more than
* the whole budget. `retries` is 0 in `playwright.config.ts`, so that failure
* would land on an unrelated pull request.
*/
async function waitForRailQuiet(page: Page): Promise<void> {
let quietPolls = 0;
await expect
.poll(
async () => {
const delta = await page.evaluate(() => {
const counters = (window as unknown as RailWindow).__railCounters;
const seen = counters.delta;
counters.delta = 0;
return seen;
});
quietPolls = delta === 0 ? quietPolls + 1 : 0;
return quietPolls;
},
{ timeout: 15_000, intervals: [100] },
)
.toBeGreaterThanOrEqual(3);
}

test('switching sessions does not rewrite the whole Session rail', async ({
railRenderWindow: page,
}) => {
await ensureSidebarExpanded(page);

const rows = page.locator('.maka-session-row');
await expect(rows).toHaveCount(RAIL_RENDER_SESSION_COUNT);

const target = page.locator('.maka-session-row button.astryx-side-nav-item', {
hasText: 'Rail row 3',
});
const selected = page.locator('.maka-session-row button.astryx-side-nav-item.selected');
await expect(target).toBeVisible();

await page.evaluate(() => {
const counters = { styleWrites: 0, rowIds: [] as string[], rowRemounts: 0, delta: 0 };
(window as unknown as { __railCounters: typeof counters }).__railCounters = counters;
const observer = new MutationObserver((records) => {
for (const record of records) {
if (record.type === 'childList') {
for (const node of record.addedNodes) {
const element = node as Element;
if (element.nodeType !== 1) continue;
// A row that unmounts and remounts writes its `anchor-name` once
// on the way in, from a ref callback that runs AFTER insertion —
// so an attribute-only counter reads a whole rail remount as
// cheaper than a rail re-render. Count the remounts directly.
if (element.classList?.contains('maka-session-row')) counters.rowRemounts += 1;
}
continue;
}
const row = (record.target as Element).closest?.('.maka-session-row');
if (!row) continue;
counters.styleWrites += 1;
counters.delta += 1;
const rowId = row.getAttribute('data-session-id');
if (rowId && !counters.rowIds.includes(rowId)) counters.rowIds.push(rowId);
}
});
observer.observe(document.body, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ['style'],
});
(window as unknown as { __railObserver: MutationObserver }).__railObserver = observer;
});

// Settle first: the budget is about a switch, not about arriving.
await waitForRailQuiet(page);
await page.evaluate(() => {
const counters = (window as unknown as RailWindow).__railCounters;
counters.styleWrites = 0;
counters.rowIds = [];
counters.rowRemounts = 0;
counters.delta = 0;
});

await target.click();
await expect(selected).toHaveText(/Rail row 3/);
// Let the post-switch commit cascade finish before reading the counters.
await waitForRailQuiet(page);

const counted = await page.evaluate(() => {
const scope = window as unknown as RailWindow & { __railObserver: MutationObserver };
scope.__railObserver.disconnect();
const { styleWrites, rowIds, rowRemounts } = scope.__railCounters;
return { styleWrites, rowIds, rowRemounts };
});

expect(
counted.rowIds.length,
`rail rows touched by one session switch, of ${RAIL_RENDER_SESSION_COUNT} (${counted.rowIds.join(', ')})`,
).toBeLessThanOrEqual(RAIL_ROWS_TOUCHED_BUDGET);
expect(counted.rowRemounts, 'rail rows remounted by one session switch').toBe(0);
expect(counted.styleWrites, 'the style-write counter never fired').toBeGreaterThan(0);
expect(counted.styleWrites, 'rail inline-style writes for one session switch').toBeLessThanOrEqual(
RAIL_STYLE_WRITE_BUDGET,
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -96,24 +96,21 @@ export function createActionsDeps() {
return {
uiLocale: 'en' as const,
activeIdRef,
addPendingSessionAction: () => true,
captureComposerImportOwner: () => ({
sessionId: undefined,
navSection: 'sessions' as const,
}),
checkTaskSubmissionReadiness: async () => true,
clearPendingSessionAction: () => undefined,
isNewChatSendSurfaceActive: () => true,
isShellSurfaceOwnerActive: () => true,
markSessionReadLocally: () => undefined,
messageRetryPendingRef: { current: new Set<string>() },
messageRetryPending: { claim: () => true, release: () => undefined },
refreshSessions: async () => [],
activateSessionForFirstSend: async (sessionId: string) => {
activeIdRef.current = sessionId;
},
setActiveId: () => undefined,
setMessageLoadErrorBySession: () => undefined,
setMessageRetryPendingBySession: () => undefined,
setMessages: () => undefined,
addTransientMessage: () => undefined,
updateTransientMessage: () => undefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type { LlmConnection } from '@maka/core/llm-connections';
import type { StoredMessage } from '@maka/core/session';
import type { DesktopSessionSummary } from '../../preload/bridge-contract.js';
import { createAppShellSessionSettingsActions } from '../../renderer/app-shell-session-settings-actions.js';
import type { SessionPendingClaim } from '../../renderer/app-shell-session-ui-state.js';

function deferred<T>() {
let resolve!: (value: T) => void;
Expand Down Expand Up @@ -56,6 +57,20 @@ function session(id: string): DesktopSessionSummary {
};
}

/** The store's claim semantics over a plain map the assertions can read. */
function pendingClaimOver(state: Record<string, boolean>): SessionPendingClaim {
return {
claim(key) {
if (state[key] === true) return false;
state[key] = true;
return true;
},
release(key) {
delete state[key];
},
};
}

function createHarness(options: {
confirm?: () => Promise<boolean>;
connections?: LlmConnection[];
Expand All @@ -65,8 +80,8 @@ function createHarness(options: {
const activeIdRef = { current: 'session-a' as string | undefined };
const sessions = [session('session-a'), session('session-b')];
const sessionsRef = { current: sessions };
const pending = new Set<string>();
const pendingBySession: Record<string, boolean> = {};
const permissionModePending: Record<string, boolean> = {};
const sessionModelPending: Record<string, boolean> = {};
const modelCalls: string[] = [];
const permissionCalls: string[] = [];
const thinkingCalls: string[] = [];
Expand Down Expand Up @@ -107,18 +122,12 @@ function createHarness(options: {
activeIdRef,
connections: options.connections ?? ([{ slug: 'e2e', name: 'E2E' }] as LlmConnection[]),
messages: options.messages ?? [],
pendingPermissionModeChangesRef: { current: new Set() },
pendingSessionModelChangesRef: { current: pending },
permissionModePending: pendingClaimOver(permissionModePending),
sessionModelPending: pendingClaimOver(sessionModelPending),
refreshSessions: async () => sessions,
saveComposerDefaults: () => undefined,
sessionsRef,
setNewTaskPermissionMode: (mode) => void newTaskPermissionModes.push(mode),
setPendingPermissionModeBySession: () => undefined,
setPendingSessionModelBySession: (update) => {
const next = update(pendingBySession);
for (const key of Object.keys(pendingBySession)) delete pendingBySession[key];
Object.assign(pendingBySession, next);
},
toastApi: {
success: (title, description) => successes.push({ title, description }),
error: (title, _description, _details, target) => {
Expand All @@ -137,8 +146,8 @@ function createHarness(options: {
modelCalls,
modelResult,
newTaskPermissionModes,
pending,
pendingBySession,
permissionModePending,
sessionModelPending,
permissionCalls,
sessionsRef,
thinkingCalls,
Expand Down Expand Up @@ -225,7 +234,7 @@ describe('AppShell session settings actions', () => {

assert.deepEqual(harness.modelCalls, ['session-a']);
assert.deepEqual(harness.thinkingCalls, []);
assert.equal(harness.pendingBySession['session-a'], true);
assert.equal(harness.sessionModelPending['session-a'], true);

harness.modelResult.resolve(session('session-a'));
await modelChange;
Expand Down Expand Up @@ -312,7 +321,7 @@ describe('AppShell session settings actions', () => {

assert.deepEqual(harness.modelCalls, ['session-a']);
assert.deepEqual(harness.thinkingCalls, ['session-b']);
assert.deepEqual(harness.pending, new Set(['session-a', 'session-b']));
assert.deepEqual(Object.keys(harness.sessionModelPending), ['session-a', 'session-b']);

harness.thinkingResult.resolve(session('session-b'));
await thinkingChange;
Expand All @@ -332,11 +341,11 @@ describe('AppShell session settings actions', () => {

assert.deepEqual(harness.thinkingCalls, ['session-a']);
assert.deepEqual(harness.modelCalls, []);
assert.equal(harness.pendingBySession['session-a'], true);
assert.equal(harness.sessionModelPending['session-a'], true);

harness.thinkingResult.resolve(session('session-a'));
await thinkingChange;
assert.equal(harness.pendingBySession['session-a'], undefined);
assert.equal(harness.sessionModelPending['session-a'], undefined);
});

it('releases the session owner after a failed mutation so the next action can run', async () => {
Expand All @@ -346,8 +355,7 @@ describe('AppShell session settings actions', () => {
harness.thinkingResult.reject(new Error('fixture failure'));
await thinkingChange;

assert.equal(harness.pending.has('session-a'), false);
assert.equal(harness.pendingBySession['session-a'], undefined);
assert.equal(harness.sessionModelPending['session-a'], undefined);
assert.equal(harness.errors.length, 1);
assert.deepEqual(harness.errorTargets, [{ sessionId: 'session-a' }]);

Expand Down
Loading