Files
orca/tests/e2e/plugin-command-replacement-deadline.spec.ts
Neil 186b18e2b9 fix(plugins): stop a late replacing command from repeating the built-in action (#39407)
* fix(plugins): stop a late replacing command from repeating the built-in action

The renderer ran Orca's own action after 2 s but the still-running replacing
command could then call commands.run for the same action, doing it twice.
Main now owns the 2 s cutoff: it rejects at the deadline (the renderer falls
back on that answer) and from then refuses the late handler's commands.run of
the replaced action; a handler that already ran the action is not followed by a
fallback. The deadline now starts when the renderer asked, not after plugins
load. The renderer's own timer is only a safety net. (STA-13530)

* test(plugins): cover replacement races and verify the sidebar toggles once end to end

A handler's commands.run now counts for the oldest in-flight press only, so a second
concurrent press whose handler fails still gets Orca's fallback. Adds unit cases for a
throwing/crashed handler, two presses racing, and a press after a late handler returns,
plus an Electron e2e spec driving fast, late (2.1 s), throwing and no-op replacements of
sidebar.left.toggle.
2026-11-07 02:50:17 -08:00

205 lines
7.8 KiB
TypeScript

/**
* Invariant: a plugin command that replaces a built-in action (here `sidebar.left.toggle`) gets
* the action exactly once per press. A fast handler that calls `commands.run` toggles it, a
* handler that throws gets Orca's own toggle, a handler that never calls `commands.run` leaves
* it alone, and a handler that calls `commands.run` after the 2 s deadline is refused because
* Orca already ran its own: the sidebar never toggles twice.
*/
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { ElectronApplication, Page } from '@stablyai/playwright-test'
import { expect, test } from './helpers/orca-app'
import { disablePluginSystem, enableDevPlugins } from './helpers/plugin-smoke'
type Mode = 'fast' | 'late' | 'throw' | 'noop'
declare global {
// oxlint-disable-next-line typescript-eslint/consistent-type-definitions -- declaration merging requires interface
interface Window {
/** Left sidebar open/close changes the renderer committed, counted by this spec. */
__sidebarToggles?: number
}
}
const manifest = {
manifestVersion: 2,
execution: 'trusted-worker',
id: 'e2e-sidebar-replacement',
name: 'E2E Sidebar Replacement',
version: '1.0.0',
description: 'Replaces the left sidebar toggle with a handler whose timing the test picks.',
engines: { orca: '>=1.4.248' },
pluginApi: 1,
main: 'worker.mjs',
contributes: {
commands: [{ id: 'toggle', title: 'Toggle sidebar', replaces: 'sidebar.left.toggle' }],
settings: [
{
key: 'mode',
title: 'Mode',
type: 'string',
enum: ['fast', 'late', 'throw', 'noop'],
default: 'fast'
},
{ key: 'logPath', title: 'Log path', type: 'string', default: '' }
]
},
capabilities: [{ kind: 'settings:own' }, { kind: 'workspace:write' }]
}
const worker = `import { appendFileSync } from 'node:fs'
export default function activate(orca) {
orca.commands.register('toggle', async () => {
const mode = await orca.settings.get('mode')
const logPath = String((await orca.settings.get('logPath')) ?? '')
const note = (line) => {
if (logPath) appendFileSync(logPath, mode + ':' + line + '\\n')
}
note('start')
if (mode === 'throw') {
note('threw')
throw new Error('replacement failed')
}
if (mode === 'noop') {
note('done')
return
}
if (mode === 'late') {
await new Promise((resolve) => setTimeout(resolve, 2_100))
}
try {
await orca.host.call('commands.run', { action: 'sidebar.left.toggle' })
note('ran')
} catch (error) {
note('refused')
throw error
}
})
}
`
async function setMode(page: Page, pluginKey: string, key: string, value: string): Promise<void> {
await page.evaluate(
async ({ pluginKey, key, value }) => {
const written = await window.api.plugins.setSetting?.({ pluginKey, key, value })
if (!written?.ok) {
throw new Error(`setSetting failed: ${JSON.stringify(written)}`)
}
},
{ pluginKey, key, value }
)
}
async function pressToggle(electronApp: ElectronApplication): Promise<void> {
await electronApp.evaluate(({ BrowserWindow }) => {
BrowserWindow.getAllWindows()[0]?.webContents.send('ui:toggleLeftSidebar')
})
}
function toggleCount(page: Page): Promise<number> {
return page.evaluate(() => window.__sidebarToggles ?? 0)
}
async function logLines(logPath: string, mode: Mode): Promise<string[]> {
const text = await readFile(logPath, 'utf8').catch(() => '')
return text
.split('\n')
.filter((line) => line.startsWith(`${mode}:`))
.map((line) => line.slice(mode.length + 1))
}
test('a replacing command gets the left sidebar toggle exactly once, early, late or failing', async ({
orcaPage,
electronApp
}) => {
// Why: four timed presses each wait out the 2 s deadline and 3 s safety net, plus plugin setup.
test.setTimeout(120_000)
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-replacement-deadline-'))
const pluginRoot = join(tempRoot, 'sidebar-replacement')
const logPath = join(tempRoot, 'handler.log')
try {
await mkdir(pluginRoot)
await writeFile(join(pluginRoot, 'orca-plugin.json'), JSON.stringify(manifest, null, 2))
await writeFile(join(pluginRoot, 'worker.mjs'), worker)
await enableDevPlugins(orcaPage, [pluginRoot])
const pluginKey = await orcaPage.evaluate(async () => {
const plugin = (await window.api.plugins.list()).find(
(entry) => entry.name === 'E2E Sidebar Replacement'
)
if (!plugin) {
throw new Error('plugin not discovered')
}
return plugin.pluginKey
})
// Starts the worker up front, so the timed cases below measure a warm handler. A dev plugin
// can still be settling its first review right after approval, so retry until it runs.
await expect
.poll(
() =>
orcaPage.evaluate(async (key) => {
const plugin = (await window.api.plugins.list()).find((p) => p.pluginKey === key)
if (plugin?.status === 'pending' && plugin.consentFingerprint) {
await window.api.plugins.consent({
pluginKey: key,
reviewedFingerprint: plugin.consentFingerprint,
decision: 'approve'
})
}
return window.api.plugins.invokeCommand({ pluginKey: key, commandId: 'toggle' }).then(
() => 'ran',
(error: unknown) => String(error)
)
}, pluginKey),
{ timeout: 30_000 }
)
.toBe('ran')
await setMode(orcaPage, pluginKey, 'logPath', logPath)
// Counts every open/close of the left sidebar the renderer actually commits.
await orcaPage.evaluate(() => {
window.__sidebarToggles = 0
window.__store?.subscribe((state, previous) => {
if (state.sidebarOpen !== previous.sidebarOpen) {
window.__sidebarToggles = (window.__sidebarToggles ?? 0) + 1
}
})
})
// The renderer learns of the replacement through its plugin list; wait until a press reaches
// the plugin (the fast handler runs the toggle itself) before measuring each mode.
await expect(async () => {
await pressToggle(electronApp)
await expect.poll(() => logLines(logPath, 'fast'), { timeout: 2_000 }).toContain('ran')
}).toPass({ timeout: 30_000 })
await rm(logPath, { force: true })
const cases: { mode: Mode; settle: number; toggles: number; log: string[] }[] = [
{ mode: 'fast', settle: 3_500, toggles: 1, log: ['start', 'ran'] },
{ mode: 'late', settle: 4_500, toggles: 1, log: ['start', 'refused'] },
{ mode: 'throw', settle: 3_500, toggles: 1, log: ['start', 'threw'] },
{ mode: 'noop', settle: 3_500, toggles: 0, log: ['start', 'done'] }
]
for (const { mode, settle, toggles, log } of cases) {
await setMode(orcaPage, pluginKey, 'mode', mode)
const before = await toggleCount(orcaPage)
const openBefore = await orcaPage.evaluate(() => window.__store?.getState().sidebarOpen)
await pressToggle(electronApp)
// Past the 2 s deadline and the renderer's 3 s safety net, so a second toggle would show:
// the assertion is that nothing more happens, which no condition can wait for.
// ast-grep-ignore: test-no-real-time-wait -- proves the absence of a late second toggle
await orcaPage.waitForTimeout(settle)
expect({ mode, toggles: (await toggleCount(orcaPage)) - before }).toEqual({ mode, toggles })
expect({ mode, log: await logLines(logPath, mode) }).toEqual({ mode, log })
const openAfter = await orcaPage.evaluate(() => window.__store?.getState().sidebarOpen)
expect(openAfter).toBe(toggles === 1 ? !openBefore : openBefore)
}
} finally {
await disablePluginSystem(orcaPage).catch(() => undefined)
await rm(tempRoot, { recursive: true, force: true })
}
})