- shot --act: say edits only dirty the tab; end with Cmd/Ctrl+S to save - manifest: compound suffixes like .sketch.json open by default - cli.md: list plugin check's exit codes, including plugin_check_failed - workflow: how to run and stop the dev app from a source checkout - plugin create --help: real descriptions for --template/--publisher/--id/--name - recipes: declaration file for vendored ESM libraries
15 KiB
Plugin dev loop, shots, tests and checks
Hand-written companion to the orca-plugin-authoring guide. ORCA is the Orca executable you
resolved; pass --json and branch on error.code.
dev watch
ORCA plugin dev watch <dir> [--check] [--shot <out-dir> [--shot-panel <id>]] [--approve <fp>] --json
loads the folder (refreshing .orca/types) and prints one JSON object per line:
loaded,unloaded,disconnected,warning.log: what panels, editors and the worker print. Console errors and uncaught exceptions read[frame] <file>:<line>:<col> <message>at your source position, with engine frames such asArray.forEach (<anonymous>)dropped. A[frame]line repeated within one reload prints once (count: copies that came together, shown×N); later copies arrive as onerepeat: trueline with theircountat the next reload or when the watch ends.reloaded(before that reload's logs) andstatus(errored/invalidwith the error).approval: an edit asked for new access; it carries thedev reload … --approvecommand to show the user. The watch keeps running and never approves.check(with--check):orca plugin checkafter the load and each reload, withpanelsandeditors(each custom editor checked without running it).okis false unless Orca also runs the plugin;checkOkis the check alone,pluginStatus/pluginErrorwhat Orca reports.shot(with--shot):{ path, width, height, settled, errors }for the panel opened once at start (the first, or--shot-panel <id>). Read the PNG.shot_skippedmeans the user switched the sidebar away: leave it, shots resume when they show the panel again.
A first load that needs consent prints one error line and exits 3; re-run with --approve
(Ctrl-C still unloads the folder the refused attempt added). Ctrl-C unloads only what this watch
loaded; a second Ctrl-C exits at once.
Off the watch: ORCA plugin logs <plugin-key> --json (the same lines), ORCA plugin restart <plugin-key> (reruns a worker's startup code), ORCA plugin dev reload <dir> (a missed save, or
re-checks changed commands and shortcuts), ORCA plugin dev unload <dir>.
Seeing and showing the panel
ORCA plugin shot <plugin-key> [--panel <id>] [--theme light|dark] [--width <px>] [--out <png>] --jsonwaits for the panel's current code to draw and, for a components panel, for its host calls (plugin.requestincluded) to answer and redraw (at most 3s more), then writes a PNG cropped to it (default.orca/shots/<plugin-key>-<panel>.pngin the plugin folder when run inside one, else in<cwd>) and returns{ path, width, height, theme, themeApplied, themeReason?, settled, errors, pendingRequests?, actions?, restored? }.pendingRequestsabove 0 means a worker had not answered, so the PNG may show the loading state. Check a narrow--widthand both themes.--themeapplies only while Orca's window is hidden or minimized; otherwisethemeAppliedis false with athemeReason, so nothing on the user's screen repaints.--act '<json>'drives a components panel before the capture, in the test kit's verbs:[{"type":{"label":"New task"},"text":"Ship","submit":true},{"press":{"text":"Add"}}, {"select":{"label":"View"},"value":"done"},{"toggle":{"label":"Hide done"}}](at most 20; queries taketext,label,testId,type,role). Also{"slide":Q,"value":7}, for a Table{"sort":Q,"column":"name"},{"selectRows":Q,"keys":["a"]},{"activateRow":Q,"key":"a"},{"loadMore":Q}, and for a Tree{"expand":Q,"key":"src"},collapse,selectItem,activateItem(samekey). Each act goes through Orca's own control, then waits for the panel to redraw and its host calls to answer;actionshas one{ ok, error? }each, the first failure skips the rest and exits 1. After the capture the panel's atoms are put back, persisted ones included (restored; false if it reloaded meanwhile), but not what handlers changed elsewhere: aplugin.requestthat wrote worker storage stays written. It needs Orca's window hidden or minimized, so it never clicks under the user, and refuses html panels. Acts are not a person's press: a Link's href stays closed andshell.openExternal/files.saveAsare refused.ORCA plugin shot <dir> --scenario <name> [--panel <id>] [--theme] [--width] [--out]shoots one scenario of the panel's<entry>.check.tsinstead of the live panel: the CLI renders it asplugin checkdoes (the panel and the worker against a stand-in host, so the scenario's data never reaches the real panel, storage, settings or worker), then Orca draws that tree in a preview apart from the plugin and captures it; the sidebar is not opened or switched. It runs the plugin's code on this machine, unsandboxed, likeplugin check. A key works only for a dev plugin, run inside its folder. The result addsreplay: trueandscenario, anderrorshas what the render reported; an unknown name fails and lists the scenarios. Limits: a still frame (host-owned control values start from the tree's props), images show their placeholder, no--act, local only (no--environment), and Orca's window must be hidden or minimized. Default file.orca/shots/<plugin-key>-<panel>.<scenario>.png.ORCA plugin open <plugin-key> [--panel <id> | --editor <file>]shows the panel in the right sidebar, or a file in the plugin's custom editor. Neither raises the window.--editoropens the file in the workspace that holds it (its real path counts:/tmpis/private/tmp), or else opens the file's folder as a folder workspace first.
Seeing and driving a custom editor
The editor loop: edit the editor's code (a dev watch reloads it), then
ORCA plugin shot <dir> --editor <file> [--theme light|dark] [--width <px>] [--height <px>] [--act '<json>'] --json, then read the PNG. It opens the file like plugin open --editor, loads
the editor afresh, waits for it to get its document and ask for it (orca.document.get or
onDidChange) and paint, then captures exactly the editor with the window hidden. --act is real
input in the editor's own CSS pixels: [{"click":[200,150]},{"drag":[[200,150],[400,300]]}, {"key":"r"}] (key takes a name such as Enter and optional "modifiers":["meta"]), each
painted before the next; it needs the window hidden or minimized. A shot never changes your file:
--act runs on a copy in a temporary folder workspace that is removed afterwards (--in-place
acts on the file itself and keeps its tab open), and a shot without acts closes the tab it
opened. Edits only mark the tab dirty, as typing does: to write them to disk, end an --in-place
--act with {"key":"s","modifiers":["meta"]} ("control" on Linux and Windows). Before each key Orca focuses the editor's guest, so a canvas keeps getting keys. It exits 1 and lists errors when the editor threw, was blocked by its
content policy, or never asked for its document within 5s; the PNG is written anyway. The theme
and size are put back. Default file .orca/shots/<plugin-key>-<editor>.png.
ORCA plugin shot <dir> --status-bar [--theme light|dark] captures Orca's status bar, where your
status bar items draw, and --window the whole window, both from the hidden window. Default files
.orca/shots/<plugin-key>-status-bar.png and -window.png.
- Both need the desktop app with a workspace. With none showing, a shot selects one of this
machine's workspaces in the hidden window. A shot never changes the view: it fails with
no_workspacewhile Orca shows Settings or another full page. Without them, useplugin check. - From an Orca source checkout,
ORCAisnode config/scripts/orca-dev.mjsafterpnpm run build:cliandpnpm run build:electron-vite;ORCA openstarts that checkout's dev app (ORCA_BACKGROUND_LAUNCH=1keeps its window hidden). The CLI has no quit command: stop a hidden dev app by ending the Electron process whose arguments name the checkout.
Events
ORCA plugin dev emit <dir|plugin-key> <event> --data '<json>' fires a test event under the real
rules (approved dev plugin, the event's capability, payload checked against its schema). The
worker hears it when it lists the event in contributes.events; each open panel hears it once it
called orca.on(event, …). Nothing listening is refused. Handler errors arrive as log lines.
ORCA plugin dev request <dir|plugin-key> <name> --data '<json>' calls the worker's
orca.panels.handle(name, …) handler the way a panel's plugin.request does (approved dev plugin
with main; the worker starts if needed) and prints { pluginKey, name, result }. A failure is
plugin_request_failed with the worker's code in error.data.requestCode (unknown_method,
timeout, ...). --panel <id> sets the caller panel id; --timeout-ms the handler's deadline.
test and check
Both run the plugin's code on this machine, outside Orca's sandbox: only on code you trust.
ORCA plugin test [dir] --jsonruns every*.test.ts(x)(also.mts,.js(x),.mjs), skippingnode_modules,.orca,distand dot-folders, withnode:testor the plugin's own vitest. Stdout is{ ok, result: { runner, code, node: 'system' | 'orca', files } }; the exit code is the runner's (plugin_tests_failed).ORCA plugin check [dir] --jsonvalidates, then renders each components panel once against a stand-in host with the manifest's capabilities: what it threw (file:line:col), layouts Orca would refuse, props it would drop, and host calls Orca would refuse (host refused …warnings, even when the panel caught the error). With a worker (main), it mounts the worker on the same host first, so the panel'splugin.requestreaches itsorca.panels.handlehandlers; a worker that fails to activate is an issue namedworker (<file>): …. Each result carries asummaryof what drew (headings, callouts, empty states, table sizes, the selected tab) and, with--json, the wholetree: read them to confirm each scenario shows what it should. Exit0,1plugin_check_failed,2invalid.
The full kit API, generated from this Orca, is in references/testing.md.
Check fixtures
Check renders against an empty host (no agents, no storage) unless the panel has scenarios. Put
them beside its entry (src/panel.check.ts for src/panel.tsx); check renders the panel once per
scenario, each on a fresh host with the worker, and labels each result with scenario:
import { defineCheck } from 'orca/testing'
export default defineCheck({
empty: {},
busy: {
results: {
'agents.list': {
agents: [
{
terminalId: 't1',
title: 'Claude',
agent: 'claude',
state: 'working',
stateStartedAt: 0
}
]
}
},
settings: { notifyOnBlocked: true },
events: [
{
event: 'agent.status.changed',
payload: {
workspaceId: null,
paneKey: 'p',
terminalId: 't1',
state: 'blocked',
receivedAt: 0
}
}
]
}
})
- A scenario takes
mockHost()options (results,requests,storage,settings,secrets,capabilities), pluspersistedandthemeasmount()takes them, andeventsdelivered in order after the first draw.requestsstubs a name; any other goes to the worker. - Add one per state worth seeing: empty, busy, an error answer (
hostError(code)inresults). - Orca itself never loads
*.check.ts; it may importorca/testing.
Panels with orca/testing
Put tests next to the panel (src/panel.test.tsx; the components template writes one):
import { afterEach, assert, mount, test, type TestPanel } from 'orca/testing'
import * as panelModule from './panel'
let panel: TestPanel | undefined
afterEach(() => panel?.unmount())
test('adds a task', async () => {
panel = await mount(panelModule, { persisted: { plugin: { todos: [] } } })
await panel.type({ label: 'New task' }, 'Ship it', { submit: true })
assert.ok(panel.find({ role: 'button', text: /Ship it/ }))
assert.equal(panel.valueOf({ label: 'New task' }), '')
assert.deepEqual(panel.errors, [])
})
mount(module, { host, theme, locale, visible, panelId, persisted })runssetup, renders and resolves once drawn; every mount resets every atom; one panel at a time.stored(value, version)seeds what an older version wrote, to testmigrate.find(innermost match ornull) andfindAlltaketype,role,text,labelortestId(the element'skey);textandlabelmatch exactly, so use a RegExp for part.press,type(el, text, { replace?, submit? }),submit,toggle,select(el, value)resolve once renders, host calls and persisted writes settle:awaitthem, never sleep.valueOf,tree(),errors,warnings,persistedinspect.mockHost({ capabilities, results, requests, storage, settings, secrets })(pass ashost) refuses what Orca refuses: undeclared capabilities, bad params, andshell.openExternalorfiles.saveAswithout apress.resultsmay throwhostError(code);callsOf(method)andhost.data()read back calls and data.requestsfakes the worker forplugin.request;panel.post(event, payload)is a worker post,panel.emitan Orca event.panel.refusals(andhost.refusals) lists calls Orca would refuse, with theircode, even ones the code caught: assert it is[].- Mount the module, never call its default export. A control with
valuebut noonChangeis read-only, sotype/togglethrow on it.
Workers with orca/testing
mountWorker(main, { host, events, commands }) calls the module's default-exported
activate(orca) with Orca's worker orca backed by mockHost, loading .ts mains as Orca does.
import { assert, mountWorker, test } from 'orca/testing'
import * as main from './worker'
test('answers the panel and notifies on blocked agents', async () => {
const worker = await mountWorker(main)
assert.deepEqual(await worker.request('todos.list'), [])
await worker.emit('agent.status.changed', {
workspaceId: null,
paneKey: 'tab-1:pane-1',
state: 'blocked',
receivedAt: 1
})
assert.equal(worker.host.callsOf('notifications.show').length, 1)
assert.deepEqual(worker.refusals, [])
await worker.deactivate()
})
- Its
activate(orca)must be the module's default export. Events, commands and capabilities default to the manifest's (mountWorker(main, { host, events, commands })overrides). emitdelivers only eventscontributes.eventslists, checks the payload and waits for every handler.request(name, input, { panel })is a panel'splugin.requestanswered byorca.panels.handle;postsrecordsorca.panels.post;command(id, args)runs a registered command;logsholdsorca.loglines;refusalsthe host calls Orca would refuse (assert[]);deactivate()runs the module'sdeactivate.- Seed storage, settings and secrets with
mockHost({ storage, settings, secrets })as{ host }; a panel mounted on the same host sees the same data.