mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
* fix(plugins): isolate development watcher retries by root * test(plugins): keep coverage for late binding checks after housekeeping disposal The per-root rewrite replaced the old housekeeping unit tests; restore the disposal/coalescing case so a slow check that resolves after dispose cannot restart a watcher or refresh, and per-root checks do not stack. * fix(plugins): skip the full plugin rediscovery while a dev folder retry keeps failing A maintenance retry that subscribes already refreshes itself; one that keeps failing (an unreachable WSL share) rediscovered every plugin each tick. Only a changed root binding needs discovery now. Also covers what the per-root rewrite must keep: adding/removing a folder leaves the others' watchers and frame revisions alone and releases its subscriptions, a rejecting registration retries once per tick, Linux sibling folders keep separate parent watches, and an Electron e2e where deleting/recreating/removing one dev folder never reloads another folder's open panel. --------- Co-authored-by: be0hhh <182023099+be0hhh@users.noreply.github.com> Co-authored-by: Neil <neil@stably.ai>
179 lines
7.5 KiB
TypeScript
179 lines
7.5 KiB
TypeScript
/**
|
|
* Invariant: one dev plugin folder failing (deleted, recreated, removed from settings) never
|
|
* reloads another dev plugin's open panel, while an edit in the healthy folder still reloads it once.
|
|
* On macOS the recreated folder is found by the 60-second maintenance check, so this spans two ticks.
|
|
*/
|
|
|
|
import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import type { ElectronApplication, Page } from '@stablyai/playwright-test'
|
|
import { expect, test } from './helpers/orca-app'
|
|
import { disablePluginSystem, enableDevPlugins } from './helpers/plugin-smoke'
|
|
import {
|
|
evalInGuest,
|
|
ISOLATED_FRAMES_E2E,
|
|
pluginGuest,
|
|
waitForGuestReady
|
|
} from './helpers/plugin-frame-guest'
|
|
|
|
test.use(ISOLATED_FRAMES_E2E)
|
|
|
|
const HEALTHY_TITLE = 'Hello Orca'
|
|
const BROKEN_ID = 'hello-orca-broken'
|
|
|
|
async function copyPlugin(target: string, id?: string): Promise<void> {
|
|
await cp(join(process.cwd(), 'examples', 'plugins', 'hello-orca'), target, { recursive: true })
|
|
if (id) {
|
|
const manifestPath = join(target, 'orca-plugin.json')
|
|
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
|
manifest.id = id
|
|
manifest.name = 'Broken Orca'
|
|
manifest.contributes.panels[0].title = 'Broken Orca'
|
|
await writeFile(manifestPath, JSON.stringify(manifest))
|
|
}
|
|
}
|
|
|
|
function listsBrokenPlugin(page: Page): Promise<boolean> {
|
|
return page.evaluate(
|
|
async (id) => (await window.api.plugins.list()).some((p) => p.pluginKey.endsWith(`.${id}`)),
|
|
BROKEN_ID
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Why count restarts: a loaded host can restart the shared watcher process (delivery canary); every
|
|
* subscriber, healthy or not, then catches up once by design. Those reloads are not root coupling.
|
|
*/
|
|
function countWatcherProcessRestarts(app: ElectronApplication): () => number {
|
|
let restarts = 0
|
|
app.process().stderr?.on('data', (chunk: Buffer) => {
|
|
restarts += String(chunk).match(/watcher process crashed; resubscribing/g)?.length ?? 0
|
|
})
|
|
return () => restarts
|
|
}
|
|
|
|
/** Every distinct guest load of the healthy panel (a new webview element or a new URL). */
|
|
async function healthyLoadLog(page: Page): Promise<string[]> {
|
|
const raw = await page.evaluate(() => document.documentElement.dataset.e2eHealthyLoads ?? '[]')
|
|
const loads: unknown = JSON.parse(raw)
|
|
return Array.isArray(loads) ? loads.map(String) : []
|
|
}
|
|
|
|
async function healthyLoads(page: Page): Promise<number> {
|
|
return (await healthyLoadLog(page)).length
|
|
}
|
|
|
|
function readHeading(page: Page): Promise<string | null> {
|
|
return evalInGuest<string | null>(
|
|
pluginGuest(page, HEALTHY_TITLE),
|
|
`document.querySelector('h1')?.textContent ?? null`
|
|
).catch(() => null)
|
|
}
|
|
|
|
test('a failing dev folder does not reload another dev plugin panel', async ({
|
|
electronApp,
|
|
orcaPage
|
|
}) => {
|
|
// Why 7 minutes: two 60-second maintenance ticks plus slow FSEvents delivery on loaded hosts.
|
|
test.setTimeout(7 * 60_000)
|
|
const watcherRestarts = countWatcherProcessRestarts(electronApp)
|
|
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-dev-root-isolation-'))
|
|
const healthy = join(tempRoot, 'healthy')
|
|
const broken = join(tempRoot, 'broken')
|
|
try {
|
|
await copyPlugin(healthy)
|
|
await copyPlugin(broken, BROKEN_ID)
|
|
await enableDevPlugins(orcaPage, [healthy, broken])
|
|
await expect.poll(() => listsBrokenPlugin(orcaPage)).toBe(true)
|
|
|
|
await orcaPage.evaluate(() => {
|
|
const state = window.__store?.getState()
|
|
if (state && !state.rightSidebarOpen) {
|
|
state.toggleRightSidebar()
|
|
}
|
|
})
|
|
const panelButton = orcaPage.getByRole('button', { name: HEALTHY_TITLE, exact: true })
|
|
await expect(panelButton).toBeVisible({ timeout: 15_000 })
|
|
await panelButton.click()
|
|
await waitForGuestReady(pluginGuest(orcaPage, HEALTHY_TITLE))
|
|
await expect.poll(() => readHeading(orcaPage)).toBe('Hello Orca 👋')
|
|
|
|
await orcaPage.evaluate((title) => {
|
|
const seen = new WeakMap<Element, string>()
|
|
const loads: string[] = []
|
|
setInterval(() => {
|
|
const webview = document.querySelector<Electron.WebviewTag>(`webview[title="${title}"]`)
|
|
let url = ''
|
|
try {
|
|
url = webview?.getURL() ?? ''
|
|
} catch {
|
|
return
|
|
}
|
|
if (webview && url && seen.get(webview) !== url) {
|
|
seen.set(webview, url)
|
|
loads.push(`${url} @${Date.now()}`)
|
|
document.documentElement.dataset.e2eHealthyLoads = JSON.stringify(loads)
|
|
}
|
|
}, 50)
|
|
}, HEALTHY_TITLE)
|
|
await expect.poll(() => healthyLoads(orcaPage)).toBe(1)
|
|
// Why a warm-up edit: FSEvents can hold the copy's create events and flush them with the next
|
|
// change, which would look like a coupled reload. One real edit drains them first.
|
|
await writeFile(join(healthy, 'warm-up.txt'), 'x')
|
|
await expect.poll(() => healthyLoads(orcaPage), { timeout: 60_000 }).toBeGreaterThan(1)
|
|
// ast-grep-ignore: test-no-real-time-wait -- the warm-up batch must finish before the baseline
|
|
await orcaPage.waitForTimeout(2_000)
|
|
const baselineLoads = await healthyLoads(orcaPage)
|
|
const restartsBefore = watcherRestarts()
|
|
const expectNoCoupledReloads = async (): Promise<void> => {
|
|
const loads = await healthyLoadLog(orcaPage)
|
|
expect(loads.length, JSON.stringify(loads)).toBeLessThanOrEqual(
|
|
baselineLoads + watcherRestarts() - restartsBefore
|
|
)
|
|
}
|
|
|
|
// The broken folder disappears, then comes back; each change refreshes only its own watcher.
|
|
await rm(broken, { recursive: true, force: true })
|
|
await expect.poll(() => listsBrokenPlugin(orcaPage), { timeout: 90_000 }).toBe(false)
|
|
await expectNoCoupledReloads()
|
|
await copyPlugin(broken, BROKEN_ID)
|
|
await expect.poll(() => listsBrokenPlugin(orcaPage), { timeout: 90_000 }).toBe(true)
|
|
await expectNoCoupledReloads()
|
|
// Another full maintenance tick with both folders present stays quiet.
|
|
// ast-grep-ignore: test-no-real-time-wait -- the claim is that a real maintenance tick stays quiet
|
|
await orcaPage.waitForTimeout(62_000)
|
|
await expectNoCoupledReloads()
|
|
// Dropping the broken folder from settings retires only its watcher.
|
|
await orcaPage.evaluate(
|
|
async (devPluginPaths) => {
|
|
const settings = await window.api.settings.set({ devPluginPaths })
|
|
window.__store?.setState({ settings })
|
|
await window.api.plugins.refresh()
|
|
},
|
|
[healthy]
|
|
)
|
|
await expect.poll(() => listsBrokenPlugin(orcaPage)).toBe(false)
|
|
// ast-grep-ignore: test-no-real-time-wait -- the claim is that no reload follows the removal
|
|
await orcaPage.waitForTimeout(1_500)
|
|
await expectNoCoupledReloads()
|
|
|
|
// A real edit in the healthy folder still reloads its panel, exactly once.
|
|
const loadsBeforeEdit = await healthyLoads(orcaPage)
|
|
const restartsBeforeEdit = watcherRestarts()
|
|
const panelPath = join(healthy, 'panel.html')
|
|
const html = await readFile(panelPath, 'utf8')
|
|
await writeFile(panelPath, html.replace('Hello Orca 👋', 'Hello Orca reloaded'))
|
|
await expect.poll(() => readHeading(orcaPage), { timeout: 60_000 }).toBe('Hello Orca reloaded')
|
|
// ast-grep-ignore: test-no-real-time-wait -- the claim is that the edit reloads only once
|
|
await orcaPage.waitForTimeout(2_000)
|
|
expect(await healthyLoads(orcaPage)).toBeGreaterThan(loadsBeforeEdit)
|
|
expect(await healthyLoads(orcaPage)).toBeLessThanOrEqual(
|
|
loadsBeforeEdit + 1 + watcherRestarts() - restartsBeforeEdit
|
|
)
|
|
} finally {
|
|
await disablePluginSystem(orcaPage).catch(() => undefined)
|
|
await rm(tempRoot, { recursive: true, force: true })
|
|
}
|
|
})
|