Plugin shots wait for the worker and restore after --act; terminal approval prompt (#37066)

* feat(plugins): dev watch asks for first-load approval in a terminal; acronym-aware names

- In an interactive terminal without --json, a first dev watch load that needs approval shows
  the grants (as plugin inspect prints them) and asks Approve and load? [y/N]; yes reloads with
  the host's fingerprint and keeps watching. --json and non-TTY runs keep the exit-3 flow.
- create keeps known acronyms uppercase when it derives a name from the id (pr-queue → PR Queue).

* fix(plugins): a declined dev watch approval prints only the re-run command

The person just read the grants in the prompt, so the exit-3 error no longer repeats them:
Not approved. To approve later, run: <command>.

* feat(plugins): shots wait for the worker; --act restores the panel; Table and Tree acts

- A shot settles once the panel has no plugin.request or host call in flight and no new tree for
  a quiet spell (capped at 3 s), so a cold worker's skeleton is not captured; pendingRequests
  reports what was still pending.
- After --act and the capture, the guest restores every plugin-written atom it had before (new
  ui.snapshot/ui.restore; persisted atoms re-persist) and Orca remounts its drawn controls;
  restored: true|false. Worker-side effects are not rolled back.
- --act gains the kit's slide, sort, selectRows, activateRow, loadMore, expand, collapse,
  selectItem and activateItem.
- Docs: the dev watch first-load prompt; shot settle, restore and the new verbs.

* fix(plugins): a shot waits for the theme repaint and retries a bare capture

Linux CI captured a dark --theme shot as a bare background (2 colors). The renderer now waits
until the document shows the asked color scheme and paints, adds a frame after a --width
resize, and main recaptures a panel image that is still a background and a border (two colors
or fewer) up to 8 times, 150 ms apart, before returning it. Html panels keep their guest
composite.

* fix(plugins): capture the main window with stayHidden

webContents.capturePage(rect, { stayHidden: true }) keeps a hidden window rendering for the
capture, as the components gallery screenshots do. The repaint wait stays, and the bare-capture
retry stays as a fallback.
This commit is contained in:
Neil
2026-11-01 09:32:38 -08:00
committed by GitHub
parent 64829c264d
commit dfbaa2fc29
52 changed files with 2081 additions and 412 deletions
+24 -10
View File
@@ -359,9 +359,13 @@ to exit at once. With `--json` it prints one JSON object per line, with a `type`
prints what each components panel drew or threw, with `file:line:col`; it reports a failure while
Orca does not run the plugin (pending approval, errored, invalid), even when the panels render
(`ok` false, `checkOk` true, and `pluginStatus`). Like `orca plugin check`,
it runs your code on this machine outside Orca's sandbox, and it needs a local folder. The first load needs approval like `orca plugin dev load` (it exits with code 3 and
prints the command to re-run with `--approve`; with `--json`, as one line). The re-run owns the
folder the refused attempt added, so its Ctrl-C unloads it.
it runs your code on this machine outside Orca's sandbox, and it needs a local folder. When the first load needs approval and you run it in a terminal, it prints what the plugin
would be granted (execution, each capability, what it adds) and asks `Approve and load? [y/N]`;
`y` loads it with that approval and keeps watching. Any other answer, Ctrl-C, `--json`, or
output that is not a terminal (a coding agent, a pipe) fails like `orca plugin dev load`: it
exits with code 3 and prints the command to re-run with `--approve` (with `--json`, as one line).
It never approves on its own. The re-run owns the folder the refused attempt added, so its
Ctrl-C unloads it.
### See the panel
@@ -370,23 +374,33 @@ panel, to `.orca/shots/<plugin-key>-<panel>.png` (or `--out <file>`): in the plu
you run it inside one (the nearest `orca-plugin.json`, up to the repository root), else in the
current folder. It opens the panel in the
right sidebar, waits until the current code has drawn (a components panel's first tree, an HTML
panel's page loaded; up to 15 seconds), and captures it even while Orca's window is hidden or
panel's page loaded; up to 15 seconds) and, for a components panel, until its host calls such as
`plugin.request` have answered and redrawn (at most 3 seconds more), and captures it even while Orca's window is hidden or
behind others, without showing, raising or focusing it. `--width <px>` sizes the panel for that
capture only. `--theme light|dark` paints Orca in that mode for that capture only, and only while
its window is hidden or minimized: with the window on screen, the shot keeps the current theme
rather than repaint the app in front of you, and says so (`themeApplied: false` and a
`themeReason`). Your width and theme setting are always put back. `--json` prints `{ path, width,
height, theme, themeApplied, themeReason?, settled, errors }`, where `errors` holds the error
banner the panel shows and the errors it logged during the capture. A coding agent reads the
height, theme, themeApplied, themeReason?, settled, errors, pendingRequests?, actions?,
restored? }`, where `errors` holds the error banner the panel shows and the errors it logged
during the capture, and `pendingRequests` counts host calls still unanswered (above 0, the image
may show the panel still loading). A coding agent reads the
image to see what it built.
`--act '<json>'` drives a components panel before the capture, with the test kit's verbs: a list
(at most 20) such as `[{"type":{"label":"New task"},"text":"Ship","submit":true},
{"press":{"text":"Add"}},{"select":{"label":"View"},"value":"done"},{"toggle":{"label":"Hide
done"}}]`, whose queries match like the kit's (`text`, `label`, `testId`, `type`, `role`). Each
act goes through Orca's own control, as your typing or click would, and waits for the panel to
redraw. `actions` in the result has one `{ ok, error? }` per act; the first failure skips the
rest, the PNG shows the panel at that point, and the command exits 1. It acts only on that
done"}}]`, whose queries match like the kit's (`text`, `label`, `testId`, `type`, `role`). The
kit's Slider, Table and Tree verbs work too: `{"slide":Q,"value":7}`, `{"sort":Q,"column":"name"}`,
`{"selectRows":Q,"keys":["a"]}`, `{"activateRow":Q,"key":"a"}`, `{"loadMore":Q}`, and
`expand`, `collapse`, `selectItem`, `activateItem` with a `key`. Each act goes through Orca's own
control, as your typing or click would, and waits for the panel to redraw and its host calls to
answer. `actions` in the result has one `{ ok, error? }` per act; the first failure skips the
rest, the PNG shows the panel at that point, and the command exits 1. Once captured, Orca puts
the panel's atoms back, persisted ones included, so later shots and you see the panel as it was
(`restored: true`; false when it reloaded during the acts). What handlers did outside the panel
is not rolled back: a press whose handler sends `plugin.request('counter.add')` leaves the
worker's storage changed. It acts only on that
plugin's panel and only while Orca's window is hidden or minimized, so it never clicks under you;
html panels refuse it. An act is not a person's press: a Link's href stays unopened and calls
that need a gesture (`shell.openExternal`, `files.saveAs`) are refused.
+58 -22
View File
@@ -257,6 +257,17 @@ function applyStorageValue(key, value) {
assign(inner, value === void 0 ? inner.initial : value, false);
}
}
function captureAtomValues() {
const values = /* @__PURE__ */ new Map();
for (const inner of writableAtoms) {
values.set(inner, inner.value);
}
return () => {
for (const [inner, value] of values) {
assign(inner, value, true);
}
};
}
function hydrateAtoms(scope, values) {
snapshot[scope] = new Map(values);
for (const target of keyed.values()) {
@@ -359,7 +370,11 @@ var PLUGIN_DECLARATIVE_MESSAGES = {
/** guest → host, `{ entries: [{ scope, key, version, value }] }`: persisted atoms changed. */
persist: "ui.persist",
/** guest → host, `{ document, entries: [{ key, version, value? }] }`: keyed memory atoms changed. */
retain: "ui.retain"
retain: "ui.retain",
/** host → guest, `{ document }`: remember every atom's value before `orca plugin shot --act`. */
snapshot: "ui.snapshot",
/** host → guest, `{ document }`: put the atoms back to the last `ui.snapshot`. */
restore: "ui.restore"
};
var PLUGIN_DECLARATIVE_CATALOG_VERSION = 3;
var PLUGIN_DECLARATIVE_COMPONENT_PROPS = {
@@ -755,27 +770,6 @@ function createMemoryRetention(post) {
};
}
// src/shared/plugins/declarative/runtime/declarative-renderer-support.ts
var MIN_RENDER_INTERVAL_MS = 16;
var MAX_RETAINED_RENDERS = 32;
var HYDRATION_TIMEOUT_MS = 3e3;
function errorMessage(error) {
return describePluginError(error).slice(0, 4096);
}
function defaultSchedule(flush, delayMs) {
if (delayMs > 0) {
setTimeout(flush, delayMs);
} else {
queueMicrotask(flush);
}
}
function newDocumentId(now) {
return `${now.toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
function isCallable(value) {
return typeof value === "function";
}
// src/shared/plugins/declarative/runtime/declarative-host-messages.ts
function readHostMessage(payload) {
if (typeof payload !== "object" || payload === null) {
@@ -816,6 +810,47 @@ function readStoredValues(raw) {
return values;
}
// src/shared/plugins/declarative/runtime/declarative-act-snapshot.ts
function actSnapshotSubscriptions(on2, document2) {
let restore = null;
const ours = (payload) => readHostMessage(payload).document === document2;
return [
on2(PLUGIN_DECLARATIVE_MESSAGES.snapshot, (payload) => {
if (ours(payload)) {
restore = captureAtomValues();
}
}),
on2(PLUGIN_DECLARATIVE_MESSAGES.restore, (payload) => {
if (ours(payload) && restore) {
const pending = restore;
restore = null;
pending();
}
})
];
}
// src/shared/plugins/declarative/runtime/declarative-renderer-support.ts
var MIN_RENDER_INTERVAL_MS = 16;
var MAX_RETAINED_RENDERS = 32;
var HYDRATION_TIMEOUT_MS = 3e3;
function errorMessage(error) {
return describePluginError(error).slice(0, 4096);
}
function defaultSchedule(flush, delayMs) {
if (delayMs > 0) {
setTimeout(flush, delayMs);
} else {
queueMicrotask(flush);
}
}
function newDocumentId(now) {
return `${now.toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
function isCallable(value) {
return typeof value === "function";
}
// src/shared/plugins/declarative/runtime/declarative-panel-context.ts
function createPanelContext(onRetain) {
const visibility = hostAtom(true);
@@ -1121,6 +1156,7 @@ function createOrcaDeclarativeRuntime(options) {
bridge.on(PLUGIN_DECLARATIVE_MESSAGES.ack, acknowledge),
bridge.on(PLUGIN_DECLARATIVE_MESSAGES.hydrate, panel.hydrate),
bridge.on(PLUGIN_DECLARATIVE_MESSAGES.visibility, panel.setVisibility),
...actSnapshotSubscriptions(bridge.on.bind(bridge), document2),
bridge.on("theme", (payload) => {
const { colorScheme } = readHostMessage(payload);
if ((colorScheme === "light" || colorScheme === "dark") && read(theme.atom).colorScheme !== colorScheme) {
File diff suppressed because one or more lines are too long
+7 -3
View File
@@ -66,10 +66,14 @@ Exit codes: `0` ok, `1` error, `2` `plugin_invalid`, `3` `approval_required` or
draws it (default `.orca/shots/<plugin-key>-<panel>.png`, in the plugin folder when run inside
one; `--width` applies to that capture only, and `--theme` too but only while Orca's window is
hidden or minimized, else `themeApplied: false` with a `themeReason`) and lists the errors the
panel showed or logged; read the image file. `--act '[{"type":{"label":"New task"},"text":"Ship"},{"press":{"text":"Add"}}]'`
panel showed or logged; read the image file. A components panel's shot also waits for its
host calls (`plugin.request`) to answer, at most 3s; `pendingRequests` counts any still out.
`--act '[{"type":{"label":"New task"},"text":"Ship"},{"press":{"text":"Add"}}]'`
first drives a components panel with the test kit's verbs (`press`, `type`, `select`,
`toggle`; at most 20) and reports `actions: [{ ok, error? }]`; it needs Orca's window hidden
or minimized and never counts as a user gesture. `plugin dev watch <dir> --shot <out-dir>` opens one panel once (the
`toggle`, `slide`, Table `sort`/`selectRows`/`activateRow`/`loadMore`, Tree
`expand`/`collapse`/`selectItem`/`activateItem`; at most 20) and reports
`actions: [{ ok, error? }]`; it needs Orca's window hidden or minimized and never counts as a
user gesture. Afterwards the panel's atoms are put back (`restored`); worker-side effects stay. `plugin dev watch <dir> --shot <out-dir>` opens one panel once (the
first, or `--shot-panel <id>`) and captures it after every reload while it stays on screen;
when the user shows something else it skips (`shot_skipped`) and never switches back. `plugin open
<plugin-key>` shows a panel to the user (`--editor <file>` opens a file in its custom editor).
@@ -35,18 +35,26 @@ 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>] --json`
waits for the panel's current code to draw, then writes a PNG cropped to it (default
`.orca/shots/<plugin-key>-<panel>.png` in the plugin folder when run inside one, else in
`<cwd>`) and returns `{ path, width, height, theme, themeApplied, themeReason?, settled, errors,
actions? }`. Check a narrow `--width` and both themes. `--theme` applies only while Orca's
waits for the panel's current code to draw and, for a components panel, for its host calls
(`plugin.request` included) to answer and redraw (at most 3s more), then writes a PNG cropped to
it (default `.orca/shots/<plugin-key>-<panel>.png` in the plugin folder when run inside one, else
in `<cwd>`) and returns `{ path, width, height, theme, themeApplied, themeReason?, settled,
errors, pendingRequests?, actions?, restored? }`. `pendingRequests` above 0 means a worker had
not answered, so the PNG may show the loading state. Check a narrow `--width` and both themes. `--theme` applies only while Orca's
window is hidden or minimized; otherwise `themeApplied` is false with a `themeReason`, 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 take `text`, `label`, `testId`, `type`, `role`). Each act goes through Orca's own
control, then waits for the panel to redraw; `actions` has one `{ ok, error? }` each, the first
failure skips the rest and exits 1. It needs Orca's window hidden or minimized, so it never
queries take `text`, `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` (same `key`). Each act goes through Orca's own control,
then waits for the panel to redraw and its host calls to answer; `actions` has 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: a `plugin.request` that 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 and `shell.openExternal` / `files.saveAs` are refused.
- `ORCA plugin open <plugin-key> [--panel <id> | --editor <file>]` shows the panel in the right
+22 -10
View File
@@ -984,10 +984,14 @@ const ORCA_CLI_PLUGINS_REFERENCE_MARKDOWN = [
" draws it (default `.orca/shots/<plugin-key>-<panel>.png`, in the plugin folder when run inside",
" one; `--width` applies to that capture only, and `--theme` too but only while Orca's window is",
" hidden or minimized, else `themeApplied: false` with a `themeReason`) and lists the errors the",
" panel showed or logged; read the image file. `--act '[{\"type\":{\"label\":\"New task\"},\"text\":\"Ship\"},{\"press\":{\"text\":\"Add\"}}]'`",
" panel showed or logged; read the image file. A components panel's shot also waits for its",
" host calls (`plugin.request`) to answer, at most 3s; `pendingRequests` counts any still out.",
" `--act '[{\"type\":{\"label\":\"New task\"},\"text\":\"Ship\"},{\"press\":{\"text\":\"Add\"}}]'`",
" first drives a components panel with the test kit's verbs (`press`, `type`, `select`,",
" `toggle`; at most 20) and reports `actions: [{ ok, error? }]`; it needs Orca's window hidden",
" or minimized and never counts as a user gesture. `plugin dev watch <dir> --shot <out-dir>` opens one panel once (the",
" `toggle`, `slide`, Table `sort`/`selectRows`/`activateRow`/`loadMore`, Tree",
" `expand`/`collapse`/`selectItem`/`activateItem`; at most 20) and reports",
" `actions: [{ ok, error? }]`; it needs Orca's window hidden or minimized and never counts as a",
" user gesture. Afterwards the panel's atoms are put back (`restored`); worker-side effects stay. `plugin dev watch <dir> --shot <out-dir>` opens one panel once (the",
" first, or `--shot-panel <id>`) and captures it after every reload while it stays on screen;",
" when the user shows something else it skips (`shot_skipped`) and never switches back. `plugin open",
" <plugin-key>` shows a panel to the user (`--editor <file>` opens a file in its custom editor).",
@@ -3316,18 +3320,26 @@ const ORCA_PLUGIN_AUTHORING_WORKFLOW_REFERENCE_MARKDOWN = [
"## Seeing and showing the panel",
"",
"- `ORCA plugin shot <plugin-key> [--panel <id>] [--theme light|dark] [--width <px>] [--out <png>] --json`",
" waits for the panel's current code to draw, then writes a PNG cropped to it (default",
" `.orca/shots/<plugin-key>-<panel>.png` in the plugin folder when run inside one, else in",
" `<cwd>`) and returns `{ path, width, height, theme, themeApplied, themeReason?, settled, errors,",
" actions? }`. Check a narrow `--width` and both themes. `--theme` applies only while Orca's",
" waits for the panel's current code to draw and, for a components panel, for its host calls",
" (`plugin.request` included) to answer and redraw (at most 3s more), then writes a PNG cropped to",
" it (default `.orca/shots/<plugin-key>-<panel>.png` in the plugin folder when run inside one, else",
" in `<cwd>`) and returns `{ path, width, height, theme, themeApplied, themeReason?, settled,",
" errors, pendingRequests?, actions?, restored? }`. `pendingRequests` above 0 means a worker had",
" not answered, so the PNG may show the loading state. Check a narrow `--width` and both themes. `--theme` applies only while Orca's",
" window is hidden or minimized; otherwise `themeApplied` is false with a `themeReason`, 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 take `text`, `label`, `testId`, `type`, `role`). Each act goes through Orca's own",
" control, then waits for the panel to redraw; `actions` has one `{ ok, error? }` each, the first",
" failure skips the rest and exits 1. It needs Orca's window hidden or minimized, so it never",
" queries take `text`, `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` (same `key`). Each act goes through Orca's own control,",
" then waits for the panel to redraw and its host calls to answer; `actions` has 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: a `plugin.request` that 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 and `shell.openExternal` / `files.saveAs` are refused.",
"- `ORCA plugin open <plugin-key> [--panel <id> | --editor <file>]` shows the panel in the right",
+2 -4
View File
@@ -24,6 +24,7 @@ import { findPluginTestFiles, runPluginTests } from '../plugin-authoring/plugin-
import { checkPluginPanels, formatPluginPanelCheck } from '../plugin-authoring/plugin-panel-check'
import { RuntimeClientError } from '../runtime/types'
import { PLUGIN_INVALID_CODE } from '../../shared/plugins/approval/plugin-cli-approval'
import { formatPluginGrantLines } from '../plugin-authoring/plugin-grant-lines'
function optionalString(flags: Map<string, string | boolean>, name: string): string | undefined {
const value = flags.get(name)
@@ -36,10 +37,7 @@ function formatGrants(report: PluginValidationReport): string[] {
return []
}
return [
`${plugin.pluginKey} ${plugin.version} (${plugin.grants.execution})`,
` ${plugin.grants.executionDescription}`,
...plugin.grants.capabilities.map((capability) => ` - ${capability.description}`),
...plugin.grants.contributions.map((item) => ` adds ${item.kind}: ${item.name}`),
...formatPluginGrantLines(plugin),
plugin.needsApproval
? ' Loading or enabling it needs --approve <fingerprint>; Orca prints the fingerprint.'
: ' Content only: loads without approval.'
+6 -3
View File
@@ -17,6 +17,7 @@ import { writeStdoutLine } from '../stdout-line'
import type { PluginHostListEntry } from '../../shared/plugins/plugin-list-entry'
import { CLI_BOOLEAN_FLAGS } from '../../shared/cli-argument-boundary'
import { checkPluginDevFolder } from '../plugin-dev-watch/plugin-dev-watch-check'
import { pluginDevWatchConfirmFor } from '../plugin-dev-watch/plugin-dev-watch-approval'
import {
pluginDevWatchShotPanel,
type PluginDevWatchShotHost
@@ -133,10 +134,11 @@ export function bindPluginDevWatchHost(ctx: HandlerContext, path: string): Plugi
...(check ? { check: () => checkPluginDevFolder(path) } : {}),
...(shotDir ? { shot: bindShotHost(ctx, shotDir, shotPanel) } : {}),
refreshTypes: () => refreshDevTypes(ctx, path),
devLoad: () =>
devLoad: (approvalFingerprint) =>
callPlugins<DevLoadResult>(ctx, 'plugins.devLoad', {
path,
approvalFingerprint: typeof approve === 'string' ? approve : undefined
approvalFingerprint:
approvalFingerprint ?? (typeof approve === 'string' ? approve : undefined)
}),
list: () => pollHost<PluginHostListEntry[]>(ctx, 'plugins.list'),
logs: (pluginKey, after) =>
@@ -170,7 +172,8 @@ export const PLUGIN_DEV_WATCH_HANDLERS: Record<string, CommandHandler> = {
wait: waitUnlessAborted,
onInterrupt: onPluginDevWatchInterrupt,
exitNow: (code) => process.exit(code),
isConnectionLost: (error) => error instanceof PluginHostUnreachableError
isConnectionLost: (error) => error instanceof PluginHostUnreachableError,
confirm: pluginDevWatchConfirmFor(ctx.json)
})
}
}
+31
View File
@@ -277,4 +277,35 @@ describe('orca plugin shot', () => {
)
).rejects.toMatchObject({ code: 'runtime_unsupported' })
})
it('passes pendingRequests and restored on, and notes them in text', async () => {
const call = vi
.fn()
.mockResolvedValue(
shotReply({ pendingRequests: 2, actions: [{ ok: true }], restored: false })
)
await PLUGIN_VIEW_HANDLERS['plugin shot'](
context(['plugin', 'shot', 'acme.todos', '--act', '{"press":{"text":"Add"}}'], call, { cwd })
)
expect(JSON.parse(stdout.join('')).result).toMatchObject({
pendingRequests: 2,
actions: [{ ok: true }],
restored: false
})
const text = formatPluginShot({
path: '/w/a.png',
pluginKey: 'acme.todos',
panelId: 'todos',
width: 300,
height: 600,
theme: 'dark',
themeApplied: false,
settled: true,
errors: [],
pendingRequests: 2,
restored: false
})
expect(text).toContain('2 host call(s) (plugin.request included) were unanswered after 3s')
expect(text).toContain('its acted state was not put back')
})
})
+17 -1
View File
@@ -31,8 +31,12 @@ export type PluginShotReply = {
themeReason?: string
settled: boolean
errors: string[]
/** Host calls still unanswered at the capture (components panels); absent on older hosts. */
pendingRequests?: number
/** Present when `acts` were sent; absent from hosts that predate --act. */
actions?: PluginShotActResult[]
/** With acts: the panel's atoms were put back after the capture; absent on older hosts. */
restored?: boolean
}
/** `ifShown` and the panel was not on screen: the host opened, switched and captured nothing. */
@@ -49,7 +53,9 @@ export type PluginShotSaved = {
themeReason?: string
settled: boolean
errors: string[]
pendingRequests?: number
actions?: PluginShotActResult[]
restored?: boolean
}
type PluginOpenReply =
@@ -139,7 +145,9 @@ async function shoot(
...(reply.themeReason ? { themeReason: reply.themeReason } : {}),
settled: reply.settled,
errors: reply.errors,
...(reply.actions ? { actions: reply.actions } : {})
...(reply.pendingRequests === undefined ? {} : { pendingRequests: reply.pendingRequests }),
...(reply.actions ? { actions: reply.actions } : {}),
...(reply.restored === undefined ? {} : { restored: reply.restored })
}
}
@@ -174,9 +182,17 @@ export function formatPluginShot(shot: PluginShotSaved, requestedTheme?: string)
...(shot.settled
? []
: [' note: the panel had not finished drawing; this is what it showed.']),
...(shot.pendingRequests
? [
` note: ${shot.pendingRequests} host call(s) (plugin.request included) were unanswered after 3s; the panel may still show its loading state.`
]
: []),
...(shot.actions ?? []).map((action, index) =>
action.ok ? ` act ${index + 1}: ok` : ` act ${index + 1} failed: ${action.error}`
),
...(shot.restored === false
? [' note: the panel reloaded during --act, so its acted state was not put back.']
: []),
...shot.errors.map((error) => ` error: ${error}`)
].join('\n')
}
+2 -9
View File
@@ -2,6 +2,7 @@ import { existsSync, readdirSync, statSync } from 'node:fs'
import { mkdir, writeFile } from 'node:fs/promises'
import { basename, dirname, join, resolve } from 'node:path'
import { parsePluginManifest } from '../../shared/plugins/manifest/plugin-manifest'
import { pluginDisplayNameFromId } from './plugin-display-name'
import { PLUGIN_TEMPLATE_NAMES, type PluginTemplateName } from './plugin-template-names'
import { pluginTemplateKey } from './plugin-template-files'
import { renderPluginTemplate } from './plugin-templates'
@@ -26,14 +27,6 @@ export function pluginIdFromFolder(folder: string): string {
.replace(/^-+|-+$/g, '')
}
function titleFromId(id: string): string {
return id
.split('-')
.filter(Boolean)
.map((word) => word[0].toUpperCase() + word.slice(1))
.join(' ')
}
function assertEmptyFolder(dir: string): void {
if (!existsSync(dir)) {
return
@@ -58,7 +51,7 @@ export async function createPluginFromTemplate(input: {
}): Promise<PluginCreateResult> {
const dir = resolve(input.dir)
const id = input.id ?? pluginIdFromFolder(dir)
const name = input.name ?? (titleFromId(id) || id)
const name = input.name ?? (pluginDisplayNameFromId(id) || id)
const templateInput = { publisher: input.publisher, id, name }
const pluginKey = pluginTemplateKey(templateInput)
const template = renderPluginTemplate(input.template, templateInput)
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { pluginDisplayNameFromId } from './plugin-display-name'
describe('pluginDisplayNameFromId', () => {
it.each([
['pr-queue', 'PR Queue'],
['api-explorer', 'API Explorer'],
['ci-status', 'CI Status'],
['github-pr-ui', 'Github PR UI'],
['k8s-pods', 'K8s Pods'],
['notes', 'Notes'],
['prism', 'Prism'],
['idle', 'Idle'],
['url-shortener', 'URL Shortener'],
['apis', 'Apis'],
['my--tool-', 'My Tool'],
['2fa-codes', '2fa Codes']
])('%s → %s', (id, name) => {
expect(pluginDisplayNameFromId(id)).toBe(name)
})
})
@@ -0,0 +1,15 @@
// Why a short list: only words that read wrong title-cased ("Pr Queue"); a word must match whole.
const ACRONYMS =
'AI API AWS CD CI CLI CPU CSS CSV DNS GCP GPU HTML HTTP HTTPS ID IO JS JSON K8s LLM MCP OS PDF PR SDK SQL SSH TS TSX UI URI URL VM XML YAML'.split(
' '
)
const SPELLING = new Map(ACRONYMS.map((word) => [word.toLowerCase(), word]))
/** `pr-queue` → "PR Queue": dash-separated words title-cased, known acronyms kept as written. */
export function pluginDisplayNameFromId(id: string): string {
return id
.split('-')
.filter(Boolean)
.map((word) => SPELLING.get(word.toLowerCase()) ?? word[0].toUpperCase() + word.slice(1))
.join(' ')
}
@@ -0,0 +1,20 @@
/** The displayed part of `PluginApprovalGrants`; also what a host's `approval_required` data carries. */
export type PluginGrantSummary = {
execution: string
executionDescription: string
capabilities: { description: string }[]
contributions: { kind: string; name: string }[]
}
type GrantSubject = { pluginKey: string; version: string; grants: PluginGrantSummary }
/** What a plugin would be granted, as `orca plugin inspect` and the dev watch prompt print it. */
export function formatPluginGrantLines(subject: GrantSubject): string[] {
const { grants } = subject
return [
`${subject.pluginKey} ${subject.version} (${grants.execution})`,
` ${grants.executionDescription}`,
...grants.capabilities.map((capability) => ` - ${capability.description}`),
...grants.contributions.map((item) => ` adds ${item.kind}: ${item.name}`)
]
}
@@ -0,0 +1,183 @@
import { PassThrough } from 'node:stream'
import { describe, expect, it, vi } from 'vitest'
import type { PluginHostListEntry } from '../../shared/plugins/plugin-list-entry'
import { RuntimeClientError } from '../runtime/types'
import {
createTerminalConfirm,
pluginDevWatchConfirmFor,
type PluginDevWatchConfirm
} from './plugin-dev-watch-approval'
import type { PluginDevWatchEvent } from './plugin-dev-watch-events'
import { runPluginDevWatch, type PluginDevWatchHost } from './plugin-dev-watch-loop'
const KEY = 'acme.notes'
const PATH = '/work/notes'
const FINGERPRINT = 'ap1-0123456789abcdef'
const COMMAND = `orca plugin dev watch ${PATH} --approve ${FINGERPRINT}`
const plugin: PluginHostListEntry = {
pluginKey: KEY,
consentFingerprint: 'sha256-x',
name: 'Notes',
version: '1.0.0',
publisher: 'acme',
status: 'running',
needsReconsent: false,
isDev: true,
devChangeCount: 0,
official: false,
bundled: false,
capabilities: [],
panels: [],
commands: [],
hasWorker: true,
restarts: 0
}
function approvalRequired(): RuntimeClientError {
return new RuntimeClientError(
'approval_required',
`${KEY} 1.0.0 needs approval before it can run.`,
{
pluginKey: KEY,
name: 'Notes',
version: '1.0.0',
action: 'dev-load',
approvalFingerprint: FINGERPRINT,
isDev: true,
grants: {
execution: 'trusted-worker',
executionDescription: 'Runs its own code in a separate Node.js process on the Orca host.',
capabilities: [{ kind: 'git.read', description: 'Read git status.' }],
contributions: [{ kind: 'panels', name: 'Notes' }]
},
why: 'It can run code.',
command: COMMAND,
nextSteps: [`To approve, run: ${COMMAND}`]
}
)
}
/** The first devLoad needs approval; one with the fingerprint loads. Ctrl-C after the first poll. */
function watch(confirm: PluginDevWatchConfirm | undefined) {
const events: PluginDevWatchEvent[] = []
let interrupt: () => void = () => {}
const devLoad = vi.fn(async (fingerprint?: string) => {
if (fingerprint !== FINGERPRINT) {
throw approvalRequired()
}
return { path: PATH, pluginKey: KEY, added: true, approval: 'approve-flag' as const, plugin }
})
const host: PluginDevWatchHost = {
refreshTypes: async () => [],
devLoad,
list: async () => [plugin],
logs: async () => ({ pluginKey: KEY, lines: [] }),
approvalNeeded: async () => null,
devUnload: vi.fn(async () => {})
}
const run = runPluginDevWatch({
host,
emit: (event) => events.push(event),
wait: async () => interrupt(),
onInterrupt: (listener) => {
interrupt = listener
return () => {}
},
exitNow: vi.fn(),
isConnectionLost: () => false,
...(confirm ? { confirm } : {})
})
return { run, events, devLoad, host }
}
describe('first-load approval prompt', () => {
it('on yes, loads again with the host fingerprint and keeps watching', async () => {
const confirm = vi.fn(async (_question: string) => true)
const { run, events, devLoad, host } = watch(confirm)
await expect(run).resolves.toBe(0)
expect(devLoad.mock.calls).toEqual([[undefined], [FINGERPRINT]])
const question = confirm.mock.calls[0]?.[0] ?? ''
expect(question).toContain('Approving grants:')
expect(question).toContain(`${KEY} 1.0.0 (trusted-worker)`)
expect(question).toContain(' - Read git status.')
expect(question).toContain(' adds panels: Notes')
expect(question.endsWith('Approve and load? [y/N] ')).toBe(true)
expect(events.map((event) => event.type)).toEqual(['loaded', 'unloaded'])
expect(host.devUnload).toHaveBeenCalledTimes(1)
})
it('on no or no answer, fails approval_required with only the re-run command', async () => {
const confirm = vi.fn(async () => false)
const { run, devLoad } = watch(confirm)
await expect(run).rejects.toMatchObject({
code: 'approval_required',
message: `Not approved. To approve later, run: ${COMMAND}`,
data: { command: COMMAND, nextSteps: [] }
})
// The grants were just on screen; the error does not print them again.
await expect(run).rejects.not.toHaveProperty('data.grants')
expect(confirm).toHaveBeenCalledTimes(1)
expect(devLoad).toHaveBeenCalledTimes(1)
})
it('without a prompt (--json or no terminal) fails as before', async () => {
const { run, devLoad } = watch(undefined)
await expect(run).rejects.toMatchObject({ code: 'approval_required' })
expect(devLoad).toHaveBeenCalledTimes(1)
})
it('never prompts for a stale fingerprint passed with --approve', async () => {
const confirm = vi.fn(async () => true)
const { run, host } = watch(confirm)
const stale = new RuntimeClientError('approval_stale', 'changed', approvalRequired().data)
vi.mocked(host.devLoad).mockRejectedValueOnce(stale)
await expect(run).rejects.toBe(stale)
expect(confirm).not.toHaveBeenCalled()
})
})
function terminal(isTTY = true) {
const input = Object.assign(new PassThrough(), { isTTY })
const output = Object.assign(new PassThrough(), { isTTY })
let printed = ''
output.on('data', (chunk: Buffer) => {
printed += chunk.toString()
})
return { input, output, printed: () => printed }
}
describe('pluginDevWatchConfirmFor', () => {
it('prompts only when both streams are terminals and --json is off', () => {
expect(pluginDevWatchConfirmFor(false, terminal())).toBeTypeOf('function')
expect(pluginDevWatchConfirmFor(true, terminal())).toBeUndefined()
expect(pluginDevWatchConfirmFor(false, terminal(false))).toBeUndefined()
const { input } = terminal(false)
expect(pluginDevWatchConfirmFor(false, { input, output: terminal().output })).toBeUndefined()
})
it.each([
['y\n', true],
['YES\n', true],
[' yes \n', true],
['n\n', false],
['\n', false],
['sure\n', false]
])('reads %j as %s', async (typed, expected) => {
const streams = terminal()
const answer = createTerminalConfirm(streams)('Approve and load? [y/N] ')
streams.input.write(typed)
await expect(answer).resolves.toBe(expected)
expect(streams.printed()).toContain('Approve and load? [y/N] ')
})
it.each([
['end of input', (input: PassThrough) => input.end()],
['Ctrl-C', (input: PassThrough) => input.write('\u0003')]
])('reads %s as no', async (_name, stop) => {
const streams = terminal()
const answer = createTerminalConfirm(streams)('Approve and load? [y/N] ')
stop(streams.input)
await expect(answer).resolves.toBe(false)
})
})
@@ -0,0 +1,128 @@
import { createInterface } from 'node:readline'
import { z } from 'zod'
import { formatPluginGrantLines } from '../plugin-authoring/plugin-grant-lines'
import { RuntimeClientError } from '../runtime/types'
import type { DevLoadResult } from '../handlers/plugin-runtime'
/** Asks a yes/no question; false on anything but yes, end of input or Ctrl-C. */
export type PluginDevWatchConfirm = (question: string) => Promise<boolean>
const firstLoadApprovalSchema = z.object({
pluginKey: z.string(),
name: z.string(),
version: z.string(),
approvalFingerprint: z.string(),
why: z.string().optional(),
grants: z.object({
execution: z.string(),
executionDescription: z.string(),
capabilities: z.array(z.object({ description: z.string() })),
contributions: z.array(z.object({ kind: z.string(), name: z.string() }))
})
})
type FirstLoadApproval = z.infer<typeof firstLoadApprovalSchema>
function firstLoadApprovalOf(error: unknown): FirstLoadApproval | null {
// Why not `approval_stale`: it answers a fingerprint passed with --approve, which stays explicit.
if (!(error instanceof RuntimeClientError) || error.code !== 'approval_required') {
return null
}
const parsed = firstLoadApprovalSchema.safeParse(error.data)
return parsed.success ? parsed.data : null
}
export function pluginDevWatchApprovalQuestion(approval: FirstLoadApproval): string {
return [
`${approval.name} needs your approval before Orca runs it. Approving grants:`,
...formatPluginGrantLines(approval),
...(approval.why ? [approval.why] : []),
'Approve and load? [y/N] '
].join('\n')
}
/** The first dev load; with `confirm` (a person at a terminal) an approval is asked, never assumed.
* A declined or unanswered ask fails with approval_required (exit 3) and only the re-run command. */
export async function devLoadWithApproval(
devLoad: (approvalFingerprint?: string) => Promise<DevLoadResult>,
confirm: PluginDevWatchConfirm | undefined
): Promise<DevLoadResult> {
try {
return await devLoad()
} catch (error) {
const approval = confirm ? firstLoadApprovalOf(error) : null
if (!confirm || !approval) {
throw error
}
if (!(await confirm(pluginDevWatchApprovalQuestion(approval)))) {
throw declinedApprovalError(error, approval)
}
return devLoad(approval.approvalFingerprint)
}
}
/** Why trimmed: the person just read the grants; repeating them buries the re-run command. */
function declinedApprovalError(error: unknown, approval: FirstLoadApproval): RuntimeClientError {
const data = error instanceof RuntimeClientError ? error.data : undefined
const command =
typeof data === 'object' &&
data !== null &&
'command' in data &&
typeof data.command === 'string'
? data.command
: null
return new RuntimeClientError(
'approval_required',
command ? `Not approved. To approve later, run: ${command}` : 'Not approved.',
{
pluginKey: approval.pluginKey,
approvalFingerprint: approval.approvalFingerprint,
command,
nextSteps: []
}
)
}
type PromptStreams = {
input: NodeJS.ReadableStream & { isTTY?: boolean }
output: NodeJS.WritableStream & { isTTY?: boolean }
}
/** A prompt only for a person at a terminal; agents, pipes and `--json` keep the exit-3 error. */
export function pluginDevWatchConfirmFor(
json: boolean,
streams: PromptStreams = { input: process.stdin, output: process.stdout }
): PluginDevWatchConfirm | undefined {
return !json && streams.input.isTTY && streams.output.isTTY
? createTerminalConfirm(streams)
: undefined
}
/** readline works the same in Windows cmd, PowerShell and Windows Terminal as on POSIX ttys. */
export function createTerminalConfirm(streams: PromptStreams): PluginDevWatchConfirm {
return (question) =>
new Promise((resolve) => {
const rl = createInterface({ input: streams.input, output: streams.output })
let answered = false
const finish = (yes: boolean): void => {
if (answered) {
return
}
answered = true
rl.close()
resolve(yes)
}
// Why: with no SIGINT listener readline only pauses on Ctrl-C, leaving the prompt hanging.
rl.on('SIGINT', () => {
streams.output.write('\n')
finish(false)
})
rl.on('close', () => {
if (!answered) {
streams.output.write('\n')
}
finish(false)
})
rl.question(question, (answer) => finish(/^y(es)?$/i.test(answer.trim())))
})
}
@@ -6,6 +6,7 @@ import type {
PluginDevWatchStatus
} from './plugin-dev-watch-events'
import { PluginLogFollower } from './plugin-log-follower'
import { devLoadWithApproval, type PluginDevWatchConfirm } from './plugin-dev-watch-approval'
import { PluginFrameLogDedupe, type PluginDevWatchLogLine } from './plugin-frame-log-dedupe'
import {
createPluginDevWatchCheckQueue,
@@ -20,7 +21,8 @@ import {
/** What the loop asks of the Orca host; the handler binds it to RPC for one folder. */
export type PluginDevWatchHost = {
refreshTypes(): Promise<string[]>
devLoad(): Promise<DevLoadResult>
/** `approvalFingerprint` overrides `--approve` once the person approved at the prompt. */
devLoad(approvalFingerprint?: string): Promise<DevLoadResult>
list(): Promise<PluginHostListEntry[]>
logs(pluginKey: string, after: string | undefined): Promise<LogsResult>
/** Re-checks the folder's approval; null when it needs none. */
@@ -41,6 +43,8 @@ export type PluginDevWatchDeps = {
onInterrupt(listener: () => void): () => void
exitNow(code: number): void
isConnectionLost(error: unknown): boolean
/** Only for a person at a terminal without `--json`: asks to approve a first load that needs it. */
confirm?: PluginDevWatchConfirm
}
// Why these rates: logs feel live at 500 ms, and the plugin list (a projection of every plugin) is
@@ -131,11 +135,12 @@ async function startShooter(
}
/** Loads the folder, then streams its logs and reload status until interrupted. Returns the exit
* code. A failed first load throws (approval_required exits 3 like `orca plugin dev load`). */
* code. A failed first load throws (approval_required exits 3 like `orca plugin dev load`) unless
* the person approves it at the `confirm` prompt. */
export async function runPluginDevWatch(deps: PluginDevWatchDeps): Promise<number> {
const { host, emit } = deps
const typeFiles = await host.refreshTypes()
const loaded = await host.devLoad()
const loaded = await devLoadWithApproval((fingerprint) => host.devLoad(fingerprint), deps.confirm)
const { pluginKey } = loaded
let snapshot = snapshotOf(loaded.plugin)
emit({
+3 -3
View File
@@ -30,11 +30,11 @@ export const PLUGIN_VIEW_COMMAND_SPECS: CommandSpec[] = [
allowedFlags: [...GLOBAL_FLAGS, 'plugin', 'panel', 'theme', 'width', 'act', 'out'],
positionalArgs: ['plugin'],
notes: [
'Shows the panel like `orca plugin open`, waits until it has drawn (a components panel: its first tree from the current code; an html panel: its page loaded), then captures exactly the panel, with the window hidden or behind others. It never shows, raises or focuses the window, and it does not leave Settings or another full-page view.',
'Shows the panel like `orca plugin open`, waits until it has drawn (a components panel: its first tree from the current code, then until no host call such as plugin.request is in flight and its answer has drawn, at most 3s more; an html panel: its page loaded), then captures exactly the panel, with the window hidden or behind others. It never shows, raises or focuses the window, and it does not leave Settings or another full-page view.',
'Writes .orca/shots/<plugin-key>-<panel>.png unless --out names a file: in the plugin folder when run inside one (the nearest orca-plugin.json up to the repository root, as `dev watch --shot` does), else in <cwd>. The image is in CSS pixels. Read the image file to see the panel.',
'--width sets the panel width for this capture only. --theme paints Orca light or dark for this capture only, and only while its window is hidden or minimized, then puts your theme back; the setting never changes. With the window on screen it captures in the current theme rather than repaint the app in front of you. The result says which theme it shows, whether --theme took effect (themeApplied) and why not (themeReason).',
'--act runs input on a components panel before the capture, in the test kit\'s words: a JSON list (at most 20) of {"press":Q}, {"type":Q,"text":"…","replace"?:true,"submit"?:true}, {"select":Q,"value":"…"} and {"toggle":Q}, where Q matches like the kit\'s queries ({"text"|"label"|"testId"|"type"|"role":"…"}). Each act goes through Orca\'s own control and waits for the panel to redraw. It acts only on this panel and only while Orca\'s window is hidden or minimized; html panels refuse it. Acts are not a person\'s input: a Link\'s href stays unopened and gesture-only calls (shell.openExternal, files.saveAs) are refused. The first failed act stops the rest; the PNG shows the panel at that point and the command exits 1.',
'--json prints { path, width, height, theme, themeApplied, themeReason?, settled, errors, actions? }; actions has one { ok, error? } per act. errors lists the error banner the panel shows and the errors it logged (`orca plugin logs`) during the capture; settled is false when it had not finished drawing after 15s.',
'--act runs input on a components panel before the capture, in the test kit\'s words: a JSON list (at most 20) of {"press":Q}, {"type":Q,"text":"…","replace"?:true,"submit"?:true}, {"select":Q,"value":"…"}, {"toggle":Q}, {"slide":Q,"value":n}, and for a Table {"sort":Q,"column":"…"}, {"selectRows":Q,"keys":[…]}, {"activateRow":Q,"key":"…"}, {"loadMore":Q}, for a Tree {"expand"|"collapse"|"selectItem"|"activateItem":Q,"key":"…"}, where Q matches like the kit\'s queries ({"text"|"label"|"testId"|"type"|"role":"…"}). Each act goes through Orca\'s own control and waits for the panel to redraw and its host calls to answer. After the capture Orca puts the panel\'s atoms back (persisted ones included, so neither later shots nor the user keep the acted state); what handlers did elsewhere stays, such as a plugin.request that changed the worker\'s storage. It acts only on this panel and only while Orca\'s window is hidden or minimized; html panels refuse it. Acts are not a person\'s input: a Link\'s href stays unopened and gesture-only calls (shell.openExternal, files.saveAs) are refused. The first failed act stops the rest; the PNG shows the panel at that point and the command exits 1.',
'--json prints { path, width, height, theme, themeApplied, themeReason?, settled, errors, pendingRequests?, actions?, restored? }; pendingRequests counts host calls still unanswered at the capture (components panels), actions has one { ok, error? } per act, and restored is false when the panel reloaded during --act so its acted state stayed. errors lists the error banner the panel shows and the errors it logged (`orca plugin logs`) during the capture; settled is false when it had not finished drawing after 15s.',
'Desktop app only: a server with no window (`orca serve`) refuses it. With --environment the PNG (at most 5 MiB; it shows only the plugin panel) comes back to this machine and is written here.'
],
examples: [
+1
View File
@@ -206,6 +206,7 @@ export const PLUGIN_RUNTIME_COMMAND_SPECS: CommandSpec[] = [
positionalArgs: ['dir'],
notes: [
'The inner loop: loads the folder like `orca plugin dev load` (refreshing its .orca/types), then prints each log line its panels, editors and worker write, every reload a save causes, and status changes such as errored or invalid with the error.',
'A first load that needs approval, run in a terminal without --json, shows the grants and asks `Approve and load? [y/N]`; yes loads it with that fingerprint and keeps watching. Anything else, --json or no terminal (agents, pipes) fails like `orca plugin dev load` (exit 3, with the re-run command).',
'When an edit asks for new access it prints the `orca plugin dev reload <dir> --approve <fingerprint>` command to run from another shell; this one keeps watching.',
'Ctrl-C unloads the folder (unless it was loaded before this command) and exits 0; a second Ctrl-C exits at once. If the connection to Orca drops it says so and exits 1.',
"--check runs `orca plugin check` after the load and after each reload and prints the result inline (one `check` event with --json; it fails while Orca does not run the plugin, such as pending approval, even if the panels render). Like `orca plugin check` it runs the plugin's code on this machine, unsandboxed, so use it on code you trust; it needs a local folder.",
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest'
import {
capturePluginPanelPng,
compositeBitmap,
looksBlank,
pluginPanelCaptureRect,
PLUGIN_SHOT_MAX_PNG_BYTES,
settlePluginFrameGuest,
@@ -16,6 +17,8 @@ function image(size: { width: number; height: number }, png = Buffer.from('png')
isEmpty: () => size.width === 0,
getSize: () => size,
resize: resized,
// A drawn panel: more than a background and a border.
toBitmap: () => Buffer.from([1, 0, 0, 0, 2, 0, 0, 0, 3, 0, 0, 0]),
toPNG: () => png
} as unknown as NativeImage
}
@@ -56,7 +59,10 @@ describe('capturePluginPanelPng', () => {
it('captures the rect and scales a HiDPI image to one pixel per point', async () => {
const target = contents()
const shot = await capturePluginPanelPng(target, { x: 900, y: 40, width: 360, height: 700 })
expect(target.capturePage).toHaveBeenCalledWith({ x: 900, y: 40, width: 360, height: 700 })
expect(target.capturePage).toHaveBeenCalledWith(
{ x: 900, y: 40, width: 360, height: 700 },
{ stayHidden: true }
)
expect(shot).toMatchObject({ width: 360, height: 700 })
expect(shot.png.toString()).toBe('png')
})
@@ -158,3 +164,59 @@ describe('capturePluginPanelPng with an html guest', () => {
expect(shot).toEqual({ png: Buffer.from('png-7'), width: 20, height: 10 })
})
})
describe('capturePluginPanelPng retries a bare-background capture', () => {
function solidImage(size: { width: number; height: number }, colors: number[]): NativeImage {
const bitmap = Buffer.alloc(size.width * size.height * 4)
for (let pixel = 0; pixel < size.width * size.height; pixel += 1) {
bitmap.writeUInt32LE(colors[pixel % colors.length], pixel * 4)
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: capture reads only these NativeImage members.
return {
isEmpty: () => false,
getSize: () => size,
resize: () => solidImage(size, colors),
toBitmap: () => bitmap,
toPNG: () => Buffer.from(`png-${colors.length}`)
} as unknown as NativeImage
}
it('recaptures until the panel has drawn, then stops', async () => {
const size = { width: 4, height: 4 }
const shots = [solidImage(size, [1]), solidImage(size, [1, 2]), solidImage(size, [1, 2, 3])]
const host = contents({
capturePage: vi.fn(async () => shots.shift() ?? solidImage(size, [9]))
})
const wait = vi.fn(async () => undefined)
const shot = await capturePluginPanelPng(host, { x: 0, y: 0, ...size }, undefined, wait)
expect(host.capturePage).toHaveBeenCalledTimes(3)
expect(wait).toHaveBeenCalledTimes(2)
expect(shot.png).toEqual(Buffer.from('png-3'))
})
it('gives up after its retries and returns the last capture of a truly plain panel', async () => {
const size = { width: 2, height: 2 }
const host = contents({ capturePage: vi.fn(async () => solidImage(size, [5])) })
const shot = await capturePluginPanelPng(
host,
{ x: 0, y: 0, ...size },
undefined,
async () => {}
)
expect(host.capturePage).toHaveBeenCalledTimes(9)
expect(shot.png).toEqual(Buffer.from('png-1'))
})
})
describe('looksBlank', () => {
it('is true for one or two colors and false from three', () => {
const pixels = (...values: number[]) => {
const bitmap = Buffer.alloc(values.length * 4)
values.forEach((value, index) => bitmap.writeUInt32LE(value, index * 4))
return bitmap
}
expect(looksBlank(pixels(7, 7, 7))).toBe(true)
expect(looksBlank(pixels(7, 8, 7))).toBe(true)
expect(looksBlank(pixels(7, 8, 9))).toBe(false)
})
})
+46 -2
View File
@@ -96,6 +96,46 @@ export function compositeBitmap(
return out
}
// Why retry as well: a fallback in case even a stayHidden capture returns a bare background (Linux CI).
// Why stayHidden: Electron's documented way to keep a hidden window rendering for the capture.
const HIDDEN_CAPTURE = { stayHidden: true }
const BLANK_RETRY_WAIT_MS = 150
const BLANK_RETRIES = 8
/** Two colors or fewer: a background and maybe a border, i.e. nothing of the panel drawn yet. */
export function looksBlank(bitmap: Buffer): boolean {
const seen = new Set<number>()
for (let offset = 0; offset + 3 < bitmap.byteLength; offset += 4) {
seen.add(bitmap.readUInt32LE(offset))
if (seen.size > 2) {
return false
}
}
return true
}
async function captureUntilDrawn(
contents: WebContents,
target: CaptureRect,
wait: (ms: number) => Promise<void>
): Promise<NativeImage> {
const grab = async (): Promise<NativeImage> => {
const shot = await contents.capturePage(target, HIDDEN_CAPTURE)
return shot.isEmpty() ? shot : atSize(shot, target.width, target.height)
}
let image = await grab()
for (let tries = 0; tries < BLANK_RETRIES && !image.isEmpty(); tries += 1) {
if (!looksBlank(image.toBitmap())) {
break
}
await wait(BLANK_RETRY_WAIT_MS)
image = await grab()
}
return image
}
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
/**
* The PNG of exactly that rect, at one image pixel per window point. Why the guest separately:
* on Linux the window capture leaves a `<webview>` guest blank, so an html panel's guest is
@@ -104,14 +144,18 @@ export function compositeBitmap(
export async function capturePluginPanelPng(
contents: WebContents,
rect: PluginViewDrawnPanel['rect'],
guest?: { contents: WebContents; rect: CaptureRect; fromBitmap: BitmapImageFactory }
guest?: { contents: WebContents; rect: CaptureRect; fromBitmap: BitmapImageFactory },
wait: (ms: number) => Promise<void> = sleep
): Promise<{ png: Buffer; width: number; height: number }> {
const zoom = contents.getZoomFactor()
const target = pluginPanelCaptureRect(rect, zoom)
if (target.width < 1 || target.height < 1) {
throw new Error('The panel has no area to capture; is the right sidebar collapsed?')
}
let image: NativeImage = await contents.capturePage(target)
// An html panel's guest is drawn over this capture below, so only a bare host is retried.
let image: NativeImage = guest
? await contents.capturePage(target, HIDDEN_CAPTURE)
: await captureUntilDrawn(contents, target, wait)
if (image.isEmpty()) {
throw new Error('Orca drew nothing to capture; try again once its window has loaded.')
}
@@ -26,6 +26,8 @@ const KEY = 'acme.todos'
const CAPTURED = {
isEmpty: () => false,
getSize: () => ({ width: 300, height: 600 }),
// A drawn panel: more than a background and a border.
toBitmap: () => Buffer.from([1, 0, 0, 0, 2, 0, 0, 0, 3, 0, 0, 0]),
toPNG: () => Buffer.from('png')
} as unknown as NativeImage
@@ -240,10 +242,10 @@ describe('plugins.shot on the desktop', () => {
answer({ ...DRAWN, panel: { ...DRAWN.panel!, actions: [{ ok: true }] } })
)
const shot = await createPluginViewDesktop(deps()).shot({ pluginKey: KEY, acts })
expect(shot).toMatchObject({ actions: [{ ok: true }] })
expect(shot).toMatchObject({ actions: [{ ok: true }], restored: false })
const [, body, timeoutMs] = relay.requestPluginViewFromRenderer.mock.calls[0] ?? []
expect(body).toMatchObject({ capture: { acts } })
expect(timeoutMs).toBe(24_000)
expect(timeoutMs).toBe(27_500)
relay.requestPluginViewFromRenderer.mockClear()
await expect(
@@ -255,4 +257,18 @@ describe('plugins.shot on the desktop', () => {
})
expect(relay.requestPluginViewFromRenderer).not.toHaveBeenCalled()
})
it('reports unanswered host calls and whether --act was put back once the capture ended', async () => {
relay.requestPluginViewFromRenderer.mockImplementation(async (...args) =>
args[1]?.kind === 'open-panel'
? { ...DRAWN, panel: { ...DRAWN.panel!, pendingRequests: 1, actions: [{ ok: true }] } }
: { requestId: 'r', ok: true, restored: true }
)
const view = createPluginViewDesktop(deps())
await expect(
view.shot({ pluginKey: KEY, acts: [{ toggle: { label: 'Hide done' } }] })
).resolves.toMatchObject({ pendingRequests: 1, restored: true })
// Without acts there is nothing to put back, so no `restored`.
expect(await view.shot({ pluginKey: KEY })).not.toHaveProperty('restored')
})
})
+80 -65
View File
@@ -46,8 +46,12 @@ export type PluginShotResult = {
settled: boolean
/** Errors the panel logged during the capture and the banner it shows, if any. */
errors: string[]
/** Host calls (`plugin.request` included) still unanswered at the capture; components only. */
pendingRequests?: number
/** One result per `acts` entry, when acts were asked for. */
actions?: PluginShotActResult[]
/** With acts: the panel's atoms were put back after the capture (false: its guest reloaded). */
restored?: boolean
}
/** `ifShown` and the panel was not on screen: nothing was opened, switched or captured. */
@@ -95,6 +99,8 @@ const RENDER_TIMEOUT_MS = 15_000
const REPLY_GRACE_MS = 5_000
// Why: each act may wait up to 3s for the panel to settle, plus the frames that paint it.
const ACT_TIMEOUT_MS = 4_000
// Why: after drawing, a shot waits up to 3s more for the panel's worker to answer.
const SETTLE_TIMEOUT_MS = 3_500
const MAX_ERRORS = 20
function failed(reply: PluginViewReply): PluginCliError | null {
@@ -166,78 +172,87 @@ export function createPluginViewDesktop(deps: PluginViewDesktopDeps): PluginView
const { colorScheme, themeReason } = pluginShotThemeDecision(requested, deps.windowShown())
const { cursor } = deps.logsSince(pluginKey)
return withPaintingWhileHidden([contents, ...deps.guestsOf(pluginKey)], async () => {
try {
const reply = await requestPluginViewFromRenderer(
contents,
{
kind: 'open-panel',
pluginKey,
panelId: panel.panelId,
capture: {
guestRevision: panel.guestRevision,
...(width === undefined ? {} : { width }),
...(colorScheme === undefined ? {} : { colorScheme }),
...(ifShown ? { ifShown: true } : {}),
...(acts ? { acts } : {}),
timeoutMs: RENDER_TIMEOUT_MS
let restored: boolean | undefined
const shot = await (async (): Promise<PluginShotResult | PluginShotNotShown> => {
try {
const reply = await requestPluginViewFromRenderer(
contents,
{
kind: 'open-panel',
pluginKey,
panelId: panel.panelId,
capture: {
guestRevision: panel.guestRevision,
...(width === undefined ? {} : { width }),
...(colorScheme === undefined ? {} : { colorScheme }),
...(ifShown ? { ifShown: true } : {}),
...(acts ? { acts } : {}),
timeoutMs: RENDER_TIMEOUT_MS
}
},
RENDER_TIMEOUT_MS + SETTLE_TIMEOUT_MS + actsTimeoutMs + REPLY_GRACE_MS
)
if (ifShown && !reply.ok && reply.code === PLUGIN_VIEW_NOT_SHOWN_CODE) {
return {
pluginKey,
panelId: panel.panelId,
title: panel.title,
notShown: reply.message
}
},
RENDER_TIMEOUT_MS + actsTimeoutMs + REPLY_GRACE_MS
)
if (ifShown && !reply.ok && reply.code === PLUGIN_VIEW_NOT_SHOWN_CODE) {
}
const error = failed(reply)
if (error) {
throw error
}
const drawn = reply.ok ? reply.panel : undefined
if (!drawn) {
throw new Error('Orca did not report where the panel is drawn.')
}
const guest =
drawn.ui === 'html'
? deps.guestsOf(pluginKey).find((each) => each.getURL() === drawn.guestUrl)
: undefined
await settlePluginFrameGuest(guest)
const image = await capturePluginPanelPng(
contents,
drawn.rect,
guest && drawn.guestRect
? { contents: guest, rect: drawn.guestRect, fromBitmap: deps.imageFromBitmap }
: undefined
)
const logged = deps
.logsSince(pluginKey, cursor)
.lines.filter((line) => line.level === 'error')
.map((line) => line.line)
return {
pluginKey,
panelId: panel.panelId,
title: panel.title,
notShown: reply.message
ui: drawn.ui,
png: image.png.toString('base64'),
width: image.width,
height: image.height,
colorScheme: drawn.colorScheme,
themeApplied: drawn.themeApplied,
...(themeReason ? { themeReason } : {}),
settled: drawn.settled,
errors: [...(drawn.issue ? [drawn.issue] : []), ...logged].slice(0, MAX_ERRORS),
...(drawn.pendingRequests === undefined
? {}
: { pendingRequests: drawn.pendingRequests }),
...(drawn.actions ? { actions: drawn.actions } : {})
}
} finally {
const ended = await requestPluginViewFromRenderer(
contents,
{ kind: 'end-capture' },
OPEN_TIMEOUT_MS
).catch(() => null)
restored = ended?.ok ? ended.restored : undefined
}
const error = failed(reply)
if (error) {
throw error
}
const drawn = reply.ok ? reply.panel : undefined
if (!drawn) {
throw new Error('Orca did not report where the panel is drawn.')
}
const guest =
drawn.ui === 'html'
? deps.guestsOf(pluginKey).find((each) => each.getURL() === drawn.guestUrl)
: undefined
await settlePluginFrameGuest(guest)
const image = await capturePluginPanelPng(
contents,
drawn.rect,
guest && drawn.guestRect
? { contents: guest, rect: drawn.guestRect, fromBitmap: deps.imageFromBitmap }
: undefined
)
const logged = deps
.logsSince(pluginKey, cursor)
.lines.filter((line) => line.level === 'error')
.map((line) => line.line)
return {
pluginKey,
panelId: panel.panelId,
title: panel.title,
ui: drawn.ui,
png: image.png.toString('base64'),
width: image.width,
height: image.height,
colorScheme: drawn.colorScheme,
themeApplied: drawn.themeApplied,
...(themeReason ? { themeReason } : {}),
settled: drawn.settled,
errors: [...(drawn.issue ? [drawn.issue] : []), ...logged].slice(0, MAX_ERRORS),
...(drawn.actions ? { actions: drawn.actions } : {})
}
} finally {
await requestPluginViewFromRenderer(
contents,
{ kind: 'end-capture' },
OPEN_TIMEOUT_MS
).catch(() => undefined)
}
})()
// After --act: whether the panel's atoms were put back once the capture ended.
return 'png' in shot && acts ? { ...shot, restored: restored === true } : shot
})
}
}
@@ -21,6 +21,7 @@ const drawnPanelSchema = z.object({
colorScheme: z.enum(['light', 'dark']),
themeApplied: z.boolean(),
settled: z.boolean(),
pendingRequests: z.number().int().nonnegative().optional(),
actions: z.array(pluginShotActResultSchema).max(PLUGIN_SHOT_MAX_ACTS).optional()
})
@@ -29,7 +30,8 @@ export const pluginViewReplySchema: z.ZodType<PluginViewReply> = z.union([
z.object({
requestId: z.string().max(128),
ok: z.literal(true),
panel: drawnPanelSchema.optional()
panel: drawnPanelSchema.optional(),
restored: z.boolean().optional()
}),
z.object({
requestId: z.string().max(128),
@@ -100,6 +100,7 @@ export function PluginDeclarativeTable({
defaultValue: props.defaultSelected,
fallback: [],
acked: props.$edit,
actTarget: props,
callback: selectionCallback,
send: (callback, value, edit) => emit(callback, [{ keys: [...value] }], { edit })
})
@@ -4,6 +4,7 @@ import { LoadingSpinner } from '@/components/LoadingSpinner'
import { translate } from '@/i18n/i18n'
import type { PluginDeclarativeProps } from '../../../../../shared/plugins/declarative/plugin-declarative-catalog'
import { usePluginDeclarativeHostValue } from './plugin-declarative-host-value'
import { usePluginDeclarativeActExpansion } from './plugin-declarative-act-targets'
import type { PluginDeclarativeEmit } from './PluginDeclarativeControls'
import { PluginDeclarativeIconGlyph } from './plugin-declarative-icons'
import { visibleTreeRows, type PluginDeclarativeTreeRow } from './plugin-declarative-tree-rows'
@@ -35,6 +36,7 @@ export function PluginDeclarativeTree({
defaultValue: props.defaultSelected,
fallback: '',
acked: props.$edit,
actTarget: props,
callback: props.onSelect?.$callback,
send: (callback, key, edit) => emit(callback, [{ key }], { edit })
})
@@ -50,22 +52,25 @@ export function PluginDeclarativeTree({
elements.current.get(row.item.key)?.focus()
}
}
const toggle = (row: ItemRow, open: boolean): void => {
if (!row.expandable || row.expanded === open) {
return
}
const setExpanded = (key: string, open: boolean): void => {
setOpenItems((previous) => {
const keys = new Set(previous.keys)
if (open) {
keys.add(row.item.key)
keys.add(key)
} else {
keys.delete(row.item.key)
keys.delete(key)
}
return { ...previous, keys }
})
const callback = props.onToggle?.$callback
if (callback) {
emit(callback, [{ key: row.item.key, expanded: open }])
emit(callback, [{ key, expanded: open }])
}
}
usePluginDeclarativeActExpansion(props, { expanded: () => expanded, set: setExpanded })
const toggle = (row: ItemRow, open: boolean): void => {
if (row.expandable && row.expanded !== open) {
setExpanded(row.item.key, open)
}
}
const select = (row: ItemRow): void => {
@@ -35,9 +35,10 @@ export function usePluginDeclarativeActTarget<T>(
fallback: T,
live: { value: T; change: (next: T) => void; readOnly: boolean }
): void {
const liveRef = useRef(live)
// Why the fallback rides along: a Table's `[]` is a new array every render.
const liveRef = useRef({ ...live, fallback })
useLayoutEffect(() => {
liveRef.current = live
liveRef.current = { ...live, fallback }
})
useLayoutEffect(() => {
if (!props) {
@@ -47,7 +48,7 @@ export function usePluginDeclarativeActTarget<T>(
value: () => liveRef.current.value,
readOnly: () => liveRef.current.readOnly,
change: (next) => {
if (!isSameKind(next, fallback)) {
if (!isSameKind(next, liveRef.current.fallback)) {
return false
}
liveRef.current.change(next)
@@ -60,5 +61,42 @@ export function usePluginDeclarativeActTarget<T>(
targets.delete(props)
}
}
}, [props, fallback])
}, [props])
}
/** A mounted Tree's expanded items, which Orca holds while the person browses. */
export type PluginDeclarativeActExpansion = {
expanded(): ReadonlySet<string>
/** Opens or closes `key` as a click on its chevron would. */
set(key: string, open: boolean): void
}
const expansions = new WeakMap<PluginDeclarativeDrawnProps, PluginDeclarativeActExpansion>()
export function pluginDeclarativeActExpansion(
props: PluginDeclarativeDrawnProps
): PluginDeclarativeActExpansion | null {
return expansions.get(props) ?? null
}
export function usePluginDeclarativeActExpansion(
props: PluginDeclarativeDrawnProps,
live: PluginDeclarativeActExpansion
): void {
const liveRef = useRef(live)
useLayoutEffect(() => {
liveRef.current = live
})
useLayoutEffect(() => {
const expansion: PluginDeclarativeActExpansion = {
expanded: () => liveRef.current.expanded(),
set: (key, open) => liveRef.current.set(key, open)
}
expansions.set(props, expansion)
return () => {
if (expansions.get(props) === expansion) {
expansions.delete(props)
}
}
}, [props])
}
@@ -0,0 +1,70 @@
import { act, render } from '@testing-library/react'
import { TooltipProvider } from '@/components/ui/tooltip'
import { parsePluginDeclarativeTree } from '../../../../../shared/plugins/declarative/plugin-declarative-tree'
import type { PluginDeclarativeNode } from '../../../../../shared/plugins/declarative/plugin-declarative-catalog'
import type { PluginShotAct } from '../../../../../shared/plugins/view/plugin-shot-acts'
import { PluginDeclarativeView } from '../declarative/PluginDeclarativeView'
import type { PluginDeclarativeEmit } from '../declarative/PluginDeclarativeControls'
import {
pluginDeclarativeActExpansion,
pluginDeclarativeActTarget
} from '../declarative/plugin-declarative-act-targets'
import type { PluginPanelActDriver } from '../../right-sidebar/plugin-panel-act-driver'
import { runPluginPanelActs } from './plugin-view-acts'
export const cb = (id: string) => ({ $callback: id })
export const node = (
type: string,
props: Record<string, unknown> = {},
children: unknown[] = []
) => ({ type, props, children })
function parse(raw: unknown): PluginDeclarativeNode {
const parsed = parsePluginDeclarativeTree(raw)
if (!parsed.ok || !parsed.tree) {
throw new Error(parsed.ok ? 'empty tree' : parsed.error)
}
return parsed.tree
}
export type SentEvent = { callback: string; args: unknown[]; edit?: number }
/**
* A fake guest under Orca's real drawn view: `build` is its render, `handle` its callbacks, and
* each callback re-renders it, as a real guest's do. `run` drives it as `shot --act` would.
*/
export function mountActGuest(build: () => unknown, handle: (event: SentEvent) => void = () => {}) {
const sent: SentEvent[] = []
let tree: PluginDeclarativeNode | null = null
const receive = (callback: string, args: unknown[], edit?: number): void => {
const event = { callback, args, ...(edit === undefined ? {} : { edit }) }
sent.push(event)
handle(event)
draw()
}
const onEvent: PluginDeclarativeEmit = (callback, args, options = {}) =>
receive(callback, args, options.edit)
const view = () => (
<TooltipProvider>
<PluginDeclarativeView tree={tree} onEvent={onEvent} />
</TooltipProvider>
)
const result = render(view())
function draw(): void {
tree = parse(build())
result.rerender(view())
}
act(() => draw())
const driver: Pick<PluginPanelActDriver, 'tree' | 'send'> = {
tree: () => tree,
send: (callback, args) => act(() => receive(callback, args))
}
const run = (acts: PluginShotAct[]) =>
runPluginPanelActs(acts, {
driver,
targetOf: pluginDeclarativeActTarget,
expansionOf: pluginDeclarativeActExpansion,
settle: () => act(async () => {})
})
return { sent, run }
}
@@ -1,42 +1,17 @@
// @vitest-environment happy-dom
import { act, cleanup, render, screen } from '@testing-library/react'
import { cleanup, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import { parsePluginDeclarativeTree } from '../../../../../shared/plugins/declarative/plugin-declarative-tree'
import type { PluginDeclarativeNode } from '../../../../../shared/plugins/declarative/plugin-declarative-catalog'
import type { PluginShotAct } from '../../../../../shared/plugins/view/plugin-shot-acts'
import { PluginDeclarativeView } from '../declarative/PluginDeclarativeView'
import type { PluginDeclarativeEmit } from '../declarative/PluginDeclarativeControls'
import { pluginDeclarativeActTarget } from '../declarative/plugin-declarative-act-targets'
import type { PluginPanelActDriver } from '../../right-sidebar/plugin-panel-act-driver'
import { runPluginPanelActs } from './plugin-view-acts'
import { cb, mountActGuest, node } from './plugin-view-act-test-harness'
afterEach(cleanup)
const cb = (id: string) => ({ $callback: id })
const node = (type: string, props: Record<string, unknown> = {}, children: unknown[] = []) => ({
type,
props,
children
})
function parse(raw: unknown): PluginDeclarativeNode {
const parsed = parsePluginDeclarativeTree(raw)
if (!parsed.ok || !parsed.tree) {
throw new Error(parsed.ok ? 'empty tree' : parsed.error)
}
return parsed.tree
}
/** A tiny todo guest: Orca draws its tree, and its callbacks re-render it, as a real guest's do. */
/** A tiny todo guest. */
function mountTodoGuest() {
const tasks: string[] = []
const state = { draft: '', edit: 0, tasks, hideDone: false }
const sent: { callback: string; args: unknown[]; edit?: number }[] = []
const listeners = new Set<() => void>()
let tree: PluginDeclarativeNode | null = null
const build = () =>
parse(
const { sent, run } = mountActGuest(
() =>
node('Stack', {}, [
node('Input', {
label: 'New task',
@@ -60,51 +35,21 @@ function mountTodoGuest() {
]),
node('Tab', { value: 'done', label: 'Done' }, [node('Text', {}, ['Nothing done yet'])])
])
])
)
const handle = (callback: string, args: unknown[], edit?: number): void => {
sent.push({ callback, args, ...(edit === undefined ? {} : { edit }) })
if (callback === 'draft' && typeof args[0] === 'string') {
state.draft = args[0]
state.edit = Math.max(state.edit, edit ?? 0)
} else if (callback === 'submit' || callback === 'add') {
if (state.draft) {
state.tasks.push(state.draft)
]),
({ callback, args, edit }) => {
if (callback === 'draft' && typeof args[0] === 'string') {
state.draft = args[0]
state.edit = Math.max(state.edit, edit ?? 0)
} else if (callback === 'submit' || callback === 'add') {
if (state.draft) {
state.tasks.push(state.draft)
}
state.draft = ''
} else if (callback === 'hide') {
state.hideDone = args[0] === true
}
state.draft = ''
} else if (callback === 'hide') {
state.hideDone = args[0] === true
}
draw()
}
const onEvent: PluginDeclarativeEmit = (callback, args, options = {}) =>
handle(callback, args, options.edit)
const view = () => (
<TooltipProvider>
<PluginDeclarativeView tree={tree} onEvent={onEvent} />
</TooltipProvider>
)
const result = render(view())
function draw(): void {
tree = build()
result.rerender(view())
listeners.forEach((listener) => listener())
}
act(() => draw())
const driver: PluginPanelActDriver = {
tree: () => tree,
send: (callback, args) => act(() => handle(callback, args)),
onDraw: (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
}
}
const run = (acts: PluginShotAct[]) =>
runPluginPanelActs(acts, {
driver,
targetOf: pluginDeclarativeActTarget,
settle: () => act(async () => {})
})
return { state, sent, run }
}
@@ -10,33 +10,20 @@ import { optionsOf } from '../../../../../shared/plugins/declarative/testing/tes
import { isControlValue } from '../../../../../shared/plugins/declarative/testing/testing-value-controls'
import type {
PluginShotAct,
PluginShotActResult,
PluginShotQuery
PluginShotActResult
} from '../../../../../shared/plugins/view/plugin-shot-acts'
import type {
PluginDeclarativeActTarget,
PluginDeclarativeDrawnProps
} from '../declarative/plugin-declarative-act-targets'
import type { PluginPanelActDriver } from '../../right-sidebar/plugin-panel-act-driver'
import type { PluginDeclarativeActTarget } from '../declarative/plugin-declarative-act-targets'
import {
callbackId,
runPluginPanelDataAct,
type PluginPanelActContext,
type PluginPanelActDeps
} from './plugin-view-data-acts'
export type PluginPanelActDeps = {
driver: PluginPanelActDriver
targetOf(props: PluginDeclarativeDrawnProps): PluginDeclarativeActTarget | null
/** Resolves once the panel has drawn what the last act caused. */
settle(): Promise<void>
}
export type { PluginPanelActDeps } from './plugin-view-data-acts'
const NOT_RUN = 'not run: an earlier action failed'
function callbackId(value: unknown): string | null {
return typeof value === 'object' &&
value !== null &&
'$callback' in value &&
typeof value.$callback === 'string'
? value.$callback
: null
}
const TAKES = {
type: { types: ['Input', 'Textarea'], words: 'an Input or Textarea' },
select: { types: ['Select', 'RadioGroup', 'Tabs'], words: 'a Select, RadioGroup or Tabs' },
@@ -52,103 +39,120 @@ function expectTaken(element: TestElement, verb: keyof typeof TAKES): void {
}
}
/** Runs each act on what Orca draws, as the test kit's verbs do; stops at the first failure. */
export async function runPluginPanelActs(
acts: readonly PluginShotAct[],
{ driver, targetOf, settle }: PluginPanelActDeps
): Promise<PluginShotActResult[]> {
function createActContext({
driver,
targetOf,
expansionOf,
settle
}: PluginPanelActDeps): PluginPanelActContext {
const valueOf = (element: TestElement) => {
const value = targetOf(element.props)?.value()
return isControlValue(value) ? value : ''
}
const elements = (): TestElement[] => visibleElements(listElements(driver.tree()), valueOf)
const resolve = (query: PluginShotQuery, verb: string): TestElement => {
const found = innermost(elements().filter((element) => matchesQuery(element, query)))[0]
if (!found) {
throw new Error(`${verb}: nothing in the panel matches { ${describeQuery(query)} }`)
}
if (found.disabled) {
throw new Error(`${verb}: the ${found.type} is disabled`)
}
return found
}
const editable = (element: TestElement, verb: string): PluginDeclarativeActTarget => {
const target = targetOf(element.props)
if (!target) {
throw new Error(`${verb}: the ${element.type} is not on screen`)
}
if (target.readOnly()) {
throw new Error(`${verb}: the ${element.type} is read-only (a value and no onChange)`)
}
return target
}
const change = (target: PluginDeclarativeActTarget, next: unknown, verb: string): void => {
if (!target.change(next)) {
throw new Error(`${verb}: the control does not take ${JSON.stringify(next)}`)
return {
elements,
settle,
expansionOf,
resolve: (query, verb) => {
const found = innermost(elements().filter((element) => matchesQuery(element, query)))[0]
if (!found) {
throw new Error(`${verb}: nothing in the panel matches { ${describeQuery(query)} }`)
}
if (found.disabled) {
throw new Error(`${verb}: the ${found.type} is disabled`)
}
return found
},
editable: (element, verb) => {
const target = targetOf(element.props)
if (!target) {
throw new Error(`${verb}: the ${element.type} is not on screen`)
}
if (target.readOnly()) {
throw new Error(`${verb}: the ${element.type} is read-only (a value and no onChange)`)
}
return target
},
change: (target: PluginDeclarativeActTarget, next, verb) => {
if (!target.change(next)) {
throw new Error(`${verb}: the control does not take ${JSON.stringify(next)}`)
}
},
send: async (callback, args, missing) => {
if (callback === null) {
throw new Error(missing)
}
driver.send(callback, args)
await settle()
}
}
const send = async (callback: string | null, args: string[], missing: string) => {
if (callback === null) {
throw new Error(missing)
}
driver.send(callback, args)
await settle()
}
}
const run = async (act: PluginShotAct): Promise<void> => {
if ('press' in act) {
const element = resolve(act.press, 'press')
const onPress = callbackId(element.props.onPress)
// Acts are not a person's input: a Link's href stays unopened and no gesture is granted.
async function runFormAct(act: PluginShotAct, context: PluginPanelActContext): Promise<boolean> {
const { resolve, editable, change, send, settle } = context
if ('press' in act) {
const element = resolve(act.press, 'press')
// Acts are not a person's input: a Link's href stays unopened and no gesture is granted.
await send(
callbackId(element.props.onPress),
[],
element.type === 'Link' && typeof element.props.href === 'string'
? "press: --act does not open a Link's href; only a person's click does"
: `press: the ${element.type} has no onPress`
)
return true
}
if ('type' in act) {
const element = resolve(act.type, 'type')
expectTaken(element, 'type')
const target = editable(element, 'type')
let value = act.replace ? '' : String(target.value())
if (act.replace) {
change(target, value, 'type')
}
for (const character of act.text) {
value += character
change(target, value, 'type')
}
await settle()
if (act.submit) {
await send(
onPress,
[],
element.type === 'Link' && typeof element.props.href === 'string'
? "press: --act does not open a Link's href; only a person's click does"
: `press: the ${element.type} has no onPress`
callbackId(element.props.onSubmit),
[String(target.value())],
`type: the ${element.type} has no onSubmit`
)
return
}
if ('type' in act) {
const element = resolve(act.type, 'type')
expectTaken(element, 'type')
const target = editable(element, 'type')
let value = act.replace ? '' : String(target.value())
if (act.replace) {
change(target, value, 'type')
}
for (const character of act.text) {
value += character
change(target, value, 'type')
}
await settle()
if (act.submit) {
await send(
callbackId(element.props.onSubmit),
[String(target.value())],
`type: the ${element.type} has no onSubmit`
)
}
return
}
if ('select' in act) {
const element = resolve(act.select, 'select')
expectTaken(element, 'select')
const option = optionsOf(element, elements()).find((entry) => entry.value === act.value)
if (!option || option.disabled) {
throw new Error(`select: the ${element.type} has no enabled option "${act.value}"`)
}
change(editable(element, 'select'), act.value, 'select')
await settle()
return
return true
}
if ('select' in act) {
const element = resolve(act.select, 'select')
expectTaken(element, 'select')
const option = optionsOf(element, context.elements()).find((entry) => entry.value === act.value)
if (!option || option.disabled) {
throw new Error(`select: the ${element.type} has no enabled option "${act.value}"`)
}
change(editable(element, 'select'), act.value, 'select')
await settle()
return true
}
if ('toggle' in act) {
const element = resolve(act.toggle, 'toggle')
expectTaken(element, 'toggle')
const target = editable(element, 'toggle')
change(target, target.value() !== true, 'toggle')
await settle()
return true
}
return false
}
/** Runs each act on what Orca draws, as the test kit's verbs do; stops at the first failure. */
export async function runPluginPanelActs(
acts: readonly PluginShotAct[],
deps: PluginPanelActDeps
): Promise<PluginShotActResult[]> {
const context = createActContext(deps)
const results: PluginShotActResult[] = []
for (const act of acts) {
if (results.some((result) => !result.ok)) {
@@ -156,7 +160,9 @@ export async function runPluginPanelActs(
continue
}
try {
await run(act)
if (!(await runFormAct(act, context))) {
await runPluginPanelDataAct(act, context)
}
results.push({ ok: true })
} catch (error) {
results.push({ ok: false, error: error instanceof Error ? error.message : String(error) })
@@ -0,0 +1,65 @@
import type {
PluginShotAct,
PluginShotActResult
} from '../../../../../shared/plugins/view/plugin-shot-acts'
import {
pluginDeclarativeActExpansion,
pluginDeclarativeActTarget
} from '../declarative/plugin-declarative-act-targets'
import {
pluginPanelActDriver,
type PluginPanelActDriver
} from '../../right-sidebar/plugin-panel-act-driver'
import { runPluginPanelActs } from './plugin-view-acts'
import {
PLUGIN_PANEL_ACT_FIRST_DRAW_MS,
PLUGIN_PANEL_SHOT_FIRST_QUIET_MS,
settlePluginPanel
} from './plugin-view-panel-settle'
// The panel `--act` changed, until `end-capture` puts its atoms back.
let acted: PluginPanelActDriver | null = null
/**
* A drawn components panel before its capture: waits for its worker's answers, then runs `acts`
* (remembering the panel's atoms first). Not ok: acts for a panel that is not drawn components.
*/
export async function settleAndActOnPanel(
tabKey: string,
ui: 'components' | 'html' | undefined,
acts: readonly PluginShotAct[] | undefined
): Promise<
{ ok: true; pendingRequests?: number; actions?: PluginShotActResult[] } | { ok: false }
> {
const driver = ui === 'components' ? pluginPanelActDriver(tabKey) : null
if (!driver) {
return acts ? { ok: false } : { ok: true }
}
await settlePluginPanel(driver, PLUGIN_PANEL_SHOT_FIRST_QUIET_MS)
if (!acts) {
return { ok: true, pendingRequests: driver.pending() }
}
acted = driver
driver.snapshot()
const actions = await runPluginPanelActs(acts, {
driver,
targetOf: pluginDeclarativeActTarget,
expansionOf: pluginDeclarativeActExpansion,
settle: () => settlePluginPanel(driver, PLUGIN_PANEL_ACT_FIRST_DRAW_MS)
})
return { ok: true, pendingRequests: driver.pending(), actions }
}
/** After the capture: the acted panel's atoms back, drawn; undefined when nothing acted. */
export async function restoreActedPanel(): Promise<boolean | undefined> {
const driver = acted
acted = null
if (!driver) {
return undefined
}
const restored = driver.restore()
if (restored) {
await settlePluginPanel(driver, PLUGIN_PANEL_ACT_FIRST_DRAW_MS)
}
return restored
}
@@ -0,0 +1,164 @@
// @vitest-environment happy-dom
import { cleanup, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { cb, mountActGuest, node } from './plugin-view-act-test-harness'
afterEach(cleanup)
const FILES = ['b.ts', 'a.ts', 'c.ts']
/** A file browser guest: a sortable, selectable, paged Table, a lazy Tree and a Slider. */
type FilesState = {
sort: { column: string; direction: string }
selected: readonly string[]
opened: string[]
pages: number
lazy: boolean
treeSelected: string
volume: number
}
function mountFilesGuest() {
const state: FilesState = {
sort: { column: 'name', direction: 'asc' },
selected: [],
opened: [],
pages: 1,
lazy: false,
treeSelected: '',
volume: 3
}
const guest = mountActGuest(
() =>
node('Stack', {}, [
node('Table', {
label: 'Files',
columns: [
{ key: 'name', label: 'Name', sortable: true },
{ key: 'size', label: 'Size' }
],
rows: FILES.map((name) => ({ key: name, cells: { name, size: 1 } })),
sort: state.sort,
selectionMode: 'multiple',
selected: [...state.selected],
hasMore: state.pages < 5,
onSortChange: cb('sort'),
onSelectionChange: cb('select'),
onRowAction: cb('open'),
onLoadMore: cb('more')
}),
node('Tree', {
label: 'Folders',
items: [
{
key: 'src',
label: 'src',
hasChildren: true,
...(state.lazy ? { children: [{ key: 'src/main.ts', label: 'main.ts' }] } : {})
},
{ key: 'README.md', label: 'README.md' }
],
selected: state.treeSelected,
onToggle: cb('toggle'),
onSelect: cb('pick'),
onAction: cb('run')
}),
node('Slider', {
label: 'Volume',
min: 0,
max: 10,
value: state.volume,
onChange: cb('vol')
})
]),
({ callback, args }) => {
const arg = args[0]
const field = (name: string): unknown =>
typeof arg === 'object' && arg !== null && name in arg
? Object.entries(arg).find(([key]) => key === name)?.[1]
: undefined
if (callback === 'sort') {
state.sort = { column: String(field('column')), direction: String(field('direction')) }
} else if (callback === 'select') {
const keys = field('keys')
state.selected = Array.isArray(keys) ? keys.map(String) : []
} else if (callback === 'open') {
state.opened.push(String(field('key')))
} else if (callback === 'more') {
state.pages += 1
} else if (callback === 'toggle') {
state.lazy = field('expanded') === true
} else if (callback === 'pick') {
state.treeSelected = String(field('key'))
} else if (callback === 'vol' && typeof arg === 'number') {
state.volume = arg
}
}
)
return { ...guest, state }
}
describe('shot --act Table, Tree and Slider verbs', () => {
it('sorts, selects, activates and pages a Table as its header, rows and scroll would', async () => {
const guest = mountFilesGuest()
const table = { type: 'Table' }
// Orca itself asks for more once the last row shows; the act asks once more on top.
const pagesBefore = guest.state.pages
expect(
await guest.run([
{ sort: table, column: 'name' },
{ selectRows: table, keys: ['a.ts', 'c.ts'] },
{ activateRow: table, key: 'b.ts' },
{ loadMore: table }
])
).toEqual([{ ok: true }, { ok: true }, { ok: true }, { ok: true }])
// Ascending was on, so the header flips it.
expect(guest.state.sort).toEqual({ column: 'name', direction: 'desc' })
expect(guest.state.selected).toEqual(['a.ts', 'c.ts'])
expect(guest.state.opened).toEqual(['b.ts'])
expect(guest.state.pages).toBeGreaterThan(pagesBefore)
// The last page: the guest re-renders without hasMore on its next event.
guest.state.pages = 5
await guest.run([{ activateRow: table, key: 'a.ts' }])
expect(await guest.run([{ loadMore: table }])).toEqual([
{ ok: false, error: 'loadMore: the Table has no more rows (hasMore and onLoadMore)' }
])
expect(await guest.run([{ sort: table, column: 'size' }])).toEqual([
{ ok: false, error: 'sort: the Table has no sortable column "size"' }
])
expect(await guest.run([{ activateRow: table, key: 'z.ts' }])).toEqual([
{ ok: false, error: 'activateRow: the Table has no enabled row "z.ts"' }
])
})
it('expands a lazy Tree item, selects and activates what it loaded, then collapses it', async () => {
const guest = mountFilesGuest()
const tree = { label: 'Folders' }
expect(
await guest.run([
{ expand: tree, key: 'src' },
{ selectItem: tree, key: 'src/main.ts' },
{ activateItem: tree, key: 'src/main.ts' }
])
).toEqual([{ ok: true }, { ok: true }, { ok: true }])
expect(screen.getByRole('treeitem', { name: /main\.ts/ })).toBeTruthy()
expect(guest.state.treeSelected).toBe('src/main.ts')
// Already open: nothing more is sent, as the kit does.
await guest.run([{ expand: tree, key: 'src' }])
expect(guest.sent.filter((event) => event.callback === 'toggle')).toHaveLength(1)
await guest.run([{ collapse: tree, key: 'src' }])
expect(guest.state.lazy).toBe(false)
expect(await guest.run([{ expand: tree, key: 'README.md' }])).toEqual([
{ ok: false, error: 'expand: the Tree item "README.md" has no children' }
])
})
it('slides within the bounds only', async () => {
const guest = mountFilesGuest()
expect(await guest.run([{ slide: { label: 'Volume' }, value: 7 }])).toEqual([{ ok: true }])
expect(guest.state.volume).toBe(7)
expect(await guest.run([{ slide: { label: 'Volume' }, value: 11 }])).toEqual([
{ ok: false, error: "slide: 11 is outside the Slider's 0 to 10" }
])
})
})
@@ -0,0 +1,137 @@
import type { TestElement } from '../../../../../shared/plugins/declarative/testing/testing-tree'
import {
enabledRow,
expectType,
isExpandable,
nextSort,
treeItem
} from '../../../../../shared/plugins/declarative/testing/testing-data-controls'
import type { PluginDeclarativeEventArg } from '../../../../../shared/plugins/declarative/plugin-declarative-messages'
import type {
PluginShotAct,
PluginShotQuery
} from '../../../../../shared/plugins/view/plugin-shot-acts'
import type {
PluginDeclarativeActExpansion,
PluginDeclarativeActTarget,
PluginDeclarativeDrawnProps
} from '../declarative/plugin-declarative-act-targets'
import type { PluginPanelActDriver } from '../../right-sidebar/plugin-panel-act-driver'
export type PluginPanelActDeps = {
driver: Pick<PluginPanelActDriver, 'tree' | 'send'>
targetOf(props: PluginDeclarativeDrawnProps): PluginDeclarativeActTarget | null
expansionOf(props: PluginDeclarativeDrawnProps): PluginDeclarativeActExpansion | null
/** Resolves once the panel has drawn what the last act caused. */
settle(): Promise<void>
}
/** What each verb needs of the drawn panel; built once per act list. */
export type PluginPanelActContext = {
elements(): TestElement[]
resolve(query: PluginShotQuery, verb: string): TestElement
editable(element: TestElement, verb: string): PluginDeclarativeActTarget
change(target: PluginDeclarativeActTarget, next: unknown, verb: string): void
/** Sends `callback`, then settles; throws `missing` when the element has no such handler. */
send(callback: string | null, args: PluginDeclarativeEventArg[], missing: string): Promise<void>
expansionOf: PluginPanelActDeps['expansionOf']
settle(): Promise<void>
}
export function callbackId(value: unknown): string | null {
return typeof value === 'object' &&
value !== null &&
'$callback' in value &&
typeof value.$callback === 'string'
? value.$callback
: null
}
async function expandOrCollapse(
tree: TestElement,
key: string,
open: boolean,
context: PluginPanelActContext
): Promise<void> {
const verb = open ? 'expand' : 'collapse'
expectType(tree, 'Tree', verb)
if (!isExpandable(treeItem(tree, key, verb))) {
throw new Error(`${verb}: the Tree item "${key}" has no children`)
}
const expansion = context.expansionOf(tree.props)
if (!expansion) {
throw new Error(`${verb}: the Tree is not on screen`)
}
if (expansion.expanded().has(key) !== open) {
expansion.set(key, open)
await context.settle()
}
}
/** Slider, Table and Tree verbs, with the test kit's checks and messages. */
export async function runPluginPanelDataAct(
act: PluginShotAct,
context: PluginPanelActContext
): Promise<void> {
const { resolve, editable, change, send, settle } = context
if ('slide' in act) {
const slider = resolve(act.slide, 'slide')
expectType(slider, 'Slider', 'slide')
const min = typeof slider.props.min === 'number' ? slider.props.min : 0
const max = typeof slider.props.max === 'number' ? slider.props.max : 100
if (act.value < min || act.value > max) {
throw new Error(`slide: ${act.value} is outside the Slider's ${min} to ${max}`)
}
change(editable(slider, 'slide'), act.value, 'slide')
await settle()
} else if ('sort' in act) {
const table = resolve(act.sort, 'sort')
expectType(table, 'Table', 'sort')
const sort = nextSort(table, act.column)
await send(callbackId(table.props.onSortChange), [sort], 'sort: the Table has no onSortChange')
} else if ('selectRows' in act) {
const table = resolve(act.selectRows, 'selectRows')
expectType(table, 'Table', 'selectRows')
const mode = table.props.selectionMode ?? 'none'
if (mode === 'none' || (mode === 'single' && act.keys.length > 1)) {
throw new Error(`selectRows: the Table's selectionMode is ${String(mode)}`)
}
act.keys.forEach((key) => enabledRow(table, key, 'selectRows'))
change(editable(table, 'selectRows'), [...act.keys], 'selectRows')
await settle()
} else if ('activateRow' in act) {
const table = resolve(act.activateRow, 'activateRow')
expectType(table, 'Table', 'activateRow')
enabledRow(table, act.key, 'activateRow')
await send(
callbackId(table.props.onRowAction),
[{ key: act.key }],
'activateRow: the Table has no onRowAction'
)
} else if ('loadMore' in act) {
const table = resolve(act.loadMore, 'loadMore')
expectType(table, 'Table', 'loadMore')
const more = table.props.hasMore === true ? callbackId(table.props.onLoadMore) : null
await send(more, [], 'loadMore: the Table has no more rows (hasMore and onLoadMore)')
} else if ('expand' in act) {
await expandOrCollapse(resolve(act.expand, 'expand'), act.key, true, context)
} else if ('collapse' in act) {
await expandOrCollapse(resolve(act.collapse, 'collapse'), act.key, false, context)
} else if ('selectItem' in act) {
const tree = resolve(act.selectItem, 'selectItem')
expectType(tree, 'Tree', 'selectItem')
if (treeItem(tree, act.key, 'selectItem').disabled === true) {
throw new Error(`selectItem: the Tree item "${act.key}" is disabled`)
}
change(editable(tree, 'selectItem'), act.key, 'selectItem')
await settle()
} else if ('activateItem' in act) {
const tree = resolve(act.activateItem, 'activateItem')
expectType(tree, 'Tree', 'activateItem')
const action = callbackId(tree.props.onAction)
if (treeItem(tree, act.key, 'activateItem').disabled === true || action === null) {
throw new Error(`activateItem: the Tree item "${act.key}" cannot be activated`)
}
await send(action, [{ key: act.key }], '')
}
}
@@ -0,0 +1,85 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
PLUGIN_PANEL_MAX_SETTLE_MS,
PLUGIN_PANEL_SHOT_FIRST_QUIET_MS,
settlePluginPanel
} from './plugin-view-panel-settle'
function fakePanel() {
let pending = 0
const listeners = new Set<() => void>()
const changed = (): void => listeners.forEach((listener) => listener())
return {
driver: {
pending: () => pending,
onChange: (listener: () => void) => {
listeners.add(listener)
return () => listeners.delete(listener)
}
},
start: () => {
pending += 1
changed()
},
answer: () => {
pending -= 1
changed()
},
draw: changed,
listening: () => listeners.size
}
}
function track(promise: Promise<void>): { done: () => boolean } {
let done = false
void promise.then(() => {
done = true
})
return { done: () => done }
}
// Past the two painted frames (their 100ms fallbacks) that follow the wait.
const FRAMES_MS = 250
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
describe('settlePluginPanel', () => {
it('settles a quiet panel after the first quiet spell', async () => {
const panel = fakePanel()
const settled = track(settlePluginPanel(panel.driver, PLUGIN_PANEL_SHOT_FIRST_QUIET_MS))
await vi.advanceTimersByTimeAsync(PLUGIN_PANEL_SHOT_FIRST_QUIET_MS - 1)
expect(settled.done()).toBe(false)
await vi.advanceTimersByTimeAsync(1 + FRAMES_MS)
expect(settled.done()).toBe(true)
expect(panel.listening()).toBe(0)
})
it("waits for the worker's answer and the tree it causes", async () => {
const panel = fakePanel()
panel.start()
const settled = track(settlePluginPanel(panel.driver, PLUGIN_PANEL_SHOT_FIRST_QUIET_MS))
await vi.advanceTimersByTimeAsync(2_000)
expect(settled.done()).toBe(false)
panel.answer()
await vi.advanceTimersByTimeAsync(100)
// The answer's tree restarts the quiet spell.
panel.draw()
await vi.advanceTimersByTimeAsync(149)
expect(settled.done()).toBe(false)
await vi.advanceTimersByTimeAsync(1 + FRAMES_MS)
expect(settled.done()).toBe(true)
})
it('gives up on a request that never answers at the cap', async () => {
const panel = fakePanel()
panel.start()
const settled = track(settlePluginPanel(panel.driver, PLUGIN_PANEL_SHOT_FIRST_QUIET_MS))
await vi.advanceTimersByTimeAsync(PLUGIN_PANEL_MAX_SETTLE_MS - 1)
expect(settled.done()).toBe(false)
await vi.advanceTimersByTimeAsync(1 + FRAMES_MS)
expect(settled.done()).toBe(true)
expect(panel.driver.pending()).toBe(1)
})
})
@@ -4,6 +4,7 @@ import {
subscribePluginPanelDraw,
type PluginPanelDrawStatus
} from '../../right-sidebar/plugin-panel-draw-status'
import type { PluginPanelActDriver } from '../../right-sidebar/plugin-panel-act-driver'
// Why a fallback: a hidden window may not tick animation frames, and a capture must not hang.
const FRAME_FALLBACK_MS = 100
@@ -77,27 +78,51 @@ export function measurePluginPanelGuest(
}
// Why these: a handler that awaits a host call draws well within 1s, a burst of renders ends within
// a quiet 150ms, and 3s caps an act whose panel keeps re-rendering.
const ACT_FIRST_DRAW_MS = 1_000
const ACT_QUIET_MS = 150
export const PLUGIN_PANEL_ACT_MAX_SETTLE_MS = 3_000
// a quiet 150ms, a cold panel asks its worker within 300ms of its first tree, and 3s caps a panel
// that keeps re-rendering or a request that never answers.
export const PLUGIN_PANEL_ACT_FIRST_DRAW_MS = 1_000
export const PLUGIN_PANEL_SHOT_FIRST_QUIET_MS = 300
const QUIET_MS = 150
export const PLUGIN_PANEL_MAX_SETTLE_MS = 3_000
/** After an act: waits for the trees it causes to stop coming, then for them to paint. */
export async function settlePluginPanelAfterAct(onDraw: (listener: () => void) => () => void) {
/**
* Waits until the panel has no host call in flight (its worker answered) and no new tree for a
* quiet spell (`firstQuietMs` at first), then for what it drew to paint; at most 3s.
*/
export async function settlePluginPanel(
driver: Pick<PluginPanelActDriver, 'pending' | 'onChange'>,
firstQuietMs: number
): Promise<void> {
await new Promise<void>((resolve) => {
let quiet: ReturnType<typeof setTimeout> | undefined
let unsubscribe = (): void => {}
const finish = (): void => {
clearTimeout(quiet)
clearTimeout(cap)
unsubscribe()
resolve()
}
const cap = setTimeout(finish, PLUGIN_PANEL_ACT_MAX_SETTLE_MS)
quiet = setTimeout(finish, ACT_FIRST_DRAW_MS)
const unsubscribe = onDraw(() => {
const arm = (ms: number): void => {
clearTimeout(quiet)
quiet = setTimeout(finish, ACT_QUIET_MS)
})
quiet = driver.pending() > 0 ? undefined : setTimeout(finish, ms)
}
const cap = setTimeout(finish, PLUGIN_PANEL_MAX_SETTLE_MS)
arm(firstQuietMs)
unsubscribe = driver.onChange(() => arm(QUIET_MS))
})
await nextPaintedFrames()
}
// Why a cap: a theme Orca cannot apply must not hold the shot forever.
const COLOR_SCHEME_MAX_FRAMES = 60
/** Frames until the document shows the asked color scheme, so the capture is of that repaint. */
export async function waitForColorScheme(wantDark: boolean | null): Promise<void> {
for (let frame = 0; wantDark !== null && frame < COLOR_SCHEME_MAX_FRAMES; frame += 1) {
if (document.documentElement.classList.contains('dark') === wantDark) {
break
}
await nextFrame()
}
await nextPaintedFrames()
}
@@ -7,7 +7,6 @@ import {
type PluginViewReply,
type PluginViewRequest
} from '../../../../../shared/plugins/view/plugin-view-protocol'
import type { PluginShotActResult } from '../../../../../shared/plugins/view/plugin-shot-acts'
import { detectLanguage } from '@/lib/language-detect'
import { canShowRightSidebarForView } from '@/lib/right-sidebar-visibility'
import { useAppStore } from '@/store'
@@ -23,12 +22,10 @@ import {
measurePluginPanelGuest,
measurePluginPanelSurface,
nextPaintedFrames,
settlePluginPanelAfterAct,
waitForColorScheme,
waitForPluginPanelDraw
} from './plugin-view-panel-settle'
import { runPluginPanelActs } from './plugin-view-acts'
import { pluginPanelActDriver } from '../../right-sidebar/plugin-panel-act-driver'
import { pluginDeclarativeActTarget } from '../declarative/plugin-declarative-act-targets'
import { restoreActedPanel, settleAndActOnPanel } from './plugin-view-capture-acts'
import { pluginPanelNotShownReason } from './plugin-view-shown'
type Failure = Extract<PluginViewReply, { ok: false }>
@@ -103,18 +100,10 @@ async function capturePanel(
if (status.value?.failure) {
return fail(requestId, 'panel_failed', `The plugin panel ${status.value.failure}.`)
}
await nextPaintedFrames()
let actions: PluginShotActResult[] | undefined
if (capture.acts) {
const driver = status.value?.ui === 'components' ? pluginPanelActDriver(tabKey) : null
if (!driver) {
return fail(requestId, 'act_unsupported', 'Only a drawn components panel takes --act.')
}
actions = await runPluginPanelActs(capture.acts, {
driver,
targetOf: pluginDeclarativeActTarget,
settle: () => settlePluginPanelAfterAct(driver.onDraw)
})
await waitForColorScheme(wantDark)
const prepared = await settleAndActOnPanel(tabKey, status.value?.ui, capture.acts)
if (!prepared.ok) {
return fail(requestId, 'act_unsupported', 'Only a drawn components panel takes --act.')
}
let rect = measurePluginPanelSurface(tabKey)
// The panel sits inside the sidebar's border; one correction makes it exactly --width wide.
@@ -122,6 +111,8 @@ async function capturePanel(
usePluginViewCaptureStore
.getState()
.setPanelWidth(capture.width + (capture.width - Math.round(rect.width)))
// A resize relayouts the panel; let it paint before main captures.
await nextPaintedFrames()
await nextPaintedFrames()
rect = measurePluginPanelSurface(tabKey)
}
@@ -142,7 +133,10 @@ async function capturePanel(
colorScheme: dark ? 'dark' : 'light',
themeApplied: wantDark !== null && dark === wantDark,
settled: status.settled,
...(actions ? { actions } : {})
...(prepared.pendingRequests === undefined
? {}
: { pendingRequests: prepared.pendingRequests }),
...(prepared.actions ? { actions: prepared.actions } : {})
}
}
}
@@ -193,9 +187,15 @@ export async function handlePluginViewRequest(
return await openPanel(request)
case 'open-editor':
return await openEditor(request)
case 'end-capture':
case 'end-capture': {
usePluginViewCaptureStore.getState().end()
return { requestId: request.requestId, ok: true }
const restored = await restoreActedPanel()
return {
requestId: request.requestId,
ok: true,
...(restored === undefined ? {} : { restored })
}
}
}
} catch (error) {
return fail(
@@ -101,16 +101,6 @@ export function PluginDeclarativePanel({
useReportDeclarativePanelDraw(tabKey, guest.url, rendered?.document ?? null, issue)
usePluginFrameHost({
surface: 'panel',
frameRef,
frameKey,
sessionToken,
declarative: channel,
consumeHostActivation: consumePress,
onUnresponsive
})
const shownDocument = rendered?.document ?? ''
const revision = rendered?.revision ?? 0
const onEvent = useCallback<PluginDeclarativeEmit>(
@@ -130,7 +120,23 @@ export function PluginDeclarativePanel({
},
[admitPress, revision, sendToGuest, shownDocument]
)
usePluginPanelActDriver(tabKey, rendered?.tree ?? null, onEvent)
const { viewEpoch, trackHostCall } = usePluginPanelActDriver({
tabKey,
tree: rendered?.tree ?? null,
document: rendered?.document ?? null,
send: onEvent,
sendToGuest
})
usePluginFrameHost({
surface: 'panel',
frameRef,
frameKey,
sessionToken,
declarative: channel,
consumeHostActivation: consumePress,
trackHostCall,
onUnresponsive
})
// A click on Orca's own Link (or a Markdown link): no frame-activation check, but its control
// must have settled, and main keeps the URL allowlist and rate limit.
const onOpenLink = (href: string, gestureId: string): void => {
@@ -167,8 +173,8 @@ export function PluginDeclarativePanel({
) : null}
{reloads > 0 ? <PluginDeclarativeReloadedNotice key={reloads} /> : null}
<PluginDeclarativeView
// A new guest document starts its value controls' edit counts over.
key={shownDocument}
// A new guest document starts its value controls' edit counts over; so does a --act restore.
key={`${shownDocument}:${viewEpoch}`}
tree={rendered?.tree ?? null}
onEvent={onEvent}
onOpenLink={onOpenLink}
@@ -89,4 +89,31 @@ describe('declarative render lane', () => {
await flush()
expect(callHostMethod).toHaveBeenCalledOnce()
})
it('tracks each relayed host call until it settles, so a shot can wait for the worker', async () => {
let answer = (_value: unknown): void => {}
const callHostMethod = vi
.fn()
.mockImplementationOnce(() => new Promise((resolve) => (answer = resolve)))
.mockRejectedValueOnce(new Error('ipc broke'))
let pending = 0
const trackHostCall = () => {
pending += 1
return () => {
pending -= 1
}
}
const { handle } = declarativeGuest({ callHostMethod, trackHostCall })
const request = (id: number, method: string) => ({ orca: 1, kind: 'req', id, method })
handle(request(1, 'plugin.request'), guestContext)
handle(request(2, 'storage.get'), guestContext)
// Refused before it reaches main: nothing to wait for.
handle(request(3, 'shell.openExternal'), guestContext)
expect(pending).toBe(2)
await flush()
expect(pending).toBe(1)
answer({ ok: true, value: 1 })
await flush()
expect(pending).toBe(0)
})
})
@@ -89,6 +89,8 @@ export type PluginFrameHostOptions = {
renderBudget?: PanelMessageBudget
/** One-shot activation from a press on Orca's own controls (declarative panels). */
consumeHostActivation?: () => boolean
/** Called as a host call (`plugin.request` included) starts; the result runs when it settles. */
trackHostCall?: () => () => void
now?: () => number
}
@@ -278,6 +280,7 @@ export function createPluginFrameEnvelopeHandler(
respond(pluginFailure(request.id, pluginError('unavailable', 'plugin session is not ready')))
return
}
const settled = options.trackHostCall?.()
callHostMethod({ sessionToken, action: request.method, params: request.params })
.then((outcome) => respond(pluginOutcomeResponse(request.id, outcome)))
.catch((error: unknown) =>
@@ -288,5 +291,6 @@ export function createPluginFrameEnvelopeHandler(
)
)
)
.finally(() => settled?.())
}
}
@@ -0,0 +1,74 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { pluginPanelActDriver, usePluginPanelActDriver } from './plugin-panel-act-driver'
afterEach(cleanup)
function mount(document: string | null = 'doc-1') {
const sendToGuest = vi.fn()
const send = vi.fn()
const hook = renderHook(
(props: { document: string | null }) =>
usePluginPanelActDriver({
tabKey: 'plugin:acme.todos/todos',
tree: null,
document: props.document,
send,
sendToGuest
}),
{ initialProps: { document } }
)
const driver = pluginPanelActDriver('plugin:acme.todos/todos')
if (!driver) {
throw new Error('the panel did not register')
}
return { hook, driver, sendToGuest, send }
}
describe('usePluginPanelActDriver', () => {
it('counts host calls in flight, telling listeners as each starts and settles', () => {
const { hook, driver } = mount()
const changes = vi.fn()
driver.onChange(changes)
const first = hook.result.current.trackHostCall()
const second = hook.result.current.trackHostCall()
expect(driver.pending()).toBe(2)
first()
first()
expect(driver.pending()).toBe(1)
second()
expect(driver.pending()).toBe(0)
expect(changes).toHaveBeenCalledTimes(4)
})
it("snapshots and restores the shown guest's atoms, then redraws Orca's controls", () => {
const { hook, driver, sendToGuest } = mount()
driver.snapshot()
expect(sendToGuest).toHaveBeenLastCalledWith('ui.snapshot', { document: 'doc-1' })
let restored = false
act(() => {
restored = driver.restore()
})
expect(restored).toBe(true)
expect(sendToGuest).toHaveBeenLastCalledWith('ui.restore', { document: 'doc-1' })
expect(hook.result.current.viewEpoch).toBe(1)
// Once: a second restore has nothing to put back.
expect(driver.restore()).toBe(false)
})
it('does not restore into a guest that reloaded since the snapshot', () => {
const { hook, driver, sendToGuest } = mount()
driver.snapshot()
hook.rerender({ document: 'doc-2' })
expect(driver.restore()).toBe(false)
expect(sendToGuest).toHaveBeenCalledTimes(1)
expect(hook.result.current.viewEpoch).toBe(0)
})
it('unregisters when the panel unmounts', () => {
const { hook } = mount()
hook.unmount()
expect(pluginPanelActDriver('plugin:acme.todos/todos')).toBeNull()
})
})
@@ -1,17 +1,25 @@
import { useLayoutEffect, useRef } from 'react'
import { useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { PluginDeclarativeNode } from '../../../../shared/plugins/declarative/plugin-declarative-catalog'
import type { PluginDeclarativeEventArg } from '../../../../shared/plugins/declarative/plugin-declarative-messages'
import { PLUGIN_DECLARATIVE_MESSAGES } from '../../../../shared/plugins/declarative/plugin-declarative-message-names'
/**
* What `orca plugin shot --act` drives on a mounted components panel: the tree on screen and the
* guest callbacks Orca's own controls fire. Sends carry no press, so they never grant a gesture.
* What `orca plugin shot` drives and waits on in a mounted components panel: the tree on screen,
* the guest callbacks Orca's own controls fire, and the host calls in flight. Sends carry no
* press, so they never grant a gesture.
*/
export type PluginPanelActDriver = {
/** The tree React last committed for this panel. */
tree(): PluginDeclarativeNode | null
send(callback: string, args: PluginDeclarativeEventArg[]): void
/** Called after each tree this panel commits. */
onDraw(listener: () => void): () => void
/** Host calls (`plugin.request` included) the panel is still waiting on. */
pending(): number
/** Called after each committed tree and each change in `pending()`. */
onChange(listener: () => void): () => void
/** Asks the guest to remember its atoms before `--act`. */
snapshot(): void
/** Puts them back and redraws Orca's controls; false when that guest is gone (a reload). */
restore(): boolean
}
const drivers = new Map<string, PluginPanelActDriver>()
@@ -20,32 +28,70 @@ export function pluginPanelActDriver(tabKey: string): PluginPanelActDriver | nul
return drivers.get(tabKey) ?? null
}
/** Registers the panel for `tabKey` while it is mounted. */
export function usePluginPanelActDriver(
tabKey: string,
tree: PluginDeclarativeNode | null,
/**
* Registers the panel for `tabKey` while it is mounted. `viewEpoch` keys Orca's drawn view, so a
* restore also resets what its controls hold locally; `trackHostCall` goes to the frame host.
*/
export function usePluginPanelActDriver(options: {
tabKey: string
tree: PluginDeclarativeNode | null
document: string | null
send: (callback: string, args: PluginDeclarativeEventArg[]) => void
): void {
sendToGuest: (event: string, payload: unknown) => void
}): { viewEpoch: number; trackHostCall: () => () => void } {
const { tabKey, tree } = options
const [viewEpoch, setViewEpoch] = useState(0)
const latest = useRef(options)
const treeRef = useRef(tree)
const sendRef = useRef(send)
const listenersRef = useRef(new Set<() => void>())
const [listeners] = useState(() => new Set<() => void>())
const pendingRef = useRef(0)
useLayoutEffect(() => {
sendRef.current = send
latest.current = options
})
useLayoutEffect(() => {
treeRef.current = tree
for (const listener of listenersRef.current) {
listener()
}
}, [tree])
listeners.forEach((listener) => listener())
}, [listeners, tree])
const trackHostCall = useMemo(
() => () => {
pendingRef.current += 1
listeners.forEach((listener) => listener())
let done = false
return () => {
if (!done) {
done = true
pendingRef.current -= 1
listeners.forEach((listener) => listener())
}
}
},
[listeners]
)
useLayoutEffect(() => {
const listeners = listenersRef.current
let snapshotOf: string | null = null
const driver: PluginPanelActDriver = {
tree: () => treeRef.current,
send: (callback, args) => sendRef.current(callback, args),
onDraw: (listener) => {
send: (callback, args) => latest.current.send(callback, args),
pending: () => pendingRef.current,
onChange: (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
snapshot: () => {
snapshotOf = latest.current.document
if (snapshotOf) {
latest.current.sendToGuest(PLUGIN_DECLARATIVE_MESSAGES.snapshot, { document: snapshotOf })
}
},
restore: () => {
const document = snapshotOf
snapshotOf = null
if (!document || document !== latest.current.document) {
return false
}
latest.current.sendToGuest(PLUGIN_DECLARATIVE_MESSAGES.restore, { document })
setViewEpoch((epoch) => epoch + 1)
return true
}
}
drivers.set(tabKey, driver)
@@ -54,5 +100,6 @@ export function usePluginPanelActDriver(
drivers.delete(tabKey)
}
}
}, [tabKey])
}, [listeners, tabKey])
return { viewEpoch, trackHostCall }
}
@@ -31,12 +31,22 @@ export function usePluginFrameHost(options: {
declarative?: PluginFrameDeclarativeChannel
/** Must be stable: a one-shot grant from presses on Orca's own controls. */
consumeHostActivation?: () => boolean
/** Must be stable: counts the panel's host calls in flight. */
trackHostCall?: () => () => void
onUnresponsive: () => void
/** The guest's WebContents went away under an unchanged document (closed, crashed). */
onGuestGone?: () => void
}): { onFrameLoad: () => void } {
const { surface, frameRef, frameKey, sessionToken, editor, declarative, consumeHostActivation } =
options
const {
surface,
frameRef,
frameKey,
sessionToken,
editor,
declarative,
consumeHostActivation,
trackHostCall
} = options
const [loadedFrameKey, setLoadedFrameKey] = useState<string | null>(null)
const onUnresponsiveRef = useRef(options.onUnresponsive)
const onGuestGoneRef = useRef(options.onGuestGone)
@@ -75,7 +85,8 @@ export function usePluginFrameHost(options: {
onPong: (pingId: number) => watchdog.handlePong(pingId),
editor,
declarative,
consumeHostActivation
consumeHostActivation,
trackHostCall
}
if (element instanceof HTMLIFrameElement) {
const handler = createPluginFrameMessageHandler(hostOptions)
@@ -128,6 +139,7 @@ export function usePluginFrameHost(options: {
frameRef,
getFrameWindow,
surface,
trackHostCall,
watchdog
])
@@ -24,7 +24,11 @@ export const PLUGIN_DECLARATIVE_MESSAGES = {
/** guest → host, `{ entries: [{ scope, key, version, value }] }`: persisted atoms changed. */
persist: 'ui.persist',
/** guest → host, `{ document, entries: [{ key, version, value? }] }`: keyed memory atoms changed. */
retain: 'ui.retain'
retain: 'ui.retain',
/** host → guest, `{ document }`: remember every atom's value before `orca plugin shot --act`. */
snapshot: 'ui.snapshot',
/** host → guest, `{ document }`: put the atoms back to the last `ui.snapshot`. */
restore: 'ui.restore'
} as const
/** Grows with the component catalog, so plugins can feature-detect with `ui.catalog`. */
@@ -0,0 +1,66 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
atom,
persistedEntry,
read,
resetAtoms,
set,
watchPersistedAtoms,
type OrcaAtom
} from './declarative-atoms'
import { actSnapshotSubscriptions } from './declarative-act-snapshot'
function fakeBridge() {
const handlers = new Map<string, (payload: unknown) => void>()
return {
on: (event: string, handler: (payload: unknown) => void) => {
handlers.set(event, handler)
return { dispose: () => handlers.delete(event) }
},
send: (event: string, payload: unknown) => handlers.get(event)?.(payload)
}
}
afterEach(() => {
watchPersistedAtoms(null)
resetAtoms()
})
describe('shot --act atom snapshot', () => {
it('puts every plugin-written atom back, re-persisting the persisted ones', () => {
const tab = atom('snapshot.tab', 'all', { persist: 'plugin' })
const draft = atom('snapshot.draft', '')
const count = atom(0)
const persisted: unknown[] = []
watchPersistedAtoms((target: OrcaAtom<unknown>) => persisted.push(persistedEntry(target)))
const bridge = fakeBridge()
actSnapshotSubscriptions(bridge.on, 'doc-1')
bridge.send('ui.snapshot', { document: 'doc-1' })
set(tab, 'done')
set(draft, 'Ship')
set(count, 3)
persisted.length = 0
bridge.send('ui.restore', { document: 'doc-1' })
expect([read(tab), read(draft), read(count)]).toEqual(['all', '', 0])
expect(persisted).toEqual([{ scope: 'plugin', key: 'snapshot.tab', version: 1, value: 'all' }])
})
it("ignores another document's messages and restores at most once", () => {
const count = atom(1)
const bridge = fakeBridge()
actSnapshotSubscriptions(bridge.on, 'doc-1')
bridge.send('ui.snapshot', { document: 'doc-2' })
set(count, 2)
bridge.send('ui.restore', { document: 'doc-1' })
expect(read(count)).toBe(2)
bridge.send('ui.snapshot', { document: 'doc-1' })
set(count, 5)
bridge.send('ui.restore', { document: 'doc-1' })
set(count, 6)
bridge.send('ui.restore', { document: 'doc-1' })
expect(read(count)).toBe(6)
})
})
@@ -0,0 +1,31 @@
import { PLUGIN_DECLARATIVE_MESSAGES } from '../plugin-declarative-message-names'
import { captureAtomValues } from './declarative-atoms'
import { readHostMessage } from './declarative-host-messages'
import type { OrcaBridge, OrcaDisposable } from './declarative-runtime-types'
/**
* `orca plugin shot --act` changes the live panel; Orca asks this guest to remember its atoms
* before acting (`ui.snapshot`) and to put them back after the capture (`ui.restore`). Only this
* guest's atoms: what handlers did elsewhere (worker requests, storage writes) stays.
*/
export function actSnapshotSubscriptions(
on: NonNullable<OrcaBridge['on']>,
document: string
): OrcaDisposable[] {
let restore: (() => void) | null = null
const ours = (payload: unknown): boolean => readHostMessage(payload).document === document
return [
on(PLUGIN_DECLARATIVE_MESSAGES.snapshot, (payload) => {
if (ours(payload)) {
restore = captureAtomValues()
}
}),
on(PLUGIN_DECLARATIVE_MESSAGES.restore, (payload) => {
if (ours(payload) && restore) {
const pending = restore
restore = null
pending()
}
})
]
}
@@ -280,6 +280,22 @@ export function applyStorageValue(key: string, value: unknown): void {
}
}
/**
* `orca plugin shot --act`: remembers every plugin-written atom's value; the returned function
* sets them back as plugin code would, so persisted atoms store their old values again.
*/
export function captureAtomValues(): () => void {
const values = new Map<AtomState<unknown>, unknown>()
for (const inner of writableAtoms) {
values.set(inner, inner.value)
}
return () => {
for (const [inner, value] of values) {
assign(inner, value, true)
}
}
}
/** Applies stored values for a scope (on boot, or the workspace's after a workspace switch). */
export function hydrateAtoms(
scope: SnapshotScope,
@@ -335,6 +335,8 @@ describe('declarative runtime', () => {
'ui.ack',
'ui.event',
'ui.hydrate',
'ui.restore',
'ui.snapshot',
'ui.visibility'
])
runtime.dispose()
@@ -10,6 +10,7 @@ import type {
} from './declarative-runtime-types'
import { evaluateOrcaTree, type OrcaCallback } from './declarative-evaluate'
import { createMemoryRetention } from './declarative-memory-retention'
import { actSnapshotSubscriptions } from './declarative-act-snapshot'
import {
defaultSchedule,
errorMessage,
@@ -199,6 +200,7 @@ export function createOrcaDeclarativeRuntime(options: OrcaRuntimeOptions): OrcaD
bridge.on(PLUGIN_DECLARATIVE_MESSAGES.ack, acknowledge),
bridge.on(PLUGIN_DECLARATIVE_MESSAGES.hydrate, panel.hydrate),
bridge.on(PLUGIN_DECLARATIVE_MESSAGES.visibility, panel.setVisibility),
...actSnapshotSubscriptions(bridge.on.bind(bridge), document),
bridge.on('theme', (payload) => {
const { colorScheme } = readHostMessage(payload)
if (
@@ -16,10 +16,36 @@ describe('parsePluginShotActs', () => {
})
})
it("takes the kit's Slider, Table and Tree verbs with their arguments", () => {
const table = { type: 'Table' }
const tree = { label: 'Folders' }
const json = JSON.stringify([
{ slide: { label: 'Volume' }, value: 7 },
{ sort: table, column: 'name' },
{ selectRows: table, keys: ['a', 'b'] },
{ activateRow: table, key: 'a' },
{ loadMore: table },
{ expand: tree, key: 'src' },
{ collapse: tree, key: 'src' },
{ selectItem: tree, key: 'src/main.ts' },
{ activateItem: tree, key: 'src/main.ts' }
])
expect(parsePluginShotActs(json)).toMatchObject({ ok: true, acts: { length: 9 } })
expect(parsePluginShotActs('{"slide":{"label":"Volume"},"value":"7"}')).toMatchObject({
ok: false,
error: expect.stringMatching(/^action 1 \(slide\) \(value\): /)
})
expect(parsePluginShotActs('{"expand":{"label":"Folders"}}')).toMatchObject({
ok: false,
error: expect.stringMatching(/^action 1 \(expand\) \(key\): /)
})
})
it('names the act and field that is wrong', () => {
expect(parsePluginShotActs('[{"press":{"text":"A"}},{"click":{}}]')).toEqual({
ok: false,
error: 'action 2: expected an object with one of press, type, select, toggle'
error:
'action 2: expected an object with one of press, type, select, toggle, slide, sort, selectRows, activateRow, loadMore, expand, collapse, selectItem, activateItem'
})
expect(parsePluginShotActs('[{"type":{"label":"Name"}}]')).toMatchObject({
ok: false,
+46 -5
View File
@@ -2,13 +2,17 @@ import { z } from 'zod'
/**
* `orca plugin shot --act`: input run against the live components panel before the capture, in
* the test kit's vocabulary (`press(query)`, `type(query, text, { replace, submit })`,
* `select(query, value)`, `toggle(query)`). Queries match as the kit's do, with plain strings.
* the test kit's vocabulary: `press(query)`, `type(query, text, { replace, submit })`,
* `select(query, value)`, `toggle(query)`, `slide(query, value)`, and the Table and Tree verbs
* (`sort`, `selectRows`, `activateRow`, `loadMore`, `expand`, `collapse`, `selectItem`,
* `activateItem`). Queries match as the kit's do, with plain strings.
*/
export const PLUGIN_SHOT_MAX_ACTS = 20
const QUERY_TEXT_MAX = 200
const TYPE_TEXT_MAX = 500
// A Table's `selectRows`; well above a person's multi-select.
const PLUGIN_SHOT_MAX_ROW_KEYS = 200
const queryText = z.string().min(1).max(QUERY_TEXT_MAX)
@@ -26,6 +30,8 @@ export const pluginShotQuerySchema = z
export type PluginShotQuery = z.infer<typeof pluginShotQuerySchema>
const keyText = z.string().min(1).max(QUERY_TEXT_MAX)
const ACT_SCHEMAS = {
press: z.strictObject({ press: pluginShotQuerySchema }),
type: z.strictObject({
@@ -35,14 +41,35 @@ const ACT_SCHEMAS = {
submit: z.boolean().optional()
}),
select: z.strictObject({ select: pluginShotQuerySchema, value: z.string().max(QUERY_TEXT_MAX) }),
toggle: z.strictObject({ toggle: pluginShotQuerySchema })
toggle: z.strictObject({ toggle: pluginShotQuerySchema }),
slide: z.strictObject({ slide: pluginShotQuerySchema, value: z.number().finite() }),
sort: z.strictObject({ sort: pluginShotQuerySchema, column: keyText }),
selectRows: z.strictObject({
selectRows: pluginShotQuerySchema,
keys: z.array(keyText).max(PLUGIN_SHOT_MAX_ROW_KEYS)
}),
activateRow: z.strictObject({ activateRow: pluginShotQuerySchema, key: keyText }),
loadMore: z.strictObject({ loadMore: pluginShotQuerySchema }),
expand: z.strictObject({ expand: pluginShotQuerySchema, key: keyText }),
collapse: z.strictObject({ collapse: pluginShotQuerySchema, key: keyText }),
selectItem: z.strictObject({ selectItem: pluginShotQuerySchema, key: keyText }),
activateItem: z.strictObject({ activateItem: pluginShotQuerySchema, key: keyText })
}
export const pluginShotActSchema = z.union([
ACT_SCHEMAS.press,
ACT_SCHEMAS.type,
ACT_SCHEMAS.select,
ACT_SCHEMAS.toggle
ACT_SCHEMAS.toggle,
ACT_SCHEMAS.slide,
ACT_SCHEMAS.sort,
ACT_SCHEMAS.selectRows,
ACT_SCHEMAS.activateRow,
ACT_SCHEMAS.loadMore,
ACT_SCHEMAS.expand,
ACT_SCHEMAS.collapse,
ACT_SCHEMAS.selectItem,
ACT_SCHEMAS.activateItem
])
export type PluginShotAct = z.infer<typeof pluginShotActSchema>
@@ -57,7 +84,21 @@ export const pluginShotActResultSchema: z.ZodType<PluginShotActResult> = z.union
z.object({ ok: z.literal(false), error: z.string().max(2000) })
])
const VERBS = ['press', 'type', 'select', 'toggle'] as const
const VERBS = [
'press',
'type',
'select',
'toggle',
'slide',
'sort',
'selectRows',
'activateRow',
'loadMore',
'expand',
'collapse',
'selectItem',
'activateItem'
] as const satisfies readonly (keyof typeof ACT_SCHEMAS)[]
function issueOf(index: number, value: unknown): string {
const where = `action ${index + 1}`
@@ -46,12 +46,15 @@ export type PluginViewDrawnPanel = {
themeApplied: boolean
/** False when the wait timed out and the rect shows whatever was drawn by then. */
settled: boolean
/** Host calls (`plugin.request` included) still unanswered when it was measured. */
pendingRequests?: number
/** One result per requested act. */
actions?: PluginShotActResult[]
}
export type PluginViewReply =
| { requestId: string; ok: true; panel?: PluginViewDrawnPanel }
/** `restored`: on `end-capture` after `--act`, whether the panel's atoms were put back. */
| { requestId: string; ok: true; panel?: PluginViewDrawnPanel; restored?: boolean }
| { requestId: string; ok: false; code: string; message: string }
/** The renderer could not show the view; `code` is a stable reason callers may branch on. */
+145 -5
View File
@@ -13,6 +13,7 @@ import { expect, test } from './helpers/orca-app'
import { orcaCliResult } from './helpers/compiled-orca-cli'
import { RuntimeClient } from '../../src/cli/runtime/client'
import { disablePluginSystem, enableDevPlugins } from './helpers/plugin-smoke'
import { createPluginFromTemplate } from '../../src/cli/plugin-authoring/plugin-create'
import { ISOLATED_FRAMES_E2E } from './helpers/plugin-frame-guest'
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
@@ -275,7 +276,12 @@ test('plugin shot --act types and presses on the hidden components panel before
const userDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData'))
const shownBefore = await windowsShown(electronApp)
const client = new RuntimeClient(userDataDir, 60_000, null, null)
type ActShot = { png: string; errors: string[]; actions?: { ok: boolean; error?: string }[] }
type ActShot = {
png: string
errors: string[]
actions?: { ok: boolean; error?: string }[]
restored?: boolean
}
const before = await client.call<ActShot>('plugins.shot', { pluginKey, width: 360 })
// Orca's floating launcher parks over the sidebar; a capture hides it so it is never in a shot.
@@ -308,11 +314,11 @@ test('plugin shot --act types and presses on the hidden components panel before
{ ok: false, error: 'not run: an earlier action failed' }
])
expect(acted.result.errors).toEqual([])
// The shot shows the acted panel; afterwards the persisted list and the draft are put back.
expect(acted.result.png).not.toBe(before.result.png)
// The guest ran the handlers: the task is listed and toggled done, as Orca draws it.
const task = orcaPage.getByRole('button', { name: /Ship the act/ })
await expect(task).toBeVisible()
await expect(task).toContainText('Done')
expect(acted.result.restored).toBe(true)
await expect(orcaPage.getByRole('heading', { name: 'Tasks' })).toBeVisible()
await expect(orcaPage.getByRole('button', { name: /Ship the act/ })).toHaveCount(0)
await expect(orcaPage.getByRole('textbox', { name: 'New task' })).toHaveValue('')
// The launcher is back once the capture ended; the window never showed.
await expect.poll(() => launcher.count()).toBe(launchers)
@@ -322,3 +328,137 @@ test('plugin shot --act types and presses on the hidden components panel before
await rm(tempRoot, { recursive: true, force: true })
}
})
test('plugin shot waits for a cold worker, and --act state is put back after the capture', async ({
electronApp,
orcaPage
}) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-plugin-shot-worker-'))
try {
const created = await createPluginFromTemplate({
dir: join(tempRoot, 'shot-counter'),
template: 'worker-panel',
id: 'shot-counter',
name: 'Shot Counter'
})
await enableDevPlugins(orcaPage, [created.path])
const pluginKey = await devPluginKey(orcaPage, 'Shot Counter')
const userDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData'))
const client = new RuntimeClient(userDataDir, 60_000, null, null)
type WorkerShot = {
panelId: string
pendingRequests?: number
actions?: { ok: boolean; error?: string }[]
restored?: boolean
}
// The first shot starts the panel and its worker: it waits for counter.get's answer.
const cold = await client.call<WorkerShot>('plugins.shot', { pluginKey })
expect(cold.result.pendingRequests).toBe(0)
const surface = orcaPage.locator(
`[data-plugin-panel-surface="plugin:${pluginKey}/${cold.result.panelId}"]`
)
// Read at once, no retry: the capture already showed the worker's total, not the placeholder.
expect(await surface.innerText()).toMatch(/Counter\s+0\b/)
// `step` is persisted ('plugin'): the act flips it for the shot only.
const acted = await client.call<WorkerShot>(
'plugins.shot',
{ pluginKey, acts: [{ press: { text: 'Step 10' } }] },
{ timeoutMs: 60_000 }
)
expect(acted.result).toMatchObject({ actions: [{ ok: true }], restored: true })
await expect(surface.getByRole('button', { name: 'Add 1' })).toBeVisible()
const later = await client.call<WorkerShot>('plugins.shot', { pluginKey })
expect(later.result.pendingRequests).toBe(0)
await expect(surface.getByRole('button', { name: 'Add 1' })).toBeVisible()
await expect(surface.getByRole('button', { name: 'Step 10' })).toBeVisible()
} finally {
await disablePluginSystem(orcaPage).catch(() => undefined)
await rm(tempRoot, { recursive: true, force: true })
}
})
test('plugin shot --act sorts and activates a Table row, then puts the panel back', async ({
electronApp,
orcaPage
}) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-plugin-shot-table-'))
const pluginRoot = join(tempRoot, 'shot-table')
try {
await mkdir(join(pluginRoot, 'src'), { recursive: true })
await writeFile(
join(pluginRoot, 'orca-plugin.json'),
JSON.stringify({
manifestVersion: 2,
execution: 'sandboxed-ui',
id: 'shot-table',
name: 'Shot Table',
version: '1.0.0',
engines: { orca: '>=1.4.246' },
pluginApi: 1,
contributes: {
panels: [{ id: 'files', title: 'Shot Table', entry: 'src/panel.tsx', ui: 'components' }]
},
capabilities: []
})
)
await writeFile(
join(pluginRoot, 'src', 'panel.tsx'),
[
"import { atom, read, set, Stack, Table, Text } from 'orca'",
"const sort = atom({ column: 'name', direction: 'asc' })",
"const opened = atom('none')",
'export default function Panel() {',
' const by = read(sort)',
" const names = ['alpha', 'beta', 'gamma'].sort()",
" if (by.direction === 'desc') names.reverse()",
' return (',
' <Stack>',
' <Text>{`Sorted ${by.direction}, opened ${read(opened)}`}</Text>',
' <Table',
' label="Files"',
" columns={[{ key: 'name', label: 'Name', sortable: true }]}",
' rows={names.map((name) => ({ key: name, cells: { name } }))}',
' sort={by}',
' onSortChange={(next) => set(sort, next)}',
' onRowAction={({ key }) => set(opened, key)}',
' />',
' </Stack>',
' )',
'}',
''
].join('\n')
)
await enableDevPlugins(orcaPage, [pluginRoot])
const pluginKey = await devPluginKey(orcaPage, 'Shot Table')
const userDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData'))
const client = new RuntimeClient(userDataDir, 60_000, null, null)
type TableShot = { png: string; actions?: { ok: boolean }[]; restored?: boolean }
const before = await client.call<TableShot>('plugins.shot', { pluginKey })
await expect(orcaPage.getByText('Sorted asc, opened none')).toBeVisible()
const table = { label: 'Files' }
const acted = await client.call<TableShot>(
'plugins.shot',
{
pluginKey,
acts: [
{ sort: table, column: 'name' },
{ activateRow: table, key: 'gamma' }
]
},
{ timeoutMs: 60_000 }
)
expect(acted.result).toMatchObject({ actions: [{ ok: true }, { ok: true }], restored: true })
expect(acted.result.png).not.toBe(before.result.png)
await expect(orcaPage.getByText('Sorted asc, opened none')).toBeVisible()
} finally {
await disablePluginSystem(orcaPage).catch(() => undefined)
await rm(tempRoot, { recursive: true, force: true })
}
})