Skip to content
51 changes: 49 additions & 2 deletions apps/desktop/e2e/fixtures.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { _electron as electron, test as base, expect } from '@playwright/test';
import type { ElectronApplication, Page } from '@playwright/test';
import { mkdtemp, rm } from 'node:fs/promises';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { createConnectionStore, createFileCredentialStore, createSettingsStore } from '@maka/storage';
Expand Down Expand Up @@ -35,6 +35,40 @@ async function seedE2eLocale(userDataDir: string, locale: 'zh' | 'en'): Promise<
});
}

async function seedE2eInvocableSkills(userDataDir: string): Promise<void> {
const workspaceRoot = path.join(userDataDir, 'workspaces', 'default');
const projectRoot = path.join(userDataDir, 'project');
const projectSkillRoot = path.join(projectRoot, '.maka', 'skills');
const workspaceSkillRoot = path.join(workspaceRoot, 'skills');
await Promise.all([
mkdir(path.join(projectSkillRoot, 'project-only'), { recursive: true }),
mkdir(path.join(projectSkillRoot, 'host-incompatible'), { recursive: true }),
mkdir(path.join(workspaceSkillRoot, 'workspace-only'), { recursive: true }),
]);
await Promise.all([
writeFile(
path.join(projectSkillRoot, 'project-only', 'SKILL.md'),
`---\nname: Project Only\ndescription: Project-scoped suggestion.\n---\n# Project Only`,
'utf8',
),
writeFile(
path.join(projectSkillRoot, 'host-incompatible', 'SKILL.md'),
`---\nname: Host Incompatible\ndescription: Must be hidden from this host.\nrequired-tools: [DefinitelyMissingTool]\n---\n# Host Incompatible`,
'utf8',
),
writeFile(
path.join(workspaceSkillRoot, 'workspace-only', 'SKILL.md'),
`---\nname: Workspace Only\ndescription: Maka workspace suggestion.\n---\n# Workspace Only`,
'utf8',
),
writeFile(
path.join(workspaceRoot, 'last-project-path.json'),
JSON.stringify({ projectPath: projectRoot }),
'utf8',
),
]);
}

