diff --git a/CHANGELOG.md b/CHANGELOG.md index a2caeef..e99796c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.7.1] - Unreleased + +### Fixed + +- Use the supported BlueprintJS v6 `globe-network` icon in the packaged HostsManager example. +- Validate packaged example metadata against the SDK runtime validator during release checks. +- Expand Git operations and service content hub tests with handler validation, broker failures and degraded-service scenarios. + +This patch preserves public APIs and requires no plugin code migration. + ## [1.7.0](https://github.com/anikitenko/fdo-sdk/compare/fdo-sdk-v1.6.0...fdo-sdk-v1.7.0) (2026-10-15) diff --git a/docs/EXAMPLES_AND_FIXTURES.md b/docs/EXAMPLES_AND_FIXTURES.md index 586a0e9..e99fef9 100644 --- a/docs/EXAMPLES_AND_FIXTURES.md +++ b/docs/EXAMPLES_AND_FIXTURES.md @@ -84,6 +84,8 @@ const result = await window.createBackendReq("UI_MESSAGE", { The examples surface is considered stable only when all of the following stay true: - `npm run test:examples` passes +- `npm run verify:pack` validates every packaged example's literal `PluginMetadata` with the installed SDK runtime, including BlueprintJS v6 icon names; HostsManager uses `globe-network` +- Host coverage suites merge counters from registered backend handlers and serialized frontend callbacks for split-runtime examples such as Git operations and service content hub. Backend-only counters cannot represent their full line coverage. Exercise validation failures, broker errors and real frontend controls with deterministic test services; keep privileged execution simulated and retain the existing coverage thresholds - public example docs do not reference local-only or internal planning files - public example docs do not reference stale docs domains - `createBackendReq(...)` examples reflect the real `UI_MESSAGE` handler pattern diff --git a/examples/11-hosts-manager-plugin/index.ts b/examples/11-hosts-manager-plugin/index.ts index 369b101..65daa9f 100644 --- a/examples/11-hosts-manager-plugin/index.ts +++ b/examples/11-hosts-manager-plugin/index.ts @@ -39,7 +39,7 @@ export default class HostsManagerPlugin extends FDO_SDK implements FDOInterface version: "1.0.0", author: "FDO Explorer", description: "A real-world multi-file example for managing host environments.", - icon: "social-network", + icon: "globe-network", }; get metadata(): PluginMetadata { diff --git a/scripts/lib/verify-example-metadata.mjs b/scripts/lib/verify-example-metadata.mjs new file mode 100644 index 0000000..04c25f2 --- /dev/null +++ b/scripts/lib/verify-example-metadata.mjs @@ -0,0 +1,35 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { Node, Project, SyntaxKind } from 'ts-morph'; + +/** Read literal example metadata without importing/initializing plugin backend code. */ +export function readExampleMetadata(examplesRoot) { + const project = new Project({ useInMemoryFileSystem: true }); + const entries = []; + const walk = directory => { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const file = path.join(directory, entry.name); + if (entry.isDirectory()) { walk(file); continue; } + if (!file.endsWith('.ts') || file.endsWith('.d.ts')) continue; + const source = project.createSourceFile('/' + path.relative(examplesRoot, file).replaceAll('\\', '/'), fs.readFileSync(file, 'utf8')); + for (const cls of source.getClasses()) { + for (const field of cls.getProperties()) { + if (field.getTypeNode()?.getText() !== 'PluginMetadata') continue; + const object = field.getInitializerIfKind(SyntaxKind.ObjectLiteralExpression); + if (!object) throw Error(`Example metadata must be statically reviewable: ${file}:${field.getStartLineNumber()}`); + const metadata = {}; + for (const property of object.getProperties()) { + if (!Node.isPropertyAssignment(property)) continue; + const literal = property.getInitializerIfKind(SyntaxKind.StringLiteral); + if (!literal) throw Error(`Expected literal metadata property in ${file}: ${property.getText()}`); + metadata[property.getName()] = literal.getLiteralValue(); + } + entries.push({ file: path.relative(examplesRoot, file).replaceAll('\\', '/'), className: cls.getName(), metadata }); + } + } + } + }; + walk(examplesRoot); + if (!entries.length) throw Error('No packaged PluginMetadata declarations were found.'); + return entries; +} diff --git a/scripts/verify-authoring-release.mjs b/scripts/verify-authoring-release.mjs index 34b9777..4c0fcd2 100644 --- a/scripts/verify-authoring-release.mjs +++ b/scripts/verify-authoring-release.mjs @@ -5,6 +5,7 @@ import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import { createRequire } from 'node:module'; import { verifyAuthoring } from './lib/verify-authoring.mjs'; +import { readExampleMetadata } from './lib/verify-example-metadata.mjs'; import { componentBrowserChecks } from './lib/verify-browser-components.mjs'; import { verifyBrowserOverlays } from './lib/verify-browser-overlays.mjs'; @@ -98,6 +99,11 @@ new sdk.DOM().collectStylesFrom().createClassFromStyle(style); assert(!/ sdk.validatePluginMetadata(entry.metadata), `Invalid packaged example metadata: ${entry.file}`); + } + console.log(`Validated metadata for ${exampleMetadata.length} installed plugin examples without executing their backends.`); for (const entry of verified.contract.classes) { const instance = new sdk[entry.name](); for (const method of entry.methods) assert.equal(typeof instance[method.name], 'function', `${entry.name}.${method.name}`); diff --git a/tests/examples.git-operations.test.ts b/tests/examples.git-operations.test.ts new file mode 100644 index 0000000..93418f1 --- /dev/null +++ b/tests/examples.git-operations.test.ts @@ -0,0 +1,67 @@ +import { beforeAll, beforeEach, afterEach, describe, expect, test, vi } from 'vitest'; +import { PluginRegistry } from '../src'; +let GitOperationsPlugin: typeof import('../examples/12-git-operations-plugin').default; +const call = (handler: string, payload: unknown = {}) => PluginRegistry.callHandler(handler.startsWith('gitOps.') || handler.startsWith('fdo.') ? handler : `gitOps.v1.${handler}`, payload); +beforeAll(async () => { + (process as any).parentPort = { on: vi.fn(), postMessage: vi.fn() }; + GitOperationsPlugin = (await import('../examples/12-git-operations-plugin')).default; +}); +beforeEach(() => { + PluginRegistry.clearAllHandlers(); PluginRegistry.clearPlugin(); PluginRegistry.configureStorage({ rootDir: undefined }); PluginRegistry.configureCapabilities({ granted: [] }); + for (const name of ['__FDO_AI_REQUEST', '__FDO_AI_ASSISTANT', '__FDO_AI_LIST_ASSISTANTS', '__FDO_AI_ASSISTANTS']) delete (globalThis as any)[name]; + vi.stubGlobal('require', (name: string) => { + if (name !== 'fs') throw Error(`Unexpected module: ${name}`); + return { existsSync: (value: string) => !value.includes('missing'), statSync: (value: string) => ({ isDirectory: () => !value.endsWith('.txt') }), accessSync: () => {}, constants: { X_OK: 1 } }; + }); + new GitOperationsPlugin(); +}); +afterEach(() => vi.unstubAllGlobals()); +describe('Git operations example handlers', () => { + test('prepares a session fallback and registers quick/side-panel intents without executing commands', async () => { + const prepared = await call('prepare') as any; + expect(prepared).toMatchObject({ ok: true, storageMode: 'session-fallback', jsonStoreAvailable: false }); expect(prepared.operations.length).toBeGreaterThan(5); + for (const handler of ['quick.status', 'quick.snapshot', 'quick.prefetch', 'sidePanel.console', 'sidePanel.recent', 'sidePanel.profiles']) expect(await call(handler)).toMatchObject({ ok: true }); + }); + test('validates paths and persists the original configured repository', async () => { + expect(await call('saveRepoPath')).toMatchObject({ ok: false, code: 'INVALID_REPO_PATH' }); + for (const cwd of ['relative', '/missing', '/file.txt']) expect(await call('saveRepoPath', { cwd })).toMatchObject({ ok: false, code: 'SAVE_REPO_PATH_FAILED' }); + expect(await call('saveRepoPath', { cwd: '/fixture/repository' })).toMatchObject({ ok: true, persistence: 'session-fallback' }); + const prepared = await call('prepare') as any; expect(prepared.originalConfiguredRepoPath).toBe('/fixture/repository'); + }); + test.each(['status', 'branches', 'log', 'diffStat', 'showHead', 'fetch', 'pullFFOnly'])('builds a validated %s request without executing Git', async operation => { + const response = await call('buildRequest', { operation, cwd: '/fixture/repository', dryRun: true }) as any; + expect(response.request.action).toBe('system.process.exec'); expect(response.request.payload).toMatchObject({ scope: 'git', cwd: '/fixture/repository', dryRun: true }); + expect(response.request.payload.args.length).toBeGreaterThan(0); expect(response.correlationId).toContain('git-'); + }); + test('rejects unknown operations and missing commit messages; preserves explicit commit arguments', async () => { + expect(await call('buildRequest', { operation: 'erase-everything' })).toMatchObject({ ok: false, code: 'INVALID_OPERATION' }); + expect(await call('buildCommitRequest', { cwd: '/fixture/repository', message: '' })).toMatchObject({ ok: false, code: 'COMMIT_MESSAGE_REQUIRED' }); + for (const dryRun of [true, false]) { + const response = await call('buildCommitRequest', { cwd: '/fixture/repository', message: ' fix: preserve text ', dryRun }) as any; + expect(response.request.payload.args).toEqual(dryRun ? ['commit', '--dry-run', '-m', 'fix: preserve text'] : ['commit', '-m', 'fix: preserve text']); + } + }); + test('prefetch builds a scoped origin request and degrades safely when metadata is unavailable', async () => { + const request = await call('prefetchOriginalRepoRequest', { cwd: '/fixture/repository' }) as any; + expect(request).toMatchObject({ ok: true }); expect(request.requestEnvelope.request.payload.args).toEqual(['config', '--get', 'remote.origin.url']); + expect(await call('prefetchOriginalRepo', { cwd: '/fixture/repository' })).toMatchObject({ ok: true, remoteRepository: null }); + expect(await call('prefetchOriginalRepo', { remoteOriginUrl: 'https://github.com/example/repo.git' })).toMatchObject({ ok: true, warning: expect.any(String) }); + }); + test('normalizes GitHub metadata and reports upstream errors without aborting the repository profile', async () => { + PluginRegistry.configureCapabilities({ granted: ['system.network', 'system.network.https', 'system.network.scope.public-web-secure'] }); + const fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ default_branch: 'main', description: 'Example', private: false, stargazers_count: 4 }) }); vi.stubGlobal('fetch', fetch); + const response = await call('prefetchOriginalRepo', { cwd: '/fixture/repository', remoteOriginUrl: 'git@github.com:example/repo.git' }) as any; + expect(response.remoteRepository).toMatchObject({ provider: 'github', repository: 'example/repo', defaultBranch: 'main', stars: 4 }); expect(fetch).toHaveBeenCalledTimes(1); + fetch.mockResolvedValue({ ok: false, status: 503, statusText: 'Unavailable', text: async () => 'Try later' }); + expect(await call('prefetchOriginalRepo', { remoteOriginUrl: 'https://github.com/example/repo' })).toMatchObject({ ok: true, warning: expect.stringContaining('503') }); + }); + test('filters/deduplicates assistant discovery and supports explicit AI providers and local fallback tasks', async () => { + (globalThis as any).__FDO_AI_ASSISTANTS = [null, { id: 'one', name: 'First', purpose: 'automation', provider: 'local', model: 'small', default: true }, { id: 'one', name: 'Duplicate' }, { id: 'other', purpose: 'chat' }]; + expect(await call('fdo.ai.assistants.list.v1', { purpose: 'automation', provider: 'local', model: 'small' })).toMatchObject({ ok: true, assistants: [{ id: 'one', name: 'First' }] }); + for (const task of ['commit-message', 'summarize-output', 'suggest-next-command']) expect(await call('fdo.ai.request.v1', { task, repoPath: '/fixture/repo', summary: { staged: 2, modified: 1, untracked: 1, branch: 'main' }, commandOutput: 'file.ts changed\nSecond line' })).toMatchObject({ ok: true, source: 'fallback', message: expect.any(String) }); + (globalThis as any).__FDO_AI_REQUEST = vi.fn().mockResolvedValue({ commitMessage: 'feat: test provider' }); + expect(await call('fdo.ai.request.v1', { task: 'commit-message' })).toMatchObject({ ok: true, source: 'fdo-ai', commitMessage: 'feat: test provider' }); + (globalThis as any).__FDO_AI_LIST_ASSISTANTS = () => { throw Error('Discovery failed'); }; + expect(await call('fdo.ai.assistants.list.v1')).toMatchObject({ ok: false, code: 'AI_ASSISTANTS_LIST_FAILED' }); + }); +}); diff --git a/tests/examples.metadata.test.ts b/tests/examples.metadata.test.ts new file mode 100644 index 0000000..1fe5d4d --- /dev/null +++ b/tests/examples.metadata.test.ts @@ -0,0 +1,14 @@ +import path from 'node:path'; +import { readExampleMetadata } from '../scripts/lib/verify-example-metadata.mjs'; +import { validatePluginMetadata } from '../src'; + +const examples = readExampleMetadata(path.resolve('examples')); +describe('packaged example metadata', () => { + test.each(examples)('$file is accepted by the SDK metadata validator', ({ metadata }) => { + expect(() => validatePluginMetadata(metadata)).not.toThrow(); + }); + test('HostsManager uses the supported BlueprintJS v6 network icon', () => { + const hosts = examples.find(entry => entry.file === '11-hosts-manager-plugin/index.ts'); + expect(hosts?.metadata.icon).toBe('globe-network'); + }); +}); diff --git a/tests/examples.service-content-hub.test.ts b/tests/examples.service-content-hub.test.ts index 035066b..bdbf013 100644 --- a/tests/examples.service-content-hub.test.ts +++ b/tests/examples.service-content-hub.test.ts @@ -19,6 +19,7 @@ beforeEach(() => { delete (globalThis as { __FDO_CONTENT_BROKER?: unknown }).__FDO_CONTENT_BROKER; delete (globalThis as { __FDO_AUTH_BROKER?: unknown }).__FDO_AUTH_BROKER; delete (globalThis as { __FDO_AI_LIST_ASSISTANTS?: unknown }).__FDO_AI_LIST_ASSISTANTS; + for (const name of ['__FDO_AI_REQUEST', '__FDO_SESSION_REQUEST', '__FDO_BROWSER_BROKER']) delete (globalThis as any)[name]; }); describe("Service content hub example handlers", () => { @@ -353,3 +354,75 @@ describe("Service content hub example handlers", () => { }); }); }); + +describe('Service content hub validation and broker failure scenarios', () => { + const context = { providerId: 'custom-api', endpointUrl: 'https://content.example.test', sessionId: 'session', workspaceId: 'workspace' }; + const call = (name: string, payload: unknown = context) => PluginRegistry.callHandler(`serviceHub.v1.${name}`, payload); + const grantNetwork = () => PluginRegistry.configureCapabilities({ granted: ['system.network', 'system.network.https', 'system.network.scope.external-services'] }); + beforeEach(() => { new ServiceContentHubPlugin(); grantNetwork(); }); + test.each([ + ['content.listWorkspaces', {}, 'SESSION_REQUIRED'], + ['content.listItems', {}, 'CONTEXT_REQUIRED'], + ['content.search', { sessionId: 'session', query: '' }, 'SEARCH_INPUT_INVALID'], + ['auth.refresh', {}, 'SESSION_REQUIRED'], + ['auth.logout', {}, 'SESSION_REQUIRED'], + ])('%s validates missing required context', async (handler, payload, code) => { + expect(await call(handler as string, payload)).toMatchObject({ ok: false, code }); + }); + test('normalizes and persists context, rejects unsafe endpoints, and stores favorites by identity', async () => { + expect(await call('saveContext', { ...context, accountId: ' user ', path: '/documents', authConfig: { clientId: 'client', tenantId: 'tenant' } })).toMatchObject({ ok: true, context: { accountId: 'user', authClientId: 'client' } }); + expect(await call('saveContext', { ...context, endpointUrl: 'file:///etc/hosts' })).toMatchObject({ ok: false, code: 'ENDPOINT_URL_INVALID' }); + expect(await call('addFavorite', { item: { id: 'doc', name: 'Document', webUrl: 'https://content.example.test/doc' } })).toMatchObject({ ok: true }); + expect(await call('addFavorite', { item: { id: 'doc', name: 'Document' } })).toMatchObject({ ok: true }); + const prepared = await call('prepare') as any; expect(prepared.favorites).toHaveLength(1); expect(prepared.context.authClientId).toBe('client'); + }); + test('auth start/refresh/logout persist session transitions and clear authentication state', async () => { + const start = vi.fn().mockResolvedValue({ sessionId: 'session-new', accountId: 'user' }); + const refresh = vi.fn().mockResolvedValue({ sessionId: 'session-refreshed' }); const logout = vi.fn().mockResolvedValue({}); + (globalThis as any).__FDO_AUTH_BROKER = { start, refresh, logout }; + expect(await call('auth.start')).toMatchObject({ ok: true, sessionId: 'session-new' }); + expect(await call('auth.refresh', { ...context, sessionId: 'session-new' })).toMatchObject({ ok: true, sessionId: 'session-refreshed', authDeviceCode: '' }); + expect(await call('auth.logout', { ...context, sessionId: 'session-refreshed' })).toMatchObject({ ok: true }); + expect((await call('prepare') as any).context.sessionId).toBe(''); expect(logout).toHaveBeenCalledTimes(1); + }); + test.each([ + ['token expired', 'AUTH_TOKEN_EXPIRED'], ['permission denied 403', 'AUTH_SCOPE_DENIED'], ['401 unauthorized', 'AUTH_REQUIRED'], ['invalid payload', 'AUTH_START_FAILED'], + ])('auth start classifies %s without exposing a false success', async (message, code) => { + (globalThis as any).__FDO_AUTH_BROKER = { start: vi.fn().mockRejectedValue(Error(message)) }; + expect(await call('auth.start')).toMatchObject({ ok: false, code, error: expect.stringContaining(message) }); + }); + test('missing brokers, missing sessions and empty provider responses are actionable failures', async () => { + expect(await call('auth.start')).toMatchObject({ ok: false, code: 'AUTH_START_FAILED' }); + (globalThis as any).__FDO_AUTH_BROKER = { start: vi.fn().mockResolvedValue({}) }; + expect(await call('auth.start')).toMatchObject({ ok: false, code: 'AUTH_START_FAILED' }); + (globalThis as any).__FDO_AUTH_BROKER.start.mockResolvedValue({ ok: false, code: 'CONSENT_DENIED', error: 'Consent denied' }); + expect(await call('auth.start')).toMatchObject({ ok: false, code: 'CONSENT_DENIED' }); + }); + test('content results normalize provider data, persist pagination and build share links through an explicit broker', async () => { + const request = vi.fn().mockResolvedValue({ items: [{ id: 'doc', name: 'Document', webUrl: 'https://content.example.test/doc' }, null, { name: 'No id' }], nextCursor: 'page-2' }); + (globalThis as any).__FDO_CONTENT_BROKER = { request }; + for (const handler of ['content.listWorkspaces', 'content.listItems', 'content.search']) { + const result = await call(handler, { ...context, query: 'Document', cursor: 'page-1' }) as any; + expect(result).toMatchObject({ ok: true, nextCursor: 'page-2' }); expect(result.items[0]).toMatchObject({ id: 'doc', name: 'Document' }); + } + request.mockResolvedValue({ webUrl: 'https://content.example.test/shared' }); + expect(await call('content.createShareLink', { ...context, itemId: 'doc' })).toMatchObject({ ok: true, webUrl: 'https://content.example.test/shared' }); + request.mockResolvedValue({}); expect(await call('content.createShareLink', { ...context, itemId: 'doc' })).toMatchObject({ ok: false, code: 'CREATE_SHARE_LINK_FAILED' }); + }); + test('content upstream failures retain classified codes and never invoke an implicit browser', async () => { + (globalThis as any).__FDO_CONTENT_BROKER = { request: vi.fn().mockRejectedValue(Error('403 forbidden')) }; + expect(await call('content.listItems')).toMatchObject({ ok: false, code: 'AUTH_SCOPE_DENIED' }); + expect(await call('openBrowser', { webUrl: 'javascript:alert(1)' })).toMatchObject({ ok: false, code: 'ENDPOINT_URL_INVALID' }); + const open = vi.fn().mockResolvedValue({ ok: true }); (globalThis as any).__FDO_BROWSER_BROKER = { open }; + expect(await call('openBrowser', { webUrl: 'https://content.example.test/doc' })).toMatchObject({ ok: true }); expect(open).toHaveBeenCalledTimes(1); + }); + test('AI provider success, fallback, malformed replies and failures remain deterministic', async () => { + expect(await PluginRegistry.callHandler('fdo.ai.request.v1', { summary: { count: 3 } })).toMatchObject({ ok: true, source: 'fallback', message: expect.stringContaining('3 result') }); + (globalThis as any).__FDO_AI_REQUEST = vi.fn().mockResolvedValue({ text: 'Summary' }); + expect(await PluginRegistry.callHandler('fdo.ai.request.v1', {})).toMatchObject({ ok: true, source: 'fdo-ai', message: 'Summary' }); + (globalThis as any).__FDO_AI_REQUEST.mockRejectedValue(Error('Provider down')); + expect(await PluginRegistry.callHandler('fdo.ai.request.v1', {})).toMatchObject({ ok: false, code: 'AI_ASSIST_FAILED' }); + (globalThis as any).__FDO_AI_LIST_ASSISTANTS = vi.fn().mockRejectedValue(Error('Discovery down')); + expect(await PluginRegistry.callHandler('fdo.ai.assistants.list.v1', {})).toMatchObject({ ok: false, code: 'AI_ASSISTANTS_LIST_FAILED' }); + }); +});