Skip to content
Open
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
105 changes: 105 additions & 0 deletions apps/cli/src/commands/validate.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import {
createLocalRegistryBackend,
publishManifest,
validateManifests,
} from '@federation-wizards/core';
import { stringify as stringifyYaml } from 'yaml';

import { resolveValidateExitCode } from './validate.js';

function createWorkspace(): string {
const dir = mkdtempSync(join(tmpdir(), 'fwiz-cli-validate-'));

const config = {
version: '1',
workspace: { type: 'plain' },
hosts: [{ name: 'shell', port: 4200 }],
remotes: [{ name: 'checkout', port: 4201 }],
shared: {
react: {
singleton: true,
requiredVersion: '^19.0.0',
eager: false,
},
},
registry: {
type: 'http',
baseUrl: 'https://cdn.example.com',
prefix: 'mf',
},
};

writeFileSync(
join(dir, 'fwiz.config.yaml'),
`${stringifyYaml(config)}\n`,
'utf8',
);

return dir;
}

describe('fwiz validate command', () => {
it('resolves CI exit codes for errors and warnings', () => {
expect(
resolveValidateExitCode(
{ version: '1.0.0', remotes: ['checkout'], errors: [], warnings: [] },
true,
),
).toBe(0);
expect(
resolveValidateExitCode(
{
version: '1.0.0',
remotes: ['checkout'],
errors: [{ level: 'error', code: 'x', message: 'bad' }],
warnings: [],
},
true,
),
).toBe(1);
expect(
resolveValidateExitCode(
{
version: '2.0.0',
remotes: ['checkout'],
errors: [],
warnings: [{ level: 'warning', code: 'x', message: 'warn' }],
},
true,
),
).toBe(2);
expect(
resolveValidateExitCode(
{
version: '2.0.0',
remotes: ['checkout'],
errors: [],
warnings: [{ level: 'warning', code: 'x', message: 'warn' }],
},
false,
),
).toBe(0);
});

it('validates a published remote through core APIs', async () => {
const dir = createWorkspace();
const backend = createLocalRegistryBackend(join(dir, '.registry'));

await publishManifest(
{ cwd: dir, version: '1.0.0', remote: 'checkout' },
backend,
);

const result = await validateManifests(
{ cwd: dir, version: '1.0.0', remote: 'checkout' },
backend,
);

expect(result.errors).toHaveLength(0);
expect(result.warnings).toHaveLength(0);
});
});
106 changes: 106 additions & 0 deletions apps/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import type { Command } from 'commander';

import {
ValidateManifestError,
validateManifests,
type ValidateManifestResult,
} from '@federation-wizards/core';

export function resolveValidateExitCode(
result: ValidateManifestResult,
ci: boolean,
): number {
if (result.errors.length > 0) {
return 1;
}

if (ci && result.warnings.length > 0) {
return 2;
}

return 0;
}

export function registerValidateCommand(program: Command): void {
program
.command('validate')
.description(
'Validate local manifests against the registry before deployment',
)
.summary('Compare manifests, shared deps, and breaking changes')
.addHelpText(
'after',
`
Examples:
$ fwiz validate
$ fwiz validate --version 2.0.0
$ fwiz validate --remote checkout
$ fwiz validate --ci

Compares locally generated manifests with the current registry versions,
validates shared dependency rules from fwiz.config.yaml, and reports
breaking changes such as major version bumps or removed exposes.

Exit codes:
0 validation passed
1 validation errors
2 validation warnings (with --ci only)
`,
)
.option(
'--cwd <path>',
'Workspace directory (defaults to current working directory)',
process.cwd(),
)
.option('--version <version>', 'Manifest version to validate')
.option('--remote <name>', 'Validate a single remote')
.option(
'--ci',
'Use CI exit codes (warnings exit with code 2 instead of 0)',
)
.action(
async (options: {
cwd: string;
version?: string;
remote?: string;
ci?: boolean;
}) => {
try {
const result = await validateManifests({
cwd: options.cwd,
version: options.version,
remote: options.remote,
});

if (result.errors.length === 0 && result.warnings.length === 0) {
console.log(
`Validation passed for version ${result.version} (${result.remotes.join(', ')}).`,
);
} else {
if (result.errors.length > 0) {
console.error('Validation errors:');
for (const issue of result.errors) {
console.error(` - ${issue.message}`);
}
}

if (result.warnings.length > 0) {
console.warn('Validation warnings:');
for (const issue of result.warnings) {
console.warn(` - ${issue.message}`);
}
}
}

process.exitCode = resolveValidateExitCode(result, options.ci ?? false);
} catch (error) {
const message =
error instanceof ValidateManifestError || error instanceof Error
? error.message
: String(error);
console.error(message);
process.exitCode = 1;
}
},
);
}
2 changes: 2 additions & 0 deletions apps/cli/src/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Command } from 'commander';

import { registerInitCommand } from './commands/init.js';
import { registerPublishManifestCommand } from './commands/publish-manifest.js';
import { registerValidateCommand } from './commands/validate.js';

export function createProgram(): Command {
const program = new Command();
Expand All @@ -13,6 +14,7 @@ export function createProgram(): Command {

registerInitCommand(program);
registerPublishManifestCommand(program);
registerValidateCommand(program);

return program;
}
1 change: 1 addition & 0 deletions libs/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export * from './lib/workspace/detect.js';
export * from './lib/workspace/patch.js';
export * from './lib/manifest/generate.js';
export * from './lib/registry/index.js';
export * from './lib/validate/index.js';
1 change: 1 addition & 0 deletions libs/core/src/lib/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { FwizConfig } from './types.js';
const sharedDependencySchema = Joi.object({
singleton: Joi.boolean().required(),
requiredVersion: Joi.string().required(),
strictVersion: Joi.boolean().optional(),
eager: Joi.boolean().required(),
});

Expand Down
1 change: 1 addition & 0 deletions libs/core/src/lib/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export type WorkspaceType = 'nx' | 'turbo' | 'plain';
export interface SharedDependencyConfig {
singleton: boolean;
requiredVersion: string;
strictVersion?: boolean;
eager: boolean;
}

Expand Down
81 changes: 81 additions & 0 deletions libs/core/src/lib/registry/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type { RemoteConfig, RegistryConfig } from '../config/types.js';
import type { RegistryBackend } from './backends/types.js';
import { getManifestStorageKey } from './paths.js';
import { loadRemotesRegistry } from './registry-io.js';
import type { MfManifest, RemotesRegistry } from './types.js';

export class RegistryClientError extends Error {
constructor(message: string) {
super(message);
this.name = 'RegistryClientError';
}
}

export interface FetchedRemoteManifest {
remote: string;
version: string;
manifestUrl: string;
manifest: MfManifest;
}

export interface RegistryClientResult {
remotesRegistry: RemotesRegistry;
manifests: FetchedRemoteManifest[];
}

function parseManifest(raw: string, context: string): MfManifest {
try {
return JSON.parse(raw) as MfManifest;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new RegistryClientError(
`Failed to parse manifest for ${context}: ${message}`,
);
}
}

export async function fetchRegistryManifests(
backend: RegistryBackend,
registry: RegistryConfig,
remotes: RemoteConfig[],
): Promise<RegistryClientResult> {
const remotesRegistry = await loadRemotesRegistry(backend, registry);
const manifests: FetchedRemoteManifest[] = [];

for (const remote of remotes) {
const entry = remotesRegistry.remotes[remote.name];

if (!entry?.current) {
throw new RegistryClientError(
`Remote "${remote.name}" has no published version in the registry.`,
);
}

const version = entry.current;
const versionEntry = entry.versions[version];

if (!versionEntry) {
throw new RegistryClientError(
`Remote "${remote.name}" registry entry is missing manifest metadata for version ${version}.`,
);
}

const storageKey = getManifestStorageKey(registry, remote.name, version);
const raw = await backend.get(storageKey);

if (!raw) {
throw new RegistryClientError(
`Manifest not found for remote "${remote.name}" at version ${version} (${storageKey}).`,
);
}

manifests.push({
remote: remote.name,
version,
manifestUrl: versionEntry.manifestUrl,
manifest: parseManifest(raw, remote.name),
});
}

return { remotesRegistry, manifests };
}
1 change: 1 addition & 0 deletions libs/core/src/lib/registry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export * from './paths.js';
export * from './publish.js';
export * from './rollback.js';
export * from './registry-io.js';
export * from './client.js';
export * from './backends/types.js';
export * from './backends/factory.js';
export * from './backends/http.js';
Expand Down
55 changes: 55 additions & 0 deletions libs/core/src/lib/validate/breaking-changes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { MfManifest } from '../registry/types.js';
import { isMajorVersionBump } from './shared-deps.js';
import type { ValidationIssue } from './types.js';

export function detectBreakingChanges(
local: MfManifest,
registry: MfManifest,
remoteName: string,
): ValidationIssue[] {
const issues: ValidationIssue[] = [];
const localVersion = local.metaData.buildInfo.buildVersion;
const registryVersion = registry.metaData.buildInfo.buildVersion;

if (isMajorVersionBump(registryVersion, localVersion)) {
issues.push({
level: 'warning',
code: 'major-version-bump',
message: `Remote "${remoteName}" has a major version bump from ${registryVersion} to ${localVersion}.`,
remote: remoteName,
});
}

const localExposeNames = new Set(local.exposes.map((expose) => expose.name));

for (const expose of registry.exposes) {
if (!localExposeNames.has(expose.name)) {
issues.push({
level: 'error',
code: 'removed-expose',
message: `Remote "${remoteName}" removed expose "${expose.name}" published in registry version ${registryVersion}.`,
remote: remoteName,
});
}
}

for (const shared of local.shared) {
const registryShared = registry.shared.find(
(entry) => entry.name === shared.name,
);

if (
registryShared &&
isMajorVersionBump(registryShared.version, shared.version)
) {
issues.push({
level: 'warning',
code: 'shared-major-bump',
message: `Remote "${remoteName}" shared dependency "${shared.name}" has a major version bump from ${registryShared.version} to ${shared.version}.`,
remote: remoteName,
});
}
}

return issues;
}
Loading
Loading