Files
orca/tests/e2e/plugin-update-review.spec.ts
T
Neil e648eb9f04 fix(plugins): say that full-access plugins can also control Orca (#37933)
* fix(plugins): say that full-access plugins can also control Orca

Worker plugins can use Orca's local runtime API, so the consent text now
mentions terminals and agents alongside files, network and programs.

* fix(plugins): keep the glossary term in the Japanese full-access warning

* test(e2e): expect the updated plugin full-access text

* test(e2e): expect the updated full-access text in the plugin demo spec
2026-11-06 01:11:55 -08:00

339 lines
15 KiB
TypeScript

import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { expect, test, type Page } from '@stablyai/playwright-test'
import { hashPluginTree } from '../../src/main/plugins/plugin-content-hash'
import { pluginManifestSchema } from '../../src/shared/plugins/manifest/plugin-manifest'
import { pluginContributionSummary } from '../../src/shared/plugins/contributions/plugin-contribution-summary'
import { pluginCatalogTypes } from '../../src/shared/plugins/catalog/plugin-catalog-release'
import { PLUGIN_MANIFEST_V2_SINCE } from '../../src/shared/plugins/manifest/plugin-schema-field-docs'
import { createRestartSession } from './helpers/orca-restart'
import {
commitRepository,
configureFixtureGit,
headCommit,
runGit
} from './helpers/plugin-git-fixture'
const PLUGINS = ['orca-review-notes', 'orca-review-tasks'] as const
// Why: install and update review fetch Git; on a loaded runner that alone outlasts the 10s default.
const GIT_STEP = { timeout: 60_000 }
const FULL_ACCESS =
'Full access: runs as a normal program and can read your files, use the network, run other programs and control Orca, including your terminals and agents.'
/** Each fixture plugin's installed version and whether it waits on the page's review queue. */
async function installedState(page: Page): Promise<{ version: string; review: boolean }[]> {
return page.evaluate(async (ids) => {
const plugins = await window.api.plugins.list()
return ids.map((id) => {
const plugin = plugins.find((entry) => entry.pluginKey === `stablyai.${id}`)
return {
version: plugin?.version ?? 'missing',
review: Boolean(plugin && (plugin.needsReconsent || plugin.status === 'pending'))
}
})
}, PLUGINS)
}
async function writePlugin(repository: string, id: string, version: string): Promise<void> {
await mkdir(repository, { recursive: true })
await writeFile(
join(repository, 'orca-plugin.json'),
JSON.stringify({
manifestVersion: 2,
id,
publisher: 'stablyai',
name: id === PLUGINS[0] ? 'Review Notes' : 'Review Tasks',
version,
execution: version === '1.0.0' ? 'data' : 'trusted-worker',
engines: { orca: `>=${PLUGIN_MANIFEST_V2_SINCE}` },
...(version === '1.0.0' ? {} : { main: 'worker.mjs' }),
contributes:
id === PLUGINS[0]
? { iconThemes: [{ id: 'review', title: 'Review Icons', path: 'icons.json' }] }
: { terminalThemes: [{ id: 'review', title: 'Review Terminal', path: 'terminal.json' }] },
capabilities: []
})
)
await writeFile(
join(repository, 'icons.json'),
JSON.stringify({ schemaVersion: 1, icons: { file: 'file.svg' } })
)
await writeFile(
join(repository, 'file.svg'),
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect width="16" height="16" fill="#888888"/></svg>'
)
await writeFile(
join(repository, 'terminal.json'),
JSON.stringify({
schemaVersion: 1,
mode: 'light',
terminal: { background: '#eeeeee', foreground: '#181818', red: '#ff0000' }
})
)
if (version !== '1.0.0') {
await writeFile(join(repository, 'worker.mjs'), 'export function activate() {}\n')
}
}
async function publishCatalog(
repositories: string,
gitEnvironment: Record<string, string>,
version: string
): Promise<void> {
const plugins = await Promise.all(
PLUGINS.map(async (id) => {
const repository = join(repositories, `${id}.git`)
const manifest = pluginManifestSchema.parse(
JSON.parse(await readFile(join(repository, 'orca-plugin.json'), 'utf8'))
)
const tree = await hashPluginTree(repository)
if (!tree.ok) {
throw new Error(tree.error)
}
return {
id: `stablyai.${id}`,
source: { kind: 'git', url: `https://github.com/stablyai/${id}.git`, ref: 'main' },
categories: ['themes'],
releases: [
{
version,
name: manifest.name,
commit: await headCommit(repository, gitEnvironment),
contentHash: tree.hash,
execution: manifest.manifestVersion === 2 ? manifest.execution : 'data',
minimumOrca: PLUGIN_MANIFEST_V2_SINCE,
types: pluginCatalogTypes(manifest),
contributes: pluginContributionSummary(manifest),
capabilities: []
}
]
}
})
)
await writeFile(
join(repositories, 'orca-plugins.git', 'orca-catalog.json'),
JSON.stringify({ catalogVersion: 2, name: 'Orca Plugins', owner: 'stablyai', plugins })
)
}
// oxlint-disable-next-line no-empty-pattern -- Playwright passes fixtures before testInfo.
test('confirms card removal, reviews a lone access-growing update directly, and approves each of several from the list', async ({}, testInfo) => {
// Why: one Electron launch drives two Git-backed installs and an update review.
test.setTimeout(240_000)
const root = await mkdtemp(join(tmpdir(), 'orca-update-review-'))
const repositories = join(root, 'repositories')
const home = join(root, 'home')
await mkdir(repositories, { recursive: true })
await mkdir(home, { recursive: true })
const gitEnvironment = await configureFixtureGit(home, repositories, ['orca-plugins', ...PLUGINS])
for (const id of PLUGINS) {
const repository = join(repositories, `${id}.git`)
await writePlugin(repository, id, '1.0.0')
await commitRepository(repository, gitEnvironment)
}
const marketplace = join(repositories, 'orca-plugins.git')
await mkdir(marketplace, { recursive: true })
await publishCatalog(repositories, gitEnvironment, '1.0.0')
await commitRepository(marketplace, gitEnvironment)
const session = createRestartSession(testInfo, { ...gitEnvironment, ORCA_BACKGROUND_LAUNCH: '1' })
let launched: Awaited<ReturnType<typeof session.launch>> | null = null
try {
launched = await session.launch()
const { page, app } = launched
expect(await app.evaluate(({ app }) => app.getAppPath())).toBe(process.cwd())
await page.evaluate(() => {
const state = window.__store?.getState()
state?.openSettingsTarget({ pane: 'plugins', repoId: null })
state?.openSettingsPage()
})
const appearanceBefore = await page.evaluate(() => {
const settings = window.__store?.getState().settings
return {
icon: settings?.pluginIconTheme,
terminal: settings?.terminalThemeLight,
app: settings?.pluginAppTheme
}
})
for (const id of PLUGINS) {
await page.locator(`[data-plugin-key="stablyai.${id}"] [data-plugin-row-name]`).click()
await page.getByRole('button', { name: /^Install / }).click()
const themeName = id === PLUGINS[0] ? 'Review Icons' : 'Review Terminal'
const use = page.getByRole('button', { name: `Use now: ${themeName}`, exact: true })
await expect(use).toBeVisible(GIT_STEP)
const chosen = (key: string) =>
page.evaluate((pluginId) => {
const settings = window.__store?.getState().settings
return pluginId === 'orca-review-notes'
? settings?.pluginIconTheme
: settings?.terminalThemeLight
}, key)
// Installing by click applies the theme at once; its toast offers one click back.
await expect.poll(() => chosen(id)).toBe(`plugin:stablyai.${id}/review`)
await expect(use).toHaveAttribute('aria-pressed', 'true')
await expect(page.getByText(`Now using ${themeName}`, { exact: true })).toBeVisible()
// Why dispatch: Sonner restacks toasts off-screen as others arrive, so a pointer click can miss.
await page.getByRole('button', { name: 'Switch back', exact: true }).dispatchEvent('click')
await expect
.poll(() =>
page.evaluate(() => {
const settings = window.__store?.getState().settings
return {
icon: settings?.pluginIconTheme,
terminal: settings?.terminalThemeLight,
app: settings?.pluginAppTheme
}
})
)
.toEqual(appearanceBefore)
await expect(use).toHaveAttribute('aria-pressed', 'false')
await use.click()
await expect(use).toHaveAttribute('aria-pressed', 'true')
await expect.poll(() => chosen(id)).toBe(`plugin:stablyai.${id}/review`)
if (id !== PLUGINS[0]) {
await expect
.poll(() =>
page.evaluate(() => window.__store?.getState().settings?.terminalUseSeparateLightTheme)
)
.toBe(true)
}
await page.getByRole('article').getByRole('button', { name: 'Plugins', exact: true }).click()
Object.assign(
appearanceBefore,
await page.evaluate(() => {
const settings = window.__store?.getState().settings
return {
icon: settings?.pluginIconTheme,
terminal: settings?.terminalThemeLight,
app: settings?.pluginAppTheme
}
})
)
}
// Why first: the installed row at the top of All repeats the catalog card.
const notes = page.locator(`[data-plugin-key="stablyai.${PLUGINS[0]}"]`).first()
await notes.getByRole('button', { name: 'More actions for Review Notes', exact: true }).click()
await page.getByRole('menuitem', { name: 'Remove…', exact: true }).click()
const confirmation = page.getByRole('dialog', { name: 'Remove Review Notes?' })
await expect(confirmation).toContainText('Its data is removed too.')
await confirmation.getByRole('button', { name: 'Cancel', exact: true }).click()
await expect(
notes.getByRole('button', { name: 'More actions for Review Notes', exact: true })
).toBeVisible()
for (const id of PLUGINS) {
const repository = join(repositories, `${id}.git`)
await writePlugin(repository, id, '2.0.0')
await runGit(repository, ['add', '--all'], gitEnvironment)
await runGit(
repository,
[
'-c',
'user.name=Orca Test',
'-c',
'user.email=orca-test@example.invalid',
'commit',
'--quiet',
'-m',
'worker update'
],
gitEnvironment
)
}
await publishCatalog(repositories, gitEnvironment, '2.0.0')
await runGit(marketplace, ['add', '--all'], gitEnvironment)
await runGit(
marketplace,
[
'-c',
'user.name=Orca Test',
'-c',
'user.email=orca-test@example.invalid',
'commit',
'--quiet',
'-m',
'publish updates'
],
gitEnvironment
)
await page.getByRole('button', { name: 'Refresh catalogs', exact: true }).click()
await notes.getByRole('button', { name: 'Update Review Notes to 2.0.0', exact: true }).click()
// A lone update that grows access skips the list and opens straight into its own review.
const single = page.getByRole('dialog', { name: 'Review update to Review Notes', exact: true })
const singleReview = single.locator(`[data-plugin-update-review="stablyai.${PLUGINS[0]}"]`)
await expect(singleReview).toBeVisible(GIT_STEP)
await expect(single.locator('[data-plugin-update]')).toHaveCount(0)
await expect(singleReview).toContainText(
'This update asks for more than the version you approved. It installs only if you approve it here.'
)
await expect(singleReview.locator('[data-added]', { hasText: FULL_ACCESS })).toBeVisible()
await expect(singleReview.getByRole('button', { name: 'See all access' })).toBeVisible()
await expect(single.getByRole('button', { name: 'Back', exact: true })).toHaveCount(0)
await single.getByRole('button', { name: 'Cancel', exact: true }).click()
await expect(single).toBeHidden()
expect(await installedState(page)).toEqual(
PLUGINS.map(() => ({ version: '1.0.0', review: false }))
)
await page.getByRole('tab', { name: /^Updates/ }).click()
await page.getByRole('button', { name: 'Update all…', exact: true }).click()
const list = page.getByRole('dialog', { name: 'Update 2 plugins', exact: true })
await expect(list.locator('[data-plugin-update]')).toHaveCount(2, GIT_STEP)
for (const id of PLUGINS) {
const row = list.locator(`[data-plugin-update="stablyai.${id}"]`)
// Only what's new shows; the rest of its access waits behind "See all access".
await expect(row).toContainText(`New: ${FULL_ACCESS}`)
await expect(row.getByRole('button', { name: 'See all access' })).toBeVisible()
// Access-growing updates trade the bulk checkbox for their own Review.
await expect(row.getByRole('checkbox')).toHaveCount(0)
await expect(row.getByRole('button', { name: 'Review', exact: true })).toBeEnabled()
}
await expect(list.getByRole('button', { name: 'Update', exact: true })).toBeDisabled()
const notesRow = list.locator(`[data-plugin-update="stablyai.${PLUGINS[0]}"]`)
await notesRow.getByRole('button', { name: 'Review', exact: true }).click()
const notesReview = page.getByRole('dialog', { name: 'Review update to Review Notes' })
await expect(
notesReview.locator(`[data-plugin-update-review="stablyai.${PLUGINS[0]}"]`)
).toBeVisible()
// Back from one review returns to the list without installing anything.
await notesReview.getByRole('button', { name: 'Back', exact: true }).click()
await expect(list.locator('[data-plugin-update]')).toHaveCount(2)
expect(await installedState(page)).toEqual(
PLUGINS.map(() => ({ version: '1.0.0', review: false }))
)
for (const [index, id] of PLUGINS.entries()) {
const name = index === 0 ? 'Review Notes' : 'Review Tasks'
const row = list.locator(`[data-plugin-update="stablyai.${id}"]`)
await row.getByRole('button', { name: 'Review', exact: true }).click()
const review = page.getByRole('dialog', { name: `Review update to ${name}`, exact: true })
await expect(review.locator(`[data-plugin-update-review="stablyai.${id}"]`)).toBeVisible()
await review.getByRole('button', { name: 'Approve & update', exact: true }).click()
// Approving one of several returns to the list, which keeps the rest pending.
await expect(row).toContainText('Updated', GIT_STEP)
await expect(row.getByRole('button', { name: 'Review', exact: true })).toBeDisabled()
}
// Why the footer: the dialog's corner X is also named Close; the footer one reflects progress.
await list
.locator('[data-slot="dialog-footer"]')
.getByRole('button', { name: 'Close', exact: true })
.click()
await expect(list).toBeHidden()
// Approval covered exactly the reviewed bytes, so nothing lands in the page's review queue.
expect(await installedState(page)).toEqual(
PLUGINS.map(() => ({ version: '2.0.0', review: false }))
)
await expect(page.locator('#plugin-detail-heading')).toBeHidden()
await expect(
page.locator('[data-plugin-key^="stablyai.orca-review-"]').getByRole('button', {
name: 'Review',
exact: true
})
).toHaveCount(0)
} finally {
await session.dispose(launched?.app)
await rm(root, { recursive: true, force: true })
}
})