Files
orca/config/scripts/pr-plugin-e2e-routing.test.mjs
T
Neil b17d408416 Plugin dev loop: TS workers, hot reload with state, orca plugin dev watch, authoring skill (#36982)
* feat(plugins): build-less TypeScript workers

A worker's main may be .ts/.mts. The plugin host installs module.registerHooks scoped to the
plugin folder: .ts files are transpiled with the frames' sucrase options (lines kept, inline
source map, source maps on), relative imports fall back to the frames' candidate files, and a
value import of the type-only 'orca/worker' fails with a clear error. Needs engines.orca
>=1.4.246.

* feat(plugins): components panels hot reload with their state

- In dev folders a save swaps only the hidden guest: keyed memory atoms are handed to the new
  guest (ui.retain → ui.hydrate memory), dropped when an atom's version changes.
- A save that fails to load keeps the last good render and shows an inline file:line:col
  error banner; the next good save clears it. A subtle Reloaded mark follows a reload.
- Transpile and import errors carry line and column; dev worker restarts keep storage.

* feat(plugins): orca plugin dev watch

One command for the inner loop: refreshes .orca/types, dev-loads the folder, follows its frame
and worker logs (plugins.getLogs gains an optional cursor), prints reloads (optional
devChangeCount on list entries) and status changes, and unloads on Ctrl-C. --json streams
NDJSON events for agents.

* feat(skills): orca-plugin-authoring bundled skill guide

A guide for agents to build Orca plugins end to end (decision table, build-less rules, the
dev watch loop, test/check, validate, release-entry, approvals, gotchas) with references
generated from the component catalog, host API table, event schemas, manifest schema and CLI
specs, checked for drift. Fixes the components template's Reset to set(count, 0).

* feat(plugins): orca plugin dev watch --check; test kit details in the authoring skill

- --check runs orca plugin check after the load and after each reload (one at a time; saves
  made meanwhile fold into one rerun) and prints each panel's render or errors inline, as a
  `check` event with --json. Local folders only, since it runs the plugin's code here.
- The orca-plugin-authoring guide's Test and check section now documents orca plugin test and
  check from #36969 exactly (JSON results, exit codes, test placement, the orca/testing kit and
  its common mistakes), and the loop uses dev watch --check.

* refactor(plugins): move the components renderer's timing and helper constants to their own module
2026-11-01 03:32:25 -08:00

145 lines
6.6 KiB
JavaScript

import { describe, expect, it } from 'vitest'
import { existsSync, readdirSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { classifyE2eJobs, generalE2eShards } from './ci-e2e-job-selection.mjs'
import { testImportClosure } from './e2e-test-import-closure.mjs'
import { selectPrE2eSpecs } from './pr-e2e-source-routing.mjs'
import {
PLUGIN_SYSTEM_SOURCE,
PR_PLUGIN_E2E_SOURCE_ROUTES
} from './pr-plugin-e2e-source-routes.mjs'
const projectDir = resolve(import.meta.dirname, '../..')
const UNROUTED_ID = 'plugins.unrouted-source'
function repoFiles(directory) {
return readdirSync(join(projectDir, directory), { recursive: true, withFileTypes: true })
.filter((entry) => entry.isFile())
.map((entry) => join(entry.parentPath, entry.name).slice(projectDir.length + 1))
.map((file) => file.split('\\').join('/'))
}
const pluginSources = [
...repoFiles('src/renderer/src/components/plugins'),
...repoFiles('src/main/plugins'),
...repoFiles('src/shared/plugins'),
...readdirSync(join(projectDir, 'src/renderer/src/store'))
.filter((name) => name.startsWith('plugin-'))
.map((name) => `src/renderer/src/store/${name}`)
].filter(
(file) =>
/\.(?:ts|tsx|cjs)$/.test(file) && !/\.test\.tsx?$/.test(file) && !file.includes('/__mocks__/')
)
const pluginSpecs = readdirSync(join(projectDir, 'tests/e2e'))
.filter((name) => /^plugin-.*\.spec\.ts$/.test(name))
.map((name) => `tests/e2e/${name}`)
const routedPluginSpecs = (file) =>
PR_PLUGIN_E2E_SOURCE_ROUTES.filter((route) => route.matches(file)).flatMap((route) => route.specs)
describe('plugin PR E2E routing', () => {
it('runs the update review specs for the #36218 change set', () => {
const specs = selectPrE2eSpecs([
'src/renderer/src/components/plugins/dialogs/PluginUpdateAllDialog.tsx',
'src/renderer/src/components/plugins/list/PluginsAlphaNotice.tsx',
'src/renderer/src/components/plugins/page/PluginDetailPage.tsx',
'src/renderer/src/components/plugins/presentation/plugin-publisher-trust.ts',
'src/renderer/src/components/plugins/review/PluginUpdateReview.tsx',
'src/renderer/src/components/plugins/state/use-plugin-page-model.ts'
])
expect(specs).toContain('tests/e2e/plugin-update-review.spec.ts')
expect(specs).toContain('tests/e2e/plugin-update-review-toast.spec.ts')
expect(classifyE2eJobs(JSON.stringify(specs)).e2e_run_changed).toBe(true)
})
it.each([
['src/renderer/src/components/plugins/review/PluginInstallConsent.tsx', 'plugin-update-review'],
['src/renderer/src/components/plugins/catalog/PluginCardAction.tsx', 'plugin-install-link'],
['src/renderer/src/components/plugins/state/plugin-try-action.ts', 'plugin-try-it'],
['src/renderer/src/store/plugin-themes.ts', 'plugin-theme-smoke'],
['src/renderer/src/store/plugin-syntax-packs.ts', 'plugin-syntax-smoke'],
['src/renderer/src/store/plugin-custom-editors.ts', 'plugin-custom-editor'],
['src/main/plugins/panels/plugin-panel-controller.ts', 'plugin-panel-containment'],
['src/main/plugins/catalog/plugin-file-type-suggestion.ts', 'plugin-file-type-suggestion'],
['src/main/plugins/catalog/plugin-auto-updater.ts', 'plugin-update-review-toast'],
['src/shared/plugins/manifest/plugin-manifest.ts', 'plugin-startup-budget'],
['src/main/plugins/panels/plugin-frame-module-transform.ts', 'plugin-buildless-tsx'],
['src/cli/plugin-authoring/plugin-type-files.ts', 'plugin-buildless-tsx'],
['examples/plugins/hello-tsx/src/panel.tsx', 'plugin-buildless-tsx'],
[
'src/shared/plugins/declarative/runtime/declarative-memory-retention.ts',
'plugin-components-hot-reload'
],
[
'src/renderer/src/components/right-sidebar/PluginDeclarativePanelNotices.tsx',
'plugin-components-hot-reload'
],
['src/main/plugins/plugin-dev-watcher.ts', 'plugin-components-hot-reload'],
['resources/plugins/declarative/orca-runtime.mjs', 'plugin-components-hot-reload'],
['examples/plugins/hello-orca/main.mjs', 'plugin-demo'],
['examples/plugins/test-fixtures/hostile-panel/panel.html', 'plugin-panel-containment']
])('routes %s to %s', (file, spec) => {
expect(selectPrE2eSpecs([file])).toContain(`tests/e2e/${spec}.spec.ts`)
})
it('routes every plugin product source to at least one plugin spec', () => {
expect(pluginSources.length).toBeGreaterThan(100)
for (const file of pluginSources) {
expect(routedPluginSpecs(file).length, file).toBeGreaterThan(0)
}
expect(routedPluginSpecs('src/main/plugins/plugin-future-feature.ts')).toEqual([
'tests/e2e/plugin-demo.spec.ts'
])
})
it('leaves the unrouted fallback for sources no specific route names', () => {
const fallback = PR_PLUGIN_E2E_SOURCE_ROUTES.find((route) => route.id === UNROUTED_ID)
const caught = pluginSources.filter(fallback.matches)
// Grows only when a plugin source lands without a targeted route; add one instead.
expect(caught.length, caught.join('\n')).toBeLessThan(pluginSources.length / 10)
})
it('gives every plugin spec a source route, and every routed spec exists', () => {
const routed = new Set(PR_PLUGIN_E2E_SOURCE_ROUTES.flatMap((route) => route.specs))
for (const spec of routed) {
expect(existsSync(join(projectDir, spec)), spec).toBe(true)
}
expect([...routed].sort()).toEqual(pluginSpecs.sort())
})
it('routes each plugin spec from every plugin helper it imports', () => {
for (const spec of pluginSpecs) {
const helpers = testImportClosure(projectDir, [spec]).filter((file) =>
file.startsWith('tests/e2e/helpers/plugin-')
)
for (const helper of helpers) {
expect(selectPrE2eSpecs([helper]), `${helper} -> ${spec}`).toContain(spec)
}
}
})
it('keeps plugin selections in one changed-spec shard', () => {
const everything = selectPrE2eSpecs(
pluginSources.filter((file) => PLUGIN_SYSTEM_SOURCE.test(file))
)
expect(everything.filter((spec) => !pluginSpecs.includes(spec))).toEqual([])
expect(generalE2eShards(JSON.stringify(everything))).toEqual(['1/1'])
for (const file of pluginSources) {
expect(new Set(routedPluginSpecs(file)).size, file).toBeLessThanOrEqual(4)
}
})
it('leaves unit tests and unrelated sources alone', () => {
for (const file of [
'src/renderer/src/components/plugins/review/PluginUpdateReview.test.tsx',
'src/main/plugins/plugin-service.test.ts',
'src/main/plugins/__mocks__/plugin-git-ssh-remote-fixture.ts',
'src/renderer/src/components/settings/GeneralPane.tsx',
'src/main/ipc/filesystem.ts'
]) {
expect(routedPluginSpecs(file), file).toEqual([])
}
})
})