Skip to content
Open
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
7 changes: 7 additions & 0 deletions apps/desktop/src/main/__tests__/appShortcuts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ function electronInput(code: string, mods: Partial<AppShortcutCombo> = {}) {
}

describe('matching', () => {
it('provides the app-wide quick switch shortcut on each platform and respects existing user bindings', () => {
for (const platform of ['win32', 'linux', 'darwin']) {
const expected = combo('KeyK', platform === 'darwin' ? { meta: true } : { ctrl: true });
expect(getEffectiveAppShortcuts({}, platform).get('open-quick-switcher')).toEqual([expected]);
expect(getEffectiveAppShortcuts({ 'new-maker': expected }, platform).get('open-quick-switcher')).toEqual([]);
}
});
it('matches exact modifier state only', () => {
const c = combo('KeyB', { meta: true });
expect(matchesKeyboardEvent(keyboardEvent('KeyB', { meta: true }), c)).toBe(true);
Expand Down
129 changes: 129 additions & 0 deletions apps/desktop/src/main/__tests__/webview-security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,25 @@ import { EventEmitter } from 'node:events';

import type { BrowserWindow, Session, WebContents } from 'electron';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { AppShortcutCombo, AppShortcutOverrides } from '../../shared/appShortcuts';
import type { VoiceInputShortcut } from '../../shared/voiceInputData';

const shortcutMocks = vi.hoisted((): {
overrides: AppShortcutOverrides;
platform: string;
voiceShortcut: VoiceInputShortcut | null;
} => ({ overrides: {}, platform: 'win32', voiceShortcut: null }));
vi.mock('../app-shortcuts/index.js', () => ({
getAppShortcutStore: () => ({
getEffectiveMap: (yieldToCombos: ReadonlyArray<AppShortcutCombo> = []) =>
getEffectiveAppShortcuts(shortcutMocks.overrides, shortcutMocks.platform, yieldToCombos),
}),
}));
vi.mock('../voice-input/VoiceInputDataStore.js', () => ({
voiceInputDataStore: {
getShortcut: () => shortcutMocks.voiceShortcut,
},
}));

const nativeSurfaceMocks = vi.hoisted(() => ({
create: vi.fn(() => 'surface-oauth'),
Expand All @@ -46,6 +65,7 @@ import {
setRsbPopupHostResolver,
setRsbPopupOpenerReportSubscriber,
RSB_BROWSER_POPUP_CHANNEL,
RSB_BROWSER_COMMAND_CHANNEL,
applyGhostWebviewHardening,
applyLoginCaptchaWebviewHardening,
applyWebviewHardening,
Expand Down Expand Up @@ -210,6 +230,9 @@ describe('BLANK_POPUP_WINDOW_WEB_PREFERENCES(popup WebContents 安全集)', () =

describe('installBrowserGuestHandlers(main-owned popup)', () => {
afterEach(() => {
shortcutMocks.overrides = {};
shortcutMocks.platform = 'win32';
shortcutMocks.voiceShortcut = null;
nativeSurfaceMocks.create.mockClear();
nativeSurfaceMocks.attribute.mockClear();
setRsbPopupOpenerResolver(null);
Expand Down Expand Up @@ -237,6 +260,91 @@ describe('installBrowserGuestHandlers(main-owned popup)', () => {
return contents;
}

it('forwards guest keydown to the current host, using effective bindings and ignoring keyup', () => {
const host = makeContents(1);
const guest = makeContents(42);
const currentHost = makeContents(2);
setRsbPopupHostResolver(() => currentHost as never);
installBrowserGuestHandlers(host as never, guest as never);
const event = { preventDefault: vi.fn() };
const input = { code: 'KeyK', control: true, meta: false, alt: false, shift: false };
guest.emit('before-input-event', event, { ...input, type: 'keyUp' });
expect(event.preventDefault).not.toHaveBeenCalled();
guest.emit('before-input-event', event, { ...input, type: 'keyDown' });
expect(currentHost.send).toHaveBeenCalledExactlyOnceWith(RSB_BROWSER_COMMAND_CHANNEL, {
command: 'open-quick-switcher',
});
expect(host.send).not.toHaveBeenCalled();
expect(event.preventDefault).toHaveBeenCalledOnce();

currentHost.send.mockClear();
event.preventDefault.mockClear();
for (const state of [{ isAutoRepeat: true }, { isComposing: true }]) {
guest.emit('before-input-event', event, { ...input, ...state, type: 'keyDown' });
}
expect(currentHost.send).not.toHaveBeenCalled();
expect(event.preventDefault).not.toHaveBeenCalled();
// The quick switcher's default must yield to an existing user binding, just as in the host.
shortcutMocks.overrides = {
'new-maker': { code: 'KeyK', ctrl: true, meta: false, alt: false, shift: false },
};
guest.emit('before-input-event', event, { ...input, type: 'keyDown' });
expect(currentHost.send).not.toHaveBeenCalled();
expect(event.preventDefault).not.toHaveBeenCalled();
});

it.each(['darwin', 'win32', 'linux'])(
'yields guest quick-switcher keys to live voice bindings on %s',
(platform) => {
shortcutMocks.platform = platform;
const host = makeContents(1);
const guest = makeContents(42);
installBrowserGuestHandlers(host as never, guest as never);
const event = { preventDefault: vi.fn() };
const input = {
type: 'keyDown',
code: 'KeyK',
key: 'k',
meta: platform === 'darwin',
control: platform !== 'darwin',
alt: false,
shift: false,
};
const voiceShortcut: VoiceInputShortcut = {
trigger: 'keyboard',
code: 'KeyK',
key: 'k',
modifiers: { meta: input.meta, ctrl: input.control, alt: false, shift: false, fn: false },
};
shortcutMocks.voiceShortcut = voiceShortcut;
guest.emit('before-input-event', event, input);
expect(event.preventDefault).not.toHaveBeenCalled();
expect(host.send).not.toHaveBeenCalled();

// Rebinds, clearing and native-only keys release the default on the same guest.
const nonConflicting: Array<VoiceInputShortcut | null> = [
{ ...voiceShortcut, code: 'KeyJ', key: 'j' },
null,
{ ...voiceShortcut, modifiers: { ...voiceShortcut.modifiers, fn: true } },
{ ...voiceShortcut, trigger: 'modifier', code: 'ControlLeft', key: 'Control' },
];
for (const shortcut of nonConflicting) {
shortcutMocks.voiceShortcut = shortcut;
guest.emit('before-input-event', event, input);
expect(event.preventDefault).toHaveBeenCalledOnce();
expect(host.send).toHaveBeenCalledExactlyOnceWith(RSB_BROWSER_COMMAND_CHANNEL, {
command: 'open-quick-switcher',
});
event.preventDefault.mockClear();
host.send.mockClear();
}
shortcutMocks.voiceShortcut = voiceShortcut;
guest.emit('before-input-event', event, input);
expect(event.preventDefault).not.toHaveBeenCalled();
expect(host.send).not.toHaveBeenCalled();
},
);

it.each([
['direct URL', 'https://accounts.example.com/oauth'],
['about:blank', 'about:blank'],
Expand Down Expand Up @@ -1000,6 +1108,27 @@ describe('resolveGuestShortcutAction', () => {
keyValue?: string,
) => ({ code, key: keyValue, meta: false, control: false, alt: false, shift: false, ...mods });

it.each(['darwin', 'win32', 'linux'])('forwards the quick switcher shortcut on %s', (platform) => {
expect(
resolveGuestShortcutAction(
key('KeyK', platform === 'darwin' ? { meta: true } : { control: true }),
combosFor(platform),
),
).toEqual({ kind: 'command', command: 'open-quick-switcher' });
});

it('uses the current quick switcher override and releases its old default', () => {
const effective = getEffectiveAppShortcuts(
{ 'open-quick-switcher': { code: 'KeyJ', ctrl: true, meta: false, alt: true, shift: false } },
'win32',
);
const getCombos = (id: AppShortcutId) => effective.get(id) ?? [];
expect(resolveGuestShortcutAction(key('KeyK', { control: true }), getCombos)).toBeNull();
expect(
resolveGuestShortcutAction(key('KeyJ', { control: true, alt: true }), getCombos),
).toEqual({ kind: 'command', command: 'open-quick-switcher' });
});

it('maps darwin default combos to host actions (incl. ⌘W close-tab)', () => {
const getCombos = combosFor('darwin');
expect(resolveGuestShortcutAction(key('KeyL', { meta: true }), getCombos)).toEqual({
Expand Down
6 changes: 4 additions & 2 deletions apps/desktop/src/main/app-shortcuts/AppShortcutStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,10 @@ export class AppShortcutStore {
return getAppShortcutDefinition(id).getDefaultCombos(this.options.platform);
}

getEffectiveMap(): Map<AppShortcutId, AppShortcutCombo[]> {
return getEffectiveAppShortcuts(this.load(), this.options.platform);
getEffectiveMap(
yieldToCombos: ReadonlyArray<AppShortcutCombo> = [],
): Map<AppShortcutId, AppShortcutCombo[]> {
return getEffectiveAppShortcuts(this.load(), this.options.platform, yieldToCombos);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,21 @@ afterEach(() => {
});

describe('AppShortcutStore', () => {
it('yields defaults to external keys without persisting them or suppressing explicit overrides', () => {
const store = makeStore('win32');
const voiceCombo = combo('KeyK', { ctrl: true });
expect(store.getEffectiveMap([voiceCombo]).get('open-quick-switcher')).toEqual([]);
expect(store.getEffectiveMap().get('open-quick-switcher')).toEqual([voiceCombo]);
expect(fs.existsSync(filePath())).toBe(false);

expect(store.setOverride('open-quick-switcher', combo('KeyJ', { ctrl: true }))).toBeNull();
expect(store.getEffectiveMap([voiceCombo]).get('open-quick-switcher')).toEqual([
combo('KeyJ', { ctrl: true }),
]);
store.setOverride('open-quick-switcher', null);
expect(store.getEffectiveMap([voiceCombo]).get('open-quick-switcher')).toEqual([]);
});

it('returns registry defaults when no overrides exist', () => {
const store = makeStore('darwin');
expect(store.getEffectiveCombos('toggle-sidebar')).toEqual([
Expand Down
152 changes: 152 additions & 0 deletions apps/desktop/src/main/localDb/__tests__/quickSwitcher.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
catalogSessionForGrouping,
quickSwitcherProjects,
searchQuickSwitcher,
} from '../../../renderer/features/cc-agent/lib/quickSwitcher';

const h = vi.hoisted(() => ({ db: null as ReturnType<typeof drizzle> | null }));
vi.mock('../client/current', () => ({ getDbClient: () => ({ drizzle: h.db }) }));
import { listQuickSwitcherCatalog } from '../quickSwitcher';

let sqlite: Database.Database;
afterEach(() => sqlite?.close());

describe('title catalogue database query', () => {
beforeEach(() => {
sqlite = new Database(':memory:');
sqlite.exec(`CREATE TABLE sessions (id TEXT PRIMARY KEY, title TEXT, working_dir TEXT, workspace_kind TEXT, remote_host_id TEXT, agent_kind TEXT, status TEXT, source TEXT, orca_role TEXT, parent_session_id TEXT, pinned_at INTEGER, user_send_at INTEGER, updated_at INTEGER, created_at INTEGER);
CREATE TABLE messages (session_id TEXT, rewind_at INTEGER);
CREATE INDEX messages_session_id_idx ON messages(session_id);`);
h.db = drizzle(sqlite);
});

it('paginates all visible history and excludes messages, deleted rows and workers', async () => {
const insert = sqlite.prepare(
'INSERT INTO sessions VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
);
for (let i = 0; i < 270; i++)
insert.run(
String(i).padStart(3, '0'),
`History ${i}`,
'/repo',
'project',
null,
'cc',
i === 269 ? 'archived' : 'active',
'desktop',
null,
null,
null,
null,
i,
i,
);
insert.run(
'deleted',
'Deleted',
'/repo',
'project',
null,
'cc',
'deleted',
'desktop',
null,
null,
null,
null,
1,
1,
);
insert.run(
'worker',
'Worker',
'/repo',
'project',
null,
'cc',
'active',
'desktop',
'worker',
null,
null,
null,
1,
1,
);
sqlite.prepare('INSERT INTO messages VALUES (?, NULL)').run('269');
const first = await listQuickSwitcherCatalog(null);
const second = await listQuickSwitcherCatalog(first.nextCursor);
const third = await listQuickSwitcherCatalog(second.nextCursor);
expect(first.sessions).toHaveLength(128);
expect(second.sessions).toHaveLength(128);
expect(third.sessions).toHaveLength(14);
expect(third.nextCursor).toBeNull();
expect(third.sessions.at(-1)).toMatchObject({
id: '269',
status: 'archived',
_count: { messages: 1 },
});
expect(first.sessions[0]._count.messages).toBe(0);
expect(first.sessions[0]).not.toHaveProperty('preview');
});

it.each(['desktop', 'shared'])(
'finds a %s project by name when userSendAt is null and all its messages are rewound',
async (source) => {
const insertSession = sqlite.prepare(
'INSERT INTO sessions VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
);
for (const id of ['draft', 'live', 'rewound', 'mixed']) {
// These logical project paths are grouping inputs, never host filesystem paths.
insertSession.run(
id,
'History',
`/projects/${id}`,
'project',
null,
'cc',
'active',
source,
null,
null,
null,
null,
1,
1,
);
}
const insertMessage = sqlite.prepare('INSERT INTO messages VALUES (?, ?)');
insertMessage.run('live', null);
insertMessage.run('rewound', 2);
insertMessage.run('mixed', null);
insertMessage.run('mixed', 2);

const page = await listQuickSwitcherCatalog(null);
const sessions = page.sessions.map(catalogSessionForGrouping);
const projects = quickSwitcherProjects(sessions, new Map(), [], process.platform);
const result = searchQuickSwitcher({
query: 'rewound',
sessions,
projects,
hiddenProjectKeys: new Set(),
platform: process.platform,
unnamedLabel: 'Untitled',
});
expect(result.total).toBe(1);
expect(result.results[0]).toMatchObject({
kind: 'project',
project: { workingDir: '/projects/rewound', sessions: [{ id: 'rewound' }] },
});
expect(page.sessions.map((session) => [session.id, session._count.messages])).toEqual([
['draft', 0],
['live', 1],
['mixed', 1],
['rewound', 1],
]);
expect(projects.map((project) => project.workingDir)).not.toContain('/projects/draft');
},
);
});
Loading