/**
* Build the minimal env for an E2E Electron launch. Inheriting `process.env`
* wholesale would leak the host's provider keys (which auto-bootstrap a
Expand Down Expand Up @@ -89,13 +123,14 @@ function buildE2eEnv(
* Electron and a leaked `maka-e2e-*` directory.
*/
async function withE2eWindow(
{ seed, readinessSelector, e2eFixtureScenario, locale, platform }: {
{ seed, readinessSelector, e2eFixtureScenario, locale, platform, invocableSkills }: {
seed: boolean;
readinessSelector: string;
e2eFixtureScenario?: string;
locale?: 'zh' | 'en';
/** #1312: force app:info's platform so the window boots natively into that platform's `data-os` cascade. */
platform?: 'darwin' | 'win32' | 'linux';
invocableSkills?: boolean;
},
use: (page: Page) => Promise<void>,
): Promise<void> {
Expand All @@ -105,6 +140,7 @@ async function withE2eWindow(
const rendererLogs: string[] = [];
try {
if (seed) await seedE2eConnection(userDataDir);
if (invocableSkills) await seedE2eInvocableSkills(userDataDir);
// Legacy E2E specs assert Chinese labels and should not inherit the CI
// host locale. E2e-fixture workspaces use the explicit renderer override.
if (locale && !e2eFixtureScenario) await seedE2eLocale(userDataDir, locale);
Expand Down Expand Up @@ -166,6 +202,7 @@ export const test = base.extend<{
zhLocaleWindow: Page;
enLocaleWindow: Page;
localeSwitchWindow: Page;
invocableSkillsWindow: Page;
}>({
// Seeded: a pre-staged connection clears onboarding so the composer is ready.
// Used by chat / session / settings / attachment specs.
Expand Down Expand Up @@ -276,6 +313,16 @@ export const test = base.extend<{
localeSwitchWindow: async ({}, use) => {
await withE2eWindow({ seed: true, readinessSelector: '.maka-onboarding-quickchat-input' }, use);
},
// Project + Maka-workspace Skills with one deliberately host-incompatible
// entry. Proves `/` uses Runtime discovery/gating rather than management UI data.
invocableSkillsWindow: async ({}, use) => {
await withE2eWindow({
seed: true,
readinessSelector: '.maka-onboarding-quickchat-input',
locale: 'zh',
invocableSkills: true,
}, use);
},
});

export { expect };
81 changes: 81 additions & 0 deletions apps/desktop/e2e/onboarding-skill-invocation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type { Page } from '@playwright/test';
import { expect, test } from './fixtures';

async function createStarterSkillAndReload(page: Page): Promise<void> {
const result = await page.evaluate(() => window.maka.skills.createStarter());
expect(result.ok).toBe(true);
await page.reload();
await expect(page.locator('.maka-onboarding-quickchat-input')).toBeVisible();
}

async function selectStarterSkill(page: Page): Promise<void> {
const quickChat = page.locator('.maka-onboarding-quickchat-input');
await quickChat.fill('/');
const listbox = page.getByRole('listbox', { name: '技能' });
await expect(listbox).toBeVisible();
await expect(listbox.getByRole('option', { name: /示例技能/ })).toBeVisible();
await quickChat.press('Enter');
await expect(page.locator('.maka-composer-skill-chip')).toContainText('示例技能');
await expect(quickChat).toHaveValue('');
}

test('first-run Quick Chat selects a structured Skill from slash suggestions', async ({
window: page,
}) => {
await createStarterSkillAndReload(page);
await selectStarterSkill(page);

const quickChat = page.locator('.maka-onboarding-quickchat-input');
const chip = page.locator('.maka-composer-skill-chip');
await expect(chip).toHaveCSS('min-height', '32px');
const removeButton = chip.getByRole('button');
await expect(removeButton).toHaveCSS('height', '32px');
await removeButton.focus();
await removeButton.press('Enter');
await expect(chip).toHaveCount(0);
await expect(quickChat).toBeFocused();

await selectStarterSkill(page);
await quickChat.press('Backspace');
await expect(chip).toHaveCount(0);
});

test('slash suggestions follow Runtime project discovery and host gating', async ({
invocableSkillsWindow: page,
}) => {
const quickChat = page.locator('.maka-onboarding-quickchat-input');
await quickChat.fill('/');
const listbox = page.getByRole('listbox', { name: '技能' });
await expect(listbox).toBeVisible();
await expect(listbox).toContainText('Project Only');
await expect(listbox).toContainText('Workspace Only');
await expect(listbox).not.toContainText('Host Incompatible');
});

test('chip-only send renders a readable user message', async ({ window: page }) => {
await createStarterSkillAndReload(page);
await selectStarterSkill(page);

const quickChat = page.locator('.maka-onboarding-quickchat-input');
await quickChat.press('Enter');

await expect(page.getByLabel('你发送的消息').first()).toContainText('/skill:starter-skill');
});

test('blocked first-run Skill invocation keeps the complete Quick Chat draft', async ({
window: page,
}) => {
await createStarterSkillAndReload(page);
await selectStarterSkill(page);
const disabled = await page.evaluate(() => window.maka.skills.setEnabled('starter-skill', false));
expect(disabled.ok).toBe(true);

const quickChat = page.locator('.maka-onboarding-quickchat-input');
await quickChat.fill('run it');
await quickChat.press('Enter');

await expect(page.getByText('Skill 调用失败,消息未发送')).toBeVisible();
await expect(quickChat).toHaveValue('run it');
await expect(page.locator('.maka-composer-skill-chip')).toContainText('示例技能');
await expect(page.locator('.maka-turn')).toHaveCount(0);
});
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ describe('AppShell composer attachment ownership', () => {
readFile(resolve(RENDERER_ROOT, 'use-app-shell-composer-attachments.ts'), 'utf8'),
]);

assert.match(shell, /const ok = await send\(text, pending, \{[^}]*quotes/);
assert.match(shell, /const ok = await send\(text, pending, \{/);
assert.match(shell, /\.\.\.\(skillIds\.length > 0 \? \{ skillIds \} : \{\}\)/);
assert.match(shell, /\.\.\.\(quotes \? \{ quotes \} : \{\}\)/);
assert.match(
shell,
/const sessionId = activeIdRef\.current;[\s\S]*window\.maka\.sessions\.compact\(sessionId\)/,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,55 @@ function createActions(toasts: ToastCall[]) {
setQuickChatPending: () => undefined,
toastApi: {
error: (title, description) => toasts.push([title, description]),
info: (title, description) => toasts.push([title, description]),
},
});
}

describe('AppShell quick-entry failure copy', () => {
it('passes structured Skill ids and keeps blocked invocation feedback localized', async () => {
let receivedInput: unknown;
const restoreWindow = installWindow({
quickChat: {
start: async (input: unknown) => {
receivedInput = input;
return {
ok: false,
reason: 'skill_invocation_failed',
skillInvocation: {
loaded: [],
failed: [{ request: 'missing-skill', reason: 'not_found' }],
},
};
},
},
expertTeam: { start: async () => ({ ok: false, reason: 'unknown_team', teamId: 'x' }) },
});
const toasts: ToastCall[] = [];

try {
const actions = createActions(toasts);
assert.equal(
await actions.handleQuickChatSubmit('run it', undefined, ['missing-skill']),
false,
);
} finally {
restoreWindow();
}

assert.deepEqual(receivedInput, {
prompt: 'run it',
mode: undefined,
skillIds: ['missing-skill'],
});
assert.deepEqual(toasts, [
[
'Skill invocation failed; message not sent',
'/skill:missing-skill (not found). Adjust the selection and try again.',
],
]);
});

it('does not surface Chinese main-process messages in the English UI', async () => {
const restoreWindow = installWindow({
quickChat: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ function createHarness(options: {
setText: (text: string) => { composerCalls.push(text); },
appendText: () => undefined,
getText: () => options.previousComposerText ?? 'Previous draft',
setSkills: () => undefined,
clearDraft: (key: string) => { composerCalls.push(`<clear:${key}>`); },
setDraft: (key: string, text: string) => { composerCalls.push(`<draft:${key}:${text}>`); },
focus: () => { composerCalls.push('<focus>'); },
Expand Down
10 changes: 6 additions & 4 deletions apps/desktop/src/main/__tests__/compact-routing-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ const REPO_ROOT = resolve(import.meta.dirname, '../../../../..');
describe('/compact routing contract', () => {
it('renderer intercepts exact /compact and routes to sessions.compact, never send', async () => {
const shell = await readRendererShellSource('app-shell.tsx');
const send = shell.match(/async function sendWithAttachments\(text: string\): Promise<boolean \| void> \{[\s\S]*?\n \}/)?.[0] ?? '';
const send = shell.match(/async function sendWithAttachments\([\s\S]*?\): Promise<boolean \| void> \{[\s\S]*?\n \}/)?.[0] ?? '';

// exact /compact (after trim) is the only trigger
assert.match(send, /if \(text\.trim\(\) === '\/compact'\) \{/);
// exact /compact (after trim) with no structured Skill invocation is the only trigger
assert.match(send, /if \(skillIds\.length === 0 && text\.trim\(\) === '\/compact'\) \{/);
// routes to compact only when an active session exists — the no-active-session guard
assert.match(send, /const sessionId = activeIdRef\.current;/);
assert.match(send, /if \(!sessionId\) return true;/);
Expand All @@ -30,7 +30,9 @@ describe('/compact routing contract', () => {
// returns early so /compact never falls through to the normal send path
assert.match(send, /return true;/);
// non-compact text still goes through the normal send path
assert.match(send, /const ok = await send\(text, pending, \{[^}]*quotes/);
assert.match(send, /const ok = await send\(text, pending, \{/);
assert.match(send, /\.\.\.\(skillIds\.length > 0 \? \{ skillIds \} : \{\}\)/);
assert.match(send, /\.\.\.\(quotes \? \{ quotes \} : \{\}\)/);
});

it('main sessions:compact IPC drives runtime.compactSession via streamEvents', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ describe('Esc clears stuck drag-active highlight (PR-COMPOSER-ESC-DRAG-CLEAR-0)'
it('OnboardingHero handleKey handles Esc + dragActive alongside Enter→submit', async () => {
const src = await readFile(ONBOARDING_HERO_SOURCE, 'utf8');
// Find the handleKey body.
const handler = src.match(/const handleKey = useCallback\([\s\S]*?\[submit[\s\S]*?\]\s*\)/);
const handler = src.match(/const handleKey = useCallback\([\s\S]*?\n \);\n\n const prefillSuggestion/);
assert.ok(handler, 'handleKey must exist on OnboardingHero');
assert.match(
handler[0],
Expand All @@ -53,8 +53,8 @@ describe('Esc clears stuck drag-active highlight (PR-COMPOSER-ESC-DRAG-CLEAR-0)'
// the current value.
assert.match(
handler[0],
/\[submit,\s*dragActive\]/,
'handleKey deps must include dragActive',
/\[[\s\S]*?\bdragActive\b[\s\S]*?\bsubmit\b[\s\S]*?\],\s*\);/,
'handleKey deps must include dragActive and submit',
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ describe('home composer new-chat model picker', () => {
assert.match(sendBlock, /upsertSessionSummary\(session\);/);
assert.match(
sendBlock,
/if \(newChatOwner && isNewChatSendSurfaceActive\(newChatOwner\)\) \{[\s\S]*setNavSelection\(\{ section: 'sessions', filter: 'chats' \}\);[\s\S]*setActiveId\(session\.id\);[\s\S]*showOptimisticUserMessage\(session\.id, turnId, text, sendResult\.attachments, \{[\s\S]*?replaceCurrentMessages: true,[\s\S]*\}/,
/if \(newChatOwner && isNewChatSendSurfaceActive\(newChatOwner\)\) \{[\s\S]*setNavSelection\(\{ section: 'sessions', filter: 'chats' \}\);[\s\S]*setActiveId\(session\.id\);[\s\S]*showOptimisticUserMessage\([\s\S]*session\.id,[\s\S]*turnId,[\s\S]*skillInvocationDisplayText\(text, sendResult\.skillInvocation\),[\s\S]*sendResult\.attachments,[\s\S]*\{[\s\S]*replaceCurrentMessages: true,[\s\S]*\}[\s\S]*\);[\s\S]*\}/,
'newly-created sessions may only become active if the user is still on the original empty new-chat surface',
);
assert.match(
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/main/__tests__/composer-send-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ describe('composer send guard', () => {
);
// U3: `noModelConnection` is folded into the guard so Send stays inert in
// the post-skip no-model dead end (the inline hint points at Settings · 模型).
assert.match(source, /const sendDisabled = props\.disabled \|\| sendPending \|\| importActionBusy \|\| !hasDraftText \|\| noModelConnection;/);
assert.match(source, /\(!hasDraftText && skillDraft\.skills\.length === 0\)/);
assert.match(source, /disabled=\{sendDisabled\}/, 'send button must be disabled while empty, in flight, or with no model connection');
assert.match(copySource, /sendLabel: '发送'/, 'Chinese UI must not keep English Send button copy');
assert.match(copySource, /stopLabel: '停止'/, 'Chinese UI must not keep English Stop button copy');
Expand All @@ -52,7 +52,7 @@ describe('composer send guard', () => {

assert.match(
sendCurrent,
/const submittedDraftKey = activeDraftKey\(\);[\s\S]*sent = await props\.onSend\(text\);[\s\S]*if \(sent === false\) return;[\s\S]*clearDraft\(submittedDraftKey\);[\s\S]*saveCurrentDraft\(''\);/,
/const submittedDraftKey = activeDraftKey\(\);[\s\S]*sent = await props\.onSend\(text, skillIds\);[\s\S]*if \(sent === false\) return;[\s\S]*clearDraft\(submittedDraftKey\);[\s\S]*saveCurrentDraft\(''\);/,
'successful sends must clear both the original draft key and the current key after a new-session send changes draftKey',
);
assert.match(
Expand Down
Loading
Loading