diff --git a/packages/app/src/cli/services/build/extension.test.ts b/packages/app/src/cli/services/build/extension.test.ts index 7f4be985566..fc8e233cb76 100644 --- a/packages/app/src/cli/services/build/extension.test.ts +++ b/packages/app/src/cli/services/build/extension.test.ts @@ -6,7 +6,7 @@ import {FunctionConfigType} from '../../models/extensions/specifications/functio import {beforeEach, describe, expect, test, vi} from 'vitest' import {exec} from '@shopify/cli-kit/node/system' import lockfile from 'proper-lockfile' -import {AbortError} from '@shopify/cli-kit/node/error' +import {AbortError, ExternalError} from '@shopify/cli-kit/node/error' import {fileExistsSync, touchFile, writeFile} from '@shopify/cli-kit/node/fs' import {joinPath} from '@shopify/cli-kit/node/path' @@ -439,4 +439,48 @@ describe('buildFunctionExtension', () => { expect(touchFile).not.toHaveBeenCalled() expect(writeFile).not.toHaveBeenCalled() }) + + test('preserves stderr details from build command failures in the error tryMessage', async () => { + // Given + // Simulate an ExternalError like exec() throws when cargo/rust is not installed + const externalError = new ExternalError('Command failed with exit code 127: cargo build --release', 'cargo', [ + 'build', + '--release', + ]) + vi.mocked(exec).mockRejectedValueOnce(externalError) + + // When + const error = await buildFunctionExtension(extension, { + stdout, + stderr, + signal, + app, + environment: 'production', + }).catch((err) => err) + + // Then + expect(error).toBeInstanceOf(AbortError) + // The tryMessage should contain the original error details, not just a generic message + expect(error.tryMessage).toContain('Command failed with exit code 127') + expect(error.tryMessage).toContain('cargo build --release') + }) + + test('preserves generic error messages from build command failures in the error tryMessage', async () => { + // Given + const genericError = new Error('cargo: command not found') + vi.mocked(exec).mockRejectedValueOnce(genericError) + + // When + const error = await buildFunctionExtension(extension, { + stdout, + stderr, + signal, + app, + environment: 'production', + }).catch((err) => err) + + // Then + expect(error).toBeInstanceOf(AbortError) + expect(error.tryMessage).toContain('cargo: command not found') + }) }) diff --git a/packages/app/src/cli/services/dev/app-events/app-event-watcher.test.ts b/packages/app/src/cli/services/dev/app-events/app-event-watcher.test.ts index c13b48cdfc2..6b489d55f10 100644 --- a/packages/app/src/cli/services/dev/app-events/app-event-watcher.test.ts +++ b/packages/app/src/cli/services/dev/app-events/app-event-watcher.test.ts @@ -15,6 +15,7 @@ import {AppLinkedInterface, CurrentAppConfiguration} from '../../../models/app/a import {AppAccessSpecIdentifier} from '../../../models/extensions/specifications/app_config_app_access.js' import {PosSpecIdentifier} from '../../../models/extensions/specifications/app_config_point_of_sale.js' import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' +import {AbortError} from '@shopify/cli-kit/node/error' import {AbortSignal, AbortController} from '@shopify/cli-kit/node/abort' import {flushPromises} from '@shopify/cli-kit/node/promises' import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs' @@ -577,6 +578,50 @@ describe('app-event-watcher', () => { }) }) + test('AbortError tryMessage details are included in stderr output', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Use a fresh extension to avoid interference from other tests sharing flowExtension + const freshExtension = await testFlowActionExtension('/extensions/flow_action_fresh') + const fileWatchEvent: WatcherEvent = { + type: 'file_updated', + path: '/extensions/flow_action_fresh/src/file.js', + extensionPath: '/extensions/flow_action_fresh', + startTime: [0, 0], + } + + // Given + // This simulates the error thrown by buildFunctionExtension when cargo/rust fails: + // AbortError('Failed to build function.', 'Command failed with exit code 127: cargo build --release') + const buildError = new AbortError( + 'Failed to build function.', + 'Command failed with exit code 127: cargo build --release', + ) + freshExtension.buildForBundle = vi.fn().mockRejectedValueOnce(buildError) + + const buildOutputPath = joinPath(tmpDir, '.shopify', 'bundle') + const app = testAppLinked({ + allExtensions: [freshExtension], + configPath: 'shopify.app.custom.toml', + configuration: testAppConfiguration, + }) + + // When + const mockManager = new MockESBuildContextManager() + const mockFileWatcher = new MockFileWatcher(app, outputOptions, [fileWatchEvent]) + const watcher = new AppEventWatcher(app, 'url', buildOutputPath, mockManager, mockFileWatcher) + const stderr = {write: vi.fn()} as unknown as Writable + const stdout = {write: vi.fn()} as unknown as Writable + + await watcher.start({stdout, stderr, signal: abortController.signal}) + + await flushPromises() + + // Then - stderr should include the tryMessage with the actual failure reason + const allStderrWrites = (stderr.write as any).mock.calls.map((call: any[]) => call[0]).join('\n') + expect(allStderrWrites).toContain('Command failed with exit code 127') + }) + }) + test('uncaught errors are emitted', async () => { await inTemporaryDirectory(async (tmpDir) => { const fileWatchEvent: WatcherEvent = { diff --git a/packages/app/src/cli/services/dev/app-events/app-event-watcher.ts b/packages/app/src/cli/services/dev/app-events/app-event-watcher.ts index 5a760de6332..6e92ee89371 100644 --- a/packages/app/src/cli/services/dev/app-events/app-event-watcher.ts +++ b/packages/app/src/cli/services/dev/app-events/app-event-watcher.ts @@ -288,6 +288,11 @@ export class AppEventWatcher extends EventEmitter { }) } else { this.options.stderr.write(error.message) + if (error.tryMessage) { + this.options.stderr.write( + typeof error.tryMessage === 'string' ? error.tryMessage : String(error.tryMessage), + ) + } } // Update all events for this extension with error result