Files
orca/src/shared/plugins/declarative/testing/testing-editor-host.test.ts
T
Neil fa7c3f95fe 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.
2026-11-01 21:28:13 -08:00

87 lines
3.3 KiB
TypeScript

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')
})
})