From e5ac2d0849d582813d61fa6ee8f98358c3b27581 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 7 Oct 2026 13:48:04 +0200 Subject: [PATCH 1/5] Add typed JSON output to app webhook trigger --- .changeset/app-webhook-trigger-json.md | 6 + .changeset/webhook-debug-secret.md | 6 + .../generated/generated_docs_data_v2.json | 38 ++- .../cli/commands/app/webhook/trigger.test.ts | 275 ++++++++++++++++++ .../src/cli/commands/app/webhook/trigger.ts | 13 +- .../src/cli/services/webhook/trigger.test.ts | 67 ++++- .../app/src/cli/services/webhook/trigger.ts | 53 ++-- .../cli/services/webhook/trigger/result.ts | 47 +++ .../services/webhook/trigger/types.test.ts | 28 ++ .../src/cli/services/webhook/trigger/types.ts | 26 ++ .../src/private/node/api/graphql.test.ts | 47 ++- .../cli-kit/src/private/node/api/graphql.ts | 2 +- packages/cli/README.md | 88 +++++- packages/cli/oclif.manifest.json | 34 ++- .../rules/json-output-command-exceptions.js | 1 - 15 files changed, 678 insertions(+), 53 deletions(-) create mode 100644 .changeset/app-webhook-trigger-json.md create mode 100644 .changeset/webhook-debug-secret.md create mode 100644 packages/app/src/cli/commands/app/webhook/trigger.test.ts create mode 100644 packages/app/src/cli/services/webhook/trigger/result.ts create mode 100644 packages/app/src/cli/services/webhook/trigger/types.test.ts create mode 100644 packages/app/src/cli/services/webhook/trigger/types.ts diff --git a/.changeset/app-webhook-trigger-json.md b/.changeset/app-webhook-trigger-json.md new file mode 100644 index 00000000000..c6557b24da3 --- /dev/null +++ b/.changeset/app-webhook-trigger-json.md @@ -0,0 +1,6 @@ +--- +'@shopify/app': minor +'@shopify/cli': minor +--- + +Add typed JSON output to `app webhook trigger`. diff --git a/.changeset/webhook-debug-secret.md b/.changeset/webhook-debug-secret.md new file mode 100644 index 00000000000..afe4a590321 --- /dev/null +++ b/.changeset/webhook-debug-secret.md @@ -0,0 +1,6 @@ +--- +'@shopify/cli-kit': patch +'@shopify/cli': patch +--- + +Hide webhook shared secrets in GraphQL request diagnostics. diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 79b22e26edf..ab88662f8cc 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -4669,6 +4669,24 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_JSON_SCHEMA" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-webhook-trigger.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--no-color", + "value": "''", + "description": "Disable color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_NO_COLOR" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-webhook-trigger.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--no-input", + "value": "''", + "description": "Disable interactive prompts and browser authentication.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_NO_INPUT" + }, { "filePath": "docs-shopify.dev/commands/interfaces/app-webhook-trigger.interface.ts", "syntaxKind": "PropertySignature", @@ -4696,6 +4714,15 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_TOPIC" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-webhook-trigger.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--verbose", + "value": "''", + "description": "Increase the verbosity of the output. May include sensitive data.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_VERBOSE" + }, { "filePath": "docs-shopify.dev/commands/interfaces/app-webhook-trigger.interface.ts", "syntaxKind": "PropertySignature", @@ -4704,9 +4731,18 @@ "description": "The name of the app configuration.", "isOptional": true, "environmentValue": "SHOPIFY_FLAG_APP_CONFIG" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-webhook-trigger.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-j, --json", + "value": "''", + "description": "Output the result as JSON. Automatically disables color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_JSON" } ], - "value": "export interface appwebhooktrigger {\n /**\n * The URL where the webhook payload should be sent.\n You will need a different address type for each delivery-method:\n · For remote HTTP testing, use a URL that starts with https://\n · For local HTTP testing, use http://localhost:{port}/{url-path}\n · For Google Pub/Sub, use pubsub://{project-id}:{topic-id}\n · For Amazon EventBridge, use an Amazon Resource Name (ARN) starting with arn:aws:events:. Required if non interactive.\n * @environment SHOPIFY_FLAG_ADDRESS\n */\n '--address '?: string\n\n /**\n * The API Version of the webhook topic. Required if non interactive.\n * @environment SHOPIFY_FLAG_API_VERSION\n */\n '--api-version '?: string\n\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * Your app's client secret. This secret allows us to return the X-Shopify-Hmac-SHA256 header that lets you validate the origin of the response that you receive.\n * @environment SHOPIFY_FLAG_CLIENT_SECRET\n */\n '--client-secret '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * Method chosen to deliver the topic payload. If not passed, it's inferred from the address.\n * @environment SHOPIFY_FLAG_DELIVERY_METHOD\n */\n '--delivery-method '?: string\n\n /**\n * This help. When you run the trigger command the CLI will prompt you for any information that isn't passed using flags.\n * @environment SHOPIFY_FLAG_HELP\n */\n '--help'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * The requested webhook topic. Required if non interactive.\n * @environment SHOPIFY_FLAG_TOPIC\n */\n '--topic '?: string\n}" + "value": "export interface appwebhooktrigger {\n /**\n * The URL where the webhook payload should be sent.\n You will need a different address type for each delivery-method:\n · For remote HTTP testing, use a URL that starts with https://\n · For local HTTP testing, use http://localhost:{port}/{url-path}\n · For Google Pub/Sub, use pubsub://{project-id}:{topic-id}\n · For Amazon EventBridge, use an Amazon Resource Name (ARN) starting with arn:aws:events:. Required if non interactive.\n * @environment SHOPIFY_FLAG_ADDRESS\n */\n '--address '?: string\n\n /**\n * The API Version of the webhook topic. Required if non interactive.\n * @environment SHOPIFY_FLAG_API_VERSION\n */\n '--api-version '?: string\n\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * Your app's client secret. This secret allows us to return the X-Shopify-Hmac-SHA256 header that lets you validate the origin of the response that you receive.\n * @environment SHOPIFY_FLAG_CLIENT_SECRET\n */\n '--client-secret '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * Method chosen to deliver the topic payload. If not passed, it's inferred from the address.\n * @environment SHOPIFY_FLAG_DELIVERY_METHOD\n */\n '--delivery-method '?: string\n\n /**\n * This help. When you run the trigger command the CLI will prompt you for any information that isn't passed using flags.\n * @environment SHOPIFY_FLAG_HELP\n */\n '--help'?: ''\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * The requested webhook topic. Required if non interactive.\n * @environment SHOPIFY_FLAG_TOPIC\n */\n '--topic '?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, "authlogin": { diff --git a/packages/app/src/cli/commands/app/webhook/trigger.test.ts b/packages/app/src/cli/commands/app/webhook/trigger.test.ts new file mode 100644 index 00000000000..3dcef2be783 --- /dev/null +++ b/packages/app/src/cli/commands/app/webhook/trigger.test.ts @@ -0,0 +1,275 @@ +import WebhookTrigger from './trigger.js' +import {linkedAppContext} from '../../../services/app-context.js' +import {requestApiVersions} from '../../../services/webhook/request-api-versions.js' +import {requestTopics} from '../../../services/webhook/request-topics.js' +import {getWebhookSample} from '../../../services/webhook/request-sample.js' +import {triggerLocalWebhook} from '../../../services/webhook/trigger-local-webhook.js' +import {appWebhookTriggerJsonOutputSchema} from '../../../services/webhook/trigger/types.js' +import { + testAppLinked, + testDeveloperPlatformClient, + testOrganization, + testOrganizationApp, + testProject, +} from '../../../models/app/app.test-data.js' +import {topicPrompt} from '../../../prompts/webhook/trigger.js' +import {Config} from '@oclif/core' +import {afterEach, beforeEach, expect, test, vi} from 'vitest' +import {AbortError, handler} from '@shopify/cli-kit/node/error' +import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events' +import {unstyled} from '@shopify/cli-kit/node/output' +import {terminalSupportsPrompting} from '@shopify/cli-kit/node/system' +import {mockAndCaptureOutput, withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' + +vi.mock('../../../services/app-context.js') +vi.mock('../../../services/webhook/request-api-versions.js') +vi.mock('../../../services/webhook/request-topics.js') +vi.mock('../../../services/webhook/request-sample.js') +vi.mock('../../../services/webhook/trigger-local-webhook.js') +vi.mock('@shopify/cli-kit/node/system', async (importOriginal) => { + const actual = await importOriginal() + return {...actual, terminalSupportsPrompting: vi.fn(actual.terminalSupportsPrompting)} +}) +vi.mock('../../../prompts/webhook/trigger.js', async (importOriginal) => { + const actual = await importOriginal() + return {...actual, topicPrompt: vi.fn()} +}) + +const app = testAppLinked() +const secret = 'PRIVATE_CLIENT_SECRET' +const sample = {success: true, samplePayload: '{}', headers: '{}', userErrors: []} +const flags = ['--api-version', '2026-10', '--address', 'https://example.com/webhooks', '--client-secret', secret] + +beforeEach(() => { + vi.mocked(linkedAppContext).mockResolvedValue({ + app, + remoteApp: testOrganizationApp(), + developerPlatformClient: testDeveloperPlatformClient(), + organization: testOrganization(), + specifications: [], + project: testProject(), + activeConfig: {} as never, + }) + vi.mocked(requestApiVersions).mockResolvedValue(['2026-10']) + vi.mocked(requestTopics).mockResolvedValue(['orders/create']) + vi.mocked(getWebhookSample).mockResolvedValue(sample) +}) + +afterEach(() => { + mockAndCaptureOutput().clear() + vi.unstubAllEnvs() +}) + +async function runCommand(argv: string[]) { + const command = new WebhookTrigger(argv, await Config.load()) + return runWithCommandEventsForCommand(argv, () => command.run()) +} + +test.each(['http', 'google-pub-sub', 'event-bridge'])( + 'writes one JSON delivery result without request credentials: %s', + async (deliveryMethod) => { + const address = { + http: 'https://example.com/webhooks', + 'google-pub-sub': 'pubsub://project:topic', + 'event-bridge': 'arn:aws:events:us-east-1::event-source/aws.partner/shopify.com/12/source', + }[deliveryMethod]! + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await expect( + runCommand([ + '--json', + '--no-input', + '--topic', + 'orders/create', + '--api-version', + '2026-10', + '--address', + address, + '--delivery-method', + deliveryMethod, + '--client-secret', + secret, + '--client-id', + 'client-id', + ]), + ).resolves.toEqual({app}) + expect(JSON.parse(stdout())).toEqual({ + status: 'success', + delivery: {topic: 'orders/create', apiVersion: '2026-10', deliveryMethod, address, status: 'enqueued'}, + }) + expect(stdout()).not.toContain(secret) + expect(stdout()).not.toContain('headers') + expect(stderr()).toBe('') + }) + }, +) + +test('writes a confirmed localhost delivery without payload or headers', async () => { + vi.mocked(getWebhookSample).mockResolvedValue({ + ...sample, + samplePayload: '{"private":"payload"}', + headers: '{"authorization":"PRIVATE_HEADER"}', + }) + vi.mocked(triggerLocalWebhook).mockResolvedValue(true) + const address = 'http://localhost:3000/webhooks' + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runCommand([ + '--json', + '--topic', + 'orders/create', + '--api-version', + '2026-10', + '--address', + address, + '--client-secret', + secret, + ]) + expect(JSON.parse(stdout())).toEqual({ + status: 'success', + delivery: { + topic: 'orders/create', + apiVersion: '2026-10', + deliveryMethod: 'localhost', + address, + status: 'delivered', + }, + }) + expect(stdout()).not.toContain('payload') + expect(stdout()).not.toContain('PRIVATE_HEADER') + expect(stderr()).toBe('') + }) +}) + +test.each([ + {sampleFailure: true, expected: 'Webhook sample request failed.'}, + {sampleFailure: false, expected: 'Localhost delivery failed'}, +])('uses the shared fatal envelope for a known delivery failure: $expected', async ({sampleFailure, expected}) => { + vi.stubEnv('SHOPIFY_FLAG_JSON', '1') + const userErrors = [{message: 'Invalid topic', fields: ['topic']}] + if (sampleFailure) vi.mocked(getWebhookSample).mockResolvedValue({...sample, success: false, userErrors}) + else vi.mocked(triggerLocalWebhook).mockResolvedValue(false) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + try { + await runCommand([ + '--json', + '--topic', + 'orders/create', + '--api-version', + '2026-10', + '--address', + 'http://localhost:3000/webhooks', + '--client-secret', + secret, + ]) + throw new Error('Expected delivery to fail') + } catch (error) { + if (!(error instanceof AbortError)) throw error + await handler(error) + } + expect(JSON.parse(stdout())).toEqual({ + error: { + type: 'abort', + message: expected, + ...(sampleFailure ? {details: {userErrors: [{message: 'Invalid topic', fieldPath: ['topic']}]}} : {}), + }, + }) + expect(stderr()).toBe('') + expect(stdout()).not.toContain(secret) + }) +}) + +test('propagates transport failure before printing a result', async () => { + vi.mocked(getWebhookSample).mockRejectedValue(new Error('Network unavailable')) + await withCapturedStandardStreams(async ({stdout}) => { + await expect(runCommand([...flags, '--json', '--topic', 'orders/create'])).rejects.toThrow('Network unavailable') + expect(stdout()).toBe('') + }) +}) + +test.each([ + {response: sample, expected: '✅ Success! Webhook has been enqueued for delivery.\n'}, + { + response: {...sample, success: false, userErrors: [{message: '["Denied"]', fields: ['topic']}]}, + expected: 'Request errors:\n · Denied\n', + }, + { + response: {...sample, success: false, userErrors: [{message: 'Denied', fields: ['topic']}]}, + expected: 'Request errors:\n[{"message":"Denied","fields":["topic"]}]\n', + }, + {response: {...sample, samplePayload: '{"unexpected":"payload"}'}, expected: ''}, +])('keeps text output and success exit behavior: $expected', async ({response, expected}) => { + vi.mocked(getWebhookSample).mockResolvedValue(response) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await expect(runCommand([...flags, '--topic', 'orders/create'])).resolves.toEqual({app}) + expect(stdout()).toBe('') + expect(unstyled(stderr())).toBe(expected) + }) +}) + +test.each([ + {delivered: true, expected: '✅ Success! Localhost delivery sucessful.\n'}, + {delivered: false, expected: 'Localhost delivery failed\n'}, +])('keeps the localhost text message and exit behavior: $delivered', async ({delivered, expected}) => { + vi.mocked(triggerLocalWebhook).mockResolvedValue(delivered) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await expect( + runCommand([ + '--topic', + 'orders/create', + '--api-version', + '2026-10', + '--address', + 'http://localhost:3000/webhooks', + '--client-secret', + secret, + ]), + ).resolves.toEqual({app}) + expect(stdout()).toBe('') + expect(unstyled(stderr())).toBe(expected) + }) +}) + +test('JSON mode can still collect a missing topic', async () => { + vi.mocked(terminalSupportsPrompting).mockReturnValue(true) + vi.mocked(topicPrompt).mockResolvedValue('orders/create') + await withCapturedStandardStreams(async ({stdout}) => { + await runCommand([...flags, '--json']) + expect(JSON.parse(stdout()).status).toBe('success') + }) + expect(topicPrompt).toHaveBeenCalledWith(['orders/create']) +}) + +test('returns the normalized topic for an accepted remote request with a nonempty payload', async () => { + vi.mocked(getWebhookSample).mockResolvedValue({...sample, samplePayload: '{"id":1}'}) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runCommand([...flags, '--json', '--topic', 'ORDERS_CREATE']) + expect(JSON.parse(stdout()).delivery).toMatchObject({topic: 'orders/create', status: 'enqueued'}) + expect(stderr()).toBe('') + }) +}) + +test('no-input does not select JSON output', async () => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runCommand([...flags, '--no-input', '--topic', 'orders/create']) + expect(stdout()).toBe('') + expect(unstyled(stderr())).toBe('✅ Success! Webhook has been enqueued for delivery.\n') + }) +}) + +test.each([{inputFlags: ['--no-input']}, {inputFlags: ['--json', '--no-input']}])( + 'requires non-interactive inputs independently of JSON: %j', + async ({inputFlags}) => { + await withCapturedStandardStreams(async ({stdout}) => { + await expect(runCommand([...flags, ...inputFlags])).rejects.toThrow() + expect(stdout()).toBe('') + }) + expect(linkedAppContext).not.toHaveBeenCalled() + expect(topicPrompt).not.toHaveBeenCalled() + }, +) + +test('exposes the schema and JSON flag in help', () => { + expect(WebhookTrigger.jsonOutputSchema).toBe(appWebhookTriggerJsonOutputSchema) + expect(WebhookTrigger.flags).toHaveProperty('json') + expect(WebhookTrigger.descriptionForHelp()).toContain('`AppWebhookTriggerResult` schema') + expect(WebhookTrigger.descriptionForHelp()).toContain('AppWebhookDelivery') +}) diff --git a/packages/app/src/cli/commands/app/webhook/trigger.ts b/packages/app/src/cli/commands/app/webhook/trigger.ts index c69bb2532e1..050cbbbb87d 100644 --- a/packages/app/src/cli/commands/app/webhook/trigger.ts +++ b/packages/app/src/cli/commands/app/webhook/trigger.ts @@ -1,11 +1,13 @@ import {DELIVERY_METHOD} from '../../../services/webhook/trigger-flags.js' import {WebhookTriggerInput, webhookTriggerService} from '../../../services/webhook/trigger.js' +import {appWebhookTriggerJsonOutputSchema} from '../../../services/webhook/trigger/types.js' +import {renderWebhookTriggerResult} from '../../../services/webhook/trigger/result.js' import {deliveryMethodInstructionsAsString} from '../../../prompts/webhook/trigger.js' import {appFlags} from '../../../flags.js' import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js' import {linkedAppContext} from '../../../services/app-context.js' import {Flags} from '@oclif/core' -import {requiredIfNonInteractive} from '@shopify/cli-kit/node/cli' +import {globalFlags, jsonFlag, requiredIfNonInteractive} from '@shopify/cli-kit/node/cli' export default class WebhookTrigger extends AppLinkedCommand { static summary = 'Trigger delivery of a sample webhook topic payload to a designated address.' @@ -27,10 +29,16 @@ export default class WebhookTrigger extends AppLinkedCommand { - You can't use this method to validate your API webhook subscriptions. ` + static get jsonOutputSchema() { + return appWebhookTriggerJsonOutputSchema + } + static description = this.descriptionForHelp() static flags = { + ...globalFlags, ...appFlags, + ...jsonFlag, help: Flags.help({ required: false, hidden: false, @@ -103,7 +111,8 @@ export default class WebhookTrigger extends AppLinkedCommand { organizationId: appContextResult.organization.id, } - await webhookTriggerService(usedFlags) + const result = await webhookTriggerService(usedFlags) + renderWebhookTriggerResult(result, flags.json ? 'json' : 'text') return {app: appContextResult.app} } } diff --git a/packages/app/src/cli/services/webhook/trigger.test.ts b/packages/app/src/cli/services/webhook/trigger.test.ts index 24e82289b47..bdfb45dd62c 100644 --- a/packages/app/src/cli/services/webhook/trigger.test.ts +++ b/packages/app/src/cli/services/webhook/trigger.test.ts @@ -10,7 +10,6 @@ import { testOrganizationApp, } from '../../models/app/app.test-data.js' import {loadApp} from '../../models/app/loader.js' -import {outputSuccess, outputWarn} from '@shopify/cli-kit/node/output' import {describe, expect, vi, test, beforeEach} from 'vitest' const samplePayload = '{ "sampleField": "SampleValue" }' @@ -79,11 +78,11 @@ describe('webhookTriggerService', () => { vi.mocked(getWebhookSample).mockResolvedValue(response) // When - await webhookTriggerService(sampleFlags()) + const result = await webhookTriggerService(sampleFlags()) // Then expectCalls(aVersion, anOrganizationId) - expect(outputWarn).toHaveBeenCalledWith(`Request errors:\n · Some error\n · Another error`) + expect(result).toEqual({status: 'failed', reason: 'sample-request', userErrors: response.userErrors}) }) test('Safe notification in case of unexpected request errors', async () => { @@ -102,11 +101,11 @@ describe('webhookTriggerService', () => { vi.mocked(getWebhookSample).mockResolvedValue(response) // When - await webhookTriggerService(sampleFlags()) + const result = await webhookTriggerService(sampleFlags()) // Then expectCalls(aVersion, anOrganizationId) - expect(outputWarn).toHaveBeenCalledWith(`Request errors:\n${JSON.stringify(response.userErrors)}`) + expect(result).toEqual({status: 'failed', reason: 'sample-request', userErrors: response.userErrors}) }) test('notifies about real delivery being sent', async () => { @@ -123,7 +122,7 @@ describe('webhookTriggerService', () => { } // When - await webhookTriggerService(sampleFlags()) + const result = await webhookTriggerService(sampleFlags()) // Then expectCalls(aVersion, anOrganizationId) @@ -133,7 +132,20 @@ describe('webhookTriggerService', () => { anOrganizationId, ) expect(triggerLocalWebhook).toHaveBeenCalledTimes(0) - expect(outputSuccess).toHaveBeenCalledWith('Webhook has been enqueued for delivery') + expect(result).toEqual({ + status: 'success', + result: { + status: 'success', + delivery: { + topic: aTopic, + apiVersion: aVersion, + deliveryMethod: expectedSampleWebhookVariables.delivery_method, + address: expectedSampleWebhookVariables.address, + status: 'enqueued', + }, + }, + samplePayloadIsEmpty: true, + }) }) test('retrieves the api-key when missing for event-bridge', async () => { @@ -155,7 +167,8 @@ describe('webhookTriggerService', () => { } // When - await webhookTriggerService(flags) + const result = await webhookTriggerService(flags) + expect(result.status).toBe('success') }) test('notifies about real event-bridge delivery being sent', async () => { @@ -176,7 +189,7 @@ describe('webhookTriggerService', () => { } // When - await webhookTriggerService(flags) + const result = await webhookTriggerService(flags) // Then expectCalls(aVersion, anOrganizationId) @@ -185,7 +198,20 @@ describe('webhookTriggerService', () => { expectedSampleWebhookVariables, anOrganizationId, ) - expect(outputSuccess).toHaveBeenCalledWith('Webhook has been enqueued for delivery') + expect(result).toEqual({ + status: 'success', + result: { + status: 'success', + delivery: { + topic: aTopic, + apiVersion: aVersion, + deliveryMethod: expectedSampleWebhookVariables.delivery_method, + address: expectedSampleWebhookVariables.address, + status: 'enqueued', + }, + }, + samplePayloadIsEmpty: true, + }) }) describe('Localhost delivery', () => { @@ -203,7 +229,7 @@ describe('webhookTriggerService', () => { } // When - await webhookTriggerService(sampleLocalhostFlags()) + const result = await webhookTriggerService(sampleLocalhostFlags()) // Then expectCalls(aVersion, anOrganizationId) @@ -213,7 +239,20 @@ describe('webhookTriggerService', () => { anOrganizationId, ) expect(triggerLocalWebhook).toHaveBeenCalledWith(aFullLocalAddress, samplePayload, sampleHeaders) - expect(outputSuccess).toHaveBeenCalledWith('Localhost delivery sucessful') + expect(result).toEqual({ + status: 'success', + result: { + status: 'success', + delivery: { + topic: aTopic, + apiVersion: aVersion, + deliveryMethod: 'localhost', + address: aFullLocalAddress, + status: 'delivered', + }, + }, + samplePayloadIsEmpty: false, + }) }) test('shows an error if localhost is not ready', async () => { @@ -230,7 +269,7 @@ describe('webhookTriggerService', () => { } // When - await webhookTriggerService(sampleLocalhostFlags()) + const result = await webhookTriggerService(sampleLocalhostFlags()) // Then expectCalls(aVersion, anOrganizationId) @@ -240,7 +279,7 @@ describe('webhookTriggerService', () => { anOrganizationId, ) expect(triggerLocalWebhook).toHaveBeenCalledWith(aFullLocalAddress, samplePayload, sampleHeaders) - expect(outputWarn).toHaveBeenCalledWith('Localhost delivery failed') + expect(result).toEqual({status: 'failed', reason: 'localhost-delivery'}) }) }) diff --git a/packages/app/src/cli/services/webhook/trigger.ts b/packages/app/src/cli/services/webhook/trigger.ts index 45677cef1ae..d6f39439040 100644 --- a/packages/app/src/cli/services/webhook/trigger.ts +++ b/packages/app/src/cli/services/webhook/trigger.ts @@ -1,11 +1,11 @@ import {DELIVERY_METHOD} from './trigger-flags.js' -import {getWebhookSample, SendSampleWebhookVariables, UserErrors} from './request-sample.js' +import {getWebhookSample, SendSampleWebhookVariables} from './request-sample.js' import {triggerLocalWebhook} from './trigger-local-webhook.js' import {collectAddressAndMethod, collectApiVersion, collectCredentials, collectTopic} from './trigger-options.js' +import {AppWebhookTriggerResult, WebhookTriggerResult} from './trigger/types.js' import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' import {AppLinkedInterface} from '../../models/app/app.js' import {OrganizationApp} from '../../models/organization.js' -import {outputWarn, outputSuccess} from '@shopify/cli-kit/node/output' export interface WebhookTriggerInput { app: AppLinkedInterface @@ -36,14 +36,13 @@ interface WebhookTriggerOptions { /** * Orchestrates the command request by collecting params, requesting the sample, and sending it to localhost if * required. - * It outputs the result * * @param flags - Passed flags */ -export async function webhookTriggerService(input: WebhookTriggerInput) { +export async function webhookTriggerService(input: WebhookTriggerInput): Promise { const options: WebhookTriggerOptions = await validateAndCollectFlags(input) - await sendSample(options) + return sendSample(options) } async function validateAndCollectFlags(input: WebhookTriggerInput): Promise { @@ -64,7 +63,7 @@ async function validateAndCollectFlags(input: WebhookTriggerInput): Promise { const variables: SendSampleWebhookVariables = { topic: options.topic, api_version: options.apiVersion, @@ -76,38 +75,34 @@ async function sendSample(options: WebhookTriggerOptions) { const sample = await getWebhookSample(options.developerPlatformClient, variables, options.organizationId) if (!sample.success) { - outputWarn(`Request errors:\n${formatErrors(sample.userErrors)}`) - return + return {status: 'failed', reason: 'sample-request', userErrors: sample.userErrors} + } + + const delivery: AppWebhookTriggerResult['delivery'] = { + topic: options.topic, + apiVersion: options.apiVersion, + deliveryMethod: options.deliveryMethod as AppWebhookTriggerResult['delivery']['deliveryMethod'], + address: options.address, + status: 'enqueued', } if (options.deliveryMethod === DELIVERY_METHOD.LOCALHOST) { const result = await triggerLocalWebhook(options.address, sample.samplePayload, sample.headers) if (result) { - outputSuccess('Localhost delivery sucessful') - return + return { + status: 'success', + result: {status: 'success', delivery: {...delivery, status: 'delivered'}}, + samplePayloadIsEmpty: sample.samplePayload === JSON.stringify({}), + } } - outputWarn('Localhost delivery failed') - return + return {status: 'failed', reason: 'localhost-delivery'} } - if (sample.samplePayload === JSON.stringify({})) { - outputSuccess('Webhook has been enqueued for delivery') - } -} - -function formatErrors(errors: UserErrors[]): string { - try { - return errors - .map((element) => - JSON.parse(element.message) - .map((msg: string) => ` · ${msg}`) - .join('\n'), - ) - .join('\n') - // eslint-disable-next-line no-catch-all/no-catch-all - } catch (err) { - return JSON.stringify(errors) + return { + status: 'success', + result: {status: 'success', delivery}, + samplePayloadIsEmpty: sample.samplePayload === JSON.stringify({}), } } diff --git a/packages/app/src/cli/services/webhook/trigger/result.ts b/packages/app/src/cli/services/webhook/trigger/result.ts new file mode 100644 index 00000000000..2b4463fe907 --- /dev/null +++ b/packages/app/src/cli/services/webhook/trigger/result.ts @@ -0,0 +1,47 @@ +import {appWebhookTriggerJsonOutputSchema, WebhookTriggerResult} from './types.js' +import {UserErrors} from '../request-sample.js' +import {AbortError} from '@shopify/cli-kit/node/error' +import {outputResult, outputSuccess, outputWarn} from '@shopify/cli-kit/node/output' + +export function renderWebhookTriggerResult(result: WebhookTriggerResult, format: 'json' | 'text'): void { + if (result.status === 'failed') { + if (format === 'json') { + const error = new AbortError( + result.reason === 'sample-request' ? 'Webhook sample request failed.' : 'Localhost delivery failed', + ) + if (result.reason === 'sample-request') { + error.details = {userErrors: result.userErrors.map(({message, fields}) => ({message, fieldPath: fields}))} + } + throw error + } + outputWarn( + result.reason === 'sample-request' + ? `Request errors:\n${formatErrors(result.userErrors)}` + : 'Localhost delivery failed', + ) + return + } + + if (format === 'json') { + outputResult(appWebhookTriggerJsonOutputSchema.encode(result.result)) + } else if (result.result.delivery.status === 'delivered') { + outputSuccess('Localhost delivery sucessful') + } else if (result.samplePayloadIsEmpty) { + outputSuccess('Webhook has been enqueued for delivery') + } +} + +function formatErrors(errors: UserErrors[]): string { + try { + return errors + .map((element) => + JSON.parse(element.message) + .map((msg: string) => ` · ${msg}`) + .join('\n'), + ) + .join('\n') + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (err) { + return JSON.stringify(errors) + } +} diff --git a/packages/app/src/cli/services/webhook/trigger/types.test.ts b/packages/app/src/cli/services/webhook/trigger/types.test.ts new file mode 100644 index 00000000000..9d7752a3fe2 --- /dev/null +++ b/packages/app/src/cli/services/webhook/trigger/types.test.ts @@ -0,0 +1,28 @@ +import {appWebhookTriggerJsonOutputSchema} from './types.js' +import {expect, test} from 'vitest' + +const result = { + status: 'success', + delivery: { + topic: 'orders/create', + apiVersion: '2026-10', + deliveryMethod: 'http', + address: 'https://example.com/webhooks', + status: 'enqueued', + }, +} as const + +test('encodes a strict delivery result', () => { + expect(JSON.parse(appWebhookTriggerJsonOutputSchema.encode(result))).toEqual(result) +}) + +test.each([ + {...result, status: 'failed'}, + {...result, clientSecret: 'private'}, + {...result, delivery: {...result.delivery, headers: {authorization: 'private'}}}, + {...result, delivery: {...result.delivery, status: 'received'}}, + {...result, delivery: {...result.delivery, deliveryMethod: 'unknown'}}, + {...result, delivery: {...result.delivery, apiVersion: null}}, +])('rejects an invalid delivery result: %j', (input) => { + expect(() => appWebhookTriggerJsonOutputSchema.validate(input)).toThrow() +}) diff --git a/packages/app/src/cli/services/webhook/trigger/types.ts b/packages/app/src/cli/services/webhook/trigger/types.ts new file mode 100644 index 00000000000..cb786f9cc29 --- /dev/null +++ b/packages/app/src/cli/services/webhook/trigger/types.ts @@ -0,0 +1,26 @@ +import {UserErrors} from '../request-sample.js' +import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' + +const deliverySchema = zod + .object({ + topic: zod.string(), + apiVersion: zod.string(), + deliveryMethod: zod.enum(['localhost', 'http', 'google-pub-sub', 'event-bridge']), + address: zod.string(), + status: zod.enum(['delivered', 'enqueued']).describe('Remote delivery is enqueued, not confirmed received.'), + }) + .strict() + +export const appWebhookTriggerJsonOutputSchema = defineJsonOutputSchema({ + name: 'AppWebhookTriggerResult', + schema: zod.object({status: zod.literal('success'), delivery: deliverySchema}).strict(), + definitions: {AppWebhookDelivery: deliverySchema}, +}) + +export type AppWebhookTriggerResult = InferJsonOutputSchema + +export type WebhookTriggerResult = + | {status: 'success'; result: AppWebhookTriggerResult; samplePayloadIsEmpty: boolean} + | {status: 'failed'; reason: 'sample-request'; userErrors: UserErrors[]} + | {status: 'failed'; reason: 'localhost-delivery'} diff --git a/packages/cli-kit/src/private/node/api/graphql.test.ts b/packages/cli-kit/src/private/node/api/graphql.test.ts index 406f16243dd..6789ae82961 100644 --- a/packages/cli-kit/src/private/node/api/graphql.test.ts +++ b/packages/cli-kit/src/private/node/api/graphql.test.ts @@ -1,8 +1,51 @@ -import {extractGraphQLErrorMessages, errorHandler} from './graphql.js' +import {debugLogRequestInfo, extractGraphQLErrorMessages, errorHandler, sanitizeVariables} from './graphql.js' import {GraphQLClientError} from './headers.js' import {AbortError} from '../../../public/node/error.js' +import {runWithCommandEventsForCommand} from '../../../public/node/command-events.js' +import {withCapturedStandardStreams} from '../../../public/node/testing/output.js' +import * as localContext from '../../../public/node/context/local.js' import {ClientError} from 'graphql-request' -import {describe, expect, test} from 'vitest' +import {describe, expect, test, vi} from 'vitest' + +test('masks webhook secrets in the diagnostic copy without changing request variables', async () => { + const verbose = vi.spyOn(localContext, 'isVerbose').mockReturnValue(true) + const variables = { + sharedSecret: 'PRIVATE_CURRENT_SECRET', + shared_secret: 'PRIVATE_LEGACY_SECRET', + topic: 'orders/create', + } + expect(JSON.parse(sanitizeVariables(variables))).toEqual({ + sharedSecret: '*****', + shared_secret: '*****', + topic: 'orders/create', + }) + try { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runWithCommandEventsForCommand(['--json', '--verbose'], async () => { + debugLogRequestInfo( + 'Webhooks', + 'mutation CliTesting { cliTesting { success } }', + 'https://example.com/graphql', + variables, + ) + }) + expect(stdout()).toBe('') + const event = JSON.parse(stderr()) + expect(event).toMatchObject({type: 'diagnostic', level: 'debug'}) + expect(event.message).toContain('"sharedSecret": "*****"') + expect(event.message).toContain('"shared_secret": "*****"') + expect(stderr()).not.toContain('PRIVATE_CURRENT_SECRET') + expect(stderr()).not.toContain('PRIVATE_LEGACY_SECRET') + }) + } finally { + verbose.mockRestore() + } + expect(variables).toEqual({ + sharedSecret: 'PRIVATE_CURRENT_SECRET', + shared_secret: 'PRIVATE_LEGACY_SECRET', + topic: 'orders/create', + }) +}) describe('extractGraphQLErrorMessages', () => { test('returns undefined for undefined errors', () => { diff --git a/packages/cli-kit/src/private/node/api/graphql.ts b/packages/cli-kit/src/private/node/api/graphql.ts index 40d272258df..9d99517f271 100644 --- a/packages/cli-kit/src/private/node/api/graphql.ts +++ b/packages/cli-kit/src/private/node/api/graphql.ts @@ -22,7 +22,7 @@ to ${sanitizeURL(url)}`) export function sanitizeVariables(variables: Variables): string { const result: Variables = {...variables} - const sensitiveKeys = ['apiKey', 'serialized_script'] + const sensitiveKeys = ['apiKey', 'serialized_script', 'sharedSecret', 'shared_secret'] const sanitizedResult = sanitizeDeepVariables(result, sensitiveKeys) diff --git a/packages/cli/README.md b/packages/cli/README.md index 95b19a8e8b7..b6c2d941568 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -4496,14 +4496,18 @@ Trigger delivery of a sample webhook topic payload to a designated address. ``` USAGE $ shopify app webhook trigger [--address ] [--api-version ] [--auth-alias ] [--client-id | - -c ] [--client-secret ] [--delivery-method http|google-pub-sub|event-bridge] [--help] [--json-schema] - [--path ] [--reset | ] [--topic ] + -c ] [--client-secret ] [--delivery-method http|google-pub-sub|event-bridge] [--help] [-j] + [--json-schema] [--no-color] [--no-input] [--path ] [--reset | ] [--topic ] [--verbose] FLAGS -c, --config= The name of the app configuration. [env: SHOPIFY_FLAG_APP_CONFIG] + -j, --json + Output the result as JSON. Automatically disables color output. + [env: SHOPIFY_FLAG_JSON] + --address= The URL where the webhook payload should be sent. You will need a different address type for each delivery-method: @@ -4545,6 +4549,14 @@ FLAGS Print the command's JSON schemas. [env: SHOPIFY_FLAG_JSON_SCHEMA] + --no-color + Disable color output. + [env: SHOPIFY_FLAG_NO_COLOR] + + --no-input + Disable interactive prompts and browser authentication. + [env: SHOPIFY_FLAG_NO_INPUT] + --path= The path to your app directory. [env: SHOPIFY_FLAG_PATH] @@ -4557,6 +4569,10 @@ FLAGS The requested webhook topic. Required if non interactive. [env: SHOPIFY_FLAG_TOPIC] + --verbose + Increase the verbosity of the output. May include sensitive data. + [env: SHOPIFY_FLAG_VERBOSE] + DESCRIPTION Trigger delivery of a sample webhook topic payload to a designated address. @@ -4582,6 +4598,74 @@ DESCRIPTION - Trigger requests are rate-limited using the "Partner API rate limit" (https://shopify.dev/docs/api/partner#rate_limits). - You can't use this method to validate your API webhook subscriptions. + + + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `AppWebhookTriggerResult` schema. + + ```json + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "success" + }, + "delivery": { + "$ref": "#/definitions/AppWebhookDelivery" + } + }, + "required": [ + "status", + "delivery" + ], + "additionalProperties": false, + "title": "AppWebhookTriggerResult", + "definitions": { + "AppWebhookDelivery": { + "type": "object", + "properties": { + "topic": { + "type": "string" + }, + "apiVersion": { + "type": "string" + }, + "deliveryMethod": { + "type": "string", + "enum": [ + "localhost", + "http", + "google-pub-sub", + "event-bridge" + ] + }, + "address": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "delivered", + "enqueued" + ], + "description": "Remote delivery is enqueued, not confirmed received." + } + }, + "required": [ + "topic", + "apiVersion", + "deliveryMethod", + "address", + "status" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` ``` ## `shopify auth login` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 31e82b9ad49..06392dfa42d 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -5385,7 +5385,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "\n Triggers the delivery of a sample Admin API event topic payload to a designated address.\n\n You should use this command to experiment with webhooks, to initially test your webhook configuration, or for unit testing. However, to test your webhook configuration from end to end, you should always trigger webhooks by performing the related action in Shopify.\n\n Because most webhook deliveries use remote endpoints, you can trigger the command from any directory where you can use Shopify CLI, and send the webhook to any of the supported endpoint types. For example, you can run the command from your app's local directory, but send the webhook to a staging environment endpoint.\n\n To learn more about using webhooks in a Shopify app, refer to \"Webhooks overview\" (https://shopify.dev/docs/apps/webhooks).\n\n ### Limitations\n\n - Webhooks triggered using this method always have the same payload, so they can't be used to test scenarios that differ based on the payload contents.\n - Webhooks triggered using this method aren't retried when they fail.\n - Trigger requests are rate-limited using the \"Partner API rate limit\" (https://shopify.dev/docs/api/partner#rate_limits).\n - You can't use this method to validate your API webhook subscriptions.\n ", + "description": "\n Triggers the delivery of a sample Admin API event topic payload to a designated address.\n\n You should use this command to experiment with webhooks, to initially test your webhook configuration, or for unit testing. However, to test your webhook configuration from end to end, you should always trigger webhooks by performing the related action in Shopify.\n\n Because most webhook deliveries use remote endpoints, you can trigger the command from any directory where you can use Shopify CLI, and send the webhook to any of the supported endpoint types. For example, you can run the command from your app's local directory, but send the webhook to a staging environment endpoint.\n\n To learn more about using webhooks in a Shopify app, refer to \"Webhooks overview\" (https://shopify.dev/docs/apps/webhooks).\n\n ### Limitations\n\n - Webhooks triggered using this method always have the same payload, so they can't be used to test scenarios that differ based on the payload contents.\n - Webhooks triggered using this method aren't retried when they fail.\n - Trigger requests are rate-limited using the \"Partner API rate limit\" (https://shopify.dev/docs/api/partner#rate_limits).\n - You can't use this method to validate your API webhook subscriptions.\n \n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `AppWebhookTriggerResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"delivery\": {\n \"$ref\": \"#/definitions/AppWebhookDelivery\"\n }\n },\n \"required\": [\n \"status\",\n \"delivery\"\n ],\n \"additionalProperties\": false,\n \"title\": \"AppWebhookTriggerResult\",\n \"definitions\": {\n \"AppWebhookDelivery\": {\n \"type\": \"object\",\n \"properties\": {\n \"topic\": {\n \"type\": \"string\"\n },\n \"apiVersion\": {\n \"type\": \"string\"\n },\n \"deliveryMethod\": {\n \"type\": \"string\",\n \"enum\": [\n \"localhost\",\n \"http\",\n \"google-pub-sub\",\n \"event-bridge\"\n ]\n },\n \"address\": {\n \"type\": \"string\"\n },\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\n \"delivered\",\n \"enqueued\"\n ],\n \"description\": \"Remote delivery is enqueued, not confirmed received.\"\n }\n },\n \"required\": [\n \"topic\",\n \"apiVersion\",\n \"deliveryMethod\",\n \"address\",\n \"status\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "\n Triggers the delivery of a sample Admin API event topic payload to a designated address.\n\n You should use this command to experiment with webhooks, to initially test your webhook configuration, or for unit testing. However, to test your webhook configuration from end to end, you should always trigger webhooks by performing the related action in Shopify.\n\n Because most webhook deliveries use remote endpoints, you can trigger the command from any directory where you can use Shopify CLI, and send the webhook to any of the supported endpoint types. For example, you can run the command from your app's local directory, but send the webhook to a staging environment endpoint.\n\n To learn more about using webhooks in a Shopify app, refer to [Webhooks overview](https://shopify.dev/docs/apps/webhooks).\n\n ### Limitations\n\n - Webhooks triggered using this method always have the same payload, so they can't be used to test scenarios that differ based on the payload contents.\n - Webhooks triggered using this method aren't retried when they fail.\n - Trigger requests are rate-limited using the [Partner API rate limit](https://shopify.dev/docs/api/partner#rate_limits).\n - You can't use this method to validate your API webhook subscriptions.\n ", "flags": { "address": { @@ -5472,6 +5472,15 @@ "required": false, "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.", @@ -5479,6 +5488,21 @@ "name": "json-schema", "type": "boolean" }, + "no-color": { + "allowNo": false, + "description": "Disable color output.", + "env": "SHOPIFY_FLAG_NO_COLOR", + "hidden": false, + "name": "no-color", + "type": "boolean" + }, + "no-input": { + "allowNo": false, + "description": "Disable interactive prompts and browser authentication.", + "env": "SHOPIFY_FLAG_NO_INPUT", + "name": "no-input", + "type": "boolean" + }, "path": { "description": "The path to your app directory.", "env": "SHOPIFY_FLAG_PATH", @@ -5508,6 +5532,14 @@ "name": "topic", "required": false, "type": "option" + }, + "verbose": { + "allowNo": false, + "description": "Increase the verbosity of the output. May include sensitive data.", + "env": "SHOPIFY_FLAG_VERBOSE", + "hidden": false, + "name": "verbose", + "type": "boolean" } }, "hasDynamicHelp": false, diff --git a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js index 0fcd20c5ff1..07f4491a28d 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -28,7 +28,6 @@ const commandExceptions = [ 'packages/app/src/cli/commands/app/subscription-migrations/schedule.ts', 'packages/app/src/cli/commands/app/subscription-migrations/status.ts', 'packages/app/src/cli/commands/app/subscription-migrations/unschedule.ts', - 'packages/app/src/cli/commands/app/webhook/trigger.ts', 'packages/cli/src/cli/commands/upgrade.ts', 'packages/plugin-did-you-mean/src/commands/config/autocorrect/off.ts', 'packages/plugin-did-you-mean/src/commands/config/autocorrect/on.ts', From 3b930400bb5e846be914173f278eca66911f1cf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 7 Oct 2026 14:40:02 +0200 Subject: [PATCH 2/5] Focus webhook JSON tests on changed output --- .../cli/commands/app/webhook/trigger.test.ts | 270 ++++-------------- .../src/cli/services/webhook/trigger.test.ts | 48 ++-- .../services/webhook/trigger/types.test.ts | 28 -- .../src/private/node/api/graphql.test.ts | 39 +-- 4 files changed, 78 insertions(+), 307 deletions(-) delete mode 100644 packages/app/src/cli/services/webhook/trigger/types.test.ts diff --git a/packages/app/src/cli/commands/app/webhook/trigger.test.ts b/packages/app/src/cli/commands/app/webhook/trigger.test.ts index 3dcef2be783..48cb03b0c5d 100644 --- a/packages/app/src/cli/commands/app/webhook/trigger.test.ts +++ b/packages/app/src/cli/commands/app/webhook/trigger.test.ts @@ -1,10 +1,7 @@ import WebhookTrigger from './trigger.js' import {linkedAppContext} from '../../../services/app-context.js' -import {requestApiVersions} from '../../../services/webhook/request-api-versions.js' -import {requestTopics} from '../../../services/webhook/request-topics.js' -import {getWebhookSample} from '../../../services/webhook/request-sample.js' -import {triggerLocalWebhook} from '../../../services/webhook/trigger-local-webhook.js' -import {appWebhookTriggerJsonOutputSchema} from '../../../services/webhook/trigger/types.js' +import {webhookTriggerService} from '../../../services/webhook/trigger.js' +import {WebhookTriggerResult} from '../../../services/webhook/trigger/types.js' import { testAppLinked, testDeveloperPlatformClient, @@ -12,33 +9,31 @@ import { testOrganizationApp, testProject, } from '../../../models/app/app.test-data.js' -import {topicPrompt} from '../../../prompts/webhook/trigger.js' import {Config} from '@oclif/core' import {afterEach, beforeEach, expect, test, vi} from 'vitest' -import {AbortError, handler} from '@shopify/cli-kit/node/error' +import {handler} from '@shopify/cli-kit/node/error' import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events' -import {unstyled} from '@shopify/cli-kit/node/output' -import {terminalSupportsPrompting} from '@shopify/cli-kit/node/system' -import {mockAndCaptureOutput, withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {outputInfo} from '@shopify/cli-kit/node/output' +import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' vi.mock('../../../services/app-context.js') -vi.mock('../../../services/webhook/request-api-versions.js') -vi.mock('../../../services/webhook/request-topics.js') -vi.mock('../../../services/webhook/request-sample.js') -vi.mock('../../../services/webhook/trigger-local-webhook.js') -vi.mock('@shopify/cli-kit/node/system', async (importOriginal) => { - const actual = await importOriginal() - return {...actual, terminalSupportsPrompting: vi.fn(actual.terminalSupportsPrompting)} -}) -vi.mock('../../../prompts/webhook/trigger.js', async (importOriginal) => { - const actual = await importOriginal() - return {...actual, topicPrompt: vi.fn()} -}) +vi.mock('../../../services/webhook/trigger.js') const app = testAppLinked() -const secret = 'PRIVATE_CLIENT_SECRET' -const sample = {success: true, samplePayload: '{}', headers: '{}', userErrors: []} -const flags = ['--api-version', '2026-10', '--address', 'https://example.com/webhooks', '--client-secret', secret] +const result: WebhookTriggerResult = { + status: 'success', + result: { + status: 'success', + delivery: { + topic: 'orders/create', + apiVersion: '2026-10', + deliveryMethod: 'http', + address: 'https://example.com/webhooks', + status: 'enqueued', + }, + }, + samplePayloadIsEmpty: true, +} beforeEach(() => { vi.mocked(linkedAppContext).mockResolvedValue({ @@ -50,226 +45,61 @@ beforeEach(() => { project: testProject(), activeConfig: {} as never, }) - vi.mocked(requestApiVersions).mockResolvedValue(['2026-10']) - vi.mocked(requestTopics).mockResolvedValue(['orders/create']) - vi.mocked(getWebhookSample).mockResolvedValue(sample) -}) - -afterEach(() => { - mockAndCaptureOutput().clear() - vi.unstubAllEnvs() }) -async function runCommand(argv: string[]) { +afterEach(() => vi.unstubAllEnvs()) + +async function runCommand() { + const argv = [ + '--json', + '--topic', + 'orders/create', + '--api-version', + '2026-10', + '--address', + 'https://example.com/webhooks', + ] const command = new WebhookTrigger(argv, await Config.load()) return runWithCommandEventsForCommand(argv, () => command.run()) } -test.each(['http', 'google-pub-sub', 'event-bridge'])( - 'writes one JSON delivery result without request credentials: %s', - async (deliveryMethod) => { - const address = { - http: 'https://example.com/webhooks', - 'google-pub-sub': 'pubsub://project:topic', - 'event-bridge': 'arn:aws:events:us-east-1::event-source/aws.partner/shopify.com/12/source', - }[deliveryMethod]! - await withCapturedStandardStreams(async ({stdout, stderr}) => { - await expect( - runCommand([ - '--json', - '--no-input', - '--topic', - 'orders/create', - '--api-version', - '2026-10', - '--address', - address, - '--delivery-method', - deliveryMethod, - '--client-secret', - secret, - '--client-id', - 'client-id', - ]), - ).resolves.toEqual({app}) - expect(JSON.parse(stdout())).toEqual({ - status: 'success', - delivery: {topic: 'orders/create', apiVersion: '2026-10', deliveryMethod, address, status: 'enqueued'}, - }) - expect(stdout()).not.toContain(secret) - expect(stdout()).not.toContain('headers') - expect(stderr()).toBe('') - }) - }, -) - -test('writes a confirmed localhost delivery without payload or headers', async () => { - vi.mocked(getWebhookSample).mockResolvedValue({ - ...sample, - samplePayload: '{"private":"payload"}', - headers: '{"authorization":"PRIVATE_HEADER"}', +test('writes one public JSON result with diagnostics on stderr', async () => { + vi.mocked(webhookTriggerService).mockImplementation(async () => { + outputInfo('Sending webhook sample.') + return result }) - vi.mocked(triggerLocalWebhook).mockResolvedValue(true) - const address = 'http://localhost:3000/webhooks' await withCapturedStandardStreams(async ({stdout, stderr}) => { - await runCommand([ - '--json', - '--topic', - 'orders/create', - '--api-version', - '2026-10', - '--address', - address, - '--client-secret', - secret, - ]) + await runCommand() expect(JSON.parse(stdout())).toEqual({ status: 'success', delivery: { topic: 'orders/create', apiVersion: '2026-10', - deliveryMethod: 'localhost', - address, - status: 'delivered', + deliveryMethod: 'http', + address: 'https://example.com/webhooks', + status: 'enqueued', }, }) - expect(stdout()).not.toContain('payload') - expect(stdout()).not.toContain('PRIVATE_HEADER') - expect(stderr()).toBe('') + expect(JSON.parse(stderr())).toMatchObject({type: 'diagnostic', message: 'Sending webhook sample.'}) }) }) -test.each([ - {sampleFailure: true, expected: 'Webhook sample request failed.'}, - {sampleFailure: false, expected: 'Localhost delivery failed'}, -])('uses the shared fatal envelope for a known delivery failure: $expected', async ({sampleFailure, expected}) => { +test('writes one fatal JSON document when the sample request fails', async () => { vi.stubEnv('SHOPIFY_FLAG_JSON', '1') - const userErrors = [{message: 'Invalid topic', fields: ['topic']}] - if (sampleFailure) vi.mocked(getWebhookSample).mockResolvedValue({...sample, success: false, userErrors}) - else vi.mocked(triggerLocalWebhook).mockResolvedValue(false) + vi.mocked(webhookTriggerService).mockResolvedValue({ + status: 'failed', + reason: 'sample-request', + userErrors: [{message: 'Invalid topic', fields: ['topic']}], + }) await withCapturedStandardStreams(async ({stdout, stderr}) => { - try { - await runCommand([ - '--json', - '--topic', - 'orders/create', - '--api-version', - '2026-10', - '--address', - 'http://localhost:3000/webhooks', - '--client-secret', - secret, - ]) - throw new Error('Expected delivery to fail') - } catch (error) { - if (!(error instanceof AbortError)) throw error - await handler(error) - } + await runCommand().catch(handler) expect(JSON.parse(stdout())).toEqual({ error: { type: 'abort', - message: expected, - ...(sampleFailure ? {details: {userErrors: [{message: 'Invalid topic', fieldPath: ['topic']}]}} : {}), + message: 'Webhook sample request failed.', + details: {userErrors: [{message: 'Invalid topic', fieldPath: ['topic']}]}, }, }) expect(stderr()).toBe('') - expect(stdout()).not.toContain(secret) }) }) - -test('propagates transport failure before printing a result', async () => { - vi.mocked(getWebhookSample).mockRejectedValue(new Error('Network unavailable')) - await withCapturedStandardStreams(async ({stdout}) => { - await expect(runCommand([...flags, '--json', '--topic', 'orders/create'])).rejects.toThrow('Network unavailable') - expect(stdout()).toBe('') - }) -}) - -test.each([ - {response: sample, expected: '✅ Success! Webhook has been enqueued for delivery.\n'}, - { - response: {...sample, success: false, userErrors: [{message: '["Denied"]', fields: ['topic']}]}, - expected: 'Request errors:\n · Denied\n', - }, - { - response: {...sample, success: false, userErrors: [{message: 'Denied', fields: ['topic']}]}, - expected: 'Request errors:\n[{"message":"Denied","fields":["topic"]}]\n', - }, - {response: {...sample, samplePayload: '{"unexpected":"payload"}'}, expected: ''}, -])('keeps text output and success exit behavior: $expected', async ({response, expected}) => { - vi.mocked(getWebhookSample).mockResolvedValue(response) - await withCapturedStandardStreams(async ({stdout, stderr}) => { - await expect(runCommand([...flags, '--topic', 'orders/create'])).resolves.toEqual({app}) - expect(stdout()).toBe('') - expect(unstyled(stderr())).toBe(expected) - }) -}) - -test.each([ - {delivered: true, expected: '✅ Success! Localhost delivery sucessful.\n'}, - {delivered: false, expected: 'Localhost delivery failed\n'}, -])('keeps the localhost text message and exit behavior: $delivered', async ({delivered, expected}) => { - vi.mocked(triggerLocalWebhook).mockResolvedValue(delivered) - await withCapturedStandardStreams(async ({stdout, stderr}) => { - await expect( - runCommand([ - '--topic', - 'orders/create', - '--api-version', - '2026-10', - '--address', - 'http://localhost:3000/webhooks', - '--client-secret', - secret, - ]), - ).resolves.toEqual({app}) - expect(stdout()).toBe('') - expect(unstyled(stderr())).toBe(expected) - }) -}) - -test('JSON mode can still collect a missing topic', async () => { - vi.mocked(terminalSupportsPrompting).mockReturnValue(true) - vi.mocked(topicPrompt).mockResolvedValue('orders/create') - await withCapturedStandardStreams(async ({stdout}) => { - await runCommand([...flags, '--json']) - expect(JSON.parse(stdout()).status).toBe('success') - }) - expect(topicPrompt).toHaveBeenCalledWith(['orders/create']) -}) - -test('returns the normalized topic for an accepted remote request with a nonempty payload', async () => { - vi.mocked(getWebhookSample).mockResolvedValue({...sample, samplePayload: '{"id":1}'}) - await withCapturedStandardStreams(async ({stdout, stderr}) => { - await runCommand([...flags, '--json', '--topic', 'ORDERS_CREATE']) - expect(JSON.parse(stdout()).delivery).toMatchObject({topic: 'orders/create', status: 'enqueued'}) - expect(stderr()).toBe('') - }) -}) - -test('no-input does not select JSON output', async () => { - await withCapturedStandardStreams(async ({stdout, stderr}) => { - await runCommand([...flags, '--no-input', '--topic', 'orders/create']) - expect(stdout()).toBe('') - expect(unstyled(stderr())).toBe('✅ Success! Webhook has been enqueued for delivery.\n') - }) -}) - -test.each([{inputFlags: ['--no-input']}, {inputFlags: ['--json', '--no-input']}])( - 'requires non-interactive inputs independently of JSON: %j', - async ({inputFlags}) => { - await withCapturedStandardStreams(async ({stdout}) => { - await expect(runCommand([...flags, ...inputFlags])).rejects.toThrow() - expect(stdout()).toBe('') - }) - expect(linkedAppContext).not.toHaveBeenCalled() - expect(topicPrompt).not.toHaveBeenCalled() - }, -) - -test('exposes the schema and JSON flag in help', () => { - expect(WebhookTrigger.jsonOutputSchema).toBe(appWebhookTriggerJsonOutputSchema) - expect(WebhookTrigger.flags).toHaveProperty('json') - expect(WebhookTrigger.descriptionForHelp()).toContain('`AppWebhookTriggerResult` schema') - expect(WebhookTrigger.descriptionForHelp()).toContain('AppWebhookDelivery') -}) diff --git a/packages/app/src/cli/services/webhook/trigger.test.ts b/packages/app/src/cli/services/webhook/trigger.test.ts index bdfb45dd62c..48016bcbc21 100644 --- a/packages/app/src/cli/services/webhook/trigger.test.ts +++ b/packages/app/src/cli/services/webhook/trigger.test.ts @@ -3,6 +3,7 @@ import {SendSampleWebhookVariables, getWebhookSample} from './request-sample.js' import {requestApiVersions} from './request-api-versions.js' import {requestTopics} from './request-topics.js' import {triggerLocalWebhook} from './trigger-local-webhook.js' +import {renderWebhookTriggerResult} from './trigger/result.js' import { testApp, testAppLinked, @@ -10,6 +11,7 @@ import { testOrganizationApp, } from '../../models/app/app.test-data.js' import {loadApp} from '../../models/app/loader.js' +import {outputSuccess, outputWarn} from '@shopify/cli-kit/node/output' import {describe, expect, vi, test, beforeEach} from 'vitest' const samplePayload = '{ "sampleField": "SampleValue" }' @@ -82,7 +84,8 @@ describe('webhookTriggerService', () => { // Then expectCalls(aVersion, anOrganizationId) - expect(result).toEqual({status: 'failed', reason: 'sample-request', userErrors: response.userErrors}) + renderWebhookTriggerResult(result, 'text') + expect(outputWarn).toHaveBeenCalledWith(`Request errors:\n · Some error\n · Another error`) }) test('Safe notification in case of unexpected request errors', async () => { @@ -105,7 +108,8 @@ describe('webhookTriggerService', () => { // Then expectCalls(aVersion, anOrganizationId) - expect(result).toEqual({status: 'failed', reason: 'sample-request', userErrors: response.userErrors}) + renderWebhookTriggerResult(result, 'text') + expect(outputWarn).toHaveBeenCalledWith(`Request errors:\n${JSON.stringify(response.userErrors)}`) }) test('notifies about real delivery being sent', async () => { @@ -146,6 +150,8 @@ describe('webhookTriggerService', () => { }, samplePayloadIsEmpty: true, }) + renderWebhookTriggerResult(result, 'text') + expect(outputSuccess).toHaveBeenCalledWith('Webhook has been enqueued for delivery') }) test('retrieves the api-key when missing for event-bridge', async () => { @@ -167,8 +173,7 @@ describe('webhookTriggerService', () => { } // When - const result = await webhookTriggerService(flags) - expect(result.status).toBe('success') + await webhookTriggerService(flags) }) test('notifies about real event-bridge delivery being sent', async () => { @@ -198,20 +203,8 @@ describe('webhookTriggerService', () => { expectedSampleWebhookVariables, anOrganizationId, ) - expect(result).toEqual({ - status: 'success', - result: { - status: 'success', - delivery: { - topic: aTopic, - apiVersion: aVersion, - deliveryMethod: expectedSampleWebhookVariables.delivery_method, - address: expectedSampleWebhookVariables.address, - status: 'enqueued', - }, - }, - samplePayloadIsEmpty: true, - }) + renderWebhookTriggerResult(result, 'text') + expect(outputSuccess).toHaveBeenCalledWith('Webhook has been enqueued for delivery') }) describe('Localhost delivery', () => { @@ -239,20 +232,9 @@ describe('webhookTriggerService', () => { anOrganizationId, ) expect(triggerLocalWebhook).toHaveBeenCalledWith(aFullLocalAddress, samplePayload, sampleHeaders) - expect(result).toEqual({ - status: 'success', - result: { - status: 'success', - delivery: { - topic: aTopic, - apiVersion: aVersion, - deliveryMethod: 'localhost', - address: aFullLocalAddress, - status: 'delivered', - }, - }, - samplePayloadIsEmpty: false, - }) + expect(result).toMatchObject({status: 'success', result: {delivery: {status: 'delivered'}}}) + renderWebhookTriggerResult(result, 'text') + expect(outputSuccess).toHaveBeenCalledWith('Localhost delivery sucessful') }) test('shows an error if localhost is not ready', async () => { @@ -280,6 +262,8 @@ describe('webhookTriggerService', () => { ) expect(triggerLocalWebhook).toHaveBeenCalledWith(aFullLocalAddress, samplePayload, sampleHeaders) expect(result).toEqual({status: 'failed', reason: 'localhost-delivery'}) + renderWebhookTriggerResult(result, 'text') + expect(outputWarn).toHaveBeenCalledWith('Localhost delivery failed') }) }) diff --git a/packages/app/src/cli/services/webhook/trigger/types.test.ts b/packages/app/src/cli/services/webhook/trigger/types.test.ts deleted file mode 100644 index 9d7752a3fe2..00000000000 --- a/packages/app/src/cli/services/webhook/trigger/types.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import {appWebhookTriggerJsonOutputSchema} from './types.js' -import {expect, test} from 'vitest' - -const result = { - status: 'success', - delivery: { - topic: 'orders/create', - apiVersion: '2026-10', - deliveryMethod: 'http', - address: 'https://example.com/webhooks', - status: 'enqueued', - }, -} as const - -test('encodes a strict delivery result', () => { - expect(JSON.parse(appWebhookTriggerJsonOutputSchema.encode(result))).toEqual(result) -}) - -test.each([ - {...result, status: 'failed'}, - {...result, clientSecret: 'private'}, - {...result, delivery: {...result.delivery, headers: {authorization: 'private'}}}, - {...result, delivery: {...result.delivery, status: 'received'}}, - {...result, delivery: {...result.delivery, deliveryMethod: 'unknown'}}, - {...result, delivery: {...result.delivery, apiVersion: null}}, -])('rejects an invalid delivery result: %j', (input) => { - expect(() => appWebhookTriggerJsonOutputSchema.validate(input)).toThrow() -}) diff --git a/packages/cli-kit/src/private/node/api/graphql.test.ts b/packages/cli-kit/src/private/node/api/graphql.test.ts index 6789ae82961..b886b08eba9 100644 --- a/packages/cli-kit/src/private/node/api/graphql.test.ts +++ b/packages/cli-kit/src/private/node/api/graphql.test.ts @@ -1,4 +1,4 @@ -import {debugLogRequestInfo, extractGraphQLErrorMessages, errorHandler, sanitizeVariables} from './graphql.js' +import {debugLogRequestInfo, extractGraphQLErrorMessages, errorHandler} from './graphql.js' import {GraphQLClientError} from './headers.js' import {AbortError} from '../../../public/node/error.js' import {runWithCommandEventsForCommand} from '../../../public/node/command-events.js' @@ -7,44 +7,29 @@ import * as localContext from '../../../public/node/context/local.js' import {ClientError} from 'graphql-request' import {describe, expect, test, vi} from 'vitest' -test('masks webhook secrets in the diagnostic copy without changing request variables', async () => { +test('masks webhook secrets in diagnostics without changing request variables', async () => { const verbose = vi.spyOn(localContext, 'isVerbose').mockReturnValue(true) - const variables = { - sharedSecret: 'PRIVATE_CURRENT_SECRET', - shared_secret: 'PRIVATE_LEGACY_SECRET', - topic: 'orders/create', - } - expect(JSON.parse(sanitizeVariables(variables))).toEqual({ - sharedSecret: '*****', - shared_secret: '*****', - topic: 'orders/create', - }) + const variables = {sharedSecret: 'CURRENT_SECRET', shared_secret: 'LEGACY_SECRET'} try { await withCapturedStandardStreams(async ({stdout, stderr}) => { - await runWithCommandEventsForCommand(['--json', '--verbose'], async () => { + await runWithCommandEventsForCommand(['--json'], () => debugLogRequestInfo( 'Webhooks', - 'mutation CliTesting { cliTesting { success } }', + 'mutation { cliTesting { success } }', 'https://example.com/graphql', variables, - ) - }) + ), + ) expect(stdout()).toBe('') - const event = JSON.parse(stderr()) - expect(event).toMatchObject({type: 'diagnostic', level: 'debug'}) - expect(event.message).toContain('"sharedSecret": "*****"') - expect(event.message).toContain('"shared_secret": "*****"') - expect(stderr()).not.toContain('PRIVATE_CURRENT_SECRET') - expect(stderr()).not.toContain('PRIVATE_LEGACY_SECRET') + expect(JSON.parse(stderr())).toMatchObject({type: 'diagnostic', level: 'debug'}) + expect(stderr()).toContain('*****') + expect(stderr()).not.toContain('CURRENT_SECRET') + expect(stderr()).not.toContain('LEGACY_SECRET') }) } finally { verbose.mockRestore() } - expect(variables).toEqual({ - sharedSecret: 'PRIVATE_CURRENT_SECRET', - shared_secret: 'PRIVATE_LEGACY_SECRET', - topic: 'orders/create', - }) + expect(variables).toEqual({sharedSecret: 'CURRENT_SECRET', shared_secret: 'LEGACY_SECRET'}) }) describe('extractGraphQLErrorMessages', () => { From 1e7cb88b86105b5f32eab0646879e7f66e95a103 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 7 Oct 2026 15:18:20 +0200 Subject: [PATCH 3/5] Simplify webhook delivery result data --- .../cli/commands/app/webhook/trigger.test.ts | 15 ++++++--------- .../src/cli/services/webhook/trigger.test.ts | 17 +++++++---------- .../app/src/cli/services/webhook/trigger.ts | 4 ++-- .../src/cli/services/webhook/trigger/result.ts | 4 ++-- .../src/cli/services/webhook/trigger/types.ts | 2 +- 5 files changed, 18 insertions(+), 24 deletions(-) diff --git a/packages/app/src/cli/commands/app/webhook/trigger.test.ts b/packages/app/src/cli/commands/app/webhook/trigger.test.ts index 48cb03b0c5d..5a59dc0ebf7 100644 --- a/packages/app/src/cli/commands/app/webhook/trigger.test.ts +++ b/packages/app/src/cli/commands/app/webhook/trigger.test.ts @@ -22,15 +22,12 @@ vi.mock('../../../services/webhook/trigger.js') const app = testAppLinked() const result: WebhookTriggerResult = { status: 'success', - result: { - status: 'success', - delivery: { - topic: 'orders/create', - apiVersion: '2026-10', - deliveryMethod: 'http', - address: 'https://example.com/webhooks', - status: 'enqueued', - }, + delivery: { + topic: 'orders/create', + apiVersion: '2026-10', + deliveryMethod: 'http', + address: 'https://example.com/webhooks', + status: 'enqueued', }, samplePayloadIsEmpty: true, } diff --git a/packages/app/src/cli/services/webhook/trigger.test.ts b/packages/app/src/cli/services/webhook/trigger.test.ts index 48016bcbc21..cf8c47e3682 100644 --- a/packages/app/src/cli/services/webhook/trigger.test.ts +++ b/packages/app/src/cli/services/webhook/trigger.test.ts @@ -138,15 +138,12 @@ describe('webhookTriggerService', () => { expect(triggerLocalWebhook).toHaveBeenCalledTimes(0) expect(result).toEqual({ status: 'success', - result: { - status: 'success', - delivery: { - topic: aTopic, - apiVersion: aVersion, - deliveryMethod: expectedSampleWebhookVariables.delivery_method, - address: expectedSampleWebhookVariables.address, - status: 'enqueued', - }, + delivery: { + topic: aTopic, + apiVersion: aVersion, + deliveryMethod: expectedSampleWebhookVariables.delivery_method, + address: expectedSampleWebhookVariables.address, + status: 'enqueued', }, samplePayloadIsEmpty: true, }) @@ -232,7 +229,7 @@ describe('webhookTriggerService', () => { anOrganizationId, ) expect(triggerLocalWebhook).toHaveBeenCalledWith(aFullLocalAddress, samplePayload, sampleHeaders) - expect(result).toMatchObject({status: 'success', result: {delivery: {status: 'delivered'}}}) + expect(result).toMatchObject({status: 'success', delivery: {status: 'delivered'}}) renderWebhookTriggerResult(result, 'text') expect(outputSuccess).toHaveBeenCalledWith('Localhost delivery sucessful') }) diff --git a/packages/app/src/cli/services/webhook/trigger.ts b/packages/app/src/cli/services/webhook/trigger.ts index d6f39439040..d8691d45d84 100644 --- a/packages/app/src/cli/services/webhook/trigger.ts +++ b/packages/app/src/cli/services/webhook/trigger.ts @@ -92,7 +92,7 @@ async function sendSample(options: WebhookTriggerOptions): Promise export type WebhookTriggerResult = - | {status: 'success'; result: AppWebhookTriggerResult; samplePayloadIsEmpty: boolean} + | (AppWebhookTriggerResult & {samplePayloadIsEmpty: boolean}) | {status: 'failed'; reason: 'sample-request'; userErrors: UserErrors[]} | {status: 'failed'; reason: 'localhost-delivery'} From e2be24cca0db935df946afb2f5fd617b2ffca96b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 7 Oct 2026 15:58:10 +0200 Subject: [PATCH 4/5] Use a static webhook JSON schema field --- bin/prettify-manifests.js | 7 ++++++- packages/app/src/cli/commands/app/webhook/trigger.ts | 4 +--- packages/eslint-plugin-cli/rules/command-json-output.js | 4 +++- .../eslint-plugin-cli/rules/command-json-output.test.js | 4 +--- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/bin/prettify-manifests.js b/bin/prettify-manifests.js index 2363b227819..5ce134edb21 100755 --- a/bin/prettify-manifests.js +++ b/bin/prettify-manifests.js @@ -13,6 +13,11 @@ const manifestFiles = glob.sync(`packages/*/oclif.manifest.json`) for (const file of manifestFiles) { console.log(`Prettifying ${file}...`) const content = fs.readFileSync(file) - const prettyContent = stringify(JSON.parse(content), {space: ' '}).replaceAll(root, '.') + const manifest = JSON.parse(content) + for (const command of Object.values(manifest.commands)) { + // Runtime schemas belong to command classes, not cached metadata. + delete command.jsonOutputSchema + } + const prettyContent = stringify(manifest, {space: ' '}).replaceAll(root, '.') fs.writeFileSync(file, prettyContent) } diff --git a/packages/app/src/cli/commands/app/webhook/trigger.ts b/packages/app/src/cli/commands/app/webhook/trigger.ts index 050cbbbb87d..3d21435d9ed 100644 --- a/packages/app/src/cli/commands/app/webhook/trigger.ts +++ b/packages/app/src/cli/commands/app/webhook/trigger.ts @@ -29,9 +29,7 @@ export default class WebhookTrigger extends AppLinkedCommand { - You can't use this method to validate your API webhook subscriptions. ` - static get jsonOutputSchema() { - return appWebhookTriggerJsonOutputSchema - } + static jsonOutputSchema = appWebhookTriggerJsonOutputSchema static description = this.descriptionForHelp() diff --git a/packages/eslint-plugin-cli/rules/command-json-output.js b/packages/eslint-plugin-cli/rules/command-json-output.js index a6493344283..4d41adab57a 100644 --- a/packages/eslint-plugin-cli/rules/command-json-output.js +++ b/packages/eslint-plugin-cli/rules/command-json-output.js @@ -62,7 +62,9 @@ function repositoryPath(filename) { function hasJsonOutputSchema(classMembers) { return classMembers.some( (member) => - member.type === 'MethodDefinition' && member.kind === 'get' && isStaticMemberNamed(member, 'jsonOutputSchema'), + isStaticMemberNamed(member, 'jsonOutputSchema') && + ((member.type === 'MethodDefinition' && member.kind === 'get') || + (member.type === 'PropertyDefinition' && Boolean(member.value))), ) } diff --git a/packages/eslint-plugin-cli/rules/command-json-output.test.js b/packages/eslint-plugin-cli/rules/command-json-output.test.js index 5eaa3d46144..b095246b579 100644 --- a/packages/eslint-plugin-cli/rules/command-json-output.test.js +++ b/packages/eslint-plugin-cli/rules/command-json-output.test.js @@ -37,9 +37,7 @@ ruleTester.run('command-json-output', rule, { code: ` export default class WidgetDelete extends Command { static flags = {...globalFlags, ...jsonFlag} - static get jsonOutputSchema() { - return widgetDeleteJsonOutputSchema - } + static jsonOutputSchema = widgetDeleteJsonOutputSchema } `, }, From 638753200c4b3769284855940e9d9dd17448e73f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Thu, 8 Oct 2026 17:51:23 +0200 Subject: [PATCH 5/5] Keep webhook JSON schema wiring and tests focused --- bin/prettify-manifests.js | 7 +-- .../cli/commands/app/webhook/trigger.test.ts | 61 +++++++++---------- .../src/cli/commands/app/webhook/trigger.ts | 4 +- .../services/webhook/trigger/types.test.ts | 19 ++++++ .../rules/command-json-output.js | 4 +- .../rules/command-json-output.test.js | 4 +- 6 files changed, 56 insertions(+), 43 deletions(-) create mode 100644 packages/app/src/cli/services/webhook/trigger/types.test.ts diff --git a/bin/prettify-manifests.js b/bin/prettify-manifests.js index 5ce134edb21..2363b227819 100755 --- a/bin/prettify-manifests.js +++ b/bin/prettify-manifests.js @@ -13,11 +13,6 @@ const manifestFiles = glob.sync(`packages/*/oclif.manifest.json`) for (const file of manifestFiles) { console.log(`Prettifying ${file}...`) const content = fs.readFileSync(file) - const manifest = JSON.parse(content) - for (const command of Object.values(manifest.commands)) { - // Runtime schemas belong to command classes, not cached metadata. - delete command.jsonOutputSchema - } - const prettyContent = stringify(manifest, {space: ' '}).replaceAll(root, '.') + const prettyContent = stringify(JSON.parse(content), {space: ' '}).replaceAll(root, '.') fs.writeFileSync(file, prettyContent) } diff --git a/packages/app/src/cli/commands/app/webhook/trigger.test.ts b/packages/app/src/cli/commands/app/webhook/trigger.test.ts index 5a59dc0ebf7..948f3415868 100644 --- a/packages/app/src/cli/commands/app/webhook/trigger.test.ts +++ b/packages/app/src/cli/commands/app/webhook/trigger.test.ts @@ -46,9 +46,9 @@ beforeEach(() => { afterEach(() => vi.unstubAllEnvs()) -async function runCommand() { +async function runCommand(flags: string[] = ['--json']) { const argv = [ - '--json', + ...flags, '--topic', 'orders/create', '--api-version', @@ -60,43 +60,40 @@ async function runCommand() { return runWithCommandEventsForCommand(argv, () => command.run()) } -test('writes one public JSON result with diagnostics on stderr', async () => { +test.each([['--json'], ['--json', '--no-input']])('writes one JSON result with flags %j', async (...flags) => { vi.mocked(webhookTriggerService).mockImplementation(async () => { outputInfo('Sending webhook sample.') return result }) await withCapturedStandardStreams(async ({stdout, stderr}) => { - await runCommand() - expect(JSON.parse(stdout())).toEqual({ - status: 'success', - delivery: { - topic: 'orders/create', - apiVersion: '2026-10', - deliveryMethod: 'http', - address: 'https://example.com/webhooks', - status: 'enqueued', - }, - }) + await runCommand(flags) + expect(JSON.parse(stdout())).toEqual({status: 'success', delivery: result.delivery}) expect(JSON.parse(stderr())).toMatchObject({type: 'diagnostic', message: 'Sending webhook sample.'}) }) }) -test('writes one fatal JSON document when the sample request fails', async () => { - vi.stubEnv('SHOPIFY_FLAG_JSON', '1') - vi.mocked(webhookTriggerService).mockResolvedValue({ - status: 'failed', - reason: 'sample-request', - userErrors: [{message: 'Invalid topic', fields: ['topic']}], - }) - await withCapturedStandardStreams(async ({stdout, stderr}) => { - await runCommand().catch(handler) - expect(JSON.parse(stdout())).toEqual({ - error: { - type: 'abort', - message: 'Webhook sample request failed.', - details: {userErrors: [{message: 'Invalid topic', fieldPath: ['topic']}]}, - }, +test.each([ + { + result: {status: 'failed', reason: 'sample-request', userErrors: [{message: 'Invalid topic', fields: ['topic']}]}, + error: { + type: 'abort', + message: 'Webhook sample request failed.', + details: {userErrors: [{message: 'Invalid topic', fieldPath: ['topic']}]}, + }, + }, + { + result: {status: 'failed', reason: 'localhost-delivery'}, + error: {type: 'abort', message: 'Localhost delivery failed'}, + }, +] satisfies {result: WebhookTriggerResult; error: object}[])( + 'writes one fatal JSON document for $result.reason', + async ({result, error}) => { + vi.stubEnv('SHOPIFY_FLAG_JSON', '1') + vi.mocked(webhookTriggerService).mockResolvedValue(result) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runCommand().catch(handler) + expect(JSON.parse(stdout())).toEqual({error}) + expect(stderr()).toBe('') }) - expect(stderr()).toBe('') - }) -}) + }, +) diff --git a/packages/app/src/cli/commands/app/webhook/trigger.ts b/packages/app/src/cli/commands/app/webhook/trigger.ts index 3d21435d9ed..050cbbbb87d 100644 --- a/packages/app/src/cli/commands/app/webhook/trigger.ts +++ b/packages/app/src/cli/commands/app/webhook/trigger.ts @@ -29,7 +29,9 @@ export default class WebhookTrigger extends AppLinkedCommand { - You can't use this method to validate your API webhook subscriptions. ` - static jsonOutputSchema = appWebhookTriggerJsonOutputSchema + static get jsonOutputSchema() { + return appWebhookTriggerJsonOutputSchema + } static description = this.descriptionForHelp() diff --git a/packages/app/src/cli/services/webhook/trigger/types.test.ts b/packages/app/src/cli/services/webhook/trigger/types.test.ts new file mode 100644 index 00000000000..bcc37c9dc1b --- /dev/null +++ b/packages/app/src/cli/services/webhook/trigger/types.test.ts @@ -0,0 +1,19 @@ +import {appWebhookTriggerJsonOutputSchema} from './types.js' +import {expect, test} from 'vitest' + +test('rejects extra fields and unknown delivery methods', () => { + const delivery = { + topic: 'orders/create', + apiVersion: '2026-10', + deliveryMethod: 'http', + address: 'https://example.com/webhooks', + status: 'enqueued', + } + expect(() => appWebhookTriggerJsonOutputSchema.validate({status: 'success', delivery, secret: 'private'})).toThrow() + expect(() => + appWebhookTriggerJsonOutputSchema.validate({status: 'success', delivery: {...delivery, secret: 'private'}}), + ).toThrow() + expect(() => + appWebhookTriggerJsonOutputSchema.validate({status: 'success', delivery: {...delivery, deliveryMethod: 'unknown'}}), + ).toThrow() +}) diff --git a/packages/eslint-plugin-cli/rules/command-json-output.js b/packages/eslint-plugin-cli/rules/command-json-output.js index 4d41adab57a..a6493344283 100644 --- a/packages/eslint-plugin-cli/rules/command-json-output.js +++ b/packages/eslint-plugin-cli/rules/command-json-output.js @@ -62,9 +62,7 @@ function repositoryPath(filename) { function hasJsonOutputSchema(classMembers) { return classMembers.some( (member) => - isStaticMemberNamed(member, 'jsonOutputSchema') && - ((member.type === 'MethodDefinition' && member.kind === 'get') || - (member.type === 'PropertyDefinition' && Boolean(member.value))), + member.type === 'MethodDefinition' && member.kind === 'get' && isStaticMemberNamed(member, 'jsonOutputSchema'), ) } diff --git a/packages/eslint-plugin-cli/rules/command-json-output.test.js b/packages/eslint-plugin-cli/rules/command-json-output.test.js index b095246b579..5eaa3d46144 100644 --- a/packages/eslint-plugin-cli/rules/command-json-output.test.js +++ b/packages/eslint-plugin-cli/rules/command-json-output.test.js @@ -37,7 +37,9 @@ ruleTester.run('command-json-output', rule, { code: ` export default class WidgetDelete extends Command { static flags = {...globalFlags, ...jsonFlag} - static jsonOutputSchema = widgetDeleteJsonOutputSchema + static get jsonOutputSchema() { + return widgetDeleteJsonOutputSchema + } } `, },