Files
ruflo/plugins/ruflo-console/tests/plugin-coverage.spec.ts
T
1af3abedd7 feat(marketplace): install Agentic QE (agentic-qe-fleet) from the ruflo marketplace (#3950)
* feat(marketplace): list Agentic QE (agentic-qe-fleet) as an external, pinned plugin

The ruflo marketplace now lists agentic-qe-fleet by a git-subdir source pinned
to agentic-qe v3.15.0 (1363bc7d), so `/plugin install agentic-qe-fleet@ruflo`
installs the plugin from its own repository: 11 QE agents, 9 skills, 9 commands,
the agentic-qe MCP server pinned to 3.15.0, and the aqe-mod guard.

Tooling that assumed every entry is a local ./plugins folder now handles an
external one:
- validate-marketplace.yml checks an external entry (git-subdir, https url,
  path without .., full sha, description) instead of failing on it
- smoke-all-plugins checks out each external entry at its sha and runs its own
  scripts/smoke.sh (--skip-external to omit); the workflow also runs when
  marketplace.json changes
- ruflo-console 0.40.9: plugin-map line (Dev Tools), the coverage test requires
  a line for every external entry, and the Plugin Catalog reads an external
  plugin from its install path in installed_plugins.json (EXT badge)
- README and plugins/README rows; ADR-484 records the decision and evidence

* docs(adr-484): record what Claude Code before 2.1.69 does with the external entry, and main's existing floor

* fix(ci): fence the external-plugin smoke: github-only sources, realpath containment, scrubbed env and cwd, read-only token

Review findings 1 and 2 on #3950:
- scripts/external-plugins.mjs: sources must be https://github.com/<owner>/<repo>(.git);
  the plugin folder, its manifest and smoke.sh must stay inside the checkout after realpath;
  the external smoke.sh runs with only PATH, LANG, TERM and CI kept (no GITHUB_TOKEN,
  ACTIONS_*, *_TOKEN, *_KEY, *SECRET*), a temporary HOME, and its checkout (in a temp
  folder outside the workspace) as cwd; git fetch runs with the same env
- validate-marketplace.yml applies the same github-only allowlist and path rule
- all-plugins-smoke.yml: permissions contents: read, persist-credentials: false,
  runs tests/external-plugins.test.mjs
- tests/external-plugins.test.mjs: non-github host and lookalikes refused by both
  validators, symlink escapes refused, child env has no GITHUB_TOKEN and cwd is outside
  the workspace

* ci(all-plugins-smoke): raise the smoke-all timeout from 10 to 15 minutes

Review finding 4 on #3950: green runs on main take 9m40-9m50s, so the job sat on the
10-minute cap and was cancelled at 10m16s on this PR.

* test(console): an external plugin's description, version, source line and docs are stripped of escapes and bidi controls

Review finding 3 on #3950: the catalog reader already passes every external manifest
string through plain() (ESC/CSI/OSC sequences, C0/C1 controls, U+202A-202E and
U+2066-2069 removed); this test holds it to that.

* ci(test-ratchet): tests/external-plugins.test.mjs runs under node --test, not the root vitest

all-plugins-smoke.yml runs it with node --test; vitest reported "No test suite found".

* feat(marketplace): move the agentic-qe-fleet pin to v3.15.1 (4f0f8412)

agentic-qe v3.15.1 changes only the aqe-mod guard (#920: hardened, still tighten-only,
same engine API and events as 3.15.0) and pins agentic-qe@3.15.1 in .mcp.json.

* docs(adr-484): install evidence re-run at the v3.15.1 pin

* feat(marketplace): move the agentic-qe-fleet pin to v3.15.2 (9db36edf)

agentic-qe v3.15.2: aqe-mod reports its real version, keeps guard totals and the last
refusal across sessions (additive totals/lastRefusal in status.json), and never reads or
writes its status file through a symlink (adds $.fs.read and $.fs.stat); guard judging
unchanged. .mcp.json pins agentic-qe@3.15.2. ADR-484 evidence re-run at this pin.

* fix(marketplace): plain-name paths for external plugins, pin-review note, one marketplace for agentic-qe-fleet

- both validators (scripts/external-plugins.mjs, validate-marketplace.yml) require the
  plugin path to match ^[A-Za-z0-9._-]+(/[A-Za-z0-9._-]+)*$ with no . or .. segment:
  %2e%2e, a\..\b, a//b and a trailing slash are refused; tests cover both validators
- ADR-484: every pin bump needs a review of the pinned scripts/smoke.sh
- READMEs: install agentic-qe-fleet from the agentic-qe or the ruflo marketplace, not both

* feat(marketplace): move the agentic-qe-fleet pin to v3.15.3 (d7e5ffa7)

The plugin at v3.15.3 differs from v3.15.2 only in its version and its MCP
pin (agentic-qe@3.15.3). ADR-484's evidence records the install re-run at
the new pin.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore(plugins): pin agentic-qe to v3.15.4 (bec02b13)

The plugin at v3.15.4 differs from v3.15.3 only in its version and its MCP
pin (agentic-qe@3.15.4). ADR-484's evidence records the install re-run at
the new pin.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore(plugins): pin agentic-qe to v3.15.5 (d65ede14)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore(console): leave the console version bump to the maintainer at merge

The console version and CHANGELOG entry collided with every console release on
main (four rebases on 2026-10-12, each only plugins/ruflo-console/CHANGELOG.md).
plugin.json, hooks/version.ts, scripts/smoke.sh and CHANGELOG.md now match
main; ADR-484 and its index row say 'next console release'.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore(plugins): pin agentic-qe to v3.15.6 (0fa34d24)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore(plugins): pin agentic-qe to v3.15.7 (7f2d091d)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore(plugins): pin agentic-qe to v3.15.8 (d9ce98f7)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore(plugins): pin agentic-qe to v3.15.9 (d1c33d2b)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore(plugins): pin agentic-qe to v3.15.10 (e5814a2f)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: rUv <ruv@ruv.net>
2026-10-19 22:55:58 -04:00

61 lines
2.9 KiB
TypeScript

/**
* Plugin coverage: every ruflo plugin directory and every external plugin the marketplace lists has a home section in the
* console, no entry names a plugin that is gone, every home is a real view, and a plugin that has no surface of its own says
* why. A new plugin added without a line fails here.
*/
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { homeOf, PLUGIN_MAP, pluginsOfView } from '../hooks/plugin-map'
import { VIEWS } from '../hooks/state'
const PLUGINS = join(__dirname, '..', '..')
const onDisk = readdirSync(PLUGINS, { withFileTypes: true })
.filter(entry => entry.isDirectory() && existsSync(join(PLUGINS, entry.name, '.claude-plugin', 'plugin.json')))
.map(entry => entry.name)
.sort()
// An external plugin is listed by a git source (an object, not a `./plugins/...` path): it has no directory here.
const external = (JSON.parse(readFileSync(join(PLUGINS, '..', '.claude-plugin', 'marketplace.json'), 'utf8')) as { plugins: { name: string; source: unknown }[] }).plugins
.filter(plugin => typeof plugin.source !== 'string')
.map(plugin => plugin.name)
const known = [...onDisk, ...external]
describe('plugin coverage', () => {
it('finds the plugin directories (the guard is not checking nothing)', () => {
expect(onDisk.length).toBeGreaterThan(40)
expect(onDisk).toContain('ruflo-console')
})
it('every plugin directory has a home section, and no home is for a plugin that is gone', () => {
expect(known.filter(name => PLUGIN_MAP[name] === undefined), 'unmapped plugins: add a line to hooks/plugin-map.ts').toEqual([])
expect(Object.keys(PLUGIN_MAP).filter(name => !known.includes(name)), 'stale entries').toEqual([])
})
it('every home is a real section, and a plugin without a surface of its own says why', () => {
const views = new Set(VIEWS.map(view => view.id))
for (const [plugin, entry] of Object.entries(PLUGIN_MAP)) {
expect(views.has(entry.view), plugin).toBe(true)
if (entry.catalogOnly !== undefined) {
expect(entry.view, plugin).toBe('market')
expect(entry.catalogOnly.length, plugin).toBeGreaterThan(20)
}
}
})
it('homeOf reads a plugin name or a slash name; pluginsOfView lists what a section owns', () => {
expect(homeOf('ruflo-cost-tracker')).toBe('cost')
expect(homeOf('ruflo-ruos:deploy')).toBe('swarm')
expect(homeOf('agentic-qe-fleet:aqe-generate')).toBe('devtools')
expect(homeOf('not-a-plugin:thing')).toBeNull()
expect(pluginsOfView('secure')).toEqual(['ruflo-aidefence', 'ruflo-protector', 'ruflo-security-audit'])
})
it('every section that owns a plugin has an ask entry (so each home can also be asked about)', async () => {
const { VIEW_ASK } = await import('../hooks/ask-claude')
for (const entry of Object.values(PLUGIN_MAP)) expect(VIEW_ASK[entry.view], entry.view).toBeDefined()
})
})