diff --git a/packages/cli-kit/src/private/node/api/graphql.test.ts b/packages/cli-kit/src/private/node/api/graphql.test.ts new file mode 100644 index 00000000000..f56f86b9729 --- /dev/null +++ b/packages/cli-kit/src/private/node/api/graphql.test.ts @@ -0,0 +1,600 @@ +import { + buildObserveUrl, + errorHandler, + extractErrorMessages, + extractGraphQLErrorMeta, + findCommonPathPrefix, + parseRubyStackEntry, + stripDeploymentPrefix, +} from './graphql.js' +import {GraphQLClientError} from './headers.js' +import {AbortError} from '../../../public/node/error.js' +import {ClientError} from 'graphql-request' +import {describe, test, expect, afterEach} from 'vitest' + +describe('extractErrorMessages', () => { + test('extracts messages from a standard GraphQL error array', () => { + const errors = [{message: 'First error'}, {message: 'Second error'}] + expect(extractErrorMessages(errors)).toEqual(['First error', 'Second error']) + }) + + test('deduplicates identical messages', () => { + const errors = [{message: 'Same error'}, {message: 'Same error'}, {message: 'Different error'}] + expect(extractErrorMessages(errors)).toEqual(['Same error', 'Different error']) + }) + + test('filters out entries without a message field', () => { + const errors = [{message: 'Valid'}, {code: 'INVALID'}, {message: ''}] + expect(extractErrorMessages(errors)).toEqual(['Valid']) + }) + + test('returns empty array for non-array input', () => { + expect(extractErrorMessages(undefined)).toEqual([]) + expect(extractErrorMessages(null)).toEqual([]) + expect(extractErrorMessages('string')).toEqual([]) + }) + + test('returns empty array for empty errors', () => { + expect(extractErrorMessages([])).toEqual([]) + }) +}) + +describe('extractGraphQLErrorMeta', () => { + test('extracts request_id from error extensions', () => { + const errors = [{message: 'err', extensions: {request_id: 'abc-123'}}] + expect(extractGraphQLErrorMeta(errors).requestId).toBe('abc-123') + }) + + test('extracts exception_class from error extensions', () => { + const errors = [{message: 'err', extensions: {exception_class: 'PublicMessageError'}}] + expect(extractGraphQLErrorMeta(errors).exceptionClass).toBe('PublicMessageError') + }) + + test('preserves full source path when no stack trace is available for prefix computation', () => { + const errors = [ + { + message: 'err', + extensions: { + source: { + file: '/Users/mitch/world/trees/root/src/areas/core/shopify/static_asset_pipeline.rb', + line: 37, + }, + }, + }, + ] + const meta = extractGraphQLErrorMeta(errors) + // With only one path, no common prefix can be computed — full path preserved + const expectedPath = '/Users/mitch/world/trees/root/src/areas/core/shopify/static_asset_pipeline.rb' + expect(meta.sourceFile).toBe(expectedPath) + expect(meta.sourceLine).toBe(37) + }) + + test('strips to components/ boundary for Shopify monorepo paths', () => { + const errors = [ + { + message: 'err', + extensions: { + source: { + file: '/Users/mitch/world/trees/root/src/areas/core/shopify/components/apps/framework/app/services/pipeline.rb', + line: 37, + }, + app_stacktrace: [ + "/Users/mitch/world/trees/root/src/areas/core/shopify/components/apps/framework/app/services/pipeline.rb:37:in 'Kernel#throw'", + "/Users/mitch/world/trees/root/src/areas/core/shopify/components/apps/app/plugins/handler.rb:20:in 'Handler#process'", + ], + }, + }, + ] + const meta = extractGraphQLErrorMeta(errors) + // Paths are stripped to start from "components/" + expect(meta.sourceFile).toBe('components/apps/framework/app/services/pipeline.rb') + expect(meta.stackTrace[0]!.file).toBe('components/apps/framework/app/services/pipeline.rb') + expect(meta.stackTrace[1]!.file).toBe('components/apps/app/plugins/handler.rb') + }) + + test('falls back to common prefix stripping for non-monorepo paths', () => { + const errors = [ + { + message: 'err', + extensions: { + source: { + file: '/deploy/app/src/areas/core/services/pipeline.rb', + line: 37, + }, + app_stacktrace: [ + "/deploy/app/src/areas/core/services/pipeline.rb:37:in 'Kernel#throw'", + "/deploy/app/src/areas/plugins/asset_handler.rb:20:in 'Handler#process'", + ], + }, + }, + ] + const meta = extractGraphQLErrorMeta(errors) + // No "components/" marker — common prefix "/deploy/app/src/areas/" is stripped + expect(meta.sourceFile).toBe('core/services/pipeline.rb') + expect(meta.stackTrace[0]!.file).toBe('core/services/pipeline.rb') + expect(meta.stackTrace[1]!.file).toBe('plugins/asset_handler.rb') + }) + + test('extracts stack trace entries from app_stacktrace', () => { + const errors = [ + { + message: 'err', + extensions: { + app_stacktrace: [ + "/path/to/services/static_asset_pipeline.rb:37:in 'Kernel#throw'", + "/path/to/models/app_version.rb:42:in 'Apps::Operations::StaticAssetPipeline.perform'", + ], + }, + }, + ] + const meta = extractGraphQLErrorMeta(errors) + expect(meta.stackTrace).toHaveLength(2) + // Common prefix "/path/to/" is stripped + expect(meta.stackTrace[0]).toEqual({ + file: 'services/static_asset_pipeline.rb', + line: '37', + method: 'Kernel#throw', + }) + expect(meta.stackTrace[1]).toEqual({ + file: 'models/app_version.rb', + line: '42', + method: 'StaticAssetPipeline.perform', + }) + }) + + test('returns the first request_id found when multiple errors have one', () => { + const errors = [ + {message: 'err1', extensions: {request_id: 'first-id'}}, + {message: 'err2', extensions: {request_id: 'second-id'}}, + ] + expect(extractGraphQLErrorMeta(errors).requestId).toBe('first-id') + }) + + test('skips errors without extensions', () => { + const errors = [{message: 'no ext'}, {message: 'has ext', extensions: {request_id: 'found-id'}}] + expect(extractGraphQLErrorMeta(errors).requestId).toBe('found-id') + }) + + test('returns empty meta for non-array input', () => { + const meta = extractGraphQLErrorMeta(undefined) + expect(meta.requestId).toBeUndefined() + expect(meta.exceptionClass).toBeUndefined() + expect(meta.stackTrace).toEqual([]) + }) + + test('returns empty meta when no extensions are present', () => { + const errors = [{message: 'no extensions'}] + const meta = extractGraphQLErrorMeta(errors) + expect(meta.requestId).toBeUndefined() + expect(meta.exceptionClass).toBeUndefined() + expect(meta.sourceFile).toBeUndefined() + expect(meta.stackTrace).toEqual([]) + }) +}) + +describe('findCommonPathPrefix', () => { + test('finds the common directory prefix across multiple paths', () => { + const paths = [ + '/Users/mitch/world/trees/root/src/areas/core/services/pipeline.rb', + '/Users/mitch/world/trees/root/src/areas/plugins/handler.rb', + ] + expect(findCommonPathPrefix(paths)).toBe('/Users/mitch/world/trees/root/src/areas/') + }) + + test('works with non-trees paths (e.g. production deployments)', () => { + const paths = ['/app/src/services/pipeline.rb', '/app/src/models/version.rb'] + expect(findCommonPathPrefix(paths)).toBe('/app/src/') + }) + + test('returns empty string for fewer than 2 non-empty paths', () => { + expect(findCommonPathPrefix([])).toBe('') + expect(findCommonPathPrefix(['/path/to/file.rb'])).toBe('') + expect(findCommonPathPrefix(['', ''])).toBe('') + }) + + test('returns empty string when paths share only the leading "/"', () => { + const paths = ['/usr/local/lib/file.rb', '/app/src/file.rb'] + expect(findCommonPathPrefix(paths)).toBe('') + }) + + test('ignores empty strings in the paths array', () => { + const paths = ['', '/app/src/services/a.rb', '', '/app/src/models/b.rb'] + expect(findCommonPathPrefix(paths)).toBe('/app/src/') + }) + + test('handles paths without directory separators', () => { + const paths = ['file_a.rb', 'file_b.rb'] + expect(findCommonPathPrefix(paths)).toBe('') + }) +}) + +describe('stripDeploymentPrefix', () => { + test('strips everything before "components/" in a Shopify monorepo path', () => { + const path = + '/Users/mitch/world/trees/root/src/areas/core/shopify/components/apps/framework/app/services/pipeline.rb' + expect(stripDeploymentPrefix(path)).toBe('components/apps/framework/app/services/pipeline.rb') + }) + + test('works with any deployment root before components/', () => { + expect(stripDeploymentPrefix('/app/src/areas/core/shopify/components/apps/file.rb')).toBe('components/apps/file.rb') + expect(stripDeploymentPrefix('/home/deploy/components/gems/my_gem/lib/file.rb')).toBe( + 'components/gems/my_gem/lib/file.rb', + ) + }) + + test('returns path unchanged when no structural marker is found', () => { + expect(stripDeploymentPrefix('/app/src/services/pipeline.rb')).toBe('/app/src/services/pipeline.rb') + }) + + test('handles empty string', () => { + expect(stripDeploymentPrefix('')).toBe('') + }) +}) + +describe('parseRubyStackEntry', () => { + test('parses a standard Ruby stack trace entry preserving raw file path', () => { + const entry = "/Users/mitch/world/trees/root/src/areas/core/static_asset_pipeline.rb:37:in 'Kernel#throw'" + expect(parseRubyStackEntry(entry)).toEqual({ + file: '/Users/mitch/world/trees/root/src/areas/core/static_asset_pipeline.rb', + line: '37', + method: 'Kernel#throw', + }) + }) + + test('shortens fully-qualified Ruby method names', () => { + const entry = "/path/to/static_asset_pipeline.rb:37:in 'Apps::Operations::StaticAssetPipeline.perform'" + expect(parseRubyStackEntry(entry)).toEqual({ + file: '/path/to/static_asset_pipeline.rb', + line: '37', + method: 'StaticAssetPipeline.perform', + }) + }) + + test('handles instance methods with #', () => { + const entry = + "/path/to/static_asset_pipeline.rb:20:in 'Apps::HostedApp::Plugins::StaticAssetPipeline#app_version_transform'" + expect(parseRubyStackEntry(entry)).toEqual({ + file: '/path/to/static_asset_pipeline.rb', + line: '20', + method: 'StaticAssetPipeline#app_version_transform', + }) + }) + + test('handles "block in" methods', () => { + const entry = + "/path/to/app_version_lifecycle.rb:26:in 'block in AppModules::Systems::Plugins::Executors::AppVersionLifecycle#transform'" + expect(parseRubyStackEntry(entry)).toEqual({ + file: '/path/to/app_version_lifecycle.rb', + line: '26', + method: 'AppVersionLifecycle#transform', + }) + }) + + test('handles entries without method name', () => { + const entry = '/path/to/some_file.rb:42' + expect(parseRubyStackEntry(entry)).toEqual({ + file: '/path/to/some_file.rb', + line: '42', + method: '', + }) + }) + + test('falls back to raw entry when nothing matches', () => { + const entry = 'some random text' + expect(parseRubyStackEntry(entry)).toEqual({ + file: '', + line: undefined, + method: 'some random text', + }) + }) +}) + +function buildClientError(status: number, errors: any[]): ClientError { + const response = { + status, + headers: new Map(), + errors, + data: undefined, + } + return new ClientError(response, {query: 'query { shop { name } }'}) +} + +/** Builds a realistic 500 error matching the user's original bug report */ +function buildRealisticServerError(): ClientError { + return buildClientError(500, [ + { + message: 'uncaught throw #', + extensions: { + request_id: '57c09886-d853-485a-85a2-a556229304c2', + exception_class: 'PublicMessageError', + message: 'uncaught throw #', + source: { + file: '/Users/mitch/world/trees/root/src/areas/core/shopify/components/apps/framework/app/services/apps/operations/static_asset_pipeline.rb', + line: 37, + }, + app_stacktrace: [ + "/Users/mitch/world/trees/root/src/areas/core/shopify/components/apps/framework/app/services/apps/operations/static_asset_pipeline.rb:37:in 'Kernel#throw'", + "/Users/mitch/world/trees/root/src/areas/core/shopify/components/apps/framework/app/services/apps/operations/static_asset_pipeline.rb:37:in 'Apps::Operations::StaticAssetPipeline.perform'", + "/Users/mitch/world/trees/root/src/areas/core/shopify/components/apps/app/services/apps/hosted_app/plugins/static_asset_pipeline.rb:20:in 'Apps::HostedApp::Plugins::StaticAssetPipeline#app_version_transform'", + "/Users/mitch/world/trees/root/src/areas/core/shopify/components/apps/framework/app/models/app_modules/systems/plugins/executors/app_version_lifecycle.rb:26:in 'block in AppModules::Systems::Plugins::Executors::AppVersionLifecycle#transform'", + ], + }, + }, + ]) +} + +describe('errorHandler', () => { + const handler = errorHandler('App Management') + + describe('server errors (5xx)', () => { + test('creates an AbortError for 500 status', () => { + const result = handler(buildRealisticServerError()) + expect(result).toBeInstanceOf(AbortError) + }) + + test('includes the API name and status in the message', () => { + const result = handler(buildRealisticServerError()) as AbortError + expect(result.message).toContain('App Management') + expect(result.message).toContain('500') + }) + + test('includes the human-readable error message without raw JSON', () => { + const result = handler(buildRealisticServerError()) as AbortError + + expect(result.message).toContain('uncaught throw #') + expect(result.message).not.toContain('"extensions"') + expect(result.message).not.toContain('"app_stacktrace"') + expect(result.message).not.toContain('"exception_class"') + }) + + test('uses bold formatting for the error message text', () => { + const result = handler(buildRealisticServerError()) as AbortError + const formatted = result.formattedMessage + expect(formatted).toBeDefined() + const serialized = JSON.stringify(formatted) + expect(serialized).toContain('"bold"') + expect(serialized).toContain('uncaught throw #') + }) + + test('does not include next steps', () => { + const result = handler(buildRealisticServerError()) as AbortError + expect(result.nextSteps).toBeUndefined() + }) + + test('includes a Request ID custom section with link in production', () => { + const originalEnv = process.env.SHOPIFY_CLOUD_ENVIRONMENT + process.env.SHOPIFY_CLOUD_ENVIRONMENT = 'production' + const result = handler(buildRealisticServerError()) as AbortError + + const requestIdSection = result.customSections?.find((sec) => sec.title === 'Request ID') + expect(requestIdSection).toBeDefined() + expect(JSON.stringify(requestIdSection!.body)).toContain('57c09886-d853-485a-85a2-a556229304c2') + expect(JSON.stringify(requestIdSection!.body)).toContain('observe.shopify.io') + + if (originalEnv === undefined) { + delete process.env.SHOPIFY_CLOUD_ENVIRONMENT + } else { + process.env.SHOPIFY_CLOUD_ENVIRONMENT = originalEnv + } + }) + + test('includes a Request ID custom section with command in local development', () => { + delete process.env.SHOPIFY_CLOUD_ENVIRONMENT + const result = handler(buildRealisticServerError()) as AbortError + + const requestIdSection = result.customSections?.find((sec) => sec.title === 'Request ID') + expect(requestIdSection).toBeDefined() + const body = JSON.stringify(requestIdSection!.body) + expect(body).toContain('57c09886-d853-485a-85a2-a556229304c2') + expect(body).toContain('Search logs: rg -u 57c09886-d853-485a-85a2-a556229304c2 path/to/shopify/log') + }) + + test('includes an Exception custom section with class and stripped source path', () => { + const result = handler(buildRealisticServerError()) as AbortError + + const exceptionSection = result.customSections?.find((sec) => sec.title === 'Exception') + expect(exceptionSection).toBeDefined() + const body = JSON.stringify(exceptionSection!.body) + expect(body).toContain('PublicMessageError') + // Paths start from the "components/" structural boundary + expect(body).toContain('components/apps/framework/app/services/apps/operations/static_asset_pipeline.rb:37') + expect(body).not.toContain('/Users/mitch/world/trees/root/') + }) + + test('includes a Stack trace custom section with stripped file paths', () => { + const result = handler(buildRealisticServerError()) as AbortError + + const stackSection = result.customSections?.find((sec) => sec.title === 'Stack trace') + expect(stackSection).toBeDefined() + const body = JSON.stringify(stackSection!.body) + // Method names should be shortened (no full namespace) + expect(body).toContain('Kernel#throw') + expect(body).toContain('StaticAssetPipeline.perform') + // Paths start from the "components/" structural boundary + expect(body).toContain('components/apps/framework/app/services/apps/operations/static_asset_pipeline.rb:37') + expect(body).not.toContain('/Users/mitch/world/trees/root/') + }) + + test('uses the passed-in requestId as fallback when extensions lack one', () => { + const errors = [{message: 'err'}] + const result = handler(buildClientError(500, errors), 'fallback-request-id') as AbortError + + const requestIdSection = result.customSections?.find((sec) => sec.title === 'Request ID') + expect(requestIdSection).toBeDefined() + expect(JSON.stringify(requestIdSection!.body)).toContain('fallback-request-id') + }) + + test('prefers request_id from extensions over the passed-in requestId', () => { + const errors = [{message: 'err', extensions: {request_id: 'from-extensions'}}] + const result = handler(buildClientError(500, errors), 'from-header') as AbortError + + const requestIdSection = result.customSections?.find((sec) => sec.title === 'Request ID') + expect(JSON.stringify(requestIdSection!.body)).toContain('from-extensions') + expect(JSON.stringify(requestIdSection!.body)).not.toContain('from-header') + }) + + test('does not show custom sections when no metadata is available', () => { + const errors = [{message: 'bare error'}] + const result = handler(buildClientError(500, errors)) as AbortError + + expect(result.customSections).toEqual([]) + }) + + test('truncates long stack traces with a count of remaining entries', () => { + const stacktrace = Array.from({length: 20}, (_, idx) => `/path/to/file.rb:${idx + 1}:in 'Method${idx}'`) + const errors = [{message: 'err', extensions: {app_stacktrace: stacktrace}}] + const result = handler(buildClientError(500, errors)) as AbortError + + const stackSection = result.customSections?.find((sec) => sec.title === 'Stack trace') + expect(stackSection).toBeDefined() + const body = JSON.stringify(stackSection!.body) + // Should show 8 entries max and indicate remaining + expect(body).toContain('12 more') + }) + }) + + describe('client errors (4xx and 200 with errors)', () => { + test('creates a GraphQLClientError for status < 500', () => { + const errors = [{message: 'Bad request'}] + const result = handler(buildClientError(400, errors)) + expect(result).toBeInstanceOf(GraphQLClientError) + }) + + test('creates a GraphQLClientError for status 200 with errors', () => { + const errors = [{message: 'Validation failed'}] + const result = handler(buildClientError(200, errors)) + expect(result).toBeInstanceOf(GraphQLClientError) + }) + + test('includes a clean error message without raw JSON', () => { + const errors = [{message: 'Field is invalid'}] + const result = handler(buildClientError(400, errors)) as GraphQLClientError + + expect(result.message).toContain('Field is invalid') + expect(result.message).toContain('App Management') + expect(result.message).not.toContain('[') + expect(result.message).not.toContain('{') + }) + + test('includes bullet points for multiple errors', () => { + const errors = [{message: 'Error one'}, {message: 'Error two'}] + const result = handler(buildClientError(400, errors)) as GraphQLClientError + + expect(result.message).toContain('• Error one') + expect(result.message).toContain('• Error two') + }) + + test('includes status code for non-200 statuses', () => { + const errors = [{message: 'Unauthorized'}] + const result = handler(buildClientError(401, errors)) as GraphQLClientError + expect(result.message).toContain('(401)') + }) + + test('omits status code for 200 with errors', () => { + const errors = [{message: 'Something went wrong'}] + const result = handler(buildClientError(200, errors)) as GraphQLClientError + expect(result.message).not.toContain('(200)') + }) + + test('enriches client errors with custom sections when extensions are present', () => { + const originalEnv = process.env.SHOPIFY_CLOUD_ENVIRONMENT + process.env.SHOPIFY_CLOUD_ENVIRONMENT = 'production' + const errors = [{message: 'err', extensions: {request_id: 'client-req-id'}}] + const result = handler(buildClientError(400, errors)) as GraphQLClientError + + const requestIdSection = result.customSections?.find((sec) => sec.title === 'Request ID') + expect(requestIdSection).toBeDefined() + expect(JSON.stringify(requestIdSection!.body)).toContain('client-req-id') + + if (originalEnv === undefined) { + delete process.env.SHOPIFY_CLOUD_ENVIRONMENT + } else { + process.env.SHOPIFY_CLOUD_ENVIRONMENT = originalEnv + } + }) + + test('preserves the original errors array', () => { + const errors = [{message: 'err', extensions: {code: 'VALIDATION'}}] + const result = handler(buildClientError(400, errors)) as GraphQLClientError + expect(result.errors).toEqual(errors) + }) + }) + + test('returns non-ClientError errors unchanged', () => { + const error = new Error('some other error') + const result = handler(error) + expect(result).toBe(error) + }) +}) + +describe('buildObserveUrl', () => { + const originalEnv = process.env.SHOPIFY_CLOUD_ENVIRONMENT + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.SHOPIFY_CLOUD_ENVIRONMENT + } else { + process.env.SHOPIFY_CLOUD_ENVIRONMENT = originalEnv + } + }) + + test('returns production URL when SHOPIFY_CLOUD_ENVIRONMENT is production', () => { + process.env.SHOPIFY_CLOUD_ENVIRONMENT = 'production' + expect(buildObserveUrl('abc-123')).toBe('https://observe.shopify.io/a/observe/investigate/query?request_id=abc-123') + }) + + test('returns undefined when SHOPIFY_CLOUD_ENVIRONMENT is not production', () => { + process.env.SHOPIFY_CLOUD_ENVIRONMENT = 'development' + expect(buildObserveUrl('abc-123')).toBeUndefined() + }) + + test('returns undefined when SHOPIFY_CLOUD_ENVIRONMENT is not set', () => { + delete process.env.SHOPIFY_CLOUD_ENVIRONMENT + expect(buildObserveUrl('xyz-456')).toBeUndefined() + }) + + test('properly encodes request ID in URL', () => { + process.env.SHOPIFY_CLOUD_ENVIRONMENT = 'production' + const requestId = '57c09886-d853-485a-85a2-a556229304c2' + const url = buildObserveUrl(requestId) + expect(url).toBe(`https://observe.shopify.io/a/observe/investigate/query?request_id=${requestId}`) + }) +}) + +describe('errorHandler with multiple API types', () => { + test('works with Partners API errors', () => { + const handler = errorHandler('Partners') + const errors = [{message: 'Organization not found'}] + const result = handler(buildClientError(404, errors)) as GraphQLClientError + + expect(result.message).toContain('Partners') + expect(result.message).toContain('Organization not found') + }) + + test('works with Admin API errors', () => { + const handler = errorHandler('Admin') + const errors = [{message: 'Shop not found'}] + const result = handler(buildClientError(404, errors)) as GraphQLClientError + + expect(result.message).toContain('Admin') + expect(result.message).toContain('Shop not found') + }) + + test('works with generic API names', () => { + const handler = errorHandler('My Custom API') + const errors = [{message: 'Resource not found'}] + const result = handler(buildClientError(404, errors)) as GraphQLClientError + + expect(result.message).toContain('My Custom API') + expect(result.message).toContain('Resource not found') + }) + + test('server error messages include API name', () => { + const handler = errorHandler('Partners') + const errors = [{message: 'Internal server error'}] + const result = handler(buildClientError(500, errors)) as AbortError + + expect(result.message).toContain('Partners') + expect(result.message).toContain('500') + }) +}) diff --git a/packages/cli-kit/src/private/node/api/graphql.ts b/packages/cli-kit/src/private/node/api/graphql.ts index 9a0136db2c6..43edfe19fde 100644 --- a/packages/cli-kit/src/private/node/api/graphql.ts +++ b/packages/cli-kit/src/private/node/api/graphql.ts @@ -1,8 +1,10 @@ import {GraphQLClientError, sanitizedHeadersOutput} from './headers.js' import {sanitizeURL} from './urls.js' -import {stringifyMessage, outputContent, outputToken, outputDebug} from '../../../public/node/output.js' +import {outputContent, outputToken, outputDebug} from '../../../public/node/output.js' import {AbortError} from '../../../public/node/error.js' import {ClientError, Variables} from 'graphql-request' +import type {CustomSection} from '../ui/components/Alert.js' +import type {Token, TokenItem, InlineToken} from '../ui/components/TokenizedText.js' export function debugLogRequestInfo( api: string, @@ -65,27 +67,347 @@ function sanitizeDeepVariables(value: unknown, sensitiveKeys: string[]): unknown return result } +// Maximum number of stack trace entries to show before truncating +const MAX_STACK_ENTRIES = 8 + +interface ParsedStackEntry { + method: string + file: string + line: string | undefined +} + +interface GraphQLErrorMeta { + requestId: string | undefined + exceptionClass: string | undefined + sourceFile: string | undefined + sourceLine: number | undefined + stackTrace: ParsedStackEntry[] +} + +/** + * Extracts human-readable error messages from a GraphQL error response. + * Filters out empty strings and deduplicates messages. + */ +export function extractErrorMessages(errors: unknown): string[] { + if (!Array.isArray(errors)) return [] + const seen = new Set() + return errors + .map((err) => (typeof err === 'object' && err !== null && 'message' in err ? String(err.message) : undefined)) + .filter((msg): msg is string => { + if (typeof msg !== 'string' || msg.length === 0 || seen.has(msg)) return false + seen.add(msg) + return true + }) +} + +/** + * Extracts structured metadata from all GraphQL error extensions. + * Pulls request ID, exception class, source location, and the server stack trace. + */ +export function extractGraphQLErrorMeta(errors: unknown): GraphQLErrorMeta { + const meta: GraphQLErrorMeta = { + requestId: undefined, + exceptionClass: undefined, + sourceFile: undefined, + sourceLine: undefined, + stackTrace: [], + } + if (!Array.isArray(errors)) return meta + + for (const err of errors) { + if (typeof err !== 'object' || err === null || !('extensions' in err)) continue + const ext = (err as {extensions: {[key: string]: unknown}}).extensions + + if (!meta.requestId && typeof ext.request_id === 'string' && ext.request_id.length > 0) { + meta.requestId = ext.request_id + } + + if (!meta.exceptionClass && typeof ext.exception_class === 'string' && ext.exception_class.length > 0) { + meta.exceptionClass = ext.exception_class + } + + if (!meta.sourceFile && typeof ext.source === 'object' && ext.source !== null) { + const source = ext.source as {[key: string]: unknown} + if (typeof source.file === 'string') { + meta.sourceFile = source.file + } + if (typeof source.line === 'number') { + meta.sourceLine = source.line + } + } + + if (meta.stackTrace.length === 0 && Array.isArray(ext.app_stacktrace)) { + meta.stackTrace = ext.app_stacktrace + .filter((entry): entry is string => typeof entry === 'string') + .map(parseRubyStackEntry) + } + } + + // Strip deployment-specific prefixes from file paths for cleaner display. + // Step 1: Strip to known structural boundaries (e.g. "components/" in Shopify's monorepo). + // This works regardless of deployment environment because the marker is part of the code + // structure, not the deployment path. + if (meta.sourceFile) { + meta.sourceFile = stripDeploymentPrefix(meta.sourceFile) + } + meta.stackTrace = meta.stackTrace.map((entry) => ({ + ...entry, + file: entry.file.length > 0 ? stripDeploymentPrefix(entry.file) : entry.file, + })) + + // Step 2: For any remaining absolute paths (marker not found), strip their common + // directory prefix as a fallback. This handles non-monorepo paths gracefully. + const absolutePaths = [meta.sourceFile, ...meta.stackTrace.map((entry) => entry.file)].filter( + (path): path is string => typeof path === 'string' && path.startsWith('/'), + ) + const prefix = findCommonPathPrefix(absolutePaths) + if (prefix.length > 0) { + if (meta.sourceFile?.startsWith(prefix)) { + meta.sourceFile = meta.sourceFile.slice(prefix.length) + } + meta.stackTrace = meta.stackTrace.map((entry) => ({ + ...entry, + file: entry.file.startsWith(prefix) ? entry.file.slice(prefix.length) : entry.file, + })) + } + + return meta +} + +/** + * Parses a Ruby stack trace entry into its component parts. + * Handles formats like: "/path/to/file.rb:37:in 'Module::Class#method'" + * Preserves the full file path for detailed debugging. + */ +export function parseRubyStackEntry(entry: string): ParsedStackEntry { + // Full Ruby format: /path/to/file.rb:line:in 'Namespace::Class#method' + const rubyMatch = entry.match(/(.+\.\w+):(\d+):in\s+'([^']+)'/) + if (rubyMatch) { + return { + file: rubyMatch[1] ?? '', + line: rubyMatch[2], + method: shortenRubyMethod(rubyMatch[3] ?? ''), + } + } + // Simpler format: /path/to/file.ext:line + const simpleMatch = entry.match(/(.+\.\w+):(\d+)/) + if (simpleMatch) { + return {file: simpleMatch[1] ?? '', line: simpleMatch[2], method: ''} + } + // Fallback: use the trimmed entry as the method name + return {method: entry.trim(), file: '', line: undefined} +} + +/** + * Strips deployment and organizational prefixes from a server-side file path, + * leaving just the meaningful code location starting from a known structural boundary. + * + * In Shopify's monorepo, code lives under "components/" — everything before that + * is deployment root + area/team organization that varies between environments. + * Returns the path unchanged if no structural marker is found. + */ +export function stripDeploymentPrefix(filePath: string): string { + const markerIndex = filePath.indexOf('components/') + if (markerIndex >= 0) return filePath.slice(markerIndex) + return filePath +} + +/** + * Finds the longest common directory prefix across multiple file paths. + * Used as a fallback when structural markers aren't found — strips deployment-specific + * root directories from server-side paths for cleaner display. + * + * Requires at least 2 non-empty paths to compute a prefix; otherwise returns "". + * Returns the prefix including a trailing "/" ready for stripping. + */ +export function findCommonPathPrefix(paths: string[]): string { + // Extract directory portions (strip filenames) and filter empties + const dirs = paths + .filter((filePath) => filePath.length > 0) + .map((filePath) => { + const lastSlash = filePath.lastIndexOf('/') + return lastSlash >= 0 ? filePath.slice(0, lastSlash) : '' + }) + .filter((dir) => dir.length > 0) + + if (dirs.length < 2) return '' + + const segments = dirs.map((dir) => dir.split('/')) + const minLength = Math.min(...segments.map((seg) => seg.length)) + const first = segments[0] ?? [] + + let commonLength = 0 + for (let idx = 0; idx < minLength; idx++) { + const segment = first[idx] + if (segments.every((seg) => seg[idx] === segment)) { + commonLength = idx + 1 + } else { + break + } + } + + // Don't strip if only the leading "/" is shared (the empty string segment from splitting an absolute path) + if (commonLength <= 1) return '' + return `${first.slice(0, commonLength).join('/')}/` +} + +/** + * Shortens a fully-qualified Ruby method name to just the class and method. + * e.g. "Apps::Operations::StaticAssetPipeline.perform" → "StaticAssetPipeline.perform" + */ +function shortenRubyMethod(method: string): string { + const parts = method.split('::') + return parts[parts.length - 1] ?? method +} + +/** + * Formats a single stack entry as a TokenItem for display in a list. + * Method names are shown prominently, file locations are subdued. + */ +function formatStackEntry(entry: ParsedStackEntry): TokenItem { + const location = entry.line ? `${entry.file}:${entry.line}` : entry.file + if (entry.method && entry.file) { + return [entry.method, ' at ', {filePath: location}] + } + if (entry.method) return entry.method + if (entry.file) return {filePath: location} + return '' +} + +/** + * Builds the Observe logs URL for a request ID. + * Returns the production Observe URL. For local development, returns undefined since logs + * are not sent to Observe and should be viewed in the terminal output instead. + */ +export function buildObserveUrl(requestId: string): string | undefined { + // When running locally (not in production), logs are not written to Observe. + // They can be found in the terminal output where the CLI command was executed. + if (process.env.SHOPIFY_CLOUD_ENVIRONMENT !== 'production') { + return undefined + } + return `https://observe.shopify.io/a/observe/investigate/query?request_id=${requestId}` +} + +/** + * Builds custom sections with detailed diagnostic information from GraphQL error metadata. + * Sections are rendered below the main message and next steps in the error banner. + */ +function buildErrorCustomSections(meta: GraphQLErrorMeta, resolvedRequestId: string | undefined): CustomSection[] { + const sections: CustomSection[] = [] + + if (resolvedRequestId) { + const observeUrl = buildObserveUrl(resolvedRequestId) + if (observeUrl) { + sections.push({ + title: 'Request ID', + body: {link: {label: resolvedRequestId, url: observeUrl}}, + }) + } else { + // Local development: Check if we're in the Shopify monorepo or using CLI externally + sections.push({ + title: 'Request ID', + body: [ + {subdued: resolvedRequestId}, + '\n', + {subdued: `Search logs: rg -u ${resolvedRequestId} path/to/shopify/log`}, + ], + }) + } + } + + // Exception class and source location + if (meta.exceptionClass ?? meta.sourceFile) { + const parts: InlineToken[] = [] + if (meta.exceptionClass) { + parts.push({warn: meta.exceptionClass}) + } + if (meta.sourceFile) { + const location = meta.sourceLine == null ? meta.sourceFile : `${meta.sourceFile}:${meta.sourceLine}` + if (parts.length > 0) parts.push(' at ') + parts.push({filePath: location}) + } + sections.push({ + title: 'Exception', + body: parts, + }) + } + + // Stack trace — truncated with a "... N more" indicator + if (meta.stackTrace.length > 0) { + const visible = meta.stackTrace.slice(0, MAX_STACK_ENTRIES) + const remaining = meta.stackTrace.length - visible.length + + const items: TokenItem[] = visible.map(formatStackEntry) + const bodyTokens: Token[] = [{list: {items}}] + if (remaining > 0) { + bodyTokens.push({subdued: `... ${remaining} more`}) + } + + sections.push({ + title: 'Stack trace', + body: bodyTokens, + }) + } + + return sections +} + +/** + * Builds a formatted TokenItem message for server errors (HTTP 5xx). + */ +function buildServerErrorMessage(api: string, status: number, errorMessages: string[]): TokenItem { + const headline = `The ${api} GraphQL API returned an internal server error (${status}).` + if (errorMessages.length === 0) return headline + if (errorMessages.length === 1) return [headline, '\n\n', {bold: errorMessages[0] ?? ''}] + return [headline, '\n\n', {list: {items: errorMessages.map((msg) => ({bold: msg}))}}] +} + +/** + * Builds a clean string message for client errors (HTTP 4xx or 200 with errors). + */ +function buildClientErrorMessage(api: string, status: number, errorMessages: string[]): string { + const statusClause = status === 200 ? '' : ` (${status})` + const headline = `The ${api} GraphQL API responded with errors${statusClause}:` + + let body = '' + if (errorMessages.length === 1) { + body = `\n\n${errorMessages[0]}` + } else if (errorMessages.length > 1) { + body = `\n\n${errorMessages.map((msg) => ` • ${msg}`).join('\n')}` + } + + return `${headline}${body}` +} + export function errorHandler(api: string): (error: unknown, requestId?: string) => unknown { return (error: unknown, requestId?: string) => { if (error instanceof ClientError) { const {status} = error.response - let errorMessage = stringifyMessage(outputContent` -The ${outputToken.raw(api)} GraphQL API responded unsuccessfully with${ - status === 200 ? '' : ` the HTTP status ${status} and` - } errors: - -${outputToken.json(error.response.errors)} - `) - if (requestId) { - errorMessage += ` -Request ID: ${requestId} -` - } + const errorMessages = extractErrorMessages(error.response.errors) + const meta = extractGraphQLErrorMeta(error.response.errors) + const resolvedRequestId = meta.requestId ?? requestId + const customSections = buildErrorCustomSections(meta, resolvedRequestId) + let mappedError: Error - if (status < 500) { - mappedError = new GraphQLClientError(errorMessage, status, error.response.errors) + if (status >= 500) { + mappedError = new AbortError( + buildServerErrorMessage(api, status, errorMessages), + null, + undefined, + customSections, + ) } else { - mappedError = new AbortError(errorMessage) + const clientError = new GraphQLClientError( + buildClientErrorMessage(api, status, errorMessages), + status, + error.response.errors, + ) + // Enrich with detailed diagnostic sections (GraphQLClientError extends AbortError, + // so customSections are rendered by the FatalError component) + if (customSections.length > 0) { + clientError.customSections = customSections + } + mappedError = clientError } mappedError.stack = error.stack return mappedError