Skip to content
Merged
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
41 changes: 35 additions & 6 deletions apps/desktop/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,15 @@ async function seedE2eConnection(userDataDir: string): Promise<void> {
* CoreFoundation / X11 / sandbox session) that an allow-list would silently
* drop and break the launch.
*/
function buildE2eEnv(userDataDir: string): NodeJS.ProcessEnv {
function buildE2eEnv(userDataDir: string, visualSmokeScenario?: string): NodeJS.ProcessEnv {
const env = { ...process.env };
for (const key of Object.keys(env)) {
if (
key === 'VITE_DEV_SERVER_URL' ||
key === 'MAKA_E2E' ||
key === 'MAKA_E2E_USER_DATA_DIR' ||
key === 'MAKA_E2E_SHOW_WINDOW' ||
key === 'MAKA_VISUAL_SMOKE_FIXTURE' ||
/_API_KEY$/.test(key) ||
/_API_TOKEN$/.test(key) ||
/_API_SECRET$/.test(key)
Expand All @@ -52,6 +54,13 @@ function buildE2eEnv(userDataDir: string): NodeJS.ProcessEnv {
}
env.MAKA_E2E = '1';
env.MAKA_E2E_USER_DATA_DIR = userDataDir;
if (visualSmokeScenario) env.MAKA_VISUAL_SMOKE_FIXTURE = visualSmokeScenario;
// E2E windows launch hidden so local runs never steal the developer's
// focus. On CI there is no desktop to protect, and a hidden window's
// compositor is throttled to ~1fps on Linux — content-visibility turns
// never inflate and frame-paced protocols crawl (measured: 38 frames in
// 31s in the scroll-geometry climb). Under xvfb, show the window.
if (process.env.CI) env.MAKA_E2E_SHOW_WINDOW = '1';
return env;
}

Expand All @@ -64,13 +73,17 @@ function buildE2eEnv(userDataDir: string): NodeJS.ProcessEnv {
*/
async function launchE2eApp(
userDataDir: string,
{ seed, readinessSelector }: { seed: boolean; readinessSelector: string },
{ seed, readinessSelector, visualSmokeScenario }: {
seed: boolean;
readinessSelector: string;
visualSmokeScenario?: string;
},
): Promise<{ app: ElectronApplication; page: Page }> {
if (seed) await seedE2eConnection(userDataDir);
const app = await electron.launch({
args: ['.'],
cwd: DESKTOP_ROOT,
env: buildE2eEnv(userDataDir),
env: buildE2eEnv(userDataDir, visualSmokeScenario),
});
const page = await app.firstWindow();
// Centralize the cold-start wait so test bodies are flake-free under retries:0.
Expand All @@ -86,13 +99,17 @@ async function launchE2eApp(
* Electron and a leaked `maka-e2e-*` directory.
*/
async function withE2eWindow(
{ seed, readinessSelector }: { seed: boolean; readinessSelector: string },
{ seed, readinessSelector, visualSmokeScenario }: {
seed: boolean;
readinessSelector: string;
visualSmokeScenario?: string;
},
use: (page: Page) => Promise<void>,
): Promise<void> {
const userDataDir = await mkdtemp(path.join(tmpdir(), 'maka-e2e-'));
let app: ElectronApplication | undefined;
try {
const launched = await launchE2eApp(userDataDir, { seed, readinessSelector });
const launched = await launchE2eApp(userDataDir, { seed, readinessSelector, visualSmokeScenario });
app = launched.app;
await use(launched.page);
} finally {
Expand All @@ -101,7 +118,7 @@ async function withE2eWindow(
}
}

export const test = base.extend<{ window: Page; emptyWindow: Page }>({
export const test = base.extend<{ window: Page; emptyWindow: Page; longTranscriptWindow: Page }>({
// Seeded: a pre-staged connection clears onboarding so the composer is ready.
// Used by chat / session / settings / attachment specs.
window: async ({}, use) => {
Expand All @@ -112,6 +129,18 @@ export const test = base.extend<{ window: Page; emptyWindow: Page }>({
emptyWindow: async ({}, use) => {
await withE2eWindow({ seed: false, readinessSelector: '#root' }, use);
},
// Long transcript: boots the visual-smoke `long-transcript` fixture, which
// seeds a 24-turn (~1300px each) session and opens it as the active
// session. Fixture mode seeds its own connections, so no connection is
// pre-staged here. Readiness = turns on screen: the session is open and
// above-viewport turns sit at their content-visibility placeholder size.
// Used by the scroll-geometry spec.
longTranscriptWindow: async ({}, use) => {
await withE2eWindow(
{ seed: false, readinessSelector: '.maka-turn', visualSmokeScenario: 'long-transcript' },
use,
);
},
});

export { expect };
160 changes: 160 additions & 0 deletions apps/desktop/e2e/scroll-geometry.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { test, expect } from './fixtures';

/**
* Scroll-geometry contract for content-visibility chat turns.
*
* `.maka-turn` renders off-screen turns at a 250px contain-intrinsic-size
* placeholder. On a fresh mount of a long session that geometry is a ~25x
* underestimate, which used to (a) strand the pinned viewport mid-document
* once turns inflated (inflation fires no mutation and no scroll event) and
* (b) make upward scrolling "endless": the document grew turn by turn while
* scroll anchoring kept repositioning. The idle warm-up + the pinned-bottom
* ResizeObserver channel fix both; this spec locks the two user-visible
* invariants.
*
* Probes read only scroller metrics. Per-turn getBoundingClientRect would
* force-render skipped turns and mask the regression being tested.
*/

const probeScroller = `(() => {
const scroller = document.querySelector('.maka-chatViewport');
return {
scrollHeight: scroller.scrollHeight,
clientHeight: scroller.clientHeight,
distanceFromBottom: Math.round(scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight),
// The warm-up forces inline content-visibility per chunk and clears it on
// release; any remaining forced turn means the walk is still in flight.
// Guards against a stalled compositor freezing MID-WALK geometry into a
// false "settled" (frozen reads are equal reads).
warming: Boolean(document.querySelector('.maka-turn[style*="content-visibility"]')),
};
})()`;

// The fixture's 24 turns are 60 filler lines each (>1000px real, 250px as a
// placeholder), so the whole transcript is ~15k px un-warmed vs ~32k warmed.
// A settle check must first see final-scale height — two early reads agreeing
// on the PLACEHOLDER height would otherwise declare "settled" before the
// warm-up even starts (fonts / lazy-markdown gates delay it on slow machines).
const WARMED_HEIGHT_FLOOR = 24 * 800;

async function settleGeometry(page: import('@playwright/test').Page, options: { pinned: boolean }): Promise<void> {
let previousHeight = -1;
await expect.poll(async () => {
const current = await page.evaluate(probeScroller) as {
scrollHeight: number;
distanceFromBottom: number;
warming: boolean;
};
const settled = !current.warming
&& current.scrollHeight > WARMED_HEIGHT_FLOOR
&& current.scrollHeight === previousHeight
&& (!options.pinned || current.distanceFromBottom === 0);
previousHeight = current.scrollHeight;
return settled;
}, { timeout: 15_000, intervals: [500] }).toBe(true);
}

test('long session opens pinned to bottom and stays pinned while geometry settles', async ({ longTranscriptWindow: page }) => {
await expect(page.locator('.maka-turn')).toHaveCount(24);

// Pinned from the start: the session-open pin must hold. During the idle
// warm-up the document grows by thousands of pixels with no mutation and
// no scroll event — the follower must ride every growth step, so the
// distance stays 0 while scrollHeight rises to its final value.
await expect.poll(async () => (await page.evaluate(probeScroller)).distanceFromBottom).toBe(0);

// Geometry settled = final-scale height and two consecutive reads agreeing
// on it while still pinned. A fixed sleep would race the warm-up.
await settleGeometry(page, { pinned: true });
});

async function climbToTop(page: import('@playwright/test').Page) {
return await page.evaluate(async () => {
const scroller = document.querySelector('.maka-chatViewport') as HTMLElement;
const started = performance.now();
// Self-imposed deadline well under the 60s test timeout: a stalled or
// crawling compositor must produce a diagnosable assertion failure with
// the numbers below, never a test-timeout hang inside this evaluate
// ("Target page ... closed" says nothing).
const deadline = started + 30_000;
let frames = 0;
let maxFrameGapMs = 0;
const frame = () => new Promise<void>((resolve) => {
const t0 = performance.now();
let done = false;
const finish = () => {
if (done) return;
done = true;
maxFrameGapMs = Math.max(maxFrameGapMs, performance.now() - t0);
resolve();
};
requestAnimationFrame(() => requestAnimationFrame(() => { frames += 2; finish(); }));
// Watchdog: never wait on a compositor that stopped ticking.
setTimeout(finish, 2_000);
});
scroller.scrollTop = scroller.scrollHeight;
await frame();
const heights = new Set<number>([scroller.scrollHeight]);
let steps = 0;
// Viewport-sized steps, like a fast user scroll. The cap is a runaway
// guard far above the honest step count.
for (; steps < 120; steps++) {
if (performance.now() > deadline) break;
scroller.scrollBy(0, -scroller.clientHeight);
await frame();
heights.add(scroller.scrollHeight);
if (scroller.scrollTop === 0) break;
}
return {
atTop: scroller.scrollTop === 0,
distinctHeights: [...heights],
steps,
honestSteps: Math.ceil(scroller.scrollHeight / scroller.clientHeight),
frames,
maxFrameGapMs: Math.round(maxFrameGapMs),
elapsedMs: Math.round(performance.now() - started),
timedOut: performance.now() > deadline,
};
});
}

function expectHonestClimb(run: Awaited<ReturnType<typeof climbToTop>>): void {
const diagnostics = JSON.stringify(run);
// A climb that hit the deadline or was paced by the watchdog instead of
// real frames proves nothing about geometry — fail with the frame stats.
expect(run.timedOut, diagnostics).toBe(false);
expect(run.maxFrameGapMs, diagnostics).toBeLessThan(2_000);
expect(run.atTop, diagnostics).toBe(true);
// One height for the whole climb: no placeholder inflated mid-scroll.
expect(run.distinctHeights, diagnostics).toHaveLength(1);
// And the climb took the honest number of steps — the "endless scroll"
// symptom was precisely needing ~2x more.
expect(run.steps, diagnostics).toBeLessThanOrEqual(run.honestSteps + 2);
}

test('scrolling a settled long session to the top never inflates the document', async ({ longTranscriptWindow: page }) => {
await expect(page.locator('.maka-turn')).toHaveCount(24);

// Wait for the warm-up to settle so this test isolates invariant (b);
// the pinned test above owns the during-warm-up behavior.
await settleGeometry(page, { pinned: false });

expectHonestClimb(await climbToTop(page));
});

test('returning to the session after visiting skills re-settles the new transcript DOM', async ({ longTranscriptWindow: page }) => {
await expect(page.locator('.maka-turn')).toHaveCount(24);
await settleGeometry(page, { pinned: true });

// A mode switch unmounts the chat scroller; coming back rebuilds every
// `.maka-turn` node with no remembered size, so the warm-up must walk the
// NEW DOM. Fixture windows don't pass OS hit-testing — dispatch clicks.
await page.locator('button[aria-label="展开侧边栏"]').dispatchEvent('click');
await page.locator('button[aria-label="技能"]').dispatchEvent('click');
await expect(page.locator('.maka-turn')).toHaveCount(0);
await page.getByText('超长会话滚动几何').first().dispatchEvent('click');
await expect(page.locator('.maka-turn')).toHaveCount(24);

await settleGeometry(page, { pinned: true });
expectHonestClimb(await climbToTop(page));
});
8 changes: 7 additions & 1 deletion apps/desktop/src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,7 +469,13 @@ const systemPromptService = createSystemPromptMainService({
// Window is created hidden for E2E and visual-smoke runs so it never steals
// focus. Derived from the same isE2e gate as userData/fake-backend so the
// hidden-window switch stays in lockstep with the rest of the E2E isolation.
const startHidden = Boolean(visualSmokeFixture) || isE2e;
// MAKA_E2E_SHOW_WINDOW opts back into a visible window where there is no
// focus to steal (CI under xvfb): hidden windows only get ~1fps compositor
// BeginFrames on Linux, which stalls content-visibility inflation and any
// frame-paced E2E protocol (measured in the scroll-geometry climb: 38 frames
// over 31s). The E2E harness sets it, not the workflow — see fixtures.ts.
const startHidden = (Boolean(visualSmokeFixture) || isE2e)
&& process.env.MAKA_E2E_SHOW_WINDOW !== '1';
const mainWindowController = createMainWindowController({
workspaceRoot,
visualSmokeFixture,
Expand Down
61 changes: 61 additions & 0 deletions apps/desktop/src/main/visual-smoke-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ const VISUAL_SMOKE_SCENARIOS = new Set<VisualSmokeScenario>([
// Captures the overflow-trigger state so reviewers can verify
// the time meta + unread dot are hidden underneath (no overlap).
'sidebar-row-actions-visible',
// Scroll-geometry contract seed: 24 tall turns opened as the active
// session on boot, so off-screen turns mount as content-visibility
// placeholders (see e2e/scroll-geometry.spec.ts).
'long-transcript',
]);

// Fixed clock for screenshot fixtures. All seeded timestamps and
Expand Down Expand Up @@ -474,6 +478,11 @@ export function getVisualSmokeState(fixture: VisualSmokeFixture | null): VisualS
sidebarCollapsed: false,
focusActiveRow: true,
};
case 'long-transcript':
// Scroll-geometry contract: boot straight into the 24-turn session so
// above-viewport turns mount render-skipped (never rendered), the
// exact state the warm-up + pinned-bottom invariants protect.
return { ...state, activeSessionId: LONG_TRANSCRIPT_SESSION_ID };
case 'all':
return {
...state,
Expand Down Expand Up @@ -527,6 +536,12 @@ export async function seedVisualSmokeFixture(input: {
await writeSession(input.workspaceRoot, seed.header, seed.messages);
}
}
// Scroll-geometry contract (e2e/scroll-geometry.spec.ts): a session tall
// enough that most turns mount as render-skipped content-visibility
// placeholders — the state the warm-up and pinned-bottom invariants cover.
if (input.fixture.scenario === 'long-transcript') {
await writeSession(input.workspaceRoot, longTranscriptSession(now), longTranscriptMessages(now));
}
// PR109f (g): all three turn-control-* scenarios share the same
// on-disk seed; only the active session selection differs. Seeding
// the same trio for any of them keeps the fixtures interchangeable
Expand Down Expand Up @@ -663,6 +678,7 @@ const TURN_CONTROL_SCENARIOS = new Set<VisualSmokeScenario>([
]);

const TURN_SESSION_ID = 'visual-smoke-turn';
const LONG_TRANSCRIPT_SESSION_ID = 'visual-smoke-long-transcript';
const PROCESSING_SESSION_ID = 'visual-smoke-processing';
const STREAMING_SESSION_ID = 'visual-smoke-streaming';
// PR-STREAM-TURN-CENTER: realistic multi-block markdown (heading + paragraph +
Expand Down Expand Up @@ -1018,6 +1034,51 @@ function turnSession(now: number): SessionHeader {
});
}

function longTranscriptSession(now: number): SessionHeader {
return header({
id: LONG_TRANSCRIPT_SESSION_ID,
name: '超长会话滚动几何',
connection: 'zai-live',
model: 'glm-5.1',
now,
lastMessageAt: now - 5 * 60_000,
});
}

/**
* 24 turns, each ~1300px tall once rendered, so the transcript is ~25x the
* 250px contain-intrinsic-size placeholder per turn and dozens of viewports
* tall overall. Plain text on purpose: the contract under test is scroll
* geometry, not markdown rendering.
*/
function longTranscriptMessages(now: number): StoredMessage[] {
const filler = Array.from(
{ length: 60 },
(_, line) => `第 ${line + 1} 行 — 用于撑高单个 turn 的占位正文内容。`,
).join(' \n');
const messages: StoredMessage[] = [];
const base = now - 60 * 60_000;
for (let turn = 0; turn < 24; turn++) {
const turnId = `long-transcript-turn-${turn}`;
messages.push({
type: 'user',
id: `long-transcript-user-${turn}`,
turnId,
ts: base + turn * 60_000,
text: `长会话问题 ${turn + 1}`,
});
messages.push({
type: 'assistant',
id: `long-transcript-assistant-${turn}`,
turnId,
ts: base + turn * 60_000 + 30_000,
text: `长会话回答 ${turn + 1}\n\n${filler}`,
modelId: 'glm-5.1',
});
}
return messages;
}

function turnMessages(now: number): StoredMessage[] {
const turnId = 'turn-fixture-1';
return [
Expand Down
9 changes: 6 additions & 3 deletions apps/desktop/src/renderer/maka-tokens.css
Original file line number Diff line number Diff line change
Expand Up @@ -1212,9 +1212,12 @@
/* Long-conversation first render: skip layout/paint for off-screen
turns (vercel react-best-practices rendering-content-visibility —
~10x initial-render win on long sessions, keeps native Cmd+F and
text selection, no virtualization library). `auto 250px` keeps a
placeholder estimate until first render, then the remembered
actual size prevents scrollbar jumps. */
text selection, no virtualization library). `auto 250px` is only a
placeholder until a turn is first rendered; the remembered actual
size then keeps scroll geometry exact. Never-rendered history would
otherwise inflate turn by turn while scrolling up ("endless scroll"),
so chat-view runs an idle-time bottom-up warm-up after session load —
see packages/ui/src/turn-size-warmup.ts. */
content-visibility: auto;
contain-intrinsic-size: auto 250px;
}
Expand Down
Loading
Loading