wgc
Version:
The official CLI tool to manage the GraphQL Federation Platform Cosmo
912 lines • 57.5 kB
JavaScript
import { readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Command } from 'commander';
import { beforeEach, afterEach, describe, expect, onTestFinished, test, vi } from 'vitest';
import { createClient, createRouterTransport } from '@connectrpc/connect';
import { create } from '@bufbuild/protobuf';
import { CheckSubgraphSchemaResponseSchema, IsGitHubAppInstalledResponseSchema, PlatformService, } from '@wundergraph/cosmo-connect/dist/platform/v1/platform_pb';
import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb';
import { config } from '../src/core/config.js';
import CheckSchema from '../src/commands/subgraph/commands/check.js';
vi.mock('../src/core/config.js', async (importOriginal) => {
const mod = await importOriginal();
return { ...mod, config: { ...mod.config } };
});
function createMockTransport(response) {
return createRouterTransport(({ service }) => {
service(PlatformService, {
checkSubgraphSchema: () => response,
isGitHubAppInstalled: () => create(IsGitHubAppInstalledResponseSchema, {
response: { code: EnumStatusCode.OK },
isInstalled: false,
}),
});
});
}
function setVcsConfig({ author = '', commitSha = '', branch = '' } = {}) {
vi.mocked(config).checkAuthor = author;
vi.mocked(config).checkCommitSha = commitSha;
vi.mocked(config).checkBranch = branch;
}
function resetVcsConfig() {
vi.mocked(config).checkAuthor = '';
vi.mocked(config).checkCommitSha = '';
vi.mocked(config).checkBranch = '';
}
async function runCheck(response, opts = {}) {
const schema = 'schema' in opts ? opts.schema : 'test/fixtures/schema.graphql';
const args = ['check', 'wg.orders'];
if (schema !== null) {
args.push('--schema', schema !== null && schema !== void 0 ? schema : 'test/fixtures/schema.graphql');
}
if (opts.delete) {
args.push('--delete');
}
if (opts.limit !== undefined) {
args.push('--limit', String(opts.limit));
}
if (opts.json) {
args.push('--json');
}
if (opts.outFile !== undefined) {
args.push('--out', opts.outFile);
}
if (opts.skipTrafficCheck) {
args.push('--skip-traffic-check');
}
const client = {
platform: createClient(PlatformService, createMockTransport(response)),
};
const program = new Command();
program.addCommand(CheckSchema({ client }));
await program.parseAsync(args, { from: 'user' });
}
function getJsonOutput(logSpy) {
const call = logSpy.mock.calls.find(([arg]) => {
if (arg !== null && typeof arg === 'object') {
return true;
}
try {
JSON.parse(String(arg));
return true;
}
catch {
return false;
}
});
if (!call) {
throw new Error('No JSON output found in console.log calls');
}
const arg = call[0];
return typeof arg === 'string' ? JSON.parse(arg) : arg;
}
describe('stdout', () => {
let logSpy;
let stderrSpy;
let exitSpy;
beforeEach(() => {
logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit');
});
});
afterEach(() => {
process.exitCode = undefined;
resetVcsConfig();
});
test('no changes logs no changes, no lint issues, and no graph pruning issues', async () => {
await runCheck(create(CheckSubgraphSchemaResponseSchema, { response: { code: EnumStatusCode.OK } }));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Detected no changes.'));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Detected no lint issues.'));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Detected no graph pruning issues.'));
});
test('proposal match warning is logged before no-changes message', async () => {
await runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
proposalMatchMessage: 'Schema does not match approved proposal',
}));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Warning: Proposal match failed'));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Schema does not match approved proposal'));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Detected no changes.'));
});
test('no operations affected succeeds and logs message when no errors', async () => {
await runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
nonBreakingChanges: [{ changeType: 'FIELD_ADDED', message: 'Field added', isBreaking: false }],
operationUsageStats: { totalOperations: 0, safeOperations: 0, firstSeenAt: '', lastSeenAt: '' },
}));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('No operations were affected by this schema change.'));
expect(process.exitCode).not.toBe(1);
});
test('no operations affected fails when composition errors are present', async () => {
await expect(runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
nonBreakingChanges: [{ changeType: 'FIELD_ADDED', message: 'Field added', isBreaking: false }],
operationUsageStats: { totalOperations: 0, safeOperations: 0, firstSeenAt: '', lastSeenAt: '' },
compositionErrors: [
{ message: 'Composition failed', federatedGraphName: 'my-graph', namespace: 'default', featureFlag: '' },
],
}))).rejects.toThrow();
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('No operations were affected by this schema change.'));
});
test('all operations safe succeeds and logs safe operations count', async () => {
await runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
nonBreakingChanges: [{ changeType: 'FIELD_ADDED', message: 'Field added', isBreaking: false }],
operationUsageStats: { totalOperations: 5, safeOperations: 5, firstSeenAt: '', lastSeenAt: '' },
clientTrafficCheckSkipped: false,
}));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('5 operations were considered safe due to overrides.'));
expect(process.exitCode).not.toBe(1);
});
test('breaking changes logs count and fails', async () => {
var _a;
await expect(runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
breakingChanges: [{ changeType: 'FIELD_REMOVED', message: 'Field removed', isBreaking: true }],
operationUsageStats: {
totalOperations: 3,
safeOperations: 0,
firstSeenAt: '2024-01-01T00:00:00Z',
lastSeenAt: '2024-01-02T00:00:00Z',
},
}))).rejects.toThrow();
expect(String((_a = logSpy.mock.calls[1]) === null || _a === void 0 ? void 0 : _a[0])).toMatch(/Found .*1.* breaking changes\./);
});
test('breaking changes reports impacted and safe operation counts', async () => {
var _a, _b;
await expect(runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
breakingChanges: [{ changeType: 'FIELD_REMOVED', message: 'Field removed', isBreaking: true }],
operationUsageStats: {
totalOperations: 3,
safeOperations: 1,
firstSeenAt: '2024-01-01T00:00:00Z',
lastSeenAt: '2024-01-02T00:00:00Z',
},
}))).rejects.toThrow();
expect(String((_a = logSpy.mock.calls[1]) === null || _a === void 0 ? void 0 : _a[0])).toMatch(/2.*operations impacted\./);
expect(String((_b = logSpy.mock.calls[1]) === null || _b === void 0 ? void 0 : _b[0])).toMatch(/1.*operations marked safe due to overrides\./);
});
test('breaking changes logs client activity timestamps when traffic check not skipped', async () => {
var _a;
await expect(runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
breakingChanges: [{ changeType: 'FIELD_REMOVED', message: 'Field removed', isBreaking: true }],
operationUsageStats: {
totalOperations: 3,
safeOperations: 0,
firstSeenAt: '2024-01-01T00:00:00Z',
lastSeenAt: '2024-01-02T00:00:00Z',
},
clientTrafficCheckSkipped: false,
}))).rejects.toThrow();
expect(String((_a = logSpy.mock.calls[1]) === null || _a === void 0 ? void 0 : _a[0])).toMatch(/Found client activity between/);
});
test('breaking changes does not log client activity timestamps when skip-traffic-check is used', async () => {
var _a;
await expect(runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
breakingChanges: [{ changeType: 'FIELD_REMOVED', message: 'Field removed', isBreaking: true }],
operationUsageStats: {
totalOperations: 3,
safeOperations: 0,
firstSeenAt: '2024-01-01T00:00:00Z',
lastSeenAt: '2024-01-02T00:00:00Z',
},
clientTrafficCheckSkipped: true,
}), { skipTrafficCheck: true })).rejects.toThrow();
expect(String((_a = logSpy.mock.calls[1]) === null || _a === void 0 ? void 0 : _a[0])).not.toMatch(/Found client activity between/);
});
test('non-breaking changes succeeds and logs detected changes table', async () => {
await runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
nonBreakingChanges: [{ changeType: 'FIELD_ADDED', message: 'New field added', isBreaking: false }],
operationUsageStats: { totalOperations: 0, safeOperations: 0, firstSeenAt: '', lastSeenAt: '' },
}));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Detected the following subgraph schema changes:'));
expect(process.exitCode).not.toBe(1);
});
test('composition errors logs table and fails', async () => {
await expect(runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
compositionErrors: [
{ message: 'Type mismatch error', federatedGraphName: 'my-graph', namespace: 'default', featureFlag: '' },
],
operationUsageStats: { totalOperations: 0, safeOperations: 0, firstSeenAt: '', lastSeenAt: '' },
}))).rejects.toThrow();
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Detected composition errors:'));
});
test('composition warnings logs table and succeeds', async () => {
await runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
nonBreakingChanges: [{ changeType: 'FIELD_ADDED', message: 'Field added', isBreaking: false }],
compositionWarnings: [
{ message: 'Deprecation warning', federatedGraphName: 'my-graph', namespace: 'default', featureFlag: '' },
],
operationUsageStats: { totalOperations: 0, safeOperations: 0, firstSeenAt: '', lastSeenAt: '' },
}));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Detected composition warnings:'));
expect(process.exitCode).not.toBe(1);
});
test('composition errors table includes FEATURE_FLAG column and flag name when attributed to a feature flag', async () => {
await expect(runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
compositionErrors: [
{
message: 'FS composition failed',
federatedGraphName: 'my-graph',
namespace: 'default',
featureFlag: 'experimental-flag',
},
],
operationUsageStats: { totalOperations: 0, safeOperations: 0, firstSeenAt: '', lastSeenAt: '' },
}))).rejects.toThrow();
const tableOutput = logSpy.mock.calls.map((c) => String(c[0])).join('\n');
expect(tableOutput).toContain('FEATURE_FLAG');
expect(tableOutput).toContain('experimental-flag');
});
test('composition warnings table includes FEATURE_FLAG column and flag name when attributed to a feature flag', async () => {
await runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
nonBreakingChanges: [{ changeType: 'FIELD_ADDED', message: 'Field added', isBreaking: false }],
compositionWarnings: [
{
message: 'Flag deprecation warning',
federatedGraphName: 'my-graph',
namespace: 'default',
featureFlag: 'beta-flag',
},
],
operationUsageStats: { totalOperations: 0, safeOperations: 0, firstSeenAt: '', lastSeenAt: '' },
}));
const tableOutput = logSpy.mock.calls.map((c) => String(c[0])).join('\n');
expect(tableOutput).toContain('FEATURE_FLAG');
expect(tableOutput).toContain('beta-flag');
});
test('composedSchemaBreakingChanges table includes FEATURE_FLAG column and flag name when attributed to a feature flag', async () => {
await expect(runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
composedSchemaBreakingChanges: [
{
changeType: 'FIELD_REMOVED',
message: 'Field removed at FS composition',
federatedGraphName: 'my-graph',
isBreaking: true,
featureFlag: 'fs-flag',
},
],
}))).rejects.toThrow();
const tableOutput = logSpy.mock.calls.map((c) => String(c[0])).join('\n');
expect(tableOutput).toContain('FEATURE_FLAG');
expect(tableOutput).toContain('fs-flag');
});
test('lint errors logs table and fails', async () => {
await expect(runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
lintErrors: [
{
lintRuleType: 'FIELD_NAMES_SHOULD_BE_CAMEL_CASE',
message: 'Field name should be camelCase',
issueLocation: { line: 10, column: 1, endLine: 10, endColumn: 20 },
},
],
operationUsageStats: { totalOperations: 0, safeOperations: 0, firstSeenAt: '', lastSeenAt: '' },
}))).rejects.toThrow();
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Detected lint issues:'));
});
test('lint warnings logs table and succeeds', async () => {
await runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
lintWarnings: [
{
lintRuleType: 'FIELD_NAMES_SHOULD_BE_CAMEL_CASE',
message: 'Consider using camelCase',
issueLocation: { line: 5, column: 1, endLine: 5, endColumn: 10 },
},
],
operationUsageStats: { totalOperations: 0, safeOperations: 0, firstSeenAt: '', lastSeenAt: '' },
}));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Detected lint issues:'));
expect(process.exitCode).not.toBe(1);
});
test('graph pruning errors logs table and fails', async () => {
await expect(runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
graphPruneErrors: [
{
graphPruningRuleType: 'UNUSED_FIELDS',
federatedGraphName: 'my-graph',
fieldPath: 'Query.deprecatedField',
message: 'Field is unused',
issueLocation: { line: 15, column: 1, endLine: 15, endColumn: 30 },
},
],
operationUsageStats: { totalOperations: 0, safeOperations: 0, firstSeenAt: '', lastSeenAt: '' },
}))).rejects.toThrow();
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Detected graph pruning issues:'));
});
test('graph pruning warnings logs table and succeeds', async () => {
await runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
graphPruneWarnings: [
{
graphPruningRuleType: 'UNUSED_FIELDS',
federatedGraphName: 'my-graph',
fieldPath: 'Query.deprecatedField',
message: 'Field might be unused',
issueLocation: { line: 20, column: 1, endLine: 20, endColumn: 25 },
},
],
operationUsageStats: { totalOperations: 0, safeOperations: 0, firstSeenAt: '', lastSeenAt: '' },
}));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Detected graph pruning issues:'));
expect(process.exitCode).not.toBe(1);
});
test('linked traffic check failure fails the check', async () => {
await expect(runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
nonBreakingChanges: [{ changeType: 'FIELD_ADDED', message: 'Field added', isBreaking: false }],
operationUsageStats: { totalOperations: 0, safeOperations: 0, firstSeenAt: '', lastSeenAt: '' },
isLinkedTrafficCheckFailed: true,
}))).rejects.toThrow();
expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('target subgraph check has failed'));
});
test('linked pruning check failure fails the check', async () => {
await expect(runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
nonBreakingChanges: [{ changeType: 'FIELD_ADDED', message: 'Field added', isBreaking: false }],
operationUsageStats: { totalOperations: 0, safeOperations: 0, firstSeenAt: '', lastSeenAt: '' },
isLinkedPruningCheckFailed: true,
}))).rejects.toThrow();
expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('target subgraph check has failed'));
});
test('extension check error fails and includes error message in output', async () => {
await expect(runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
nonBreakingChanges: [{ changeType: 'FIELD_ADDED', message: 'Field added', isBreaking: false }],
operationUsageStats: { totalOperations: 0, safeOperations: 0, firstSeenAt: '', lastSeenAt: '' },
checkExtensionErrorMessage: 'Extension validation failed',
}))).rejects.toThrow();
expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('Subgraph extension check failed with message: Extension validation failed'));
});
test('row limit exceeded logs truncation warning', async () => {
await runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
nonBreakingChanges: [{ changeType: 'FIELD_ADDED', message: 'Field added', isBreaking: false }],
operationUsageStats: { totalOperations: 0, safeOperations: 0, firstSeenAt: '', lastSeenAt: '' },
counts: {
breakingChanges: 0,
nonBreakingChanges: 60,
compositionErrors: 0,
compositionWarnings: 0,
lintErrors: 0,
lintWarnings: 0,
graphPruneErrors: 0,
graphPruneWarnings: 0,
},
}), { limit: 50 });
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Some results were truncated due to exceeding the limit of 50 rows.'));
});
test('composedSchemaBreakingChanges alone fails the check', async () => {
await expect(runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
composedSchemaBreakingChanges: [
{
changeType: 'FIELD_REMOVED',
message: 'Field removed at federated level',
federatedGraphName: 'my-graph',
isBreaking: true,
},
],
}))).rejects.toThrow();
expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('Schema check failed'));
});
test('composedSchemaBreakingChanges logs federated graph breaking changes section', async () => {
await expect(runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
composedSchemaBreakingChanges: [
{
changeType: 'FIELD_REMOVED',
message: 'Field removed at federated level',
federatedGraphName: 'my-graph',
isBreaking: true,
},
],
}))).rejects.toThrow();
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Detected the following federated graph schema breaking changes:'));
});
test('composedSchemaBreakingChanges suppresses no-changes message', async () => {
await expect(runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
composedSchemaBreakingChanges: [
{
changeType: 'FIELD_REMOVED',
message: 'Field removed at federated level',
federatedGraphName: 'my-graph',
isBreaking: true,
},
],
}))).rejects.toThrow();
expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining('Detected no changes.'));
});
test('combined breaking changes count in traffic warning includes composedSchemaBreakingChanges', async () => {
var _a;
await expect(runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
breakingChanges: [{ changeType: 'FIELD_REMOVED', message: 'Field removed', isBreaking: true }],
composedSchemaBreakingChanges: [
{ changeType: 'TYPE_CHANGED', message: 'Type changed', federatedGraphName: 'my-graph', isBreaking: true },
{
changeType: 'FIELD_TYPE_CHANGED',
message: 'Field type changed',
federatedGraphName: 'my-graph',
isBreaking: true,
},
],
operationUsageStats: {
totalOperations: 3,
safeOperations: 0,
firstSeenAt: '2024-01-01T00:00:00Z',
lastSeenAt: '2024-01-02T00:00:00Z',
},
}))).rejects.toThrow();
expect(String((_a = logSpy.mock.calls[1]) === null || _a === void 0 ? void 0 : _a[0])).toMatch(/Found .*3.* breaking changes\./);
});
test('composedSchemaBreakingChanges without subgraph breaking changes counts in traffic warning', async () => {
var _a;
await expect(runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
composedSchemaBreakingChanges: [
{ changeType: 'TYPE_CHANGED', message: 'Type changed', federatedGraphName: 'my-graph', isBreaking: true },
{
changeType: 'FIELD_TYPE_CHANGED',
message: 'Field type changed',
federatedGraphName: 'my-graph',
isBreaking: true,
},
],
operationUsageStats: {
totalOperations: 3,
safeOperations: 0,
firstSeenAt: '2024-01-01T00:00:00Z',
lastSeenAt: '2024-01-02T00:00:00Z',
},
}))).rejects.toThrow();
expect(String((_a = logSpy.mock.calls[1]) === null || _a === void 0 ? void 0 : _a[0])).toMatch(/Found .*2.* breaking changes\./);
});
test('row limit exceeded triggered by composedSchemaBreakingChanges count', async () => {
await expect(runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
composedSchemaBreakingChanges: [
{ changeType: 'FIELD_REMOVED', message: 'Field removed', federatedGraphName: 'my-graph', isBreaking: true },
],
counts: {
breakingChanges: 0,
nonBreakingChanges: 0,
compositionErrors: 0,
compositionWarnings: 0,
lintErrors: 0,
lintWarnings: 0,
graphPruneErrors: 0,
graphPruneWarnings: 0,
composedSchemaBreakingChanges: 60,
},
}), { limit: 50 })).rejects.toThrow();
// Truncation message is appended to program.error() when check fails (success=false)
expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('Some results were truncated due to exceeding the limit of 50 rows.'));
});
test('vcs context is constructed when vcs config fields are set', async () => {
setVcsConfig({ author: 'test-author', commitSha: 'abc123', branch: 'main' });
await runCheck(create(CheckSubgraphSchemaResponseSchema, { response: { code: EnumStatusCode.OK } }));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Detected no changes.'));
});
test('missing schema and delete flag logs error and exits', async () => {
await expect(runCheck(create(CheckSubgraphSchemaResponseSchema, { response: { code: EnumStatusCode.OK } }), { schema: null })).rejects.toThrow();
expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining("'--schema <path-to-schema>' or '--delete' not specified"));
});
test('non-existent schema file logs error and exits', async () => {
await expect(runCheck(create(CheckSubgraphSchemaResponseSchema, { response: { code: EnumStatusCode.OK } }), {
schema: 'test/fixtures/nonexistent.graphql',
})).rejects.toThrow();
expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('does not exist'));
});
test('delete flag sends check without schema', async () => {
await runCheck(create(CheckSubgraphSchemaResponseSchema, { response: { code: EnumStatusCode.OK } }), {
schema: null,
delete: true,
});
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Detected no changes.'));
});
test('invalid limit logs error and exits', async () => {
await expect(runCheck(create(CheckSubgraphSchemaResponseSchema, { response: { code: EnumStatusCode.OK } }), { limit: 'abc' })).rejects.toThrow();
expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('limit must be a valid number'));
});
test('limit of zero logs error and exits', async () => {
await expect(runCheck(create(CheckSubgraphSchemaResponseSchema, { response: { code: EnumStatusCode.OK } }), { limit: 0 })).rejects.toThrow();
expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('limit must be a valid number'));
});
test('limit exceeding max value logs error and exits', async () => {
await expect(runCheck(create(CheckSubgraphSchemaResponseSchema, { response: { code: EnumStatusCode.OK } }), { limit: 10001 })).rejects.toThrow();
expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('limit must be a valid number'));
});
test('ERR_SCHEMA_MISMATCH_WITH_APPROVED_PROPOSAL logs proposal match failed and sets exit code', async () => {
await runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.ERR_SCHEMA_MISMATCH_WITH_APPROVED_PROPOSAL },
proposalMatchMessage: 'Schema does not match approved proposal',
}));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Error: Proposal match failed'));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Schema does not match approved proposal'));
expect(process.exitCode).toBe(1);
});
test('ERR_INVALID_SUBGRAPH_SCHEMA logs early failure message with details and exits', async () => {
await expect(runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.ERR_INVALID_SUBGRAPH_SCHEMA, details: 'Syntax error in schema' },
}))).rejects.toThrow();
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Check has failed early because the schema could not be built.'));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Syntax error in schema'));
});
test('default error status logs failed to perform check with details and exits', async () => {
await expect(runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.ERR, details: 'Internal server error' },
}))).rejects.toThrow();
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to perform the check operation.'));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Internal server error'));
});
});
describe('json output', () => {
let logSpy;
let stderrSpy;
let exitSpy;
beforeEach(() => {
logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit');
});
});
afterEach(() => {
process.exitCode = undefined;
resetVcsConfig();
});
test('no changes outputs JSON with success status and proposals', async () => {
var _a;
await runCheck(create(CheckSubgraphSchemaResponseSchema, { response: { code: EnumStatusCode.OK } }), {
json: true,
});
const output = getJsonOutput(logSpy);
expect(output.status).toBe('success');
expect((_a = output.proposals) === null || _a === void 0 ? void 0 : _a.message).toContain('no changes');
});
test('proposal match warning outputs JSON with proposals message', async () => {
var _a;
await runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
proposalMatchMessage: 'Schema does not match approved proposal',
}), { json: true });
const output = getJsonOutput(logSpy);
expect((_a = output.proposals) === null || _a === void 0 ? void 0 : _a.message).toBe('Schema does not match approved proposal');
});
test('no operations affected outputs JSON with success status and traffic info', async () => {
var _a;
await runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
nonBreakingChanges: [{ changeType: 'FIELD_ADDED', message: 'Field added', isBreaking: false }],
operationUsageStats: { totalOperations: 0, safeOperations: 0, firstSeenAt: '', lastSeenAt: '' },
}), { json: true });
const output = getJsonOutput(logSpy);
expect(output.status).toBe('success');
expect((_a = output.traffic) === null || _a === void 0 ? void 0 : _a.message).toBe('No operations were affected by this schema change.');
});
test('all operations safe outputs JSON with success status', async () => {
var _a;
await runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
nonBreakingChanges: [{ changeType: 'FIELD_ADDED', message: 'Field added', isBreaking: false }],
operationUsageStats: { totalOperations: 5, safeOperations: 5, firstSeenAt: '', lastSeenAt: '' },
clientTrafficCheckSkipped: false,
}), { json: true });
const output = getJsonOutput(logSpy);
expect(output.status).toBe('success');
expect((_a = output.traffic) === null || _a === void 0 ? void 0 : _a.message).toBe('5 operations were considered safe due to overrides.');
});
test('breaking changes outputs JSON with error status and breaking changes array', async () => {
var _a, _b;
await runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
breakingChanges: [{ changeType: 'FIELD_REMOVED', message: 'Field removed', isBreaking: true }],
operationUsageStats: {
totalOperations: 3,
safeOperations: 0,
firstSeenAt: '2024-01-01T00:00:00Z',
lastSeenAt: '2024-01-02T00:00:00Z',
},
}), { json: true });
const output = getJsonOutput(logSpy);
expect(output.status).toBe('error');
expect(output.changes).toBeDefined();
expect(Array.isArray((_a = output.changes) === null || _a === void 0 ? void 0 : _a.breaking)).toBe(true);
expect(Array.isArray((_b = output.changes) === null || _b === void 0 ? void 0 : _b.nonBreaking)).toBe(true);
expect(output.operationUsageStats).toBeDefined();
});
test('non-breaking changes outputs JSON with success status and nonBreaking changes', async () => {
var _a;
await runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
nonBreakingChanges: [{ changeType: 'FIELD_ADDED', message: 'New field added', isBreaking: false }],
operationUsageStats: { totalOperations: 0, safeOperations: 0, firstSeenAt: '', lastSeenAt: '' },
}), { json: true });
const output = getJsonOutput(logSpy);
expect(output.status).toBe('success');
expect(output.changes).toBeDefined();
expect(Array.isArray((_a = output.changes) === null || _a === void 0 ? void 0 : _a.nonBreaking)).toBe(true);
});
test('composition errors outputs JSON with error status and composition.errors populated', async () => {
var _a, _b;
await runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
compositionErrors: [
{ message: 'Type mismatch error', federatedGraphName: 'my-graph', namespace: 'default', featureFlag: '' },
],
operationUsageStats: { totalOperations: 0, safeOperations: 0, firstSeenAt: '', lastSeenAt: '' },
}), { json: true });
const output = getJsonOutput(logSpy);
expect(output.status).toBe('error');
expect(output.composition).toBeDefined();
expect(Array.isArray((_a = output.composition) === null || _a === void 0 ? void 0 : _a.errors)).toBe(true);
expect(Array.isArray((_b = output.composition) === null || _b === void 0 ? void 0 : _b.warnings)).toBe(true);
});
test('composition warnings outputs JSON with success status and composition.warnings populated', async () => {
var _a, _b;
await runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
nonBreakingChanges: [{ changeType: 'FIELD_ADDED', message: 'Field added', isBreaking: false }],
compositionWarnings: [
{ message: 'Deprecation warning', federatedGraphName: 'my-graph', namespace: 'default', featureFlag: '' },
],
operationUsageStats: { totalOperations: 0, safeOperations: 0, firstSeenAt: '', lastSeenAt: '' },
}), { json: true });
const output = getJsonOutput(logSpy);
expect(output.status).toBe('success');
expect(output.composition).toBeDefined();
expect(Array.isArray((_a = output.composition) === null || _a === void 0 ? void 0 : _a.errors)).toBe(true);
expect(Array.isArray((_b = output.composition) === null || _b === void 0 ? void 0 : _b.warnings)).toBe(true);
});
test('JSON output preserves featureFlag on composition errors, warnings, and composed breaking changes', async () => {
var _a, _b, _c, _d;
await runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
compositionErrors: [
{
message: 'FS composition failed',
federatedGraphName: 'my-graph',
namespace: 'default',
featureFlag: 'flag-a',
},
{
message: 'Base composition failed',
federatedGraphName: 'my-graph',
namespace: 'default',
featureFlag: '',
},
],
compositionWarnings: [
{
message: 'FS warning',
federatedGraphName: 'my-graph',
namespace: 'default',
featureFlag: 'flag-b',
},
],
composedSchemaBreakingChanges: [
{
changeType: 'FIELD_REMOVED',
message: 'Field removed',
federatedGraphName: 'my-graph',
isBreaking: true,
featureFlag: 'flag-c',
},
],
operationUsageStats: { totalOperations: 0, safeOperations: 0, firstSeenAt: '', lastSeenAt: '' },
}), { json: true });
const output = getJsonOutput(logSpy);
expect((_a = output.composition) === null || _a === void 0 ? void 0 : _a.errors[0].featureFlag).toBe('flag-a');
expect((_b = output.composition) === null || _b === void 0 ? void 0 : _b.errors[1].featureFlag).toBe('');
expect((_c = output.composition) === null || _c === void 0 ? void 0 : _c.warnings[0].featureFlag).toBe('flag-b');
expect((_d = output.composedSchemaBreakingChanges) === null || _d === void 0 ? void 0 : _d[0].featureFlag).toBe('flag-c');
});
test('JSON output includes the feature subgraph check message when the FS is in no enabled flag', async () => {
var _a;
const message = 'Feature subgraph is not assigned to any enabled feature flag; no composition check performed.';
await runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
featureSubgraphCheckMessage: message,
operationUsageStats: { totalOperations: 0, safeOperations: 0, firstSeenAt: '', lastSeenAt: '' },
}), { json: true });
const output = getJsonOutput(logSpy);
expect((_a = output.featureSubgraphCheck) === null || _a === void 0 ? void 0 : _a.message).toBe(message);
});
test('text output prints the feature subgraph check message when the FS is in no enabled flag', async () => {
const message = 'Feature subgraph is not assigned to any enabled feature flag; no composition check performed.';
await runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
featureSubgraphCheckMessage: message,
}));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('not assigned to any enabled feature flag'));
});
test('lint errors outputs JSON with error status and lint.errors populated', async () => {
var _a, _b;
await runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
lintErrors: [
{
lintRuleType: 'FIELD_NAMES_SHOULD_BE_CAMEL_CASE',
message: 'Field name should be camelCase',
issueLocation: { line: 10, column: 1, endLine: 10, endColumn: 20 },
},
],
operationUsageStats: { totalOperations: 0, safeOperations: 0, firstSeenAt: '', lastSeenAt: '' },
}), { json: true });
const output = getJsonOutput(logSpy);
expect(output.status).toBe('error');
expect(output.lint).toBeDefined();
expect(Array.isArray((_a = output.lint) === null || _a === void 0 ? void 0 : _a.errors)).toBe(true);
expect(Array.isArray((_b = output.lint) === null || _b === void 0 ? void 0 : _b.warnings)).toBe(true);
});
test('lint warnings outputs JSON with success status and lint.warnings populated', async () => {
var _a, _b;
await runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
lintWarnings: [
{
lintRuleType: 'FIELD_NAMES_SHOULD_BE_CAMEL_CASE',
message: 'Consider using camelCase',
issueLocation: { line: 5, column: 1, endLine: 5, endColumn: 10 },
},
],
operationUsageStats: { totalOperations: 0, safeOperations: 0, firstSeenAt: '', lastSeenAt: '' },
}), { json: true });
const output = getJsonOutput(logSpy);
expect(output.status).toBe('success');
expect(output.lint).toBeDefined();
expect(Array.isArray((_a = output.lint) === null || _a === void 0 ? void 0 : _a.errors)).toBe(true);
expect(Array.isArray((_b = output.lint) === null || _b === void 0 ? void 0 : _b.warnings)).toBe(true);
});
test('graph pruning errors outputs JSON with error status and graphPrune errors', async () => {
var _a, _b;
await runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
graphPruneErrors: [
{
graphPruningRuleType: 'UNUSED_FIELDS',
federatedGraphName: 'my-graph',
fieldPath: 'Query.deprecatedField',
message: 'Field is unused',
issueLocation: { line: 15, column: 1, endLine: 15, endColumn: 30 },
},
],
operationUsageStats: { totalOperations: 0, safeOperations: 0, firstSeenAt: '', lastSeenAt: '' },
}), { json: true });
const output = getJsonOutput(logSpy);
expect(output.status).toBe('error');
expect(Array.isArray((_a = output.graphPrune) === null || _a === void 0 ? void 0 : _a.errors)).toBe(true);
expect(Array.isArray((_b = output.graphPrune) === null || _b === void 0 ? void 0 : _b.warnings)).toBe(true);
});
test('graph pruning warnings outputs JSON with success status', async () => {
var _a, _b;
await runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
graphPruneWarnings: [
{
graphPruningRuleType: 'UNUSED_FIELDS',
federatedGraphName: 'my-graph',
fieldPath: 'Query.deprecatedField',
message: 'Field might be unused',
issueLocation: { line: 20, column: 1, endLine: 20, endColumn: 25 },
},
],
operationUsageStats: { totalOperations: 0, safeOperations: 0, firstSeenAt: '', lastSeenAt: '' },
}), { json: true });
const output = getJsonOutput(logSpy);
expect(output.status).toBe('success');
expect(output.graphPrune).toBeDefined();
expect(Array.isArray((_a = output.graphPrune) === null || _a === void 0 ? void 0 : _a.errors)).toBe(true);
expect(Array.isArray((_b = output.graphPrune) === null || _b === void 0 ? void 0 : _b.warnings)).toBe(true);
});
test('linked traffic check failure outputs JSON with error status and traffic message', async () => {
var _a;
await runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
nonBreakingChanges: [{ changeType: 'FIELD_ADDED', message: 'Field added', isBreaking: false }],
operationUsageStats: { totalOperations: 0, safeOperations: 0, firstSeenAt: '', lastSeenAt: '' },
isLinkedTrafficCheckFailed: true,
}), { json: true });
const output = getJsonOutput(logSpy);
expect(output.status).toBe('error');
expect((_a = output.traffic) === null || _a === void 0 ? void 0 : _a.message).toBe('No operations were affected by this schema change.');
});
test('linked pruning check failure outputs JSON with error status and graphPrune defined', async () => {
await runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
nonBreakingChanges: [{ changeType: 'FIELD_ADDED', message: 'Field added', isBreaking: false }],
operationUsageStats: { totalOperations: 0, safeOperations: 0, firstSeenAt: '', lastSeenAt: '' },
isLinkedPruningCheckFailed: true,
}), { json: true });
const output = getJsonOutput(logSpy);
expect(output.status).toBe('error');
expect(output.graphPrune).toBeDefined();
});
test('extension check error outputs JSON with extensions message', async () => {
var _a;
await runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
nonBreakingChanges: [{ changeType: 'FIELD_ADDED', message: 'Field added', isBreaking: false }],
operationUsageStats: { totalOperations: 0, safeOperations: 0, firstSeenAt: '', lastSeenAt: '' },
checkExtensionErrorMessage: 'Extension validation failed',
}), { json: true });
const output = getJsonOutput(logSpy);
expect(output.status).toBe('error');
expect((_a = output.extensions) === null || _a === void 0 ? void 0 : _a.message).toContain('Extension validation failed');
});
test('row limit exceeded outputs JSON with exceededRowLimit true', async () => {
await runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
nonBreakingChanges: [{ changeType: 'FIELD_ADDED', message: 'Field added', isBreaking: false }],
operationUsageStats: { totalOperations: 0, safeOperations: 0, firstSeenAt: '', lastSeenAt: '' },
counts: {
breakingChanges: 0,
nonBreakingChanges: 60,
compositionErrors: 0,
compositionWarnings: 0,
lintErrors: 0,
lintWarnings: 0,
graphPruneErrors: 0,
graphPruneWarnings: 0,
},
}), { json: true, limit: 50 });
const output = getJsonOutput(logSpy);
expect(output.exceededRowLimit).toBe(true);
expect(output.rowLimit).toBe(50);
});
test('skip-traffic-check with no operationUsageStats omits traffic from JSON output', async () => {
// When no operationUsageStats is returned (server omits it when traffic check is skipped),
// the traffic field should be absent from JSON output entirely
await runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
// No changes, no operationUsageStats — clean check with traffic check skipped
}), { json: true, skipTrafficCheck: true });
const output = getJsonOutput(logSpy);
expect(output.status).toBe('success');
expect(output.traffic).toBeUndefined();
});
test('skip-traffic-check with operationUsageStats omits traffic from JSON when no breaking changes', async () => {
// When clientTrafficCheckSkipped: true and no breaking changes, traffic should be absent —
// consistent with stdout which also prints nothing in this case
await runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.OK },
nonBreakingChanges: [{ changeType: 'FIELD_ADDED', message: 'Field added', isBreaking: false }],
operationUsageStats: {
totalOperations: 5,
safeOperations: 0,
firstSeenAt: '2024-01-01T00:00:00Z',
lastSeenAt: '2024-01-02T00:00:00Z',
},
clientTrafficCheckSkipped: true,
}), { json: true, skipTrafficCheck: true });
const output = getJsonOutput(logSpy);
expect(output.status).toBe('success');
expect(output.traffic).toBeUndefined();
});
test('ERR_SCHEMA_MISMATCH_WITH_APPROVED_PROPOSAL outputs JSON with error status and details', async () => {
await runCheck(create(CheckSubgraphSchemaResponseSchema, {
response: { code: EnumStatusCode.ERR_SCHEMA_MISMATCH_WITH_APPROVED_PROPOSAL },
proposalMatchMessage: 'Schema does not match approved proposal',
}), { json: true });
const output = getJsonOutput(logSpy);
expect(output.status).toBe('error');
expect(output.message).toContain('Proposal match failed');
expect(output.details).toBe('Schema does not match approved proposal');
});
test('ERR_INVALID_SUBGRAPH_SCHEMA outputs JSON with error statu