Files
orca/src/main/plugins/plugin-command-replacement-owners.test.ts
T
Neil 999160b3db feat(plugins): commands[].replaces lets a mod take over a built-in action (#37316)
* plugins: commands[].replaces lets a worker command take over a built-in action

A worker command with replaces: <action> (the declarative action list,
engines >=1.4.248, needs main) runs in place of that built-in from its
shortcut, plugin aliases and the sidebar menus; a failed or silent (2 s)
worker falls back to Orca's own. The install screen names each replacement,
the plugin page has a per-action switch (settings.pluginReplacementsOff), and
when two plugins replace one action the one installed first wins and the
other's log says so. Update review counts replaces as new access.

* fix(plugins): narrow replaced action ids before looking up their titles

* plugins: harden command replaces after review

- Consent includes the replaced actions, so adding replaces to a dev folder
  needs a new approval; CLI approval and update review name each takeover.
- Shortcuts main intercepts (Tasks, Workspace Board, history), search with a
  selection and floating maximize now go through the replacement too.
- A worker that only starts after the 2 s fallback no longer runs the
  command as well: main refuses to start it past the deadline.
- A workspace-only command does not replace an action without a workspace.
- An update keeps the plugin's first install time, so it keeps its takeover.
- Drop the commands.run promise until Orca offers it.

* plugins: commands.run always runs the built-in, never a replacement

A5b's commands.run dispatches with the 'plugin-host' source; the app
dispatcher now builds handlers that run Orca's own action for it, so a
plugin that replaces an action and calls commands.run cannot loop. Docs and
schema describe wrapping the built-in with commands.run; regenerated.
2026-11-01 21:59:27 -08:00

77 lines
2.4 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import type { PluginHostListEntry } from '../../shared/plugins/plugin-list-entry'
import type { PluginLockfile } from '../../shared/plugins/installation/plugin-install-lockfile'
import { markCommandReplacementOwners } from './plugin-command-replacement-owners'
function entry(
pluginKey: string,
status: PluginHostListEntry['status'] = 'idle'
): PluginHostListEntry {
return {
pluginKey,
consentFingerprint: null,
name: pluginKey,
version: '1.0.0',
publisher: 'acme',
status,
needsReconsent: false,
isDev: false,
official: false,
bundled: false,
capabilities: [],
panels: [],
commands: [
{
id: 'tasks',
title: 'Tasks',
context: 'global',
handler: { type: 'worker' },
keybindings: [],
replaces: 'view.tasks'
}
],
hasWorker: true,
restarts: 0
}
}
function lock(times: Record<string, number>): Pick<PluginLockfile, 'plugins'> {
const plugins = Object.fromEntries(
Object.entries(times).map(([key, installedAt]) => [key, { installedAt }])
)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the owners read only installedAt.
return { plugins } as unknown as Pick<PluginLockfile, 'plugins'>
}
describe('who replaces a built-in action', () => {
it('lets the plugin installed first replace it, marks and logs the other once', () => {
const log = vi.fn()
const entries = [entry('acme.later'), entry('acme.first'), entry('acme.dev')]
const marked = markCommandReplacementOwners(
entries,
lock({ 'acme.first': 1, 'acme.later': 2 }),
log
)
expect(marked.map((each) => each.commands[0]?.replacedBy)).toEqual([
'acme.first',
undefined,
'acme.first'
])
expect(log).toHaveBeenCalledWith(
'acme.later',
'command tasks does not replace view.tasks: acme.first was installed first and replaces it'
)
markCommandReplacementOwners(entries, lock({ 'acme.first': 1, 'acme.later': 2 }), log)
expect(log).toHaveBeenCalledTimes(2)
})
it('ignores plugins that are off', () => {
const marked = markCommandReplacementOwners(
[entry('acme.first', 'disabled'), entry('acme.later')],
lock({ 'acme.first': 1, 'acme.later': 2 }),
vi.fn()
)
expect(marked[1]?.commands[0]?.replacedBy).toBeUndefined()
})
})