Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
2 changes: 2 additions & 0 deletions docs/EXAMPLES_AND_FIXTURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion examples/11-hosts-manager-plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
35 changes: 35 additions & 0 deletions scripts/lib/verify-example-metadata.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
6 changes: 6 additions & 0 deletions scripts/verify-authoring-release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -98,6 +99,11 @@ new sdk.DOM().collectStylesFrom().createClassFromStyle(style);
assert(!/<script\b|\son[a-z]+=|createBackendReq/i.test(galleryHTML), 'Gallery must contain no scripts, handlers or backend transport');
const packageRoot = path.dirname(fixtureRequire.resolve('@anikitenko/fdo-sdk/package.json'));
const verified = verifyAuthoring(packageRoot, path.join(fixture, 'package.json'));
const exampleMetadata = readExampleMetadata(path.join(packageRoot, 'examples'));
for (const entry of exampleMetadata) {
assert.doesNotThrow(() => 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}`);
Expand Down
67 changes: 67 additions & 0 deletions tests/examples.git-operations.test.ts
Original file line number Diff line number Diff line change
@@ -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' });
});
});
14 changes: 14 additions & 0 deletions tests/examples.metadata.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading
Loading