Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-app-events-status.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/app': minor
---

Show a status message when an Analytics App Events extension loads during `shopify app dev`.
Original file line number Diff line number Diff line change
Expand Up @@ -394,9 +394,9 @@ export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfi
}
}

async getDevSessionUpdateMessages(): Promise<string[] | undefined> {
async getDevSessionUpdateMessages(devSessionStatus: 'created' | 'updated'): Promise<string[] | undefined> {
if (!this.specification.getDevSessionUpdateMessages) return undefined
return this.specification.getDevSessionUpdateMessages(this.configuration)
return this.specification.getDevSessionUpdateMessages(this.configuration, devSessionStatus)
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import editorExtensionCollectionSpecification from './specifications/editor_exte
import channelSpecificationSpec from './specifications/channel.js'
import orderAttributionConfigSpec from './specifications/order_attribution_config.js'
import adminLinkSpec from './specifications/admin_link.js'
import analyticsAppEventsSpec from './specifications/analytics_app_events.js'

const SORTED_CONFIGURATION_SPEC_IDENTIFIERS = [
BrandingSpecIdentifier,
Expand Down Expand Up @@ -82,6 +83,7 @@ function loadSpecifications() {
channelSpecificationSpec,
orderAttributionConfigSpec,
adminLinkSpec,
analyticsAppEventsSpec,
]

return [...configModuleSpecs, ...moduleSpecs] as ExtensionSpecification[]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,13 @@ describe('allLocalSpecs', () => {
test('loads the specifications successfully', async () => {
// When
const got = await loadLocalExtensionsSpecifications()
const analyticsAppEventsSpec = got.find((specification) => specification.identifier === 'analytics_app_events')
const adminLinkSpec = got.find((specification) => specification.identifier === 'admin_link')

// Then
expect(got.length).not.toEqual(0)
expect(analyticsAppEventsSpec).toBeDefined()
expect(adminLinkSpec?.getDevSessionUpdateMessages).toBeUndefined()
})
})

Expand Down Expand Up @@ -95,6 +99,24 @@ describe('createContractBasedModuleSpecification', () => {
// Then
expect(got.clientSteps).toBeUndefined()
})

test('passes dev session update messages through to the created specification', async () => {
// Given
const getDevSessionUpdateMessages = async () => ['Extension loaded']
const specification = createContractBasedModuleSpecification({
identifier: 'test',
uidStrategy: 'uuid',
experience: 'extension',
appModuleFeatures: () => [],
getDevSessionUpdateMessages,
})

// When
const messages = await specification.getDevSessionUpdateMessages!({})

// Then
expect(messages).toEqual(['Extension loaded'])
})
})

describe('createExtensionSpecification', () => {
Expand Down
6 changes: 4 additions & 2 deletions packages/app/src/cli/models/extensions/specification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export interface ExtensionSpecification<TConfiguration extends BaseConfigType =
buildValidation?: (extension: ExtensionInstance<TConfiguration>, outputPath: string) => Promise<void>
hasExtensionPointTarget?(config: TConfiguration, target: string): boolean
appModuleFeatures: (config?: TConfiguration) => ExtensionFeature[]
getDevSessionUpdateMessages?: (config: TConfiguration) => Promise<string[]>
getDevSessionUpdateMessages?: (config: TConfiguration, devSessionStatus?: 'created' | 'updated') => Promise<string[]>
patchWithAppDevURLs?: (config: TConfiguration, urls: ApplicationURLs) => void

/**
Expand Down Expand Up @@ -271,7 +271,7 @@ export function createConfigExtensionSpecification<TConfiguration extends BaseCo
appModuleFeatures?: (config?: TConfiguration) => ExtensionFeature[]
transformConfig: TransformationConfig | CustomTransformationConfig
uidStrategy?: UidStrategy
getDevSessionUpdateMessages?: (config: TConfiguration) => Promise<string[]>
getDevSessionUpdateMessages?: (config: TConfiguration, devSessionStatus?: 'created' | 'updated') => Promise<string[]>
patchWithAppDevURLs?: (config: TConfiguration, urls: ApplicationURLs) => void
}): ExtensionSpecification<TConfiguration> {
const appModuleFeatures = spec.appModuleFeatures ?? (() => [])
Expand Down Expand Up @@ -301,6 +301,7 @@ export function createContractBasedModuleSpecification<TConfiguration extends Ba
| 'experience'
| 'transformRemoteToLocal'
| 'devSessionWatchConfig'
| 'getDevSessionUpdateMessages'
>,
) {
return createExtensionSpecification({
Expand All @@ -312,6 +313,7 @@ export function createContractBasedModuleSpecification<TConfiguration extends Ba
uidStrategy: spec.uidStrategy,
transformRemoteToLocal: spec.transformRemoteToLocal,
devSessionWatchConfig: spec.devSessionWatchConfig,
getDevSessionUpdateMessages: spec.getDevSessionUpdateMessages,
deployConfig: async (config, directory) => {
let parsedConfig = configWithoutFirstClassFields(config)
if (spec.appModuleFeatures().includes('localization')) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import analyticsAppEventsSpec from './analytics_app_events.js'
import {describe, expect, test} from 'vitest'

describe('analytics_app_events', () => {
test('reports when the extension has loaded', async () => {
// When
const messages = await analyticsAppEventsSpec.getDevSessionUpdateMessages!({}, 'created')

// Then
expect(messages).toEqual(['Extension loaded'])
})

test('does not report a load message after a dev session update', async () => {
// When
const messages = await analyticsAppEventsSpec.getDevSessionUpdateMessages!({}, 'updated')

// Then
expect(messages).toEqual([])
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {createContractBasedModuleSpecification} from '../specification.js'

// The platform owns the App Events contract; CLI contributes only this dev-session status message.
const analyticsAppEventsSpec = createContractBasedModuleSpecification({
identifier: 'analytics_app_events',
uidStrategy: 'single',
experience: 'extension',
appModuleFeatures: () => [],
getDevSessionUpdateMessages: async (_config, devSessionStatus = 'created') =>
devSessionStatus === 'created' ? ['Extension loaded'] : [],
})

export default analyticsAppEventsSpec
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,16 @@ import {DevSessionLogger} from './dev-session-logger.js'
import {UserError} from './dev-session.js'
import {AppEvent, EventType} from '../../app-events/app-event-watcher.js'
import {ExtensionInstance} from '../../../../models/extensions/extension-instance.js'
import analyticsAppEventsSpec from '../../../../models/extensions/specifications/analytics_app_events.js'
import {describe, expect, test, vi, beforeEach} from 'vitest'
import {JsonMapType} from '@shopify/cli-kit/node/toml'
import {useConcurrentOutputContext} from '@shopify/cli-kit/node/ui/components'
import {Writable} from 'stream'

vi.mock('@shopify/cli-kit/node/ui/components', () => ({
useConcurrentOutputContext: vi.fn((_, callback: () => void) => callback()),
}))

describe('DevSessionLogger', () => {
let output: string[]
let stdout: Writable
Expand Down Expand Up @@ -166,7 +172,7 @@ describe('DevSessionLogger', () => {

describe('logExtensionUpdateMessages', () => {
test('does nothing when no event is provided', async () => {
await logger.logExtensionUpdateMessages()
await logger.logExtensionUpdateMessages(undefined, 'updated')
expect(output).toMatchInlineSnapshot(`[]`)
})

Expand All @@ -192,7 +198,7 @@ describe('DevSessionLogger', () => {
startTime: [0, 0],
}

await logger.logExtensionUpdateMessages(event)
await logger.logExtensionUpdateMessages(event, 'updated')
expect(output).toMatchInlineSnapshot(`
[
"└ This has been updated.",
Expand Down Expand Up @@ -222,10 +228,73 @@ describe('DevSessionLogger', () => {
startTime: [0, 0],
}

await logger.logExtensionUpdateMessages(event)
await logger.logExtensionUpdateMessages(event, 'updated')
expect(output).toMatchInlineSnapshot(`[]`)
expect(mockExtension.getDevSessionUpdateMessages).not.toHaveBeenCalled()
})

test('prefixes Analytics App Events messages with the extension handle', async () => {
// Given
const analyticsAppEventsExtension = new ExtensionInstance({
configuration: {},
configurationPath: '',
directory: '',
specification: analyticsAppEventsSpec,
})
const event: AppEvent = {
app: {configuration: {}} as any,
extensionEvents: [
{
// The watcher reports initial extensions as updated; the dev session result identifies this as creation.
type: EventType.Updated,
extension: analyticsAppEventsExtension,
},
],
path: '',
startTime: [0, 0],
}

// When
await logger.logExtensionUpdateMessages(event, 'created')

// Then
expect(output).toMatchInlineSnapshot(`
[
"\u001b[90m└ \u001b[39mExtension loaded",
]
`)
expect(vi.mocked(useConcurrentOutputContext)).toHaveBeenCalledWith(
{outputPrefix: 'analytics_app_events', stripAnsi: false},
expect.any(Function),
)
})

test('does not repeat Analytics App Events messages after an update', async () => {
// Given
const analyticsAppEventsExtension = new ExtensionInstance({
configuration: {},
configurationPath: '',
directory: '',
specification: analyticsAppEventsSpec,
})
const event: AppEvent = {
app: {configuration: {}} as any,
extensionEvents: [
{
type: EventType.Updated,
extension: analyticsAppEventsExtension,
},
],
path: '',
startTime: [0, 0],
}

// When
await logger.logExtensionUpdateMessages(event, 'updated')

// Then
expect(output).toMatchInlineSnapshot(`[]`)
})
})

describe('logMultipleErrors', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,17 +70,17 @@ export class DevSessionLogger {
}

/**
* Display update messages from extensions after a dev session update.
* Display messages from extensions after a dev session completes.
* This function collects and displays update messages from all extensions.
*/
async logExtensionUpdateMessages(event?: AppEvent) {
async logExtensionUpdateMessages(event: AppEvent | undefined, devSessionStatus: 'created' | 'updated') {
if (!event) return
const extensionEvents = event.extensionEvents ?? []
const messageArrays = await Promise.all(
extensionEvents.map(async (eve) => {
// Don't log messages for deleted extensions
if (eve.type === EventType.Deleted) return []
const messages = await eve.extension.getDevSessionUpdateMessages()
const messages = await eve.extension.getDevSessionUpdateMessages(devSessionStatus)
return messages?.map((message) => ({message, prefix: eve.extension.handle})) ?? []
}),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -228,12 +228,12 @@ export class DevSession {
private async handleDevSessionResult(result: DevSessionResult, event?: AppEvent) {
if (result.status === 'updated') {
await this.logger.success(`✅ Updated dev preview on ${this.options.storeFqdn}`)
await this.logger.logExtensionUpdateMessages(event)
await this.logger.logExtensionUpdateMessages(event, result.status)
await this.setUpdatedStatusMessage()
} else if (result.status === 'created') {
this.statusManager.updateStatus({isReady: true})
await this.logger.success(`✅ Ready, watching for changes in your app `)
await this.logger.logExtensionUpdateMessages(event)
await this.logger.logExtensionUpdateMessages(event, result.status)
this.statusManager.setMessage('READY')
} else if (result.status === 'aborted') {
await this.logger.debug('❌ Dev preview update aborted (new change detected or error during update)')
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {fetchSpecifications} from './fetch-extension-specifications.js'
import {RemoteSpecification} from '../../api/graphql/extension_specifications.js'
import {testDeveloperPlatformClient, testOrganizationApp} from '../../models/app/app.test-data.js'
import {describe, expect, test} from 'vitest'

Expand Down Expand Up @@ -106,4 +107,41 @@ describe('fetchExtensionSpecifications', () => {
expect(withoutLocalization?.appModuleFeatures()).toEqual([])
expect(withLocalization?.appModuleFeatures()).toEqual(['localization'])
})

test('uses the remote App Events contract with the local dev session message', async () => {
// Given
const analyticsAppEventsRemoteSpec: RemoteSpecification = {
name: 'App Events',
externalName: 'App Events',
identifier: 'analytics_app_events',
externalIdentifier: 'analytics_app_events',
gated: false,
experience: 'extension',
managementExperience: 'cli',
registrationLimit: 1,
uidStrategy: 'single',
validationSchema: {
jsonSchema:
'{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","additionalProperties":false,"properties":{"namespace":{"type":"string"},"events":{"type":"array"}},"required":["namespace","events"]}',
},
}
const developerPlatformClient = testDeveloperPlatformClient({
specifications: () => Promise.resolve([analyticsAppEventsRemoteSpec]),
})

// When
const specifications = await fetchSpecifications({
developerPlatformClient,
app: testOrganizationApp(),
})
const analyticsAppEventsSpec = specifications.find(
(specification) => specification.identifier === 'analytics_app_events',
)!

// Then
expect(analyticsAppEventsSpec.uidStrategy).toBe('single')
await expect(analyticsAppEventsSpec.getDevSessionUpdateMessages!({})).resolves.toEqual(['Extension loaded'])
expect(analyticsAppEventsSpec.parseConfigurationObject({namespace: 'example-app', events: []}).state).toBe('ok')
expect(analyticsAppEventsSpec.parseConfigurationObject({namespace: 'example-app'}).state).toBe('error')
})
})
Loading