Skip to content
75 changes: 73 additions & 2 deletions cli/src/commands/subgraph/commands/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,44 @@ import {
parseGraphQLWebsocketSubprotocol,
splitLabel,
} from '@wundergraph/cosmo-shared';
import { SubgraphType } from '@wundergraph/cosmo-connect/dist/platform/v1/platform_pb';
import { SubgraphType, SubgraphPublishStats } from '@wundergraph/cosmo-connect/dist/platform/v1/platform_pb';
import { BaseCommandOptions } from '../../../core/types/types.js';
import { getBaseHeaders } from '../../../core/config.js';
import { validateSubscriptionProtocols } from '../../../utils.js';
import { websocketSubprotocolDescription } from '../../../constants.js';
import { websocketSubprotocolDescription, limitMaxValue } from '../../../constants.js';

const printTruncationWarning = (
counts: SubgraphPublishStats | undefined,
displayedCounts: {
compositionErrors: number;
compositionWarnings: number;
deploymentErrors: number;
},
) => {
if (!counts) {
return;
}

const truncatedItems: string[] = [];

if (counts.compositionErrors > displayedCounts.compositionErrors) {
truncatedItems.push(
`composition errors (${displayedCounts.compositionErrors} of ${counts.compositionErrors} shown)`,
);
}
if (counts.compositionWarnings > displayedCounts.compositionWarnings) {
truncatedItems.push(
`composition warnings (${displayedCounts.compositionWarnings} of ${counts.compositionWarnings} shown)`,
);
}
if (counts.deploymentErrors > displayedCounts.deploymentErrors) {
truncatedItems.push(`deployment errors (${displayedCounts.deploymentErrors} of ${counts.deploymentErrors} shown)`);
}

if (truncatedItems.length > 0) {
console.log(pc.yellow(`\nNote: Some results were truncated: ${truncatedItems.join(', ')}.`));
}
};

export default (opts: BaseCommandOptions) => {
const command = new Command('publish');
Expand Down Expand Up @@ -74,6 +107,11 @@ export default (opts: BaseCommandOptions) => {
'--disable-resolvability-validation',
'This flag will disable the validation for whether all nodes of the federated graph are resolvable. Do NOT use unless troubleshooting.',
);
command.option(
'-l, --limit [number]',
'The maximum number of composition errors, warnings, and deployment errors to display.',
'50',
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

command.action(async (name, options) => {
const schemaFile = resolve(options.schema);
Expand All @@ -98,6 +136,13 @@ export default (opts: BaseCommandOptions) => {
websocketSubprotocol: options.websocketSubprotocol,
});

const limit = Number(options.limit);
if (Number.isNaN(limit) || limit <= 0 || limit > limitMaxValue) {
program.error(
pc.red(`The limit must be a valid number between 1 and ${limitMaxValue}. Received: '${options.limit}'`),
);
}

const spinner = ora('Subgraph is being published...').start();

const resp = await opts.client.platform.publishFederatedSubgraph(
Expand All @@ -118,6 +163,7 @@ export default (opts: BaseCommandOptions) => {
: undefined,
labels: options.label.map((label: string) => splitLabel(label)),
type: SubgraphType.STANDARD,
limit,
},
{
headers: getBaseHeaders(),
Expand Down Expand Up @@ -177,6 +223,12 @@ export default (opts: BaseCommandOptions) => {
console.log(compositionErrorsTable.toString());

if (options.failOnCompositionError) {
// Only composition errors were displayed at this point, warnings come after switch
printTruncationWarning(resp.counts, {
compositionErrors: resp.compositionErrors.length,
compositionWarnings: 0,
deploymentErrors: 0,
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
program.error(pc.red(pc.bold('The command failed due to composition errors.')));
}

Expand Down Expand Up @@ -208,6 +260,12 @@ export default (opts: BaseCommandOptions) => {
console.log(deploymentErrorsTable.toString());

if (options.failOnAdmissionWebhookError) {
// Only deployment errors were displayed at this point, warnings come after switch
printTruncationWarning(resp.counts, {
compositionErrors: 0,
compositionWarnings: 0,
deploymentErrors: resp.deploymentErrors.length,
});
program.error(pc.red(pc.bold('The command failed due to admission webhook errors.')));
}

Expand All @@ -223,6 +281,9 @@ export default (opts: BaseCommandOptions) => {
}
}

// Track what was actually displayed
const displayedWarnings = options.suppressWarnings ? 0 : resp.compositionWarnings.length;

if (!options.suppressWarnings && resp.compositionWarnings.length > 0) {
const compositionWarningsTable = new Table({
head: [
Expand All @@ -246,6 +307,16 @@ export default (opts: BaseCommandOptions) => {
}
console.log(compositionWarningsTable.toString());
}

// Determine what was actually displayed based on the response code
const displayedCounts = {
compositionErrors:
resp.response?.code === EnumStatusCode.ERR_SUBGRAPH_COMPOSITION_FAILED ? resp.compositionErrors.length : 0,
compositionWarnings: displayedWarnings,
deploymentErrors: resp.response?.code === EnumStatusCode.ERR_DEPLOYMENT_FAILED ? resp.deploymentErrors.length : 0,
};

printTruncationWarning(resp.counts, displayedCounts);
});

return command;
Expand Down
237 changes: 235 additions & 2 deletions cli/test/publish-schema.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,54 @@
import { describe, test } from 'vitest';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, beforeEach, describe, expect, test, vi, type MockInstance } from 'vitest';
import { Command } from 'commander';
import { type PartialMessage } from '@bufbuild/protobuf';
import { createPromiseClient, createRouterTransport } from '@connectrpc/connect';
import { PlatformService } from '@wundergraph/cosmo-connect/dist/platform/v1/platform_connect';
import { PublishFederatedSubgraphResponse } from '@wundergraph/cosmo-connect/dist/platform/v1/platform_pb';
import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb';
import { dirname } from 'pathe';
import { Client } from '../src/core/client/client.js';
import PublishSchema from '../src/commands/subgraph/commands/publish.js';

const __dirname = dirname(fileURLToPath(import.meta.url));
const schemaPath = resolve(__dirname, 'fixtures', 'schema.graphql');

function createMockTransport(response: PartialMessage<PublishFederatedSubgraphResponse>) {
return createRouterTransport(({ service }) => {
service(PlatformService, {
publishFederatedSubgraph: () => response,
});
});
}

async function runPublish(
response: PartialMessage<PublishFederatedSubgraphResponse>,
opts: {
failOnCompositionError?: boolean;
failOnAdmissionWebhookError?: boolean;
suppressWarnings?: boolean;
} = {},
): Promise<void> {
const args = ['publish', 'wg.orders', '--schema', schemaPath];
if (opts.failOnCompositionError) {
args.push('--fail-on-composition-error');
}
if (opts.failOnAdmissionWebhookError) {
args.push('--fail-on-admission-webhook-error');
}
if (opts.suppressWarnings) {
args.push('--suppress-warnings');
}

const client: Client = {
platform: createPromiseClient(PlatformService, createMockTransport(response)),
};
const program = new Command();
program.addCommand(PublishSchema({ client }));
await program.parseAsync(args, { from: 'user' });
}

export const mockPlatformTransport = () =>
createRouterTransport(({ service }) => {
service(PlatformService, {
Expand Down Expand Up @@ -34,8 +77,198 @@ describe('Schema Command', () => {
client,
}),
);
const command = program.parse(['publish', 'wg.orders', '--schema', 'test/fixtures/schema.graphql'], {
const command = program.parse(['publish', 'wg.orders', '--schema', schemaPath], {
from: 'user',
});
});
});

describe('truncation warning', () => {
let logSpy: MockInstance<typeof console.log>;
let stderrSpy: MockInstance<typeof process.stderr.write>;
let exitSpy: MockInstance<typeof process.exit>;

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(() => {
logSpy.mockRestore();
stderrSpy.mockRestore();
exitSpy.mockRestore();
Comment thread
comatory marked this conversation as resolved.
Outdated
process.exitCode = undefined;
});

test('shows truncation warning when composition errors exceed displayed count', async () => {
await runPublish({
response: { code: EnumStatusCode.ERR_SUBGRAPH_COMPOSITION_FAILED },
compositionErrors: [
{ federatedGraphName: 'graph1', namespace: 'default', message: 'Error 1' },
{ federatedGraphName: 'graph2', namespace: 'default', message: 'Error 2' },
],
compositionWarnings: [],
deploymentErrors: [],
counts: {
compositionErrors: 10,
compositionWarnings: 0,
deploymentErrors: 0,
},
});

expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Some results were truncated'));
Comment thread
comatory marked this conversation as resolved.
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('composition errors (2 of 10 shown)'));
});

test('shows truncation warning when composition warnings exceed displayed count', async () => {
await runPublish({
response: { code: EnumStatusCode.OK },
compositionErrors: [],
compositionWarnings: [{ federatedGraphName: 'graph1', namespace: 'default', message: 'Warning 1' }],
deploymentErrors: [],
counts: {
compositionErrors: 0,
compositionWarnings: 5,
deploymentErrors: 0,
},
});

expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Some results were truncated'));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('composition warnings (1 of 5 shown)'));
});

test('shows truncation warning when deployment errors exceed displayed count', async () => {
await runPublish({
response: { code: EnumStatusCode.ERR_DEPLOYMENT_FAILED },
compositionErrors: [],
compositionWarnings: [],
deploymentErrors: [{ federatedGraphName: 'graph1', namespace: 'default', message: 'Deploy Error 1' }],
counts: {
compositionErrors: 0,
compositionWarnings: 0,
deploymentErrors: 3,
},
});

expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Some results were truncated'));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('deployment errors (1 of 3 shown)'));
});

test('does not show truncation warning when counts match displayed items', async () => {
await runPublish({
response: { code: EnumStatusCode.ERR_SUBGRAPH_COMPOSITION_FAILED },
compositionErrors: [
{ federatedGraphName: 'graph1', namespace: 'default', message: 'Error 1' },
{ federatedGraphName: 'graph2', namespace: 'default', message: 'Error 2' },
],
compositionWarnings: [],
deploymentErrors: [],
counts: {
compositionErrors: 2,
compositionWarnings: 0,
deploymentErrors: 0,
},
});

const truncationCalls = logSpy.mock.calls.filter(([arg]) => typeof arg === 'string' && arg.includes('truncated'));
expect(truncationCalls).toHaveLength(0);
});

test('does not show truncation warning when counts are not provided', async () => {
await runPublish({
response: { code: EnumStatusCode.ERR_SUBGRAPH_COMPOSITION_FAILED },
compositionErrors: [{ federatedGraphName: 'graph1', namespace: 'default', message: 'Error 1' }],
compositionWarnings: [],
deploymentErrors: [],
});

const truncationCalls = logSpy.mock.calls.filter(([arg]) => typeof arg === 'string' && arg.includes('truncated'));
expect(truncationCalls).toHaveLength(0);
});

test('shows truncation warning before program.error when failOnCompositionError is set', async () => {
await expect(
runPublish(
{
response: { code: EnumStatusCode.ERR_SUBGRAPH_COMPOSITION_FAILED },
compositionErrors: [{ federatedGraphName: 'graph1', namespace: 'default', message: 'Error 1' }],
compositionWarnings: [],
deploymentErrors: [],
counts: {
compositionErrors: 5,
compositionWarnings: 0,
deploymentErrors: 0,
},
},
{ failOnCompositionError: true },
),
).rejects.toThrow('process.exit');

expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Some results were truncated'));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('composition errors (1 of 5 shown)'));
});

test('shows truncation warning before program.error when failOnAdmissionWebhookError is set', async () => {
await expect(
runPublish(
{
response: { code: EnumStatusCode.ERR_DEPLOYMENT_FAILED },
compositionErrors: [],
compositionWarnings: [],
deploymentErrors: [{ federatedGraphName: 'graph1', namespace: 'default', message: 'Deploy Error 1' }],
counts: {
compositionErrors: 0,
compositionWarnings: 0,
deploymentErrors: 8,
},
},
{ failOnAdmissionWebhookError: true },
),
).rejects.toThrow('process.exit');

expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Some results were truncated'));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('deployment errors (1 of 8 shown)'));
});

test('shows multiple truncation items when multiple types are truncated', async () => {
await runPublish({
response: { code: EnumStatusCode.ERR_SUBGRAPH_COMPOSITION_FAILED },
compositionErrors: [{ federatedGraphName: 'graph1', namespace: 'default', message: 'Error 1' }],
compositionWarnings: [{ federatedGraphName: 'graph1', namespace: 'default', message: 'Warning 1' }],
deploymentErrors: [],
counts: {
compositionErrors: 100,
compositionWarnings: 50,
deploymentErrors: 0,
},
});

expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('composition errors (1 of 100 shown)'));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('composition warnings (1 of 50 shown)'));
});

test('does not show warnings truncation when suppressWarnings is set', async () => {
await runPublish(
{
response: { code: EnumStatusCode.OK },
compositionErrors: [],
compositionWarnings: [{ federatedGraphName: 'graph1', namespace: 'default', message: 'Warning 1' }],
deploymentErrors: [],
counts: {
compositionErrors: 0,
compositionWarnings: 10,
deploymentErrors: 0,
},
},
{ suppressWarnings: true },
);

const warningTableCalls = logSpy.mock.calls.filter(
([arg]) => typeof arg === 'string' && arg.includes('warnings were produced'),
);
expect(warningTableCalls).toHaveLength(0);
});
});
Loading
Loading