/** * Invariant: a plugin custom editor opens matching files in the editor tab, edits (text or * bytes) flow into Orca's buffer without dirtying it on open, the save shortcut pressed inside * the frame saves through Orca, Source shows the same buffer (or the binary placeholder), and * turning plugins off falls back to Source. */ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { Page } from '@stablyai/playwright-test' import { PLUGIN_MANIFEST_V2_SINCE } from '../../src/shared/plugins/manifest/plugin-schema-field-docs' import { expect, test } from './helpers/orca-app' import { disablePluginSystem, enableDevPlugins, openRepoFile } from './helpers/plugin-smoke' import { focusInGuest, pluginGuest, readInGuest, ISOLATED_FRAMES_E2E } from './helpers/plugin-frame-guest' // Isolated frames are off by default; these journeys exercise the guest path. test.use(ISOLATED_FRAMES_E2E) const FILE = 'custom-editor-smoke.sketch' const SAVE_KEY = process.platform === 'darwin' ? 'Meta+s' : 'Control+s' // A tiny complete editor on window.orca: a textarea that sends debounced full-text edits. const EDITOR_HTML = ` ` async function writeEditorPlugin(root: string): Promise { await mkdir(join(root, 'editor'), { recursive: true }) await writeFile(join(root, 'editor', 'index.html'), EDITOR_HTML) await writeFile( join(root, 'orca-plugin.json'), JSON.stringify({ manifestVersion: 2, execution: 'sandboxed-ui', id: 'sketch-pad', publisher: 'samples', name: 'Sketch Pad', version: '1.0.0', engines: { orca: `>=${PLUGIN_MANIFEST_V2_SINCE}` }, contributes: { customEditors: [ { id: 'sketch', title: 'Sketch Pad', extensions: ['.sketch'], entry: 'editor/index.html' } ] } }) ) } function activeFileDirty(page: Page): Promise { return page.evaluate((name) => { const state = window.__store?.getState() return state?.openFiles.find((file) => file.relativePath === name)?.isDirty }, FILE) } async function chooseView(page: Page, label: string): Promise { await page.getByRole('radio', { name: label, exact: true }).click() } test('plugin custom editor edits and saves through Orca', async ({ orcaPage, seededRepoPath }) => { const pluginRoot = await mkdtemp(join(tmpdir(), 'orca-custom-editor-')) const filePath = join(seededRepoPath, FILE) try { await writeEditorPlugin(pluginRoot) await writeFile(filePath, 'first line\n') await enableDevPlugins(orcaPage, [pluginRoot]) await openRepoFile(orcaPage, seededRepoPath, FILE) const guest = pluginGuest(orcaPage, 'Sketch Pad') const text = () => readInGuest(guest, `document.getElementById('doc').value`) await expect(guest).toBeVisible({ timeout: 15_000 }) await expect.poll(text, { timeout: 15_000 }).toBe('first line\n') // Opening a file in its custom editor must not dirty it. expect(await activeFileDirty(orcaPage)).toBe(false) // Real input: the save chord only counts with the frame's own user activation. await focusInGuest(guest, '#doc') await orcaPage.keyboard.press(process.platform === 'darwin' ? 'Meta+ArrowDown' : 'Control+End') await orcaPage.keyboard.type('second line') await expect.poll(() => activeFileDirty(orcaPage)).toBe(true) // Saved inside the debounce window: Orca must flush the frame first. await orcaPage.keyboard.type('!') await orcaPage.keyboard.press(SAVE_KEY) await expect.poll(() => readFile(filePath, 'utf8')).toBe('first line\nsecond line!') await expect.poll(() => activeFileDirty(orcaPage)).toBe(false) await chooseView(orcaPage, 'Source') await expect(guest).toHaveCount(0) await expect(orcaPage.locator('.monaco-editor .view-lines')).toContainText('second line!') await chooseView(orcaPage, 'Sketch Pad') await expect(guest).toBeVisible() await expect.poll(text).toBe('first line\nsecond line!') await disablePluginSystem(orcaPage) await expect(guest).toHaveCount(0, { timeout: 15_000 }) await expect(orcaPage.locator('.monaco-editor .view-lines')).toBeVisible() } finally { await disablePluginSystem(orcaPage).catch(() => undefined) await rm(filePath, { force: true }) await rm(pluginRoot, { recursive: true, force: true }) } }) const BYTES_FILE = 'custom-editor-smoke.hexbook' // Reports what a bytes editor receives; its button appends 0xaa as a full-bytes edit. const BYTES_EDITOR_HTML = `waiting ` test('a bytes custom editor edits and saves a binary file', async ({ orcaPage, seededRepoPath }) => { const pluginRoot = await mkdtemp(join(tmpdir(), 'orca-bytes-editor-')) const filePath = join(seededRepoPath, BYTES_FILE) // NULs make Orca treat it as binary, like a zip-based workbook. const original = Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x00, 0x00, 0xff, 0x10]) try { await mkdir(join(pluginRoot, 'editor'), { recursive: true }) await writeFile(join(pluginRoot, 'editor', 'index.html'), BYTES_EDITOR_HTML) await writeFile( join(pluginRoot, 'orca-plugin.json'), JSON.stringify({ manifestVersion: 2, execution: 'sandboxed-ui', id: 'hex-book', publisher: 'samples', name: 'Hex Book', version: '1.0.0', engines: { orca: `>=${PLUGIN_MANIFEST_V2_SINCE}` }, contributes: { customEditors: [ { id: 'hex', title: 'Hex Book', extensions: ['.hexbook'], entry: 'editor/index.html', contentKind: 'bytes' } ] } }) ) await writeFile(filePath, original) await enableDevPlugins(orcaPage, [pluginRoot]) await openRepoFile(orcaPage, seededRepoPath, BYTES_FILE) const guest = pluginGuest(orcaPage, 'Hex Book') const summary = () => readInGuest(guest, `document.getElementById('summary').textContent`) await expect(guest).toBeVisible({ timeout: 15_000 }) await expect.poll(summary, { timeout: 15_000 }).toBe('v1 hexbook false 8:50 4b 3 4 name=opaque') // Opening and the ignored text edit leave the file and the tab untouched. const dirty = () => orcaPage.evaluate( (name) => window.__store?.getState().openFiles.find((file) => file.relativePath === name)?.isDirty, BYTES_FILE ) expect(await readFile(filePath)).toEqual(original) expect(await dirty()).toBe(false) // Why a key: in a hidden Linux window the mouse never reaches a sandboxed frame's button. await focusInGuest(guest, '#append') await orcaPage.keyboard.press('Enter') await expect.poll(dirty).toBe(true) await orcaPage.keyboard.press(SAVE_KEY) const edited = Buffer.concat([original, Buffer.from([0xaa])]) await expect.poll(() => readFile(filePath)).toEqual(edited) await expect.poll(dirty).toBe(false) // Source of a binary file is the binary placeholder, never decoded bytes. await chooseView(orcaPage, 'Source') await expect(guest).toHaveCount(0) await expect(orcaPage.getByText('Binary file — cannot display')).toBeVisible() await chooseView(orcaPage, 'Hex Book') // A remount reads the saved bytes back. await expect(guest).toBeVisible() await expect.poll(summary).toContain('hexbook false 9:') await disablePluginSystem(orcaPage) await expect(guest).toHaveCount(0, { timeout: 15_000 }) await expect(orcaPage.getByText('Binary file — cannot display')).toBeVisible() } finally { await disablePluginSystem(orcaPage).catch(() => undefined) await rm(filePath, { force: true }) await rm(pluginRoot, { recursive: true, force: true }) } })