feat(plugins): check custom editors and test them with mockEditorHost (#37293)

* feat(plugins): check custom editors statically and add mockEditorHost

orca plugin check now follows each custom editor entry (local script src,
inline module scripts, relative imports), transpiled and resolved as the
frame loader does, and fails at file:line:column on syntax errors, missing
files, refused imports, string eval/Function and files over their size
cap, with no running Orca. The check ships in the test hooks bundle.

orca/testing gains mockEditorHost: it runs the frame shell's real
window.orca (document API and session) over an in-memory text or bytes
file, with edits, replace, conflictNext, flush, setTheme and dispose.
Typings, docs and the skill guide cover both.

* feat(plugins): warn on editor eval and check editors in dev watch

editor_eval comes from a lexical scan that can misread rare code, so it is
now a warning that does not fail orca plugin check. dev watch --check runs
the editor check beside the panel check and reports it under editors.

* fix(plugins): check editors by the frame protocol's rules; mock blocks lost saves

The static editor check now reads files through the loader's contained
reader (symlinks out of the plugin are refused), runs only JavaScript
script types, honors the page's import map for native modules, classifies
files by the protocol's content types (JSON imports, extensionless HTML
entries), decodes escaped script URLs, and uses the protocol's caps: 10 MiB
entry, 1 MiB transpiled module, 16 MiB checked dependency.

mockEditorHost refuses an oversized edit before reading it and then rejects
flush() until a later edit lands, as Orca blocks the save.

* test(plugins): type mockEditorHost's frame API instead of reflecting on it

* fix(plugins): keep main's case-spelling check and hook typings through the rebase

The rebase onto main resolved plugin-artifact-validation.ts and
orca-testing.d.ts to B4's side; this puts back main's exact-path-spelling
check and hooks typings beside B4's contained reader and mockEditorHost
types, and regenerates the testing bundle.
This commit is contained in:
Neil
2026-11-01 21:28:13 -08:00
committed by GitHub
parent 7989c05ad3
commit fa7c3f95fe
34 changed files with 1991 additions and 192 deletions
@@ -164,12 +164,17 @@ function renderTesting({ testing }) {
['## `TestPanel` (from `mount`)', '', ...members('TestPanel'), ''],
['## `TestWorker` (from `mountWorker`)', '', ...members('TestWorker'), ''],
['## `MockHost` (from `mockHost`)', '', ...members('MockHost'), ''],
['## `MockEditorHost` (from `mockEditorHost`)', '', ...members('MockEditorHost'), ''],
[
'## Options',
'',
...['MountOptions', 'MountWorkerOptions', 'MockHostOptions', 'CheckScenario'].map(
(name) => `- ${code(testing.typeDeclaration(name))}`
)
...[
'MountOptions',
'MountWorkerOptions',
'MockHostOptions',
'MockEditorHostOptions',
'CheckScenario'
].map((name) => `- ${code(testing.typeDeclaration(name))}`)
]
]
)
+25
View File
@@ -373,6 +373,31 @@ Theme changes do not reload the frame: the shell updates the `--orca-*` variable
`light`/`dark` class on `<html>` in place and emits `theme`. Restyle from the variables, or
listen with `orca.on('theme', …)`, to follow it.
`orca plugin check` (and `dev watch --check`) checks each custom editor without running it or
Orca: from the `entry` it follows local `<script src>` files, inline module scripts and relative
imports, and fails at `file:line:column` on a syntax error (`editor_syntax`), a missing file
(`editor_missing_file`), an import Orca refuses (`editor_import`) and a file over its limit
(`editor_too_large`). String `eval` or `Function`, which the frame refuses, is a warning
(`editor_eval`): the scan can misread rare code. `--json` lists them under `editors`.
To test an editor's sync logic in Node, `mockEditorHost` from `orca/testing` installs
`globalThis.orca` with the real document API and `session()` over an in-memory file:
```ts
import { assert, mockEditorHost, test } from 'orca/testing'
test('keeps an agent write', async () => {
const host = mockEditorHost({ text: '{"elements":[]}' }) // or { bytes }, plus { readOnly, colorScheme }
await import('./sync.ts') // your code calls orca.document.session()
host.replace('{"elements":[1]}') // an outside change
host.conflictNext() // the next edit is refused with `conflict`
await host.flush() // as a save does
assert.equal(host.edits.length, 1) // every applyEdit, refused ones with `refused: code`
host.setTheme('light')
host.dispose()
})
```
## 2. Load it while you work
From the plugin folder, run:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+48
View File
@@ -18,6 +18,7 @@ declare module 'orca/testing' {
PluginHookName,
PluginHooks
} from 'orca/worker'
import type { EditorContent, OrcaFrameApi } from 'orca/frame'
/** One element of the drawn panel. Actions take it, or a query that finds it. */
export type TestElement = {
@@ -97,6 +98,53 @@ declare module 'orca/testing' {
*/
export function mockHost(options?: MockHostOptions): MockHost
export function hostError(code: string, message?: string, retryAfterMs?: number): HostError
/** The file a mock custom editor host starts with. */
export type MockEditorFile = { text: string } | { bytes: ArrayBuffer | Uint8Array }
export type MockEditorHostOptions = {
readOnly?: boolean
/** Default `dark`. */
colorScheme?: 'light' | 'dark'
/** Default `en-US`. */
locale?: string
/** Bytes documents: the manifest suffix that matched, e.g. `xlsx`. */
fileType?: string
}
export type MockEditorEdit = {
baseVersion: number
content: EditorContent
/** The refusal's code, such as `conflict`; absent when Orca applied the edit. */
refused?: string
}
export type MockEditorHost = {
/** The frame API, also installed as `globalThis.orca`. */
readonly orca: OrcaFrameApi
/** The file as Orca holds it now; undefined for a bytes file. */
readonly text: string | undefined
readonly bytes: Uint8Array | undefined
/** Rises with each document sent to the editor. */
readonly version: number
/** Every `applyEdit`, in order, refused ones included. */
readonly edits: readonly MockEditorEdit[]
/** An outside change, such as an agent writing the file. */
replace(next: string | ArrayBuffer | Uint8Array): void
/** Refuses the next edit with `conflict`, after landing `outside` first when given. */
conflictNext(outside?: string | ArrayBuffer | Uint8Array): void
/** As a save does: asks the editor for pending edits and waits for its answer (1 s);
* rejects, as Orca refuses to save, after an edit too large to apply. */
flush(): Promise<void>
setTheme(colorScheme: 'light' | 'dark'): void
/** Removes `globalThis.orca`. */
dispose(): void
}
/**
* A stand-in for Orca's custom editor host, backed by an in-memory file: installs
* `globalThis.orca` with the real frame shell's document API and `session()`, so sync logic
* tests in Node. Answers only document calls. One at a time: a new one disposes the last.
*/
export function mockEditorHost(
file: MockEditorFile,
options?: MockEditorHostOptions
): MockEditorHost
/** A persisted value an older version wrote, to test an atom's `migrate`. */
export function stored(value: unknown, version: number): unknown
+4
View File
@@ -111,6 +111,10 @@ fingerprint Orca did not give you. New capabilities or changed commands print a
- A worker's `main` must `export default function activate(orca)`.
- A worker save restarts the worker and drops its in-memory state; keep counters and caches you need across saves in `orca.storage`.
- A plugin holds at most 2,000 files and 50 MiB; each transpiled file 1 MiB.
- Custom editors: sync with `orca.document.session()`. `plugin check` fails on an editor's syntax
errors, missing files and refused imports without running it (and warns on `eval`); test its
sync in Node with `mockEditorHost({ text })` from `orca/testing` (`replace`, `conflictNext`,
`flush`, `edits`).
## Conditional references
@@ -7,7 +7,7 @@
- `ORCA plugin create <dir> --template <theme|terminal-theme|icon-theme|syntax|language-pack|editor|panel|components|worker-panel|hook> [--publisher <name>] [--id <id>] [--name <display name>] [--json]`: Scaffold a new Orca plugin from a template
- `ORCA plugin types <dir> [--json]`: Write the TypeScript typings for Orca plugins into a plugin folder
- `ORCA plugin test [dir] [--json]`: Run a plugin's own tests on this machine, unsandboxed: only for code you trust
- `ORCA plugin check [dir] [--json]`: Validate a plugin and render each components panel once on this machine, unsandboxed: only for code you trust
- `ORCA plugin check [dir] [--json]`: Validate a plugin, render each components panel once on this machine (unsandboxed: only for code you trust), and statically check each custom editor
- `ORCA plugin validate <dir|orca-plugin.json|orca-catalog.json> [--json]`: Check a plugin folder, manifest or catalog the way Orca will load it
- `ORCA plugin inspect <dir> [--json]`: Show what a plugin folder contributes, the access it asks for, and its content hash
- `ORCA plugin release-entry <dir> [--json]`: Print the catalog release entry for a plugin folder's committed HEAD
@@ -8,6 +8,7 @@
- `function mockHost(options?: MockHostOptions): MockHost`: A stand-in Orca host that refuses what Orca refuses, with the same codes: a missing capability, bad params, a method panels cannot call, and a user-gesture method (`shell.openExternal`, `files.saveAs`) without a press first.
- `function hostError(code: string, message?: string, retryAfterMs?: number): HostError`
- `function mockEditorHost(file: MockEditorFile, options?: MockEditorHostOptions): MockEditorHost`: A stand-in for Orca's custom editor host, backed by an in-memory file: installs `globalThis.orca` with the real frame shell's document API and `session()`, so sync logic tests in Node. Answers only document calls. One at a time: a new one disposes the last.
- `function stored(value: unknown, version: number): unknown`: A persisted value an older version wrote, to test an atom's `migrate`.
- `function mount(target: PanelModule | (() => OrcaNode), options?: MountOptions): Promise<TestPanel>`: Mounts a panel module (`import * as panel from './panel'`) or its component, with the real runtime and Orca's own checks, and resolves once it has drawn. Mounting again starts over: every atom goes back to its initial value.
- `function mountWorker(module: WorkerModule, options?: MountWorkerOptions): Promise<TestWorker>`: Calls the worker's default-exported `activate(orca)` with the `orca` object Orca passes, backed by `mockHost` (worker methods, the manifest's capabilities). `.ts` mains load as Orca loads them.
@@ -75,9 +76,23 @@
- `respond(method: string, result: unknown)`
- `data()`: The plugin's storage, settings and secrets as they are now.
## `MockEditorHost` (from `mockEditorHost`)
- `orca: OrcaFrameApi`: The frame API, also installed as `globalThis.orca`.
- `text: string | undefined`: The file as Orca holds it now; undefined for a bytes file.
- `bytes: Uint8Array | undefined`
- `version: number`: Rises with each document sent to the editor.
- `edits: readonly MockEditorEdit[]`: Every `applyEdit`, in order, refused ones included.
- `replace(next: string | ArrayBuffer | Uint8Array)`: An outside change, such as an agent writing the file.
- `conflictNext(outside?: string | ArrayBuffer | Uint8Array)`: Refuses the next edit with `conflict`, after landing `outside` first when given.
- `flush()`: As a save does: asks the editor for pending edits and waits for its answer (1 s); rejects, as Orca refuses to save, after an edit too large to apply.
- `setTheme(colorScheme: 'light' | 'dark')`
- `dispose()`: Removes `globalThis.orca`.
## Options
- `MountOptions = { host?: MockHost; theme?: 'light' | 'dark'; locale?: string; visible?: boolean; panelId?: string; persisted?: { plugin?: Record<string, unknown>; workspace?: Record<string, unknown> } }`
- `MountWorkerOptions = { host?: MockHost; events?: readonly string[]; commands?: readonly string[]; hooks?: readonly (string | { event: string; onError?: 'pass' | 'deny' })[] }`
- `MockHostOptions = { capabilities?: readonly string[]; results?: Record<string, unknown>; requests?: MockRequests; storage?: Record<string, JsonValue>; settings?: Record<string, JsonValue>; secrets?: Record<string, string> }`
- `MockEditorHostOptions = { readOnly?: boolean; colorScheme?: 'light' | 'dark'; locale?: string; fileType?: string }`
- `CheckScenario = MockHostOptions & { persisted?: MountOptions['persisted']; theme?: 'light' | 'dark'; events?: readonly { [E in keyof OrcaEvents]: { event: E; payload: OrcaEvents[E] } }[keyof OrcaEvents][] }`
@@ -17,7 +17,8 @@ loads the folder (refreshing `.orca/types`) and prints one JSON object per line:
- `reloaded` (before that reload's logs) and `status` (`errored`/`invalid` with the error).
- `approval`: an edit asked for new access; it carries the `dev reload … --approve` command to
show the user. The watch keeps running and never approves.
- `check` (with `--check`): `orca plugin check` after the load and each reload. `ok` is false
- `check` (with `--check`): `orca plugin check` after the load and each reload, with `panels`
and `editors` (each custom editor checked without running it). `ok` is false
unless Orca also runs the plugin; `checkOk` is the check alone, `pluginStatus`/`pluginError`
what Orca reports.
- `shot` (with `--shot`): `{ path, width, height, settled, errors }` for the panel opened once at
+22 -2
View File
@@ -2676,6 +2676,10 @@ const ORCA_PLUGIN_AUTHORING_MARKDOWN = [
"- A worker's `main` must `export default function activate(orca)`.",
"- A worker save restarts the worker and drops its in-memory state; keep counters and caches you need across saves in `orca.storage`.",
"- A plugin holds at most 2,000 files and 50 MiB; each transpiled file 1 MiB.",
"- Custom editors: sync with `orca.document.session()`. `plugin check` fails on an editor's syntax",
" errors, missing files and refused imports without running it (and warns on `eval`); test its",
" sync in Node with `mockEditorHost({ text })` from `orca/testing` (`replace`, `conflictNext`,",
" `flush`, `edits`).",
"",
"## Conditional references",
"",
@@ -2706,7 +2710,7 @@ const ORCA_PLUGIN_AUTHORING_CLI_REFERENCE_MARKDOWN = [
"- `ORCA plugin create <dir> --template <theme|terminal-theme|icon-theme|syntax|language-pack|editor|panel|components|worker-panel|hook> [--publisher <name>] [--id <id>] [--name <display name>] [--json]`: Scaffold a new Orca plugin from a template",
"- `ORCA plugin types <dir> [--json]`: Write the TypeScript typings for Orca plugins into a plugin folder",
"- `ORCA plugin test [dir] [--json]`: Run a plugin's own tests on this machine, unsandboxed: only for code you trust",
"- `ORCA plugin check [dir] [--json]`: Validate a plugin and render each components panel once on this machine, unsandboxed: only for code you trust",
"- `ORCA plugin check [dir] [--json]`: Validate a plugin, render each components panel once on this machine (unsandboxed: only for code you trust), and statically check each custom editor",
"- `ORCA plugin validate <dir|orca-plugin.json|orca-catalog.json> [--json]`: Check a plugin folder, manifest or catalog the way Orca will load it",
"- `ORCA plugin inspect <dir> [--json]`: Show what a plugin folder contributes, the access it asks for, and its content hash",
"- `ORCA plugin release-entry <dir> [--json]`: Print the catalog release entry for a plugin folder's committed HEAD",
@@ -3322,6 +3326,7 @@ const ORCA_PLUGIN_AUTHORING_TESTING_REFERENCE_MARKDOWN = [
"",
"- `function mockHost(options?: MockHostOptions): MockHost`: A stand-in Orca host that refuses what Orca refuses, with the same codes: a missing capability, bad params, a method panels cannot call, and a user-gesture method (`shell.openExternal`, `files.saveAs`) without a press first.",
"- `function hostError(code: string, message?: string, retryAfterMs?: number): HostError`",
"- `function mockEditorHost(file: MockEditorFile, options?: MockEditorHostOptions): MockEditorHost`: A stand-in for Orca's custom editor host, backed by an in-memory file: installs `globalThis.orca` with the real frame shell's document API and `session()`, so sync logic tests in Node. Answers only document calls. One at a time: a new one disposes the last.",
"- `function stored(value: unknown, version: number): unknown`: A persisted value an older version wrote, to test an atom's `migrate`.",
"- `function mount(target: PanelModule | (() => OrcaNode), options?: MountOptions): Promise<TestPanel>`: Mounts a panel module (`import * as panel from './panel'`) or its component, with the real runtime and Orca's own checks, and resolves once it has drawn. Mounting again starts over: every atom goes back to its initial value.",
"- `function mountWorker(module: WorkerModule, options?: MountWorkerOptions): Promise<TestWorker>`: Calls the worker's default-exported `activate(orca)` with the `orca` object Orca passes, backed by `mockHost` (worker methods, the manifest's capabilities). `.ts` mains load as Orca loads them.",
@@ -3389,11 +3394,25 @@ const ORCA_PLUGIN_AUTHORING_TESTING_REFERENCE_MARKDOWN = [
"- `respond(method: string, result: unknown)`",
"- `data()`: The plugin's storage, settings and secrets as they are now.",
"",
"## `MockEditorHost` (from `mockEditorHost`)",
"",
"- `orca: OrcaFrameApi`: The frame API, also installed as `globalThis.orca`.",
"- `text: string | undefined`: The file as Orca holds it now; undefined for a bytes file.",
"- `bytes: Uint8Array | undefined`",
"- `version: number`: Rises with each document sent to the editor.",
"- `edits: readonly MockEditorEdit[]`: Every `applyEdit`, in order, refused ones included.",
"- `replace(next: string | ArrayBuffer | Uint8Array)`: An outside change, such as an agent writing the file.",
"- `conflictNext(outside?: string | ArrayBuffer | Uint8Array)`: Refuses the next edit with `conflict`, after landing `outside` first when given.",
"- `flush()`: As a save does: asks the editor for pending edits and waits for its answer (1 s); rejects, as Orca refuses to save, after an edit too large to apply.",
"- `setTheme(colorScheme: 'light' | 'dark')`",
"- `dispose()`: Removes `globalThis.orca`.",
"",
"## Options",
"",
"- `MountOptions = { host?: MockHost; theme?: 'light' | 'dark'; locale?: string; visible?: boolean; panelId?: string; persisted?: { plugin?: Record<string, unknown>; workspace?: Record<string, unknown> } }`",
"- `MountWorkerOptions = { host?: MockHost; events?: readonly string[]; commands?: readonly string[]; hooks?: readonly (string | { event: string; onError?: 'pass' | 'deny' })[] }`",
"- `MockHostOptions = { capabilities?: readonly string[]; results?: Record<string, unknown>; requests?: MockRequests; storage?: Record<string, JsonValue>; settings?: Record<string, JsonValue>; secrets?: Record<string, string> }`",
"- `MockEditorHostOptions = { readOnly?: boolean; colorScheme?: 'light' | 'dark'; locale?: string; fileType?: string }`",
"- `CheckScenario = MockHostOptions & { persisted?: MountOptions['persisted']; theme?: 'light' | 'dark'; events?: readonly { [E in keyof OrcaEvents]: { event: E; payload: OrcaEvents[E] } }[keyof OrcaEvents][] }`",
"",
].join('\n')
@@ -3419,7 +3438,8 @@ const ORCA_PLUGIN_AUTHORING_WORKFLOW_REFERENCE_MARKDOWN = [
"- `reloaded` (before that reload's logs) and `status` (`errored`/`invalid` with the error).",
"- `approval`: an edit asked for new access; it carries the `dev reload … --approve` command to",
" show the user. The watch keeps running and never approves.",
"- `check` (with `--check`): `orca plugin check` after the load and each reload. `ok` is false",
"- `check` (with `--check`): `orca plugin check` after the load and each reload, with `panels`",
" and `editors` (each custom editor checked without running it). `ok` is false",
" unless Orca also runs the plugin; `checkOk` is the check alone, `pluginStatus`/`pluginError`",
" what Orca reports.",
"- `shot` (with `--shot`): `{ path, width, height, settled, errors }` for the panel opened once at",
+16 -6
View File
@@ -22,6 +22,10 @@ import {
import { writePluginTypeFiles } from '../plugin-authoring/plugin-type-files'
import { findPluginTestFiles, runPluginTests } from '../plugin-authoring/plugin-test-runner'
import { checkPluginPanels, formatPluginPanelCheck } from '../plugin-authoring/plugin-panel-check'
import {
checkPluginEditors,
formatPluginEditorCheck
} from '../plugin-authoring/plugin-editor-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'
@@ -189,17 +193,23 @@ export const PLUGIN_AUTHORING_HANDLERS: Record<string, CommandHandler> = {
if (!report.valid) {
throw invalidError(report)
}
const panels = await checkPluginPanels(report.path)
const failed = panels.filter((panel) => panel.issues.length > 0)
const [panels, editors] = await Promise.all([
checkPluginPanels(report.path),
checkPluginEditors(report.path)
])
const failed = [...panels, ...editors].some((check) => check.issues.length > 0)
const text = [
`${report.file}: ok`,
...panels.flatMap(formatPluginPanelCheck),
...(panels.length === 0 ? ['No components panels to render.'] : [])
...editors.flatMap(formatPluginEditorCheck),
...(panels.length === 0 && editors.length === 0
? ['No components panels or custom editors to check.']
: [])
].join('\n')
if (failed.length > 0) {
throw new RuntimeClientError('plugin_check_failed', text, { ...report, panels })
if (failed) {
throw new RuntimeClientError('plugin_check_failed', text, { ...report, panels, editors })
}
printPluginResult(ctx, { ...report, panels }, text)
printPluginResult(ctx, { ...report, panels, editors }, text)
},
'plugin validate': async (ctx) => {
const path = resolveLocalPluginPath(ctx, requirePluginPathArg(ctx, 'path'), 'validate')
@@ -0,0 +1,122 @@
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { checkPluginEditors } from './plugin-editor-check'
import { createPluginFromTemplate } from './plugin-create'
const roots: string[] = []
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
/** The editor template with `files` written over it (paths under the plugin, `/`-separated). */
async function editorWith(files: Record<string, string>, entry?: string): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'orca-editor-rules-'))
roots.push(root)
const dir = join(root, 'notes')
await createPluginFromTemplate({ dir, template: 'editor', publisher: 'acme' })
for (const [path, text] of Object.entries(files)) {
await mkdir(dirname(join(dir, ...path.split('/'))), { recursive: true })
await writeFile(join(dir, ...path.split('/')), text)
}
if (entry) {
const manifestPath = join(dir, 'orca-plugin.json')
const manifest = (await readFile(manifestPath, 'utf8')).replace('editor/index.html', entry)
await writeFile(manifestPath, manifest)
}
return root
}
async function check(root: string) {
const [result] = await checkPluginEditors(join(root, 'notes'))
return result
}
const page = (body: string) => `<!doctype html><html><body>\n${body}\n</body></html>\n`
describe('orca plugin check: editors load as the frame protocol serves them', () => {
it.skipIf(process.platform === 'win32')(
'refuses a script a symlink takes outside the plugin',
async () => {
const root = await editorWith({
'editor/index.html': page('<script src="./lib.js"></script>')
})
await writeFile(join(root, 'secret.js'), 'export {}')
await symlink(join(root, 'secret.js'), join(root, 'notes', 'editor', 'lib.js'))
const result = await check(root)
expect(result.issues).toEqual([
expect.objectContaining({
code: 'editor_import',
message: expect.stringContaining('outside')
})
])
}
)
it('reads import maps and skips data blocks, and resolves bare native imports through the map', async () => {
const root = await editorWith({
'editor/index.html': page(
[
'<script type="importmap">{ "imports": { "lib": "./vendor/lib.js", "kit/": "./vendor/kit/" } }</script>',
'<script type="application/json" id="config">{ "not": "javascript" }</script>',
'<script type="module">import { a } from "lib"; import { b } from "kit/b.js"; a(b)</script>'
].join('\n')
),
'editor/vendor/lib.js': 'export const a = (x) => x\n',
'editor/vendor/kit/b.js': 'export const b = 1\n'
})
const result = await check(root)
expect(result.issues).toEqual([])
expect(result.files).toEqual([
'editor/index.html',
'editor/vendor/kit/b.js',
'editor/vendor/lib.js'
])
})
it('refuses a bare native import the import map does not name', async () => {
const root = await editorWith({
'editor/index.html': page('<script type="module">import "left-pad"</script>')
})
expect((await check(root)).issues).toEqual([
expect.objectContaining({
code: 'editor_import',
message: expect.stringContaining('import map')
})
])
})
it('checks JSON imports as JSON and an extensionless entry as a page', async () => {
const root = await editorWith(
{
'editor/page': page('<script type="module" src="./main.ts"></script>'),
'editor/main.ts': "import data from './data.json' with { type: 'json' }\nvoid data\n",
'editor/data.json': '{ "ok": true, }'
},
'editor/page'
)
expect((await check(root)).issues).toEqual([
expect.objectContaining({ code: 'editor_syntax', location: 'editor/data.json:1:1' })
])
})
it('decodes an escaped script src before looking it up', async () => {
const root = await editorWith({
'editor/index.html': page('<script src="./my%20script.js"></script>'),
'editor/my script.js': 'void 0\n'
})
const result = await check(root)
expect(result.issues).toEqual([])
expect(result.files).toContain('editor/my script.js')
})
it('lets a dependency reach the 16 MiB the protocol serves, past the 10 MiB entry cap', async () => {
const root = await editorWith({
'editor/index.html': page('<script type="module" src="./vendor.mjs"></script>'),
'editor/vendor.mjs': `export const x = 1\n//${'x'.repeat(11 * 1024 * 1024)}\n`
})
expect((await check(root)).issues).toEqual([])
})
})
@@ -0,0 +1,128 @@
import { appendFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { checkPluginEditors, formatPluginEditorCheck } from './plugin-editor-check'
import { createPluginFromTemplate } from './plugin-create'
import { validatePluginTarget } from './plugin-folder-validation'
import { checkPluginDevFolder } from '../plugin-dev-watch/plugin-dev-watch-check'
const roots: string[] = []
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
/** The editor template with its script moved to `editor/main.ts`, which imports `./sync.ts`. */
async function editorPlugin(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'orca-editor-check-'))
roots.push(root)
const dir = join(root, 'notes')
await createPluginFromTemplate({ dir, template: 'editor', publisher: 'acme' })
const html = join(dir, 'editor', 'index.html')
await writeFile(
html,
(await readFile(html, 'utf8')).replace(
/<script>[\s\S]*<\/script>/,
'<script type="module" src="./main.ts"></script>'
)
)
await writeFile(
join(dir, 'editor', 'main.ts'),
"import { start } from './sync'\nconst text: string = 'a'\nstart(text)\n"
)
await writeFile(join(dir, 'editor', 'sync.ts'), 'export function start(text: string): void {}\n')
return dir
}
describe('orca plugin check: custom editors', () => {
it('passes the editor template and follows its script and imports', async () => {
const dir = await editorPlugin()
const [check] = await checkPluginEditors(dir)
expect(check.issues).toEqual([])
expect(check.files).toEqual(['editor/index.html', 'editor/main.ts', 'editor/sync.ts'])
expect(formatPluginEditorCheck(check)[0]).toMatch(/: ok, 3 file\(s\) load$/)
})
it('fails on a syntax error that validate lets through', async () => {
const dir = await editorPlugin()
await appendFile(join(dir, 'editor', 'main.ts'), 'this is not valid ts(\n')
expect((await validatePluginTarget(dir)).valid).toBe(true)
const [check] = await checkPluginEditors(dir)
expect(check.issues).toEqual([
expect.objectContaining({ code: 'editor_syntax', location: 'editor/main.ts:4:6' })
])
})
it('reports missing files and refused imports, and warns on string eval', async () => {
const dir = await editorPlugin()
await writeFile(
join(dir, 'editor', 'main.ts'),
[
"import './gone'",
"import 'left-pad'",
'// eval("comments do not count")',
"const label = 'new Function in a string does not count'",
"eval('1')",
"const f = new Function('return 1')",
''
].join('\n')
)
const [check] = await checkPluginEditors(dir)
expect(check.issues.map(({ code, location }) => [code, location])).toEqual([
['editor_import', 'editor/main.ts:1:9'],
['editor_import', 'editor/main.ts:2:9']
])
// A lexical scan can misread code, so eval is a warning that does not fail the check.
expect(check.warnings.map(({ code, location }) => [code, location])).toEqual([
['editor_eval', 'editor/main.ts:5:1'],
['editor_eval', 'editor/main.ts:6:11']
])
})
it('reports a missing script, a broken inline script, and a script over 16 MiB', async () => {
const dir = await editorPlugin()
const html = join(dir, 'editor', 'index.html')
await writeFile(
html,
[
'<body>',
'<script src="./absent.js"></script>',
'<script type="module" src="./big.js"></script>',
'<script>',
'const x = (',
'</script>',
'</body>'
].join('\n')
)
await mkdir(join(dir, 'editor'), { recursive: true })
await writeFile(join(dir, 'editor', 'big.js'), 'x'.repeat(16 * 1024 * 1024 + 1))
const [check] = await checkPluginEditors(dir)
expect(check.issues.map(({ code, location }) => [code, location])).toEqual([
['editor_syntax', 'editor/index.html:6:1'],
['editor_missing_file', 'editor/index.html:2:1'],
['editor_too_large', 'editor/index.html:3:1']
])
})
it('caps a transpiled source module at 1 MiB', async () => {
const dir = await editorPlugin()
await writeFile(join(dir, 'editor', 'sync.ts'), `// ${'x'.repeat(1024 * 1024)}\n`)
const [check] = await checkPluginEditors(dir)
expect(check.issues).toEqual([
expect.objectContaining({ code: 'editor_too_large', location: 'editor/main.ts:1:24' })
])
})
it('fails dev watch --check on an editor issue, and passes it with only a warning', async () => {
const dir = await editorPlugin()
await appendFile(join(dir, 'editor', 'main.ts'), "eval('1')\n")
const warned = await checkPluginDevFolder(dir)
expect(warned.ok).toBe(true)
expect(warned.editors[0]?.warnings).toEqual([expect.objectContaining({ code: 'editor_eval' })])
await appendFile(join(dir, 'editor', 'main.ts'), 'this is not valid ts(\n')
const failed = await checkPluginDevFolder(dir)
expect(failed.ok).toBe(false)
expect(failed.editors[0]?.issues).toEqual([expect.objectContaining({ code: 'editor_syntax' })])
})
})
@@ -0,0 +1,62 @@
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import type {
PluginEditorCheckIssue,
PluginEditorStaticCheck
} from '../../main/plugins/testing/plugin-editor-static-check'
import { parsePluginManifest } from '../../shared/plugins/manifest/plugin-manifest'
import { pluginTestingFiles } from './plugin-test-runner'
export type PluginEditorCheck = {
editor: string
entry: string
/** Plugin-relative files the entry loads, itself included. */
files: string[]
issues: PluginEditorCheckIssue[]
/** `editor_eval`: likely refused by the frame, but found by a scan that can misread code. */
warnings: PluginEditorCheckIssue[]
}
type EditorCheckModule = {
checkPluginEditorEntry: (dir: string, entry: string) => Promise<PluginEditorStaticCheck>
}
/** Every custom editor's files, transpiled and resolved as the frame loads them; runs no code. */
export async function checkPluginEditors(
dir: string,
appRoot?: string
): Promise<PluginEditorCheck[]> {
const parsed = parsePluginManifest(
JSON.parse(await readFile(join(dir, 'orca-plugin.json'), 'utf8'))
)
const editors = parsed.ok ? parsed.manifest.contributes.customEditors : []
if (editors.length === 0) {
return []
}
// The check ships in the hooks bundle, which carries the frame's transpiler in plain Node.
const loaded: Partial<EditorCheckModule> = await import(
pathToFileURL(pluginTestingFiles(appRoot).hooks).href
)
const check = loaded.checkPluginEditorEntry
if (!check) {
throw new Error('this Orca build has no custom editor check')
}
const checks: PluginEditorCheck[] = []
for (const editor of editors) {
checks.push({ editor: editor.id, entry: editor.entry, ...(await check(dir, editor.entry)) })
}
return checks
}
/** Lines for one editor: how many files it loads, or each issue at its plugin location. */
export function formatPluginEditorCheck(check: PluginEditorCheck): string[] {
const head = `${check.editor} (${check.entry}): `
const line = (issue: PluginEditorCheckIssue) =>
`${issue.location}: ${issue.message} [${issue.code}]`
const warnings = check.warnings.map((warning) => ` warning: ${line(warning)}`)
if (check.issues.length === 0) {
return [`${head}ok, ${check.files.length} file(s) load`, ...warnings]
}
return [`${head}failed`, ...check.issues.map((issue) => ` ${line(issue)}`), ...warnings]
}
@@ -45,7 +45,8 @@ const passed: PluginDevWatchCheckResult = {
issues: [],
warnings: []
}
]
],
editors: []
}
const failed: PluginDevWatchCheckResult = {
ok: false,
@@ -59,7 +60,8 @@ const failed: PluginDevWatchCheckResult = {
issues: [{ kind: 'plugin', message: 'boom', location: 'src/panel.tsx:4:7' }],
warnings: []
}
]
],
editors: []
}
/** Polls: 1 baseline, 2 status (unchanged), 3 logs, 4 status (one save), then Ctrl-C. */
@@ -1,5 +1,6 @@
import { validatePluginTarget } from '../plugin-authoring/plugin-folder-validation'
import { checkPluginPanels, type PluginPanelCheck } from '../plugin-authoring/plugin-panel-check'
import { checkPluginEditors, type PluginEditorCheck } from '../plugin-authoring/plugin-editor-check'
/** `orca plugin check` for one folder, as data: validation errors, else each panel's render. */
export type PluginDevWatchCheckResult = {
@@ -7,16 +8,23 @@ export type PluginDevWatchCheckResult = {
/** Manifest problems; panels are not rendered while there are any. */
invalid: string[]
panels: PluginPanelCheck[]
/** Each custom editor's static check. */
editors: PluginEditorCheck[]
}
export async function checkPluginDevFolder(dir: string): Promise<PluginDevWatchCheckResult> {
const report = await validatePluginTarget(dir)
if (!report.valid) {
return { ok: false, invalid: report.errors, panels: [] }
return { ok: false, invalid: report.errors, panels: [], editors: [] }
}
const [rendered, editors] = await Promise.all([
checkPluginPanels(report.path),
checkPluginEditors(report.path)
])
// Why no tree: a watch prints one line per save; the summary says what drew.
const panels = (await checkPluginPanels(report.path)).map(({ tree: _tree, ...panel }) => panel)
return { ok: panels.every((panel) => panel.issues.length === 0), invalid: [], panels }
const panels = rendered.map(({ tree: _tree, ...panel }) => panel)
const ok = [...panels, ...editors].every((check) => check.issues.length === 0)
return { ok, invalid: [], panels, editors }
}
/** Runs checks one at a time: a request while one runs queues a single rerun of the newest code. */
@@ -1,6 +1,7 @@
import type { PluginHostStatus } from '../../shared/plugins/plugin-list-entry'
import { formatPluginPanelCheck } from '../plugin-authoring/plugin-panel-check'
import type { PluginDevWatchCheckResult } from './plugin-dev-watch-check'
import { formatPluginEditorCheck } from '../plugin-authoring/plugin-editor-check'
import { PLUGIN_DEV_WATCH_SHOOTABLE, type PluginDevWatchShot } from './plugin-dev-watch-shot'
/** `missing`: the host lists no plugin for the folder (for example, its id changed). */
@@ -120,7 +121,8 @@ export function formatPluginDevWatchEvent(event: PluginDevWatchEvent): string {
? [` in Orca: ${pluginStatusPhrase(event.pluginStatus, event.pluginError)}`]
: []),
...event.invalid.map((error) => ` invalid: ${error}`),
...event.panels.flatMap(formatPluginPanelCheck).map((line) => ` ${line}`)
...event.panels.flatMap(formatPluginPanelCheck).map((line) => ` ${line}`),
...event.editors.flatMap(formatPluginEditorCheck).map((line) => ` ${line}`)
].join('\n')
case 'shot':
return [
@@ -113,7 +113,7 @@ describe('plugin dev watch --shot', () => {
{
check: async () => {
order.push('check')
return { ok: true, invalid: [], panels: [] }
return { ok: true, invalid: [], panels: [], editors: [] }
},
shot: host
},
+1 -1
View File
@@ -66,7 +66,7 @@ export const PLUGIN_AUTHORING_COMMAND_SPECS: CommandSpec[] = [
{
path: ['plugin', 'check'],
summary:
'Validate a plugin and render each components panel once on this machine, unsandboxed: only for code you trust',
'Validate a plugin, render each components panel once on this machine (unsandboxed: only for code you trust), and statically check each custom editor',
usage: 'orca plugin check [dir] [--json]',
allowedFlags: [...LOCAL_COMMAND_FLAGS, 'dir'],
positionalArgs: ['dir'],
@@ -0,0 +1,49 @@
import { extname } from 'node:path'
// How the frame protocol types a plugin file; free of the shell so plain-Node checks share it.
const MIME_TYPES: Record<string, string> = {
'.html': 'text/html; charset=utf-8',
'.htm': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.map': 'application/json; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.md': 'text/markdown; charset=utf-8',
'.csv': 'text/csv; charset=utf-8',
'.xml': 'application/xml; charset=utf-8',
'.wasm': 'application/wasm',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.avif': 'image/avif',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.otf': 'font/otf',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.ogg': 'audio/ogg',
'.mp4': 'video/mp4',
'.webm': 'video/webm'
}
export const HTML_CONTENT_TYPE = 'text/html; charset=utf-8'
export const JAVASCRIPT_CONTENT_TYPE = 'text/javascript; charset=utf-8'
export function pluginFrameContentType(path: string): string {
return MIME_TYPES[extname(path).toLowerCase()] ?? 'application/octet-stream'
}
/** Code the guest executes or renders as a document: read whole and checked byte-for-byte. */
export function isCheckedPluginFrameType(contentType: string): boolean {
return /^(?:text\/html|text\/javascript|text\/css|application\/json|application\/wasm|image\/svg\+xml|application\/xml)/.test(
contentType
)
}
@@ -14,9 +14,9 @@ import {
} from './plugin-source-transpile-protocol'
import { parsePluginTranspileFailure } from './plugin-source-transpile-failure'
import { pluginSourceMapPrelude } from '../../../shared/plugins/frames/plugin-frame-source-map-prelude'
import { PLUGIN_SOURCE_MODULE_MAX_BYTES } from '../../../shared/plugins/frames/plugin-frame-entry-limits'
/** Source modules are read whole and transpiled per load, so they get a tighter cap. */
export const PLUGIN_SOURCE_MODULE_MAX_BYTES = 1024 * 1024
export { PLUGIN_SOURCE_MODULE_MAX_BYTES }
const CACHE_MAX_ENTRIES = 512
const CACHE_MAX_CHARS = 64 * 1024 * 1024
@@ -1,4 +1,8 @@
import { stat } from 'node:fs/promises'
import {
PLUGIN_FRAME_ASSET_MAX_BYTES,
PLUGIN_FRAME_CHECKED_MAX_BYTES
} from '../../../shared/plugins/frames/plugin-frame-entry-limits'
import {
parsePluginFrameUrl,
PLUGIN_DECLARATIVE_HOST_FILE,
@@ -45,10 +49,7 @@ import {
export { pluginFrameContentType } from './plugin-frame-responses'
/** Streamed assets (images, fonts, media); the whole plugin tree is capped at 50 MiB anyway. */
export const PLUGIN_FRAME_ASSET_MAX_BYTES = 32 * 1024 * 1024
/** Documents, scripts and WASM are read whole to check their digest, so they get a lower cap. */
export const PLUGIN_FRAME_CHECKED_MAX_BYTES = 16 * 1024 * 1024
export { PLUGIN_FRAME_ASSET_MAX_BYTES, PLUGIN_FRAME_CHECKED_MAX_BYTES }
/** Concurrent requests one guest session may hold open; more get 503 until some finish. */
export const PLUGIN_FRAME_MAX_IN_FLIGHT = 32
@@ -1,52 +1,11 @@
import { createReadStream } from 'node:fs'
import { extname } from 'node:path'
import { PLUGIN_GUEST_CSP } from '../../../shared/plugins/frames/plugin-panel-shell'
const MIME_TYPES: Record<string, string> = {
'.html': 'text/html; charset=utf-8',
'.htm': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.map': 'application/json; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.md': 'text/markdown; charset=utf-8',
'.csv': 'text/csv; charset=utf-8',
'.xml': 'application/xml; charset=utf-8',
'.wasm': 'application/wasm',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.avif': 'image/avif',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.otf': 'font/otf',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.ogg': 'audio/ogg',
'.mp4': 'video/mp4',
'.webm': 'video/webm'
}
export const HTML_CONTENT_TYPE = 'text/html; charset=utf-8'
export const JAVASCRIPT_CONTENT_TYPE = 'text/javascript; charset=utf-8'
export function pluginFrameContentType(path: string): string {
return MIME_TYPES[extname(path).toLowerCase()] ?? 'application/octet-stream'
}
/** Code the guest executes or renders as a document: read whole and checked byte-for-byte. */
export function isCheckedPluginFrameType(contentType: string): boolean {
return /^(?:text\/html|text\/javascript|text\/css|application\/json|application\/wasm|image\/svg\+xml|application\/xml)/.test(
contentType
)
}
export {
HTML_CONTENT_TYPE,
isCheckedPluginFrameType,
JAVASCRIPT_CONTENT_TYPE,
pluginFrameContentType
} from './plugin-frame-content-types'
const COMMON_HEADERS = {
// Why on every response: workers take their CSP from their own script response.
+16 -71
View File
@@ -1,6 +1,5 @@
import { createReadStream } from 'node:fs'
import { readdir, realpath, stat } from 'node:fs/promises'
import { isAbsolute, join, relative, resolve, sep } from 'node:path'
import { readdir, realpath } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import type { PluginManifest } from '../../shared/plugins/manifest/plugin-manifest'
import {
PLUGIN_SYNTAX_CONFIGURATION_MAX_BYTES,
@@ -10,11 +9,23 @@ import { parsePluginVmRecipeArtifact } from '../../shared/plugins/artifacts/plug
import { PLUGIN_IMAGE_RULES } from '../../shared/plugins/images/plugin-image-fields'
import { PLUGIN_NEW_FILE_TEMPLATE_MAX_BYTES } from '../../shared/plugins/contributions/plugin-new-file-contribution'
import { validatePluginImageArtifacts } from './plugin-image-artifact-validation'
import { PLUGIN_PANEL_ENTRY_MAX_BYTES } from '../../shared/plugins/frames/plugin-frame-entry-limits'
import {
PLUGIN_WORKER_ENTRY_MAX_BYTES,
readContainedPluginArtifactBytes,
readContainedPluginArtifactText,
resolveContainedPluginArtifact,
resolvePathFromRealRoot
} from './plugin-contained-artifact'
export type PluginArtifactValidationResult = { ok: true } | { ok: false; error: string }
export const PLUGIN_PANEL_ENTRY_MAX_BYTES = 10 * 1024 * 1024
export const PLUGIN_WORKER_ENTRY_MAX_BYTES = 50 * 1024 * 1024
export { PLUGIN_PANEL_ENTRY_MAX_BYTES, PLUGIN_WORKER_ENTRY_MAX_BYTES }
export {
readContainedPluginArtifactBytes,
readContainedPluginArtifactText,
resolveContainedPluginArtifact
}
const PLUGIN_ICON_MAX_BYTES = 2 * 1024 * 1024
export const PLUGIN_LANGUAGE_PACK_MAX_BYTES = 5 * 1024 * 1024
export const PLUGIN_VM_RECIPE_MAX_BYTES = 256 * 1024
@@ -101,72 +112,6 @@ function declaredArtifactPaths(manifest: PluginManifest): DeclaredArtifact[] {
]
}
export async function resolveContainedPluginArtifact(
rootDir: string,
relativePath: string,
maxBytes = PLUGIN_WORKER_ENTRY_MAX_BYTES
): Promise<string> {
const rootReal = await realpath(resolve(rootDir))
return resolvePathFromRealRoot(rootDir, rootReal, relativePath, 'file', maxBytes)
}
export async function readContainedPluginArtifactText(
rootDir: string,
relativePath: string,
maxBytes: number
): Promise<string> {
return (await readContainedPluginArtifactBytes(rootDir, relativePath, maxBytes)).toString('utf8')
}
export async function readContainedPluginArtifactBytes(
rootDir: string,
relativePath: string,
maxBytes: number
): Promise<Buffer> {
const artifact = await resolveContainedPluginArtifact(rootDir, relativePath, maxBytes)
const chunks: Buffer[] = []
let totalBytes = 0
for await (const chunk of createReadStream(artifact)) {
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
totalBytes += bytes.byteLength
if (totalBytes > maxBytes) {
throw new Error(`exceeds the ${maxBytes}-byte artifact limit`)
}
chunks.push(bytes)
}
return Buffer.concat(chunks, totalBytes)
}
async function resolvePathFromRealRoot(
rootDir: string,
rootReal: string,
relativePath: string,
kind: 'file' | 'directory',
maxBytes?: number
): Promise<string> {
const artifactReal = await realpath(resolve(rootDir, ...relativePath.split(/[\\/]/)))
const fromRoot = relative(rootReal, artifactReal)
if (
fromRoot.length === 0 ||
isAbsolute(fromRoot) ||
fromRoot === '..' ||
fromRoot.startsWith(`..${sep}`)
) {
throw new Error('resolves outside the plugin directory')
}
const artifactStat = await stat(artifactReal)
if (kind === 'file' && !artifactStat.isFile()) {
throw new Error('is not a regular file')
}
if (kind === 'directory' && !artifactStat.isDirectory()) {
throw new Error('is not a directory')
}
if (kind === 'file' && maxBytes !== undefined && artifactStat.size > maxBytes) {
throw new Error(`exceeds the ${maxBytes}-byte artifact limit`)
}
return artifactReal
}
/** Why: APFS and NTFS resolve `dark.json` to a committed `DARK.json`, but Linux does not. */
async function assertExactPathSpelling(
rootDir: string,
@@ -0,0 +1,74 @@
import { createReadStream } from 'node:fs'
import { realpath, stat } from 'node:fs/promises'
import { isAbsolute, relative, resolve, sep } from 'node:path'
/** Reads plugin files by their real path, refusing any that a symlink takes outside the plugin.
* Free of manifest code so the plain-Node check bundle can share it. */
export const PLUGIN_WORKER_ENTRY_MAX_BYTES = 50 * 1024 * 1024
export async function resolveContainedPluginArtifact(
rootDir: string,
relativePath: string,
maxBytes = PLUGIN_WORKER_ENTRY_MAX_BYTES
): Promise<string> {
const rootReal = await realpath(resolve(rootDir))
return resolvePathFromRealRoot(rootDir, rootReal, relativePath, 'file', maxBytes)
}
export async function readContainedPluginArtifactText(
rootDir: string,
relativePath: string,
maxBytes: number
): Promise<string> {
return (await readContainedPluginArtifactBytes(rootDir, relativePath, maxBytes)).toString('utf8')
}
export async function readContainedPluginArtifactBytes(
rootDir: string,
relativePath: string,
maxBytes: number
): Promise<Buffer> {
const artifact = await resolveContainedPluginArtifact(rootDir, relativePath, maxBytes)
const chunks: Buffer[] = []
let totalBytes = 0
for await (const chunk of createReadStream(artifact)) {
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
totalBytes += bytes.byteLength
if (totalBytes > maxBytes) {
throw new Error(`exceeds the ${maxBytes}-byte artifact limit`)
}
chunks.push(bytes)
}
return Buffer.concat(chunks, totalBytes)
}
export async function resolvePathFromRealRoot(
rootDir: string,
rootReal: string,
relativePath: string,
kind: 'file' | 'directory',
maxBytes?: number
): Promise<string> {
const artifactReal = await realpath(resolve(rootDir, ...relativePath.split(/[\\/]/)))
const fromRoot = relative(rootReal, artifactReal)
if (
fromRoot.length === 0 ||
isAbsolute(fromRoot) ||
fromRoot === '..' ||
fromRoot.startsWith(`..${sep}`)
) {
throw new Error('resolves outside the plugin directory')
}
const artifactStat = await stat(artifactReal)
if (kind === 'file' && !artifactStat.isFile()) {
throw new Error('is not a regular file')
}
if (kind === 'directory' && !artifactStat.isDirectory()) {
throw new Error('is not a directory')
}
if (kind === 'file' && maxBytes !== undefined && artifactStat.size > maxBytes) {
throw new Error(`exceeds the ${maxBytes}-byte artifact limit`)
}
return artifactReal
}
@@ -0,0 +1,93 @@
/** The scripts an editor's HTML document runs, read as Chromium would for `orca plugin check`. */
export type HtmlScript = { text: string; module: boolean; lineOffset: number }
export type HtmlScriptSrc = { src: string; module: boolean; line: number; column: number }
/** An import map's `imports`: bare specifier, or a prefix ending in `/`, to a URL. */
export type HtmlImportMap = Record<string, string>
// Chromium's JavaScript MIME types; any other `type` is a data block it never runs.
const JS_TYPES = new Set([
'',
'module',
'text/javascript',
'application/javascript',
'application/ecmascript',
'application/x-ecmascript',
'application/x-javascript',
'text/ecmascript',
'text/jscript',
'text/livescript',
'text/x-ecmascript',
'text/x-javascript'
])
export function lineColumn(text: string, offset: number): { line: number; column: number } {
const before = text.slice(0, offset).split('\n')
return { line: before.length, column: (before.at(-1)?.length ?? 0) + 1 }
}
function attribute(attributes: string, name: string): string | null {
const match = new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i').exec(
attributes
)
return match ? (match[1] ?? match[2] ?? match[3] ?? '') : null
}
function importsOf(json: string): HtmlImportMap {
try {
const parsed: unknown = JSON.parse(json)
const imports =
typeof parsed === 'object' && parsed !== null && 'imports' in parsed ? parsed.imports : null
if (typeof imports !== 'object' || imports === null) {
return {}
}
return Object.fromEntries(
Object.entries(imports).filter(
(entry): entry is [string, string] => typeof entry[1] === 'string'
)
)
} catch {
return {}
}
}
/** Inline and `src` scripts Chromium runs, and the document's import map. */
export function readHtmlScripts(html: string): {
inline: HtmlScript[]
srcs: HtmlScriptSrc[]
importMap: HtmlImportMap
} {
const inline: HtmlScript[] = []
const srcs: HtmlScriptSrc[] = []
const importMap: HtmlImportMap = {}
for (const match of html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script\s*>/gi)) {
const type = (attribute(match[1], 'type') ?? '').trim().toLowerCase()
if (type === 'importmap') {
Object.assign(importMap, importsOf(match[2]))
continue
}
if (!JS_TYPES.has(type)) {
continue
}
const module = type === 'module'
const src = attribute(match[1], 'src')
if (src !== null) {
srcs.push({ src, module, ...lineColumn(html, match.index) })
} else if (match[2].trim()) {
const contentStart = match.index + match[0].indexOf('>') + 1
inline.push({ text: match[2], module, lineOffset: lineColumn(html, contentStart).line - 1 })
}
}
return { inline, srcs, importMap }
}
/** The import map's target for a bare specifier: an exact key, else the longest `/` prefix. */
export function resolveImportMapSpecifier(map: HtmlImportMap, specifier: string): string | null {
if (map[specifier] !== undefined) {
return map[specifier]
}
const prefix = Object.keys(map)
.filter((key) => key.endsWith('/') && specifier.startsWith(key))
.sort((a, b) => b.length - a.length)[0]
return prefix === undefined ? null : map[prefix] + specifier.slice(prefix.length)
}
@@ -0,0 +1,292 @@
/**
* `orca plugin check` for custom editors, without a running Orca: every file an editor entry
* loads (its local `<script src>`, inline module scripts, and their relative imports) is
* transpiled and resolved exactly as the frame loader does. Shipped in orca-test-hooks.mjs.
*/
import { realpathSync } from 'node:fs'
import { posix } from 'node:path'
import { resolvePluginModuleSpecifier } from '../panels/plugin-frame-module-resolution'
import { parsePluginTranspileFailure } from '../panels/plugin-source-transpile-failure'
import { transpilePluginSource } from '../panels/plugin-source-transpiler'
import {
PLUGIN_FRAME_ASSET_MAX_BYTES,
PLUGIN_FRAME_CHECKED_MAX_BYTES,
PLUGIN_PANEL_ENTRY_MAX_BYTES,
PLUGIN_SOURCE_MODULE_MAX_BYTES
} from '../../../shared/plugins/frames/plugin-frame-entry-limits'
import {
isCheckedPluginFrameType,
pluginFrameContentType
} from '../panels/plugin-frame-content-types'
import {
readContainedPluginArtifactText,
resolveContainedPluginArtifact
} from '../plugin-contained-artifact'
import {
lineColumn,
readHtmlScripts,
resolveImportMapSpecifier,
type HtmlImportMap
} from './plugin-editor-html-scripts'
import { isPluginSourceModulePath } from '../../../shared/plugins/frames/plugin-frame-source-modules'
export type PluginEditorCheckIssueCode =
| 'editor_syntax'
| 'editor_missing_file'
| 'editor_import'
| 'editor_eval'
| 'editor_too_large'
export type PluginEditorCheckIssue = {
code: PluginEditorCheckIssueCode
message: string
/** `file:line:column` inside the plugin. */
location: string
}
export type PluginEditorStaticCheck = {
files: string[]
issues: PluginEditorCheckIssue[]
/** Found by a lexical scan that can misread rare code, so they do not fail the check. */
warnings: PluginEditorCheckIssue[]
}
const URL_LIKE = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i
// Why: the frame's content policy has no 'unsafe-eval', so these throw when they run.
const EVAL_CALL = /(?<![\w$.])(?:eval|Function)\s*\(|\bnew\s+Function\b/g
type FileKind = 'document' | 'source' | 'script' | 'json' | 'asset'
/** How the frame protocol treats the file: the entry is a page, `.ts`-family files are
* transpiled, and anything else goes by its served content type. */
function fileKind(path: string, isEntry: boolean): FileKind {
if (isPluginSourceModulePath(path)) {
return 'source'
}
if (isEntry) {
return 'document'
}
const type = pluginFrameContentType(path)
return type.startsWith('text/javascript')
? 'script'
: type.startsWith('application/json')
? 'json'
: 'asset'
}
/** The protocol's caps: the entry, a transpiled module, and checked or streamed dependencies. */
function capFor(kind: FileKind, path: string): number {
if (kind === 'document') {
return PLUGIN_PANEL_ENTRY_MAX_BYTES
}
if (kind === 'source') {
return PLUGIN_SOURCE_MODULE_MAX_BYTES
}
return isCheckedPluginFrameType(pluginFrameContentType(path))
? PLUGIN_FRAME_CHECKED_MAX_BYTES
: PLUGIN_FRAME_ASSET_MAX_BYTES
}
/** A URL path from `from`, decoded, as a plugin path; null when it leaves the plugin. */
function pluginPathOf(from: string, reference: string): string | null {
const [pathPart = ''] = reference.split(/[?#]/, 1)
const decoded = decodeURIComponent(pathPart)
const target = decoded.startsWith('/')
? posix.normalize(decoded.slice(1))
: posix.join(posix.dirname(from), decoded)
return target === '..' || target.startsWith('../') ? null : target
}
/** Code with comments and string contents blanked (lengths and lines kept). */
function codeOnly(code: string): string {
let out = ''
let i = 0
while (i < code.length) {
const two = code.slice(i, i + 2)
const quote = code[i]
if (two === '//' || two === '/*') {
const end = two === '//' ? code.indexOf('\n', i) : code.indexOf('*/', i + 2) + 2
const stop = end <= 1 || end === -1 ? code.length : end
out += code.slice(i, stop).replace(/[^\n]/g, ' ')
i = stop
} else if (quote === '"' || quote === "'" || quote === '`') {
let j = i + 1
while (j < code.length && code[j] !== quote) {
j += code[j] === '\\' ? 2 : 1
}
out += quote + code.slice(i + 1, j).replace(/[^\n]/g, ' ') + quote
i = j + 1
} else {
out += quote
i += 1
}
}
return out
}
type Script = { path: string; text: string; lineOffset: number; native: boolean }
type NativeImport = { path: string } | { keep: true } | { refuse: string }
export async function checkPluginEditorEntry(
pluginDir: string,
entry: string
): Promise<PluginEditorStaticCheck> {
const root = realpathSync(pluginDir)
const issues: PluginEditorCheckIssue[] = []
const warnings: PluginEditorCheckIssue[] = []
const seen = new Set<string>()
const entryPath = posix.normalize(entry)
// Document-wide, as in Chromium: it maps every native module's bare imports.
let importMap: HtmlImportMap = {}
const exists = (path: string): Promise<boolean> =>
resolveContainedPluginArtifact(root, path).then(
() => true,
() => false
)
const issue = (code: PluginEditorCheckIssueCode, message: string, location: string): void => {
issues.push({ code, message, location })
}
/** The file's text through the loader's contained reader, or null after reporting why not. */
async function read(path: string, cap: number, at: string): Promise<string | null> {
try {
return await readContainedPluginArtifactText(root, path, cap)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
issue('editor_missing_file', `${path} does not exist in the plugin`, at)
} else if (message.includes('artifact limit')) {
const mib = cap / 1024 / 1024
issue('editor_too_large', `${path} is over the ${mib} MiB Orca loads for this file`, at)
} else {
issue('editor_import', `${path} ${message}`, at)
}
return null
}
}
/** Where a native (untranspiled) module's import goes, as Chromium resolves it. */
function nativeImport(from: string, specifier: string): NativeImport {
if (URL_LIKE.test(specifier)) {
return { keep: true }
}
const relativeImport = /^\.{0,2}\//.test(specifier)
const mapped = relativeImport ? null : resolveImportMapSpecifier(importMap, specifier)
if (!relativeImport && mapped === null) {
return {
refuse: `cannot import "${specifier}": a bare name needs an entry in the page's import map`
}
}
if (mapped !== null && URL_LIKE.test(mapped)) {
return { keep: true }
}
const path = mapped === null ? pluginPathOf(from, specifier) : pluginPathOf(entryPath, mapped)
return path === null
? { refuse: `cannot import "${specifier}": it is outside the plugin` }
: { path }
}
async function checkImports(
path: string,
native: boolean,
imports: readonly { specifier: string; at: string }[]
): Promise<void> {
for (const { specifier, at } of imports) {
if (native) {
const target = nativeImport(path, specifier)
if ('refuse' in target) {
issue('editor_import', target.refuse, at)
} else if ('path' in target) {
await visit(target.path, at)
}
continue
}
const resolution = await resolvePluginModuleSpecifier(path, specifier, exists)
if (resolution.kind === 'refuse') {
issue('editor_import', resolution.reason, at)
} else if (resolution.kind === 'rewrite') {
const [target = ''] = resolution.specifier.split(/[?#]/, 1)
await visit(posix.join(posix.dirname(path), decodeURIComponent(target)), at)
}
}
}
async function checkScript({ path, text, lineOffset, native }: Script): Promise<void> {
const where = (line: number, column: number) => `${path}:${line + lineOffset}:${column}`
// A native script parses as plain JavaScript, whatever its name.
const source = native ? `${path}.js` : path
const transpiled = transpilePluginSource(source, text)
if (!transpiled.ok) {
const failure = parsePluginTranspileFailure(source, transpiled.message)
const at = failure.at ? where(failure.at.line, failure.at.column) : where(1, 1)
issue('editor_syntax', failure.message, at)
return
}
for (const match of codeOnly(transpiled.code).matchAll(EVAL_CALL)) {
const at = lineColumn(transpiled.code, match.index)
warnings.push({
code: 'editor_eval',
message: `${match[0].replace(/\s*\($/, '')} compiles a string, which the editor frame refuses`,
location: where(at.line, at.column)
})
}
const imports = transpiled.imports.map(({ specifier, start }) => {
const position = lineColumn(transpiled.code, start)
return { specifier, at: where(position.line, position.column) }
})
await checkImports(path, native, imports)
}
async function checkDocument(path: string, html: string): Promise<void> {
const scripts = readHtmlScripts(html)
importMap = { ...scripts.importMap, ...importMap }
for (const script of scripts.inline) {
await checkScript({ path, text: script.text, lineOffset: script.lineOffset, native: true })
}
for (const { src, line, column } of scripts.srcs) {
const at = `${path}:${line}:${column}`
if (URL_LIKE.test(src)) {
continue
}
let target: string | null
try {
target = pluginPathOf(path, src)
} catch {
issue('editor_import', `<script src="${src}"> has a malformed escape`, at)
continue
}
if (target === null) {
issue('editor_import', `<script src="${src}"> is outside the plugin`, at)
} else {
await visit(target, at)
}
}
}
async function visit(path: string, at: string): Promise<void> {
if (seen.has(path)) {
return
}
seen.add(path)
const kind = fileKind(path, path === entryPath)
const text = await read(path, capFor(kind, path), at)
if (text === null || kind === 'asset') {
return
}
if (kind === 'document') {
await checkDocument(path, text)
} else if (kind === 'json') {
try {
JSON.parse(text)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
issue('editor_syntax', message, `${path}:1:1`)
}
} else {
await checkScript({ path, text, lineOffset: 0, native: kind === 'script' })
}
}
await visit(entryPath, 'orca-plugin.json')
return { files: [...seen].sort(), issues, warnings }
}
@@ -169,6 +169,9 @@ export async function load(url: string, context: unknown, nextLoad: NextLoad): P
return { format: 'module', source: transpiled.code, shortCircuit: true }
}
// `orca plugin check` imports this bundle for its static editor check; Node's hooks ignore it.
export { checkPluginEditorEntry } from './plugin-editor-static-check'
/** The same transform, for a Vite plugin (`orca plugin test` with the plugin's own vitest). */
export function transformPluginSourceForTest(path: string, source: string): string {
const transpiled = transpilePluginSource(path, source)
@@ -146,6 +146,18 @@ await panel.post('todos.changed', { count: 'one' })
assert.deepEqual(panel.persisted.storage, host.data().storage)
`
const EDITOR_TEST = `
import { assert, mockEditorHost } from 'orca/testing'
const host = mockEditorHost({ text: 'a' }, { colorScheme: 'light' })
const doc = await host.orca.document!.session({ debounceMs: 0 })
doc.change(doc.value + 'b')
host.conflictNext('outside')
await host.flush()
assert.equal(host.text, 'ab')
host.edits[0]?.refused satisfies string | undefined
host.dispose()
`
describe('author typings', () => {
it('checks in the host typings generated from the host API table', () => {
const checkedIn = readFileSync(path.join(REPO_ROOT, PLUGIN_HOST_TYPINGS_FILE), 'utf8')
@@ -160,7 +172,8 @@ describe('author typings', () => {
'main.ts': WORKER_MAIN,
'src/panel.tsx': COMPONENTS_PANEL,
'src/protocol.ts': PROTOCOL,
'src/panel.test.ts': PANEL_TEST
'src/panel.test.ts': PANEL_TEST,
'editor/sync.test.ts': EDITOR_TEST
})
expect(authorTypingsDiagnostics(program)).toEqual([])
})
@@ -20,6 +20,13 @@ export { hostError } from './testing-host'
export { test, describe, beforeEach, afterEach } from 'node:test'
export { default as assert } from 'node:assert/strict'
export { stored } from './testing-panel'
export { mockEditorHost } from './testing-editor-host'
export type {
MockEditorEdit,
MockEditorFile,
MockEditorHost,
MockEditorHostOptions
} from './testing-editor-host'
export type { MockHost, MockHostOptions, MockHostRefusal } from './testing-host'
export type { HookDecision, TestWorker, WorkerModule } from './testing-worker'
export type { TestPanel, PanelIssue, PersistedState } from './testing-panel'
@@ -0,0 +1,61 @@
/** The `window.orca` an editor frame gets, as much of it as mockEditorHost's tests drive. */
export type MockEditorSession = {
readonly value: unknown
readonly readOnly: boolean
change(next: unknown): void
flush(): Promise<void>
onReplace(handler: (value: unknown) => void): { dispose(): void }
dispose(): void
}
// Method syntax: callers pass typed callbacks, which method parameters accept bivariantly.
export type MockEditorSessionOptions = {
debounceMs?: number
decode?(content: unknown): unknown
encode?(value: unknown): unknown
merge?(base: unknown, outside: unknown, mine: unknown): unknown
}
export type MockEditorFrameApi = {
on(event: string, handler: (payload: unknown) => unknown): { dispose(): void }
document: {
get(): Promise<unknown>
applyEdit(edit: unknown): Promise<unknown>
session(options?: MockEditorSessionOptions): Promise<MockEditorSession>
}
}
/** The frame shell's view of the page it runs in. */
export type MockEditorShellWindow = {
__orcaPluginFrameTransport: {
send(message: unknown): void
subscribe(receive: (message: unknown) => void): void
}
addEventListener(): void
}
export type MockEditorShellDocument = {
documentElement: { classList: { add(name: string): void; remove(...names: string[]): void } }
getElementById(): null
}
export type MockEditorShellRunner = (
window: MockEditorShellWindow,
document: MockEditorShellDocument,
navigator: { language: string }
) => void
export function isMockEditorFrameApi(value: unknown): value is MockEditorFrameApi {
if (typeof value !== 'object' || value === null || !('on' in value) || !('document' in value)) {
return false
}
const document = value.document
return (
typeof value.on === 'function' &&
typeof document === 'object' &&
document !== null &&
'session' in document &&
typeof document.session === 'function' &&
'applyEdit' in document &&
typeof document.applyEdit === 'function'
)
}
@@ -0,0 +1,86 @@
import { afterEach, describe, expect, it } from 'vitest'
import { mockEditorHost, type MockEditorHost } from './testing-editor-host'
import type { MockEditorSession, MockEditorSessionOptions } from './testing-editor-frame-api'
/** `orca.document.session(options)` on the installed host, as an editor would call it. */
function session(options: MockEditorSessionOptions = {}): Promise<MockEditorSession> {
if (!host) {
throw new Error('no mockEditorHost')
}
return host.orca.document.session({ debounceMs: 0, ...options })
}
let host: MockEditorHost | null = null
afterEach(() => {
host?.dispose()
host = null
})
describe('mockEditorHost', () => {
it('installs orca with the real document session over an in-memory file', async () => {
host = mockEditorHost({ text: 'a' })
const doc = await session()
expect(doc.value).toBe('a')
doc.change('ab')
await host.flush()
expect(host.text).toBe('ab')
expect(host.edits).toEqual([{ baseVersion: 1, content: { kind: 'text', text: 'ab' } }])
doc.dispose()
})
it('sends outside changes and refuses the next edit on conflictNext', async () => {
host = mockEditorHost({ text: 'a' })
const replaced: unknown[] = []
const doc = await session({
merge: (_base: string, outside: string, mine: string) => outside + mine
})
doc.onReplace((value) => replaced.push(value))
host.replace('outside')
await new Promise((resolve) => setTimeout(resolve, 0))
expect(replaced).toEqual(['outside'])
host.conflictNext('agent')
doc.change('outside!')
await doc.flush()
expect(host.edits.map((edit) => edit.refused ?? 'applied')).toEqual(['conflict', 'applied'])
expect(host.text).toBe('agentoutside!')
expect(replaced.at(-1)).toBe('agentoutside!')
doc.dispose()
})
it('backs a bytes editor, follows the theme, and removes orca on dispose', async () => {
host = mockEditorHost({ bytes: new Uint8Array([1]) }, { fileType: 'bin', colorScheme: 'light' })
const doc = await session({
decode: (content: { bytes: ArrayBuffer }) => [...new Uint8Array(content.bytes)],
encode: (value: number[]) => ({ kind: 'bytes', bytes: new Uint8Array(value).buffer })
})
expect(doc.value).toEqual([1])
doc.change([2, 3])
await host.flush()
expect([...(host.bytes ?? [])]).toEqual([2, 3])
const themes: unknown[] = []
host.orca.on('theme', (theme) => themes.push(theme))
host.setTheme('dark')
await new Promise((resolve) => setTimeout(resolve, 0))
expect(themes).toEqual([{ colorScheme: 'dark' }])
doc.dispose()
host.dispose()
expect('orca' in globalThis).toBe(false)
})
})
describe('mockEditorHost saves as Orca does', () => {
it('blocks a save after an oversized edit until a later edit lands', async () => {
host = mockEditorHost({ text: 'a' })
const { document } = host.orca
const send = (text: string): Promise<unknown> =>
document.applyEdit({ baseVersion: 1, content: { kind: 'text', text } })
await session()
await expect(send('x'.repeat(64 * 1024 * 1024 + 1))).rejects.toMatchObject({
code: 'too_large'
})
await expect(host.flush()).rejects.toThrow(/too large/)
await send('fits')
await expect(host.flush()).resolves.toBeUndefined()
expect(host.text).toBe('fits')
})
})
@@ -0,0 +1,296 @@
import { runInThisContext } from 'node:vm'
import {
isMockEditorFrameApi,
type MockEditorFrameApi,
type MockEditorShellDocument,
type MockEditorShellRunner,
type MockEditorShellWindow
} from './testing-editor-frame-api'
import {
CUSTOM_EDITOR_CONFLICT,
CUSTOM_EDITOR_DOCUMENT_MAX_BYTES,
CUSTOM_EDITOR_FLUSH_TIMEOUT_MS,
CUSTOM_EDITOR_TOO_LARGE,
readCustomEditorEdit,
type CustomEditorContent,
type CustomEditorDocument
} from '../../frames/plugin-custom-editor-bridge'
import { EDITOR_METHODS, FRAME_EVENTS } from '../../frames/plugin-frame-bridge'
import { buildPluginGuestShellHtml } from '../../frames/plugin-panel-shell'
import { structuredCloneMessageBytes } from '../../frames/plugin-panel-message-budget'
import {
pluginError,
PLUGIN_PROTOCOL_VERSION,
type PluginError
} from '../../frames/plugin-protocol'
/**
* A stand-in for Orca's custom editor host: runs the frame shell's real `window.orca` (document
* API and session included) against an in-memory file, so editor sync logic tests in plain Node.
*/
export type MockEditorFile = { text: string } | { bytes: ArrayBuffer | Uint8Array }
export type MockEditorHostOptions = {
readOnly?: boolean
colorScheme?: 'light' | 'dark'
locale?: string
/** Bytes documents: the manifest suffix that matched. */
fileType?: string
}
export type MockEditorEdit = {
baseVersion: number
content: CustomEditorContent
/** The refusal's code; absent when the edit was applied. */
refused?: string
}
export type MockEditorHost = {
/** The frame API, also installed as `globalThis.orca`. */
readonly orca: MockEditorFrameApi
/** The file as Orca holds it now: text, or undefined for a bytes file. */
readonly text: string | undefined
readonly bytes: Uint8Array | undefined
/** Rises with each document sent to the editor. */
readonly version: number
/** Every `applyEdit`, in order. */
readonly edits: readonly MockEditorEdit[]
/** An outside change, such as an agent writing the file. */
replace(next: string | ArrayBuffer | Uint8Array): void
/** Refuses the next edit with `conflict`, after landing `outside` first when given. */
conflictNext(outside?: string | ArrayBuffer | Uint8Array): void
/** As a save does: asks the editor to post pending edits, and waits for its answer; rejects
* after an edit too large to apply, as Orca then refuses to save. */
flush(): Promise<void>
setTheme(colorScheme: 'light' | 'dark'): void
dispose(): void
}
const isFrameMessage = (value: unknown): value is FrameMessage =>
typeof value === 'object' && value !== null
type FrameMessage = {
kind?: unknown
event?: unknown
method?: unknown
id?: unknown
params?: unknown
error?: unknown
}
const SHELL_SCRIPT = /<script>\n'use strict'\n([\s\S]*?)<\/script>/.exec(
buildPluginGuestShellHtml('', 'editor')
)?.[1]
let current: MockEditorHost | null = null
function toContent(next: string | ArrayBuffer | Uint8Array): CustomEditorContent {
if (typeof next === 'string') {
return { kind: 'text', text: next }
}
const view = next instanceof Uint8Array ? next : new Uint8Array(next)
return { kind: 'bytes', bytes: view.slice().buffer }
}
function copy(content: CustomEditorContent): CustomEditorContent {
return content.kind === 'text' ? content : { kind: 'bytes', bytes: content.bytes.slice(0) }
}
/** Installs `globalThis.orca` for a custom editor over `file`; one at a time, like `mount`. */
export function mockEditorHost(
file: MockEditorFile,
options: MockEditorHostOptions = {}
): MockEditorHost {
current?.dispose()
if (!SHELL_SCRIPT) {
throw new Error('orca/testing: the frame shell script is missing')
}
let content = toContent('text' in file ? file.text : file.bytes)
const kind = content.kind
let version = 0
let colorScheme = options.colorScheme ?? 'dark'
let disposed = false
let conflict: { outside?: CustomEditorContent } | null = null
let nextFlushId = 0
const flushes = new Map<number, (error: unknown) => void>()
const edits: MockEditorEdit[] = []
let receive: (message: unknown) => void = () => {}
const deliver = (message: Record<string, unknown>): void => {
queueMicrotask(() => {
if (!disposed) {
receive({ orca: PLUGIN_PROTOCOL_VERSION, ...message })
}
})
}
const answer = (id: unknown, error: PluginError | null): void => {
deliver(error ? { kind: 'res', id, error } : { kind: 'res', id, result: null })
}
const sendDocument = (): void => {
version += 1
const document: CustomEditorDocument = {
version,
readOnly: options.readOnly ?? false,
content: copy(content),
...(kind === 'bytes' && options.fileType ? { fileType: options.fileType } : {})
}
deliver({ kind: 'evt', event: FRAME_EVENTS.editorDocument, payload: document })
}
const theme = () => ({ colorScheme, tokens: '' })
// As Orca's host: an oversized edit is refused before it is read, and blocks saving until a
// later edit lands, because the buffer lacks the editor's newest change.
let lostEdit = false
const applyEdit = (message: FrameMessage): PluginError | null => {
if (
structuredCloneMessageBytes(message, CUSTOM_EDITOR_DOCUMENT_MAX_BYTES) >
CUSTOM_EDITOR_DOCUMENT_MAX_BYTES
) {
lostEdit = true
return CUSTOM_EDITOR_TOO_LARGE
}
const edit = readCustomEditorEdit(message.params)
if (!edit) {
return pluginError('invalid_params', 'malformed editor edit')
}
const refuse = (error: PluginError): PluginError => {
edits.push({ ...edit, refused: error.code })
return error
}
if (conflict) {
const outside = conflict.outside
conflict = null
if (outside) {
content = outside
sendDocument()
}
return refuse(CUSTOM_EDITOR_CONFLICT)
}
if (options.readOnly) {
return refuse(pluginError('not_permitted', 'This document is read-only.'))
}
if (edit.content.kind !== kind) {
return refuse(pluginError('invalid_params', `content.kind: this editor edits ${kind}`))
}
if (edit.baseVersion !== version) {
return refuse(CUSTOM_EDITOR_CONFLICT)
}
lostEdit = false
content = copy(edit.content)
edits.push({ ...edit, content: copy(edit.content) })
return null
}
const fromFrame = (message: FrameMessage): void => {
if (disposed) {
return
}
if (message.kind === 'evt' && message.event === FRAME_EVENTS.ready) {
deliver({ kind: 'evt', event: FRAME_EVENTS.hello, payload: hello() })
sendDocument()
} else if (message.kind === 'req' && message.method === EDITOR_METHODS.applyEdit) {
answer(message.id, applyEdit(message))
} else if (message.kind === 'req') {
answer(
message.id,
pluginError('unknown_method', `mockEditorHost does not answer ${String(message.method)}`)
)
} else if (message.kind === 'res' && typeof message.id === 'number') {
flushes.get(message.id)?.(message.error)
flushes.delete(message.id)
}
}
const hello = () => ({
protocol: PLUGIN_PROTOCOL_VERSION,
surface: 'editor',
locale: options.locale ?? 'en-US',
theme: theme()
})
const frameWindow: MockEditorShellWindow = {
__orcaPluginFrameTransport: {
send: (message) => fromFrame(isFrameMessage(message) ? message : {}),
subscribe: (fn: (message: unknown) => void) => {
receive = fn
}
},
addEventListener: () => {}
}
const classes = new Set<string>()
const frameDocument: MockEditorShellDocument = {
documentElement: {
classList: {
add: (name: string) => classes.add(name),
remove: (...names: string[]) => names.forEach((name) => classes.delete(name))
}
},
getElementById: () => null
}
const run: MockEditorShellRunner = runInThisContext(
`(function (window, document, navigator) {\n'use strict'\n${SHELL_SCRIPT}\n})`
)
run(frameWindow, frameDocument, { language: options.locale ?? 'en-US' })
const orca: unknown = Object.getOwnPropertyDescriptor(frameWindow, 'orca')?.value
if (!isMockEditorFrameApi(orca)) {
throw new Error('orca/testing: the frame shell did not define window.orca')
}
Object.defineProperty(globalThis, 'orca', { value: orca, configurable: true, writable: true })
const host: MockEditorHost = {
orca,
get text() {
return content.kind === 'text' ? content.text : undefined
},
get bytes() {
return content.kind === 'bytes' ? new Uint8Array(content.bytes.slice(0)) : undefined
},
get version() {
return version
},
edits,
replace(next) {
content = toContent(next)
sendDocument()
},
conflictNext(outside) {
conflict = outside === undefined ? {} : { outside: toContent(outside) }
},
flush() {
const id = nextFlushId++
return new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
flushes.delete(id)
reject(
new Error(
`the editor did not answer editor.flush within ${CUSTOM_EDITOR_FLUSH_TIMEOUT_MS} ms`
)
)
}, CUSTOM_EDITOR_FLUSH_TIMEOUT_MS)
flushes.set(id, (error) => {
clearTimeout(timer)
if (error) {
reject(new Error(`editor.flush failed: ${JSON.stringify(error)}`))
} else if (lostEdit) {
reject(new Error("Orca would not save: the editor's newest edit was too large"))
} else {
resolve()
}
})
deliver({ kind: 'req', id, method: EDITOR_METHODS.flush })
})
},
setTheme(next) {
colorScheme = next
deliver({ kind: 'evt', event: FRAME_EVENTS.theme, payload: theme() })
},
dispose() {
disposed = true
flushes.clear()
if (Object.getOwnPropertyDescriptor(globalThis, 'orca')?.value === orca) {
Reflect.deleteProperty(globalThis, 'orca')
}
if (current === host) {
current = null
}
}
}
current = host
return host
}
@@ -0,0 +1,8 @@
/** The most a panel or editor frame file may weigh: its entry, scripts and modules. */
export const PLUGIN_PANEL_ENTRY_MAX_BYTES = 10 * 1024 * 1024
/** Source modules are read whole and transpiled per load, so they get a tighter cap. */
export const PLUGIN_SOURCE_MODULE_MAX_BYTES = 1024 * 1024
/** Streamed assets (images, fonts, media); the whole plugin tree is capped at 50 MiB anyway. */
export const PLUGIN_FRAME_ASSET_MAX_BYTES = 32 * 1024 * 1024
/** Documents, scripts and WASM are read whole to check their digest, so they get a lower cap. */
export const PLUGIN_FRAME_CHECKED_MAX_BYTES = 16 * 1024 * 1024