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
6 changes: 1 addition & 5 deletions apps/desktop/e2e-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,7 @@
},
"side-chat-followups.spec.ts": {
"tests": 1,
"electron": "queue mutations and successive Side Chat Turns cross renderer/preload/main/Host; native dragging must pass the main-window drop guard and Host reconnect must restore the live fork without remounting it"
},
"skill-draft-lifecycle.spec.ts": {
"tests": 2,
"electron": "revision retry and cancel are Host-owned draft transitions across a parent and a child Session"
"electron": "native queue dragging must pass the main-window drop guard, queued follow-ups drain in order across real Host turn handoffs, and closing the Desktop transport while an external client drives the fork exercises observation reseed without remount"
},
"slash-command-compact.spec.ts": {
"tests": 1,
Expand Down
33 changes: 11 additions & 22 deletions apps/desktop/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,28 +116,6 @@ export async function waitForInvocableSkills(
.toEqual(expect.arrayContaining(expectedIds));
}

/**
* Wait for Runtime's projection to stop offering a Skill.
*
* A Skill is toggled through the raw bridge here rather than the Skills page, so
* nothing re-fetches the composer's `/` source on its own. Pressing Enter before
* Runtime has dropped the Skill lets the send resolve it and succeed, and the
* rejection the journey expects never renders — the composer keeps offering a
* Skill that is already disabled.
*/
export async function waitForSkillNotInvocable(
page: Page,
absentIds: readonly string[],
): Promise<void> {
await expect
.poll(async () =>
page.evaluate(async () =>
(await window.maka.skills.listInvocable(undefined)).map((skill) => skill.id),
),
)
.not.toEqual(expect.arrayContaining(absentIds));
}

/**
* Pre-seed a real-looking connection into the throwaway workspace so onboarding
* clears and the composer is enabled. Actual sessions still run on the fake
Expand Down Expand Up @@ -507,6 +485,12 @@ export async function withE2eWindow(
rendererLogs.push(`[pageerror] ${error.stack ?? error.message}`);
if (rendererLogs.length > 30) rendererLogs.shift();
});
const watchCrash = (crashed: Page) => crashed.on('crash', () => {
rendererLogs.push(`[crash] ${crashed.url()}`);
if (rendererLogs.length > 30) rendererLogs.shift();
});
for (const existing of app.context().pages()) watchCrash(existing);
app.context().on('page', watchCrash);
if (tracePath) {
await mkdir(path.dirname(tracePath), { recursive: true });
await app.context().tracing.start({ snapshots: true });
Expand All @@ -533,6 +517,11 @@ export async function withE2eWindow(
env: buildFixtureEnv(userDataDir, homeDir, { scenario: e2eFixtureScenario, locale, platform, showWindow: visibleWindow }),
});
const restored = await app.firstWindow();
restored.on('crash', () => {
rendererLogs.push(`[crash] ${restored.url()}`);
if (rendererLogs.length > 30) rendererLogs.shift();
});
app.context().on('page', watchCrash);
await restored.waitForSelector(readinessSelector, { timeout: readinessTimeoutMs });
return restored;
} });
Expand Down
69 changes: 16 additions & 53 deletions apps/desktop/e2e/side-chat-followups.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
* under the License.
*/

import { FAKE_HOLD_OPEN_PROMPT, FAKE_WAIT_FOR_STEERING_PROMPT } from '@maka/runtime/test-only/fake-backend';
import { FAKE_WAIT_FOR_STEERING_PROMPT } from '@maka/runtime/test-only/fake-backend';
import { connectExistingRuntimeHost } from '@maka/runtime-host/client';
import { RUNTIME_HOST_PROTOCOL_VERSION } from '@maka/runtime-host/protocol';
import type { ElectronApplication } from '@playwright/test';
Expand Down Expand Up @@ -74,9 +74,10 @@ async function armConnectionGap(app: ElectronApplication): Promise<void> {

// The real main-window capture listener previously swallowed queue drops, and
// the restored main/preload observation left completed entries in this panel.
// Component/Host tests omit those Electron owners; this one window verifies
// their wiring while the existing hook/projector suites cover state orderings.
test('Side Chat follow-ups survive queue actions, Host handoffs and reconnect', async ({}, testInfo) => {
// Row-level edit/delete/promote semantics live in the ComposerMessageQueue
// component tests now; this window keeps the native drop guard, the queue's
// drain across Host turn handoffs, and the transport reconnect journey.
test('Side Chat queue survives a native reorder and a Desktop reconnect', async ({}, testInfo) => {
await withE2eWindow({
seed: true,
readinessSelector: COMPOSER_INPUT,
Expand All @@ -95,10 +96,10 @@ test('Side Chat follow-ups survive queue actions, Host handoffs and reconnect',
await page.getByRole('button', { name: /侧边对话.*在不打断主任务的情况下追问和只读探索/ }).click();
const companion = page.locator('.maka-quote-companion');
const sideComposer = companion.locator(COMPOSER_INPUT);
await sideComposer.fill(FAKE_HOLD_OPEN_PROMPT);
await sideComposer.fill(FAKE_WAIT_FOR_STEERING_PROMPT);
await awaitSendReady(companion);
await sideComposer.press('Enter');
await expect(companion).toContainText('Fake backend waiting');
await expect(companion.getByRole('button', { name: '停止', exact: true })).toBeVisible();
const forkId = await page.evaluate(async (existingIds) => {
const created = (await window.maka.sessions.list()).filter((session) => !existingIds.includes(session.id));
if (created.length !== 1) throw new Error(`Expected one Side Chat fork, found ${created.length}`);
Expand All @@ -112,61 +113,23 @@ test('Side Chat follow-ups survive queue actions, Host handoffs and reconnect',
await sideComposer.press('Enter');
await expect(queued).toContainText(text);
}
await expect(queued.locator('.maka-composer-queue-text')).toHaveText([
'first follow-up', 'second follow-up', 'retract this follow-up',
]);
await queued.getByRole('button', { name: '编辑', exact: true }).first().click();
const edit = queued.getByRole('textbox', { name: '编辑', exact: true });
await edit.fill('edited first follow-up');
await edit.press('Enter');
await expect(queued.locator('.maka-composer-queue-text').first()).toHaveText('edited first follow-up');
// A drag only reorders Host-owned ('queued') entries; a still-pending
// admission has no draggable grip, so enabled edit buttons settle it.
await expect(queued.getByRole('button', { name: '编辑', exact: true }).nth(2)).toBeEnabled();
const grips = queued.locator('[draggable="true"]');
await grips.nth(1).dragTo(grips.nth(0));
await expect(queued.locator('.maka-composer-queue-text')).toHaveText([
'second follow-up', 'edited first follow-up', 'retract this follow-up',
'second follow-up', 'first follow-up', 'retract this follow-up',
]);
await queued.getByRole('button', { name: '删除', exact: true }).nth(2).click();
await expect(queued).not.toContainText('retract this follow-up');
await expect(companion.getByRole('button', { name: '停止', exact: true })).toBeVisible();

await sideComposer.fill('steer the current response');
await awaitSendReady(companion);
await sideComposer.press('Shift+Enter');
await expect(companion).toContainText('Acknowledged steering: steer the current response');
await expect(queued.locator('.maka-composer-queue-text')).toHaveText([
'second follow-up', 'edited first follow-up',
]);
await queued.getByRole('button', { name: '调整方向', exact: true }).first().click();
await expect(companion.locator('.maka-steering-message').last()).toContainText('second follow-up');
await expect(queued.locator('.maka-composer-queue-text')).toHaveText(['edited first follow-up']);
await queued.getByRole('button', { name: '删除', exact: true }).click();
await expect(queued).toHaveCount(0);
await page.screenshot({ path: testInfo.outputPath('side-chat-steering.png'), fullPage: true });
await companion.getByRole('button', { name: '停止', exact: true }).click();
await expect(companion.getByRole('button', { name: '停止', exact: true })).toHaveCount(0, { timeout: 20_000 });
// The held-open fixture's pipe-separated acknowledgment is an unfinished
// Markdown table candidate until Stop flushes the final assistant message.
await expect(companion).toContainText('steer the current response | second follow-up');

// Hold a second Turn before its first token, queue two successors, then
// release it by steering. All three replies must survive the Host handoffs.
await sideComposer.fill(FAKE_WAIT_FOR_STEERING_PROMPT);
await awaitSendReady(companion);
await sideComposer.press('Enter');
await expect(companion.getByRole('button', { name: '停止', exact: true })).toBeVisible();
for (const text of ['successor one', 'successor two']) {
await sideComposer.fill(text);
await awaitSendReady(companion);
await sideComposer.press('Enter');
await expect(queued).toContainText(text);
}
await page.screenshot({ path: testInfo.outputPath('side-chat-queue.png'), fullPage: true });
// Steering releases the held Turn; the reordered queue then drains into
// its own Turns in the Host-observed order and the panel clears.
await sideComposer.fill('release the held response');
await awaitSendReady(companion);
await sideComposer.press('Shift+Enter');
await expect(companion).toContainText('Acknowledged steering: release the held response');
await expect(companion).toContainText('Fake backend received: successor one', { timeout: 20_000 });
await expect(companion).toContainText('Fake backend received: successor two', { timeout: 20_000 });
await expect(companion).toContainText('Fake backend received: second follow-up', { timeout: 20_000 });
await expect(companion).toContainText('Fake backend received: first follow-up');
await expect(companion).toContainText('Fake backend received: retract this follow-up');
await expect(companion.getByRole('button', { name: '停止', exact: true })).toHaveCount(0, { timeout: 20_000 });
await expect(queued).toHaveCount(0);
await page.screenshot({ path: testInfo.outputPath('side-chat-settled.png'), fullPage: true });
Expand Down
147 changes: 0 additions & 147 deletions apps/desktop/e2e/skill-draft-lifecycle.spec.ts

This file was deleted.

7 changes: 6 additions & 1 deletion apps/desktop/e2e/workhub-layout.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,12 @@ test('WorkHub uses its coordination model and shared attachment composer', async
'webContents' in child && (child as Electron.WebContentsView).webContents.getURL().includes('surface=workhub')));
return container?.getVisible();
})).toBe(true);
await app.evaluate(() => (globalThis as unknown as { workbarMenu: Electron.Menu }).workbarMenu.closePopup());
// aria-expanded tracks the popup IPC resolution, so a menu that already
// auto-dismissed (Linux closes popups after window resizes) must not be
// closed again — closePopup on a dead popup crashes the main process.
if ((await addPanel.getAttribute('aria-expanded')) === 'true') {
await app.evaluate(() => (globalThis as unknown as { workbarMenu: Electron.Menu }).workbarMenu.closePopup());
}
await expect(addPanel).toHaveAttribute('aria-expanded', 'false');
await workhub.getByRole('button', { name: '收起任务工作栏', exact: true }).click();
await expect(page.locator('.maka-session-workbar[data-placement="right"]')).toBeHidden();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { describe, it } from 'node:test';

import type { TransientUserMessageProjection } from '@maka/ui';
import { createAppShellChatActions } from '../../renderer/app-shell-chat-actions.js';
import { getShellCopy } from '../../renderer/locales/shell-copy.js';

import {
createActionsDeps,
Expand Down Expand Up @@ -193,6 +194,35 @@ describe('busy-raced send settlement', () => {
}
});

it('surfaces the blocked-Skill toast on an outright refusal', async () => {
const errors: Array<{ title: string; description?: string }> = [];
const restoreWindow = installWindow({ sessions: {
submitMessage: async () => ({
ok: false as const,
reason: 'skill_invocation_failed' as const,
skillInvocation: {
loaded: [],
failed: [{ request: 'workspace-only', reason: 'not_found' as const }],
receipts: [],
},
}),
} });
try {
const actions = createAppShellChatActions({
...createActionsDeps(),
activeIdRef: { current: 'session-a' },
toastApi: {
error: (title: string, description?: string) => { errors.push({ title, description }); },
info: () => undefined,
},
});
assert.equal(await actions.send('edited with skill /skill:workspace-only'), false);
const copy = getShellCopy('en').chatActions;
assert.deepEqual(errors.map((entry) => entry.title), [copy.skillInvocationBlockedTitle]);
assert.match(errors[0]?.description ?? '', /workspace-only/);
} finally { restoreWindow(); }
});

it('reports a refused Follow Up as not sent', async () => {
const restoreWindow = installWindow({
sessions: {
Expand Down
Loading