diff --git a/packages/app/src/cli/services/app-context.test.ts b/packages/app/src/cli/services/app-context.test.ts index be38e6ae25..507282a79e 100644 --- a/packages/app/src/cli/services/app-context.test.ts +++ b/packages/app/src/cli/services/app-context.test.ts @@ -13,10 +13,12 @@ import {inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs' import {joinPath} from '@shopify/cli-kit/node/path' import {tryParseInt} from '@shopify/cli-kit/common/string' +vi.mock('../models/app/validation/multi-cli-warning.js') vi.mock('./generate/fetch-extension-specifications.js') vi.mock('./app/config/link.js') vi.mock('./context.js') vi.mock('./dev/fetch.js') + async function writeAppConfig(tmp: string, content: string) { const appConfigPath = joinPath(tmp, 'shopify.app.toml') const packageJsonPath = joinPath(tmp, 'package.json') 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 0232a6f112..428afbc5f8 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 @@ -11,12 +11,11 @@ import { } from '../../../models/app/app.test-data.js' import {ExtensionInstance} from '../../../models/extensions/extension-instance.js' import {loadApp, reloadApp} from '../../../models/app/loader.js' -import {describe, expect, test, vi} from 'vitest' -import {AbortSignal} from '@shopify/cli-kit/node/abort' +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' +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' import {joinPath} from '@shopify/cli-kit/node/path' -import {Writable} from 'stream' vi.mock('./file-watcher.js') vi.mock('../../../models/app/loader.js') @@ -226,165 +225,175 @@ const testCases: TestCase[] = [ }, ] -describe('app-event-watcher when receiving a file event', () => { - test.each(testCases)( - 'The event $name returns the expected AppEvent', - async ({fileWatchEvent, initialExtensions, finalExtensions, extensionEvents, needsAppReload}) => { - // Given - await inTemporaryDirectory(async (tmpDir) => { - const mockedApp = testAppLinked({allExtensions: finalExtensions}) - vi.mocked(loadApp).mockResolvedValue(mockedApp) - vi.mocked(reloadApp).mockResolvedValue(mockedApp) - vi.mocked(startFileWatcher).mockImplementation(async (app, options, onChange) => onChange([fileWatchEvent])) - - const buildOutputPath = joinPath(tmpDir, '.shopify', 'bundle') +describe('app-event-watcher', () => { + let abortController: AbortController + let stdout: any + let stderr: any - // When - const app = testAppLinked({ - allExtensions: initialExtensions, - configuration: {scopes: '', extension_directories: [], path: 'shopify.app.custom.toml'}, - }) - - const watcher = new AppEventWatcher(app, 'url', buildOutputPath, new MockESBuildContextManager()) - const emitSpy = vi.spyOn(watcher, 'emit') - await watcher.start() - - await flushPromises() + beforeEach(() => { + stdout = {write: vi.fn()} + stderr = {write: vi.fn()} + abortController = new AbortController() + }) - // Wait until emitSpy has been called at least once - // We need this because there are I/O operations that make the test finish before the event is emitted - await new Promise((resolve, reject) => { - const initialTime = Date.now() - const checkEmitSpy = () => { - const allCalled = emitSpy.mock.calls.some((call) => call[0] === 'all') - const readyCalled = emitSpy.mock.calls.some((call) => call[0] === 'ready') - if (allCalled && readyCalled) { - resolve() - } else if (Date.now() - initialTime < 3000) { - setTimeout(checkEmitSpy, 100) - } else { - reject(new Error('Timeout waiting for emitSpy to be called')) + afterEach(() => { + abortController.abort() + }) + describe('when receiving a file event', () => { + test.each(testCases)( + 'The event $name returns the expected AppEvent', + async ({fileWatchEvent, initialExtensions, finalExtensions, extensionEvents, needsAppReload}) => { + // Given + await inTemporaryDirectory(async (tmpDir) => { + const mockedApp = testAppLinked({allExtensions: finalExtensions}) + vi.mocked(loadApp).mockResolvedValue(mockedApp) + vi.mocked(reloadApp).mockResolvedValue(mockedApp) + vi.mocked(startFileWatcher).mockImplementation(async (app, options, onChange) => onChange([fileWatchEvent])) + + const buildOutputPath = joinPath(tmpDir, '.shopify', 'bundle') + + // When + const app = testAppLinked({ + allExtensions: initialExtensions, + configuration: {scopes: '', extension_directories: [], path: 'shopify.app.custom.toml'}, + }) + + const watcher = new AppEventWatcher(app, 'url', buildOutputPath, new MockESBuildContextManager()) + const emitSpy = vi.spyOn(watcher, 'emit') + await watcher.start({stdout, stderr, signal: abortController.signal}) + + await flushPromises() + + // Wait until emitSpy has been called at least once + // We need this because there are I/O operations that make the test finish before the event is emitted + await new Promise((resolve, reject) => { + const initialTime = Date.now() + const checkEmitSpy = () => { + const allCalled = emitSpy.mock.calls.some((call) => call[0] === 'all') + const readyCalled = emitSpy.mock.calls.some((call) => call[0] === 'ready') + if (allCalled && readyCalled) { + resolve() + } else if (Date.now() - initialTime < 3000) { + setTimeout(checkEmitSpy, 100) + } else { + reject(new Error('Timeout waiting for emitSpy to be called')) + } } + checkEmitSpy() + }) + + expect(emitSpy).toHaveBeenCalledWith('all', { + app: expect.objectContaining({realExtensions: finalExtensions}), + extensionEvents: expect.arrayContaining(extensionEvents), + startTime: expect.anything(), + path: expect.anything(), + }) + + const initialEvents = app.realExtensions.map((eve) => ({ + type: EventType.Updated, + extension: eve, + buildResult: {status: 'ok', handle: eve.handle}, + })) + expect(emitSpy).toHaveBeenCalledWith('ready', { + app, + extensionEvents: expect.arrayContaining(initialEvents), + }) + + if (needsAppReload) { + expect(reloadApp).toHaveBeenCalled() + } else { + expect(reloadApp).not.toHaveBeenCalled() } - checkEmitSpy() - }) - - expect(emitSpy).toHaveBeenCalledWith('all', { - app: expect.objectContaining({realExtensions: finalExtensions}), - extensionEvents: expect.arrayContaining(extensionEvents), - startTime: expect.anything(), - path: expect.anything(), }) + }, + ) + }) - const initialEvents = app.realExtensions.map((eve) => ({ - type: EventType.Updated, - extension: eve, - buildResult: {status: 'ok', handle: eve.handle}, - })) - expect(emitSpy).toHaveBeenCalledWith('ready', { - app, - extensionEvents: expect.arrayContaining(initialEvents), - }) + describe('app-event-watcher build extension errors', () => { + test('esbuild errors are logged with a custom format', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const fileWatchEvent: WatcherEvent = { + type: 'file_updated', + path: '/extensions/ui_extension_1/src/file.js', + extensionPath: '/extensions/ui_extension_1', + startTime: [0, 0], + } + vi.mocked(startFileWatcher).mockImplementation(async (app, options, onChange) => onChange([fileWatchEvent])) - if (needsAppReload) { - expect(reloadApp).toHaveBeenCalled() - } else { - expect(reloadApp).not.toHaveBeenCalled() + // Given + const esbuildError = { + errors: [ + { + text: 'Syntax error', + location: {file: 'test.js', line: 1, column: 2, lineText: 'console.log(aa);'}, + }, + ], } - }) - }, - ) -}) -describe('app-event-watcher build extension errors', () => { - test('esbuild errors are logged with a custom format', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const fileWatchEvent: WatcherEvent = { - type: 'file_updated', - path: '/extensions/ui_extension_1/src/file.js', - extensionPath: '/extensions/ui_extension_1', - startTime: [0, 0], - } - vi.mocked(startFileWatcher).mockImplementation(async (app, options, onChange) => onChange([fileWatchEvent])) - - // Given - const esbuildError = { - errors: [ - { - text: 'Syntax error', - location: {file: 'test.js', line: 1, column: 2, lineText: 'console.log(aa);'}, - }, - ], - } - - const mockManager = new MockESBuildContextManager() - mockManager.rebuildContext = vi.fn().mockRejectedValueOnce(esbuildError) - - const buildOutputPath = joinPath(tmpDir, '.shopify', 'bundle') - const app = testAppLinked({ - allExtensions: [extension1], - configuration: {scopes: '', extension_directories: [], path: 'shopify.app.custom.toml'}, - }) + const mockManager = new MockESBuildContextManager() + mockManager.rebuildContext = vi.fn().mockRejectedValueOnce(esbuildError) - // When - const watcher = new AppEventWatcher(app, 'url', buildOutputPath, mockManager) - const stderr = {write: vi.fn()} as unknown as Writable - const stdout = {write: vi.fn()} as unknown as Writable + const buildOutputPath = joinPath(tmpDir, '.shopify', 'bundle') + const app = testAppLinked({ + allExtensions: [extension1], + configuration: {scopes: '', extension_directories: [], path: 'shopify.app.custom.toml'}, + }) + + // When + const watcher = new AppEventWatcher(app, 'url', buildOutputPath, mockManager) - await watcher.start({stdout, stderr, signal: new AbortSignal()}) + await watcher.start({stdout, stderr, signal: abortController.signal}) - await flushPromises() + await flushPromises() - // Then - expect(stderr.write).toHaveBeenCalledWith( - expect.stringContaining( - `[ERROR] Syntax error + // Then + expect(stderr.write).toHaveBeenCalledWith( + expect.stringContaining( + `[ERROR] Syntax error test.js:1:2: 1 │ console.log(aa); ╵ ^ `, - ), - ) + ), + ) + }) }) - }) - test('general build errors are logged as plain messages', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const fileWatchEvent: WatcherEvent = { - type: 'file_updated', - path: '/extensions/flow_action/src/file.js', - extensionPath: '/extensions/flow_action', - startTime: [0, 0], - } - vi.mocked(startFileWatcher).mockImplementation(async (app, options, onChange) => onChange([fileWatchEvent])) - - // Given - const esbuildError = {message: 'Build failed'} - flowExtension.buildForBundle = vi.fn().mockRejectedValueOnce(esbuildError) - - const buildOutputPath = joinPath(tmpDir, '.shopify', 'bundle') - const app = testAppLinked({ - allExtensions: [flowExtension], - configuration: {scopes: '', extension_directories: [], path: 'shopify.app.custom.toml'}, - }) + test('general build errors are logged as plain messages', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const fileWatchEvent: WatcherEvent = { + type: 'file_updated', + path: '/extensions/flow_action/src/file.js', + extensionPath: '/extensions/flow_action', + startTime: [0, 0], + } + vi.mocked(startFileWatcher).mockImplementation(async (app, options, onChange) => onChange([fileWatchEvent])) + + // Given + const esbuildError = {message: 'Build failed'} + flowExtension.buildForBundle = vi.fn().mockRejectedValueOnce(esbuildError) + + const buildOutputPath = joinPath(tmpDir, '.shopify', 'bundle') + const app = testAppLinked({ + allExtensions: [flowExtension], + configuration: {scopes: '', extension_directories: [], path: 'shopify.app.custom.toml'}, + }) - // When - const watcher = new AppEventWatcher(app, 'url', buildOutputPath, new MockESBuildContextManager()) - const stderr = {write: vi.fn()} as unknown as Writable - const stdout = {write: vi.fn()} as unknown as Writable + // When + const watcher = new AppEventWatcher(app, 'url', buildOutputPath, new MockESBuildContextManager()) - await watcher.start({stdout, stderr, signal: new AbortSignal()}) + await watcher.start({stdout, stderr, signal: abortController.signal}) - await flushPromises() + await flushPromises() - // Then - expect(stderr.write).toHaveBeenCalledWith(`Build failed`) + // Then + expect(stderr.write).toHaveBeenCalledWith(`Build failed`) + }) }) }) }) - // Mock class for ESBuildContextManager // It handles the ESBuild contexts for the extensions that are being watched class MockESBuildContextManager extends ESBuildContextManager { diff --git a/packages/app/src/cli/services/dev/processes/dev-session.test.ts b/packages/app/src/cli/services/dev/processes/dev-session.test.ts index e16ccd17c1..bb6787b2c1 100644 --- a/packages/app/src/cli/services/dev/processes/dev-session.test.ts +++ b/packages/app/src/cli/services/dev/processes/dev-session.test.ts @@ -11,8 +11,8 @@ import { testWebhookExtensions, } from '../../../models/app/app.test-data.js' import {formData} from '@shopify/cli-kit/node/http' -import {describe, expect, test, vi, beforeEach} from 'vitest' -import {AbortSignal} from '@shopify/cli-kit/node/abort' +import {describe, expect, test, vi, beforeEach, afterEach} from 'vitest' +import {AbortSignal, AbortController} from '@shopify/cli-kit/node/abort' import {flushPromises} from '@shopify/cli-kit/node/promises' import {writeFile} from '@shopify/cli-kit/node/fs' import * as outputContext from '@shopify/cli-kit/node/ui/components' @@ -66,6 +66,7 @@ describe('pushUpdatesForDevSession', () => { let developerPlatformClient: any let appWatcher: AppEventWatcher let app: AppLinkedInterface + let abortController: AbortController beforeEach(() => { vi.mocked(formData).mockReturnValue({append: vi.fn(), getHeaders: vi.fn()} as any) @@ -75,7 +76,7 @@ describe('pushUpdatesForDevSession', () => { developerPlatformClient = testDeveloperPlatformClient() app = testAppLinked() appWatcher = new AppEventWatcher(app) - + abortController = new AbortController() options = { developerPlatformClient, appWatcher, @@ -85,10 +86,14 @@ describe('pushUpdatesForDevSession', () => { } }) + afterEach(() => { + abortController.abort() + }) + test('creates a new dev session successfully when receiving the app watcher start event', async () => { // When - await pushUpdatesForDevSession({stderr, stdout, abortSignal: new AbortSignal()}, options) - await appWatcher.start() + await pushUpdatesForDevSession({stderr, stdout, abortSignal: abortController.signal}, options) + await appWatcher.start({stdout, stderr, signal: abortController.signal}) await flushPromises() // Then @@ -100,8 +105,8 @@ describe('pushUpdatesForDevSession', () => { // When const spyContext = vi.spyOn(outputContext, 'useConcurrentOutputContext') - await pushUpdatesForDevSession({stderr, stdout, abortSignal: new AbortSignal()}, options) - await appWatcher.start() + await pushUpdatesForDevSession({stderr, stdout, abortSignal: abortController.signal}, options) + await appWatcher.start({stdout, stderr, signal: abortController.signal}) await flushPromises() const extension = await testUIExtension() @@ -123,7 +128,7 @@ describe('pushUpdatesForDevSession', () => { developerPlatformClient.devSessionCreate = vi.fn().mockResolvedValue({devSessionCreate: {userErrors}}) // When - await appWatcher.start() + await appWatcher.start({stdout, stderr, signal: abortController.signal}) await pushUpdatesForDevSession({stderr, stdout, abortSignal: new AbortSignal()}, options) await flushPromises() @@ -134,7 +139,7 @@ describe('pushUpdatesForDevSession', () => { test('handles receiving an event before session is ready', async () => { // When - await pushUpdatesForDevSession({stderr, stdout, abortSignal: new AbortSignal()}, options) + await pushUpdatesForDevSession({stderr, stdout, abortSignal: abortController.signal}, options) appWatcher.emit('all', {app, extensionEvents: [{type: 'updated', extension: testWebhookExtensions()}]}) await flushPromises() @@ -152,8 +157,8 @@ describe('pushUpdatesForDevSession', () => { developerPlatformClient.devSessionUpdate = vi.fn().mockResolvedValue({devSessionUpdate: {userErrors}}) // When - await pushUpdatesForDevSession({stderr, stdout, abortSignal: new AbortSignal()}, options) - await appWatcher.start() + await pushUpdatesForDevSession({stderr, stdout, abortSignal: abortController.signal}, options) + await appWatcher.start({stdout, stderr, signal: abortController.signal}) await flushPromises() appWatcher.emit('all', {app, extensionEvents: [{type: 'updated', extension: testWebhookExtensions()}]}) await flushPromises() @@ -171,8 +176,8 @@ describe('pushUpdatesForDevSession', () => { const contextSpy = vi.spyOn(outputContext, 'useConcurrentOutputContext') // When - await pushUpdatesForDevSession({stderr, stdout, abortSignal: new AbortSignal()}, options) - await appWatcher.start() + await pushUpdatesForDevSession({stderr, stdout, abortSignal: abortController.signal}, options) + await appWatcher.start({stdout, stderr, signal: abortController.signal}) await flushPromises() appWatcher.emit('all', event) await flushPromises()