/** * 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' const FILE = 'custom-editor-smoke.sketch' const FRAME = 'iframe[title="Sketch Pad"]' // 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 frame = orcaPage.frameLocator(FRAME) const textarea = frame.locator('#doc') await expect(textarea).toHaveValue('first line\n', { timeout: 15_000 }) // Opening a file in its custom editor must not dirty it. expect(await activeFileDirty(orcaPage)).toBe(false) await textarea.click() await textarea.press('End') await textarea.pressSequentially('second line') await expect.poll(() => activeFileDirty(orcaPage)).toBe(true) // Saved inside the debounce window: Orca must flush the frame first. await textarea.pressSequentially('!') await textarea.press(process.platform === 'darwin' ? 'Meta+s' : 'Control+s') await expect.poll(() => readFile(filePath, 'utf8')).toBe('first line\nsecond line!') await expect.poll(() => activeFileDirty(orcaPage)).toBe(false) await chooseView(orcaPage, 'Source') await expect(orcaPage.locator(FRAME)).toHaveCount(0) await expect(orcaPage.locator('.monaco-editor .view-lines')).toContainText('second line!') await chooseView(orcaPage, 'Sketch Pad') await expect(textarea).toHaveValue('first line\nsecond line!') await disablePluginSystem(orcaPage) await expect(orcaPage.locator(FRAME)).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' const BYTES_FRAME = 'iframe[title="Hex Book"]' // 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 bytesFrame = orcaPage.frameLocator(BYTES_FRAME) const summary = bytesFrame.locator('#summary') await expect(summary).toHaveText('v1 hexbook false 8:50 4b 3 4 name=opaque', { timeout: 15_000 }) // 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) const append = bytesFrame.locator('#append') // Why a key: in a hidden Linux window the mouse never reaches a sandboxed frame's button. await append.press('Enter') await expect.poll(dirty).toBe(true) await append.press(process.platform === 'darwin' ? 'Meta+s' : 'Control+s') 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(orcaPage.locator(BYTES_FRAME)).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(summary).toContainText('hexbook false 9:') await disablePluginSystem(orcaPage) await expect(orcaPage.locator(BYTES_FRAME)).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 }) } })