Files
orca/src/cli/plugin-dev-watch/plugin-dev-watch-shot.test.ts
T
OrcaWin 29079b567f test: remove 151 low-value test declarations (#37870)
* test: remove 151 low-value test declarations

* test: prune obsolete wall-clock test exemptions
2026-11-03 11:07:43 -08:00

220 lines
7.2 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import type { PluginHostListEntry, PluginHostStatus } from '../../shared/plugins/plugin-list-entry'
import { RuntimeClientError } from '../runtime/types'
import { formatPluginDevWatchEvent, type PluginDevWatchEvent } from './plugin-dev-watch-events'
import { runPluginDevWatch, type PluginDevWatchHost } from './plugin-dev-watch-loop'
import {
pluginDevWatchShotPanel,
type PluginDevWatchShot,
type PluginDevWatchShotHost
} from './plugin-dev-watch-shot'
const KEY = 'acme.todos'
function entry(devChangeCount: number, status: PluginHostStatus = 'idle'): PluginHostListEntry {
return {
pluginKey: KEY,
consentFingerprint: 'sha256-x',
name: 'Todos',
version: '1.0.0',
publisher: 'acme',
status,
needsReconsent: false,
isDev: true,
devChangeCount,
official: false,
bundled: false,
capabilities: [],
panels: [
{ id: 'todos', title: 'Todos', tabKey: `plugin:${KEY}/todos` },
{ id: 'stats', title: 'Stats', tabKey: `plugin:${KEY}/stats` }
],
commands: [],
hasWorker: false,
restarts: 0
}
}
const SHOT: PluginDevWatchShot = {
panelId: 'todos',
path: '/work/shots/acme.todos-todos.png',
width: 360,
height: 700,
settled: true,
errors: []
}
type Take = PluginDevWatchShotHost['take']
function shotHost(take: Take, requested?: string) {
const open = vi.fn<PluginDevWatchShotHost['open']>(async () => {})
const host: PluginDevWatchShotHost = {
panelOf: (loaded) => pluginDevWatchShotPanel(loaded, requested),
open,
take: vi.fn(take)
}
return { host, open, take: host.take }
}
/** The load, then one status poll per entry in `saves` (each one save later), then Ctrl-C. */
async function watch(
host: Partial<Pick<PluginDevWatchHost, 'check' | 'shot' | 'devUnload'>>,
saves: PluginHostListEntry[] = [entry(1)]
): Promise<PluginDevWatchEvent[]> {
const events: PluginDevWatchEvent[] = []
const lists = [[entry(0)], ...saves.map((save) => [save])]
let interrupt = () => {}
let poll = 0
await runPluginDevWatch({
host: {
refreshTypes: async () => [],
devLoad: async () => ({
path: '/work/todos',
pluginKey: KEY,
added: true,
approval: 'approve-flag',
plugin: entry(0)
}),
list: async () => lists.shift() ?? [saves.at(-1) ?? entry(0)],
logs: async () => ({ pluginKey: KEY, lines: [] }),
approvalNeeded: async () => null,
devUnload: async () => {},
...host
},
emit: (event) => events.push(event),
wait: async () => {
poll += 1
if (poll === 2 * (saves.length + 1)) {
interrupt()
}
},
onInterrupt: (listener) => {
interrupt = listener
return () => {}
},
exitNow: () => {},
isConnectionLost: () => false
})
return events
}
const ofType = <T extends PluginDevWatchEvent['type']>(events: PluginDevWatchEvent[], type: T) =>
events.filter((event): event is Extract<PluginDevWatchEvent, { type: T }> => event.type === type)
describe('plugin dev watch --shot', () => {
it('opens the panel once, then shoots it after the load and each reload, after the check', async () => {
const order: string[] = []
const { host, open } = shotHost(async (pluginKey, panelId) => {
order.push(`shot ${pluginKey}/${panelId}`)
return SHOT
})
const events = await watch(
{
check: async () => {
order.push('check')
return { ok: true, invalid: [], panels: [], editors: [] }
},
shot: host
},
[entry(1), entry(2)]
)
expect(open).toHaveBeenCalledTimes(1)
expect(open).toHaveBeenCalledWith(KEY, 'todos')
expect(order).toEqual([
'check',
`shot ${KEY}/todos`,
'check',
`shot ${KEY}/todos`,
'check',
`shot ${KEY}/todos`
])
const shots = ofType(events, 'shot')
expect(shots).toHaveLength(3)
expect(events.at(-1)?.type).toBe('unloaded')
expect(formatPluginDevWatchEvent(shots[0]!)).toBe(
'shot /work/shots/acme.todos-todos.png (360x700)'
)
})
it('shoots --shot-panel and fails at start, unloading, on an unknown id', async () => {
const { host, open } = shotHost(async () => ({ ...SHOT, panelId: 'stats' }), 'stats')
await watch({ shot: host })
expect(open).toHaveBeenCalledWith(KEY, 'stats')
const devUnload = vi.fn(async () => {})
await expect(
watch({ shot: shotHost(async () => SHOT, 'nope').host, devUnload })
).rejects.toMatchObject({
code: 'invalid_argument',
message: '--shot-panel: acme.todos has no panel nope; its panels: todos, stats.'
})
expect(devUnload).toHaveBeenCalledTimes(1)
})
it('skips while away, says so once per time away, and resumes when back', async () => {
const outcomes: Awaited<ReturnType<Take>>[] = [
SHOT,
{ notShown: 'the right sidebar shows another tab' },
{ notShown: 'the right sidebar shows another tab' },
SHOT,
{ notShown: 'the right sidebar is closed' }
]
const { host, open } = shotHost(async () => outcomes.shift() ?? SHOT)
const events = await watch({ shot: host }, [entry(1), entry(2), entry(3), entry(4)])
expect(open).toHaveBeenCalledTimes(1)
expect(
events
.filter((event) => event.type === 'shot' || event.type === 'shot_skipped')
.map((event) => event.type)
).toEqual(['shot', 'shot_skipped', 'shot', 'shot_skipped'])
const skipped = ofType(events, 'shot_skipped')
expect(skipped[0]).toEqual({
type: 'shot_skipped',
pluginKey: KEY,
panelId: 'todos',
reason: 'the right sidebar shows another tab'
})
expect(formatPluginDevWatchEvent(skipped[1]!)).toBe(
'shot skipped: the right sidebar is closed, so todos is not on screen; open it again to resume'
)
})
it('skips a reload that left the plugin errored', async () => {
const { host, take } = shotHost(async () => SHOT)
await watch({ shot: host }, [entry(1, 'errored')])
expect(take).toHaveBeenCalledTimes(1)
})
it('turns itself off when the host can never capture', async () => {
const { host, take } = shotHost(async () => {
throw new RuntimeClientError('runtime_unsupported', 'too old')
})
host.open = async () => {
throw new RuntimeClientError('runtime_unsupported', 'too old')
}
const events = await watch({ shot: host }, [entry(1), entry(2)])
expect(take).not.toHaveBeenCalled()
expect(events).toContainEqual({
type: 'warning',
message: 'could not show todos for --shot, so --shot is off: too old'
})
})
it('keeps going after a one-off failure', async () => {
const outcomes: (() => Awaited<ReturnType<Take>>)[] = [
() => {
throw new Error('timeout')
},
() => ({ ...SHOT, settled: false, errors: ['boom'] })
]
const { host, take } = shotHost(async () => (outcomes.shift() ?? (() => SHOT))())
const events = await watch({ shot: host })
expect(take).toHaveBeenCalledTimes(2)
expect(events).toContainEqual({ type: 'warning', message: 'shot could not run: timeout' })
const last = ofType(events, 'shot').at(-1)
expect(last && formatPluginDevWatchEvent(last)).toBe(
'shot /work/shots/acme.todos-todos.png (360x700), not finished drawing\n error: boom'
)
})
})