diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 66513bcce84..9e4fd13a312 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -6343,6 +6343,49 @@ "pluginType": "core", "strict": true }, + "kitchen-sink:json-output": { + "aliases": [ + ], + "args": { + }, + "description": "Exercise command JSON output infrastructure.\n\nOutput from `--json` conforms to the `KitchenSinkJsonOutputResult` schema.\n\nUse `--json-schema` to print the schema directly:\n\n```ts\ninterface KitchenSinkJsonOutputResult {\n items: SampleItem[]\n}\n\ninterface SampleItem {\n id: number\n name: string\n}\n```", + "descriptionWithMarkdown": "Exercise command JSON output infrastructure.", + "enableJsonFlag": false, + "flags": { + "fail": { + "allowNo": false, + "description": "Fail with a sample error.", + "env": "SHOPIFY_FLAG_FAIL", + "name": "fail", + "type": "boolean" + }, + "json": { + "allowNo": false, + "char": "j", + "description": "Output the result as JSON. Automatically disables color output.", + "env": "SHOPIFY_FLAG_JSON", + "hidden": false, + "name": "json", + "type": "boolean" + }, + "json-schema": { + "allowNo": false, + "description": "Print the command's JSON schemas.", + "env": "SHOPIFY_FLAG_JSON_SCHEMA", + "name": "json-schema", + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hidden": true, + "hiddenAliases": [ + ], + "id": "kitchen-sink:json-output", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true + }, "kitchen-sink:prompts": { "aliases": [ ], diff --git a/packages/cli/src/cli/commands/kitchen-sink/json-output.test.ts b/packages/cli/src/cli/commands/kitchen-sink/json-output.test.ts new file mode 100644 index 00000000000..1118b027b26 --- /dev/null +++ b/packages/cli/src/cli/commands/kitchen-sink/json-output.test.ts @@ -0,0 +1,45 @@ +import KitchenSinkJsonOutput from './json-output.js' +import {kitchenSinkJsonOutputSchema} from '../../services/kitchen-sink/json-output.js' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' +import {Config} from '@oclif/core' +import {afterEach, describe, expect, test} from 'vitest' +import {fileURLToPath} from 'node:url' + +afterEach(() => { + mockAndCaptureOutput().clear() +}) + +describe('kitchen-sink json-output command', () => { + test('prints the validated JSON result', async () => { + const output = mockAndCaptureOutput() + await KitchenSinkJsonOutput.run(['--json'], import.meta.url) + + const sideEvents = output + .info() + .split('\n') + .slice(0, 2) + .map((line) => JSON.parse(line) as unknown) + expect(sideEvents).toMatchObject([ + {type: 'diagnostic', level: 'info', message: 'Preparing the sample result.'}, + {type: 'progress', message: 'Prepared 1 item.', current: 1, total: 1}, + ]) + expect(output.info()).toContain(kitchenSinkJsonOutputSchema.encode({items: [{id: 1, name: 'Example'}]})) + }) + + test('prints a human-readable result', async () => { + const output = mockAndCaptureOutput() + await KitchenSinkJsonOutput.run([], import.meta.url) + + expect(output.info()).toContain('Prepared 1 item.') + }) + + test('exposes its JSON schema', () => { + expect(KitchenSinkJsonOutput.jsonOutputSchema).toBe(kitchenSinkJsonOutputSchema) + }) + + test('can exercise JSON error handling', async () => { + const config = await Config.load(fileURLToPath(import.meta.url)) + + await expect(new KitchenSinkJsonOutput(['--fail'], config).run()).rejects.toThrow('Sample command failure.') + }) +}) diff --git a/packages/cli/src/cli/commands/kitchen-sink/json-output.ts b/packages/cli/src/cli/commands/kitchen-sink/json-output.ts new file mode 100644 index 00000000000..8a19b804787 --- /dev/null +++ b/packages/cli/src/cli/commands/kitchen-sink/json-output.ts @@ -0,0 +1,42 @@ +import {createKitchenSinkJsonOutput, kitchenSinkJsonOutputSchema} from '../../services/kitchen-sink/json-output.js' +import {createCommandEventChannel} from '@shopify/cli-kit/common/command-events' +import Command from '@shopify/cli-kit/node/base-command' +import {jsonFlag} from '@shopify/cli-kit/node/cli' +import {AbortError} from '@shopify/cli-kit/node/error' +import {renderCommandEvent, renderCommandEventAsJson} from '@shopify/cli-kit/node/command-events' +import {outputResult} from '@shopify/cli-kit/node/output' +import {Flags} from '@oclif/core' + +export default class KitchenSinkJsonOutput extends Command { + static descriptionWithMarkdown = 'Exercise command JSON output infrastructure.' + static description = this.descriptionWithoutMarkdown() + static hidden = true + + static flags = { + ...jsonFlag, + fail: Flags.boolean({ + description: 'Fail with a sample error.', + env: 'SHOPIFY_FLAG_FAIL', + default: false, + }), + } + + static get jsonOutputSchema() { + return kitchenSinkJsonOutputSchema + } + + async run(): Promise { + const {flags} = await this.parse(KitchenSinkJsonOutput) + const events = createCommandEventChannel({ + sink: flags.json ? renderCommandEventAsJson : renderCommandEvent, + }) + const result = createKitchenSinkJsonOutput(events) + + if (flags.fail) { + events.emit({type: 'diagnostic', level: 'warning', message: 'Failing as requested.'}) + throw new AbortError('Sample command failure.') + } + + outputResult(flags.json ? kitchenSinkJsonOutputSchema.encode(result) : `Prepared ${result.items.length} item.`) + } +} diff --git a/packages/cli/src/cli/services/kitchen-sink/json-output.test.ts b/packages/cli/src/cli/services/kitchen-sink/json-output.test.ts new file mode 100644 index 00000000000..57ac5c40642 --- /dev/null +++ b/packages/cli/src/cli/services/kitchen-sink/json-output.test.ts @@ -0,0 +1,24 @@ +import {createKitchenSinkJsonOutput, kitchenSinkJsonOutputSchema} from './json-output.js' +import {describe, expect, test, vi} from 'vitest' +import type {CommandEventChannel} from '@shopify/cli-kit/common/command-events' + +describe('kitchen sink JSON output service', () => { + test('returns a valid result and reports side events', () => { + const emit = vi.fn() + + const result = createKitchenSinkJsonOutput({emit}) + + expect(kitchenSinkJsonOutputSchema.validate(result)).toEqual({items: [{id: 1, name: 'Example'}]}) + expect(emit).toHaveBeenNthCalledWith(1, { + type: 'diagnostic', + level: 'info', + message: 'Preparing the sample result.', + }) + expect(emit).toHaveBeenNthCalledWith(2, { + type: 'progress', + message: 'Prepared 1 item.', + current: 1, + total: 1, + }) + }) +}) diff --git a/packages/cli/src/cli/services/kitchen-sink/json-output.ts b/packages/cli/src/cli/services/kitchen-sink/json-output.ts new file mode 100644 index 00000000000..fa050b39b6c --- /dev/null +++ b/packages/cli/src/cli/services/kitchen-sink/json-output.ts @@ -0,0 +1,24 @@ +import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' +import type {CommandEventChannel} from '@shopify/cli-kit/common/command-events' + +const SampleItemSchema = zod.object({ + id: zod.number(), + name: zod.string(), +}) + +export const kitchenSinkJsonOutputSchema = defineJsonOutputSchema({ + name: 'KitchenSinkJsonOutputResult', + schema: zod.object({items: zod.array(SampleItemSchema)}), + definitions: {SampleItem: SampleItemSchema}, +}) + +type KitchenSinkJsonOutputResult = InferJsonOutputSchema + +export function createKitchenSinkJsonOutput(events: CommandEventChannel): KitchenSinkJsonOutputResult { + events.emit({type: 'diagnostic', level: 'info', message: 'Preparing the sample result.'}) + + const result = {items: [{id: 1, name: 'Example'}]} + events.emit({type: 'progress', message: 'Prepared 1 item.', current: 1, total: 1}) + return result +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index a7a087bd4ce..82c1ab2ab6e 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -6,6 +6,7 @@ import Logout from './cli/commands/auth/logout.js' import Login from './cli/commands/auth/login.js' import CommandFlags from './cli/commands/debug/command-flags.js' import KitchenSinkAsync from './cli/commands/kitchen-sink/async.js' +import KitchenSinkJsonOutput from './cli/commands/kitchen-sink/json-output.js' import KitchenSinkPrompts from './cli/commands/kitchen-sink/prompts.js' import KitchenSinkStatic from './cli/commands/kitchen-sink/static.js' import KitchenSink from './cli/commands/kitchen-sink/index.js' @@ -151,6 +152,7 @@ export const COMMANDS: any = { 'debug:command-flags': CommandFlags, 'kitchen-sink': KitchenSink, 'kitchen-sink:async': KitchenSinkAsync, + 'kitchen-sink:json-output': KitchenSinkJsonOutput, 'kitchen-sink:prompts': KitchenSinkPrompts, 'kitchen-sink:static': KitchenSinkStatic, 'doctor-release': Doctor,