Files
maka/apps/desktop/e2e/session-workbar.spec.ts
agent-radar 7be535059e
Dependency audit / audit (push) Canceled after 0s
CI / plan (push) Canceled after 0s
Gitoxide helper admission / macos-latest (push) Canceled after 0s
Gitoxide helper admission / ubuntu-latest (push) Canceled after 0s
Gitoxide helper admission / windows-latest (push) Canceled after 0s
Runtime Host peer admission / quality (push) Canceled after 0s
Windows recovery / windows_recovery (push) Canceled after 0s
CI / heavy (push) Canceled after 0s
CI / test (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 19:05:14 +08:00

464 lines
19 KiB
TypeScript

/*
* 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 { COMPOSER_INPUT, test, expect } from './fixtures';
import type { Page } from '@playwright/test';
import { writeFile } from 'node:fs/promises';
import { join } from 'node:path';
async function openGitChanges(page: Page) {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('create review session');
await composer.press('Enter');
await expect(page.getByText(/Fake backend received: create review session/)).toBeVisible();
await page.getByRole('button', { name: '展开任务工作栏' }).click();
await expect(page.getByRole('list', { name: '打开工具' })).toBeVisible();
await page.getByRole('button', { name: /变更.*查看当前 Git 工作区变化/ }).click();
return page.getByRole('region', { name: 'Git 变更' });
}
async function createSession(page: Page, prompt: string) {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill(prompt);
await composer.press('Enter');
await expect(page.getByText(`Fake backend received: ${prompt}`)).toBeVisible();
await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, {
timeout: 20_000,
});
const sidebar = page.getByRole('navigation', { name: '任务列表' });
const expandSidebar = page.getByRole('button', { name: '展开侧边栏' });
if (await expandSidebar.isVisible()) await expandSidebar.click();
const sessionId = await sidebar
.locator('[data-session-id]:has([aria-current="page"])')
.getAttribute('data-session-id');
expect(sessionId).toBeTruthy();
return { composer, sessionId: sessionId!, sidebar };
}
test('a collapsed workbar never flashes during the first send', async ({
window: page,
}) => {
await page.evaluate(() => {
const watch = { visibleRightWorkbar: false };
const inspect = () => {
const panel = document.querySelector<HTMLElement>(
'.maka-session-workbar[data-placement="right"]',
);
if (
panel &&
getComputedStyle(panel).display !== 'none' &&
panel.getBoundingClientRect().width > 0
) {
watch.visibleRightWorkbar = true;
}
};
const observer = new MutationObserver(inspect);
observer.observe(document.body, {
subtree: true,
childList: true,
attributes: true,
});
(
window as typeof window & {
__makaFirstSendWorkbarWatch?: typeof watch;
__makaFirstSendWorkbarWatchStop?: () => void;
}
).__makaFirstSendWorkbarWatch = watch;
(
window as typeof window & {
__makaFirstSendWorkbarWatchStop?: () => void;
}
).__makaFirstSendWorkbarWatchStop = () => {
inspect();
observer.disconnect();
};
});
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('create a session without opening the workbar');
await page.getByRole('button', { name: '发送' }).click();
const expandWorkbar = page.getByRole('button', { name: '展开任务工作栏' });
await expect(expandWorkbar).toBeVisible({
timeout: 20_000,
});
const watch = await page.evaluate(() => {
const target = window as typeof window & {
__makaFirstSendWorkbarWatch?: { visibleRightWorkbar: boolean };
__makaFirstSendWorkbarWatchStop?: () => void;
};
target.__makaFirstSendWorkbarWatchStop?.();
return target.__makaFirstSendWorkbarWatch;
});
expect(watch?.visibleRightWorkbar, 'the collapsed right workbar stayed hidden').toBe(false);
await expandWorkbar.evaluate((button) => button.click());
await expect(
page.locator('.maka-session-workbar[data-placement="right"]'),
).toBeVisible();
});
async function waitForCompanionForkId(page: Page, sourceSessionId: string) {
let forkId: string | undefined;
await expect
.poll(async () => {
forkId = (await page.evaluate(() => window.maka.sessions.list())).find(
(session) => session.id !== sourceSessionId,
)?.id;
return forkId;
})
.not.toBeUndefined();
return forkId!;
}
async function setRightWorkbarWidth(page: Page, width: number) {
const layoutOwner = page.locator('.maka-workbar-layout-vars');
const workbar = page.locator('.maka-session-workbar[data-placement="right"]');
await expect(layoutOwner).toHaveCount(1);
await expect(workbar).toBeVisible();
await layoutOwner.evaluate((element, nextWidth) => {
(element as HTMLElement).style.setProperty(
'--maka-session-workbar-width',
`${nextWidth}px`,
);
}, width);
await expect
.poll(async () => (await workbar.boundingBox())?.width)
.toBeCloseTo(width, 0);
return workbar;
}
test('narrow right workbar keeps launcher shortcuts and side-chat send button inside', async ({
window: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('create narrow workbar session');
await composer.press('Enter');
await expect(page.getByText(/Fake backend received: create narrow workbar session/)).toBeVisible();
await page.getByRole('button', { name: '展开任务工作栏' }).click();
const launcher = page.getByRole('list', { name: '打开工具' });
await expect(launcher).toBeVisible();
const workbar = await setRightWorkbarWidth(page, 320);
const workbarBox = await workbar.boundingBox();
const shortcutBoxes = await launcher
.locator('kbd')
.evaluateAll((elements) =>
elements.map((element) => {
const box = element.getBoundingClientRect();
return { left: box.left, right: box.right };
}),
);
expect(workbarBox).not.toBeNull();
expect(shortcutBoxes.length).toBeGreaterThan(0);
for (const shortcutBox of shortcutBoxes) {
expect.soft(shortcutBox.left).toBeGreaterThanOrEqual(workbarBox!.x);
expect.soft(shortcutBox.right).toBeLessThanOrEqual(workbarBox!.x + workbarBox!.width);
}
await page
.getByRole('button', {
name: /侧边对话.*在不打断主任务的情况下追问和只读探索/,
})
.click();
const companion = page.locator('.maka-quote-companion');
await expect(companion).toBeVisible();
await setRightWorkbarWidth(page, 320);
const sideChatPanel = page
.locator('.maka-session-workbar-panel[data-overlay][data-placement="right"]')
.filter({ has: companion });
await expect(sideChatPanel).toBeVisible();
await expect
.poll(async () => (await sideChatPanel.boundingBox())?.width)
.toBeCloseTo(320, 0);
const composerCard = companion.locator('.maka-composer-astryx');
// Keep ChatComposer's inner elevation visible.
await expect(composerCard).toHaveCSS('overflow', 'visible');
// #3452: Side Chat is a branch of the main conversation, not a second
// conversation surface, so its dock rounds like the main dock. Both resolve
// Astryx's `--radius-chat`, the same token `ChatMessageBubble` defaults to —
// a side-only `--_chat-composer-radius` split the bubble from the dock the
// moment the bubbles moved back to that default. Compared against the main
// composer rather than a literal so an upstream token change moves both or
// fails here.
const composerRadii = await page.evaluate(() => {
const wrappers = [...document.querySelectorAll('.maka-composer-astryx')];
const inCompanion = (element: Element) => element.closest('.maka-quote-companion') !== null;
const effectiveRadius = (element: Element | undefined) => {
if (!element) return null;
const styles = getComputedStyle(element);
return (
styles.getPropertyValue('--_chat-composer-radius').trim() ||
styles.getPropertyValue('--radius-chat').trim()
);
};
return {
side: effectiveRadius(wrappers.find(inCompanion)),
main: effectiveRadius(wrappers.find((element) => !inCompanion(element))),
};
});
expect(composerRadii.side).toBeTruthy();
expect(composerRadii.side).toBe(composerRadii.main);
// Match the long model-label pressure from the reported side-chat screenshot
// without coupling the fixture's globally useful default model to this test.
await companion.locator('.maka-composer-model-chip-text').evaluate((element) => {
element.textContent = 'Nemotron 3 Ultra Long Context Model';
});
const sendButton = companion.getByRole('button', { name: '发送' });
await expect(sendButton).toBeVisible();
const [composerBox, sendBox] = await Promise.all([
composerCard.boundingBox(),
sendButton.boundingBox(),
]);
expect(composerBox).not.toBeNull();
expect(sendBox).not.toBeNull();
expect(sendBox!.x).toBeGreaterThanOrEqual(composerBox!.x);
expect(sendBox!.x + sendBox!.width).toBeLessThanOrEqual(
composerBox!.x + composerBox!.width,
);
});
// #2188: the address field, not the nav buttons, absorbs the column's free
// width. The rule reaches into Astryx Toolbar's slot div, which nothing else
// pins — an upstream slot-wrapper change would silently regress it.
test('browser address field tracks the workbar column width', async ({ window: page }) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('create browser session');
await composer.press('Enter');
await expect(page.getByText(/Fake backend received: create browser session/)).toBeVisible();
await page.getByRole('button', { name: '展开任务工作栏' }).click();
await expect(page.getByRole('list', { name: '打开工具' })).toBeVisible();
await page.getByRole('button', { name: /浏览器.*打开内置浏览器/ }).click();
await expect(page.getByRole('region', { name: '嵌入式浏览器' })).toBeVisible();
const addressInput = page.getByRole('textbox', { name: '浏览器地址' });
const inputWidth = async () => (await addressInput.boundingBox())!.width;
// The tab's content lives in the overlay panel next to the workbar frame,
// so the width override must land there, not on `.maka-session-workbar`.
const setPanelWidth = async (width: number) => {
const panel = page.locator('.maka-session-workbar-panel[data-overlay][data-placement="right"]');
await panel.evaluate((element, nextWidth) => {
(element as HTMLElement).style.setProperty('--maka-session-workbar-width', `${nextWidth}px`);
}, width);
await expect.poll(async () => (await panel.boundingBox())?.width).toBeCloseTo(width, 0);
};
await setPanelWidth(480);
await expect.poll(inputWidth).toBeGreaterThan(250);
await setPanelWidth(320);
// Fails at a flat width without the slot rule: 480 and 320 would measure the same.
await expect.poll(inputWidth).toBeLessThan(220);
await expect.poll(inputWidth).toBeGreaterThan(100);
});
test('titlebar workbar action restores an existing tool instead of the picker', async ({
gitReviewWindow,
}) => {
const page = gitReviewWindow.page;
const workspaceActions = page.getByRole('toolbar', { name: '工作区辅助操作' });
const panel = await openGitChanges(page);
const panelToolbar = page.getByRole('toolbar', { name: '任务工作栏标签' }).first();
const collapseButton = panelToolbar.getByRole('button', { name: '收起任务工作栏' });
await expect(workspaceActions).toHaveCount(0);
await expect(collapseButton).toBeVisible();
await expect(workspaceActions.getByRole('button', { name: '打开工作栏工具' })).toHaveCount(0);
const activeTab = panelToolbar.getByRole('tab', { selected: true });
await expect(activeTab).toBeVisible();
const [toolbarBox, tabBox, toggleBox] = await Promise.all([
panelToolbar.boundingBox(),
activeTab.boundingBox(),
collapseButton.boundingBox(),
]);
expect(toolbarBox).not.toBeNull();
expect(tabBox).not.toBeNull();
expect(toggleBox).not.toBeNull();
expect(
Math.abs(tabBox!.y + tabBox!.height / 2 - (toggleBox!.y + toggleBox!.height / 2)),
).toBeLessThanOrEqual(1);
const simulatedCaptionWidth = 80;
await page.evaluate((width) => {
document.documentElement.style.setProperty(
'--maka-titlebar-overlay-right-width',
`${width}px`,
);
}, simulatedCaptionWidth);
await expect
.poll(async () => (await collapseButton.boundingBox())?.x)
.toBe(toggleBox!.x - simulatedCaptionWidth);
const safeAreaToggleBox = await collapseButton.boundingBox();
expect(safeAreaToggleBox).not.toBeNull();
await page.getByRole('button', { name: '打开工作栏标签' }).click();
const picker = page.getByRole('list', { name: '打开工具' });
await expect(picker).toBeVisible();
await collapseButton.click();
const expandButton = workspaceActions.getByRole('button', { name: '展开任务工作栏' });
await expect(expandButton).toBeVisible();
const expandButtonBox = await expandButton.boundingBox();
expect(expandButtonBox).not.toBeNull();
expect(Math.abs(expandButtonBox!.y - safeAreaToggleBox!.y)).toBeLessThanOrEqual(1);
expect(Math.abs(expandButtonBox!.x - safeAreaToggleBox!.x)).toBeLessThanOrEqual(1);
await expandButton.click();
await expect(panel).toBeVisible();
await expect(picker).not.toBeVisible();
const restoredCollapseButton = panelToolbar.getByRole('button', {
name: '收起任务工作栏',
});
await expect(restoredCollapseButton).toBeVisible();
const restoredToggleBox = await restoredCollapseButton.boundingBox();
expect(restoredToggleBox).not.toBeNull();
expect(Math.abs(restoredToggleBox!.y - safeAreaToggleBox!.y)).toBeLessThanOrEqual(1);
expect(Math.abs(restoredToggleBox!.x - safeAreaToggleBox!.x)).toBeLessThanOrEqual(1);
});
test('Git changes re-read the workspace after the app regains focus', async ({
gitReviewWindow,
}) => {
const panel = await openGitChanges(gitReviewWindow.page);
await expect(panel.getByText('新增 4 行')).toBeVisible();
await writeFile(join(gitReviewWindow.projectRoot, 'base.txt'), 'base\nunstaged\nexternal\n');
await gitReviewWindow.page.evaluate(() => window.dispatchEvent(new Event('focus')));
await expect(panel.getByText('新增 5 行')).toBeVisible();
});
test('Terminal ownership follows the active Session and stops the old resource', async ({
window: page,
}) => {
const { composer, sessionId, sidebar } = await createSession(
page,
'create terminal owner session',
);
await page.getByRole('button', { name: '展开任务工作栏' }).click();
await page
.getByRole('button', { name: /终端.*查看当前任务的终端运行和实时输出/ })
.click();
const terminal = page.getByRole('region', { name: '任务终端' });
await expect(terminal).toBeVisible();
const terminalRef = await terminal.getAttribute('data-terminal-ref');
expect(terminalRef).toBeTruthy();
await expect
.poll(async () =>
(await page.evaluate((id) => window.maka.shellRuns.list(id), sessionId))
.find((update) => update.result.ref === terminalRef)
?.result.status,
)
.toBe('running');
await sidebar.getByRole('button', { name: '新任务', exact: true }).click();
await expect(terminal).toHaveCount(0);
await expect
.poll(async () =>
(await page.evaluate((id) => window.maka.shellRuns.list(id), sessionId))
.find((update) => update.result.ref === terminalRef)
?.result.status,
)
.not.toBe('running');
await composer.fill('create replacement session');
await composer.press('Enter');
await expect(page.getByText('Fake backend received: create replacement session')).toBeVisible();
await page.getByRole('button', { name: '展开任务工作栏' }).click();
await expect(page.getByRole('list', { name: '打开工具' })).toBeVisible();
});
test('Side Chat survives collapse, confirms close, and cleans up on source switch', async ({
window: page,
}) => {
const { composer, sessionId, sidebar } = await createSession(
page,
'create side chat source session',
);
await page.getByRole('button', { name: '展开任务工作栏' }).click();
const openSideChat = page.getByRole('button', {
name: /侧边对话.*在不打断主任务的情况下追问和只读探索/,
});
await openSideChat.click();
const companion = page.locator('.maka-quote-companion');
await expect(companion).toBeVisible();
const firstForkId = await waitForCompanionForkId(page, sessionId);
await expect(sidebar.locator(`[data-session-id=${JSON.stringify(firstForkId)}]`)).toHaveCount(0);
await page.getByRole('button', { name: '收起任务工作栏' }).click();
await expect(companion).toBeAttached();
await expect(companion).not.toBeVisible();
await expect
.poll(async () =>
(await page.evaluate(() => window.maka.sessions.list()))
.some((session) => session.id === firstForkId),
)
.toBe(true);
await page.getByRole('button', { name: '展开任务工作栏' }).click();
await expect(companion).toBeVisible();
const sideComposer = companion.locator(COMPOSER_INPUT);
await sideComposer.fill('inspect this source without changing it');
await sideComposer.press('Enter');
await expect(companion).toContainText(
'Fake backend received: inspect this source without changing it',
);
const workbarToolbar = page.getByRole('toolbar', { name: '任务工作栏标签' }).first();
const closeActiveSideChat = () =>
workbarToolbar
.getByRole('tab', { selected: true })
.locator('..')
.getByRole('button', { name: /^关闭/ });
await closeActiveSideChat().click();
const confirmation = page.getByRole('dialog');
await expect(confirmation).toContainText('这个临时侧边对话会被永久删除');
await confirmation.getByRole('button', { name: '取消' }).click();
await expect(companion).toBeVisible();
await closeActiveSideChat().click();
await confirmation.getByRole('button', { name: '关闭侧边对话' }).click();
await expect(companion).toHaveCount(0);
await expect
.poll(async () =>
(await page.evaluate(() => window.maka.sessions.list()))
.some((session) => session.id === firstForkId),
)
.toBe(false);
await page.getByRole('button', { name: '展开任务工作栏' }).click();
await expect(page.getByRole('list', { name: '打开工具' })).toBeVisible();
await openSideChat.click();
await expect(companion).toBeVisible();
const secondForkId = await waitForCompanionForkId(page, sessionId);
await sidebar.getByRole('button', { name: '新任务', exact: true }).click();
await expect(companion).toHaveCount(0);
await expect
.poll(async () =>
(await page.evaluate(() => window.maka.sessions.list()))
.some((session) => session.id === secondForkId),
)
.toBe(false);
await expect(composer).toHaveText('');
});