Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,36 @@ describe('draftMessages', async () => {
})
})

describe('devSessionWatchConfig', () => {
test('returns undefined for extension experience (watch everything)', async () => {
const extensionInstance = await testUIExtension({type: 'ui_extension'})
expect(extensionInstance.devSessionWatchConfig).toBeUndefined()
})

test('returns empty paths for configuration experience (watch nothing)', async () => {
const extensionInstance = await testAppConfigExtensions()
expect(extensionInstance.devSessionWatchConfig).toEqual({paths: []})
})

test('delegates to specification devSessionWatchConfig when defined', async () => {
const config = functionConfiguration()
config.build = {
watch: 'src/**/*.rs',
wasm_opt: true,
}
const extensionInstance = await testFunctionExtension({config, dir: '/tmp/my-function'})
const watchConfig = extensionInstance.devSessionWatchConfig
expect(watchConfig).toBeDefined()
expect(watchConfig!.paths).toContain(joinPath('/tmp/my-function', 'src/**/*.rs'))
})

test('returns undefined for function extension without build.watch', async () => {
const config = functionConfiguration()
const extensionInstance = await testFunctionExtension({config})
expect(extensionInstance.devSessionWatchConfig).toBeUndefined()
})
})

describe('watchedFiles', async () => {
test('returns files based on devSessionWatchPaths when defined', async () => {
await inTemporaryDirectory(async (tmpDir) => {
Expand Down Expand Up @@ -607,6 +637,58 @@ describe('watchedFiles', async () => {
})
})

test('respects custom ignore paths from devSessionWatchConfig', async () => {
await inTemporaryDirectory(async (tmpDir) => {
// Given - create an extension with a spec that defines custom ignore paths
const config = functionConfiguration()
config.build = {
watch: '**/*',
wasm_opt: true,
}
const extensionInstance = await testFunctionExtension({
config,
dir: tmpDir,
})

// Override devSessionWatchConfig to include ignore paths
vi.spyOn(extensionInstance, 'devSessionWatchConfig', 'get').mockReturnValue({
paths: [joinPath(tmpDir, '**/*')],
ignore: ['**/ignored-dir/**'],
})

// Create files - one in a normal dir, one in the ignored dir
const srcDir = joinPath(tmpDir, 'src')
const ignoredDir = joinPath(tmpDir, 'ignored-dir')
await mkdir(srcDir)
await mkdir(ignoredDir)
await writeFile(joinPath(srcDir, 'index.js'), 'code')
await writeFile(joinPath(ignoredDir, 'should-be-ignored.js'), 'ignored')

// When
const watchedFiles = extensionInstance.watchedFiles()

// Then
expect(watchedFiles).toContain(joinPath(srcDir, 'index.js'))
expect(watchedFiles).not.toContain(joinPath(ignoredDir, 'should-be-ignored.js'))
})
})

test('returns empty watched files for configuration extensions', async () => {
await inTemporaryDirectory(async (tmpDir) => {
// Given
const extensionInstance = await testAppConfigExtensions(false, tmpDir)

// Create files that should not be watched
await writeFile(joinPath(tmpDir, 'some-file.ts'), 'code')

// When
const watchedFiles = extensionInstance.watchedFiles()

// Then - configuration extensions default to empty paths, so no files watched
expect(watchedFiles).toHaveLength(0)
})
})

test('returns all files when devSessionWatchPaths is undefined', async () => {
await inTemporaryDirectory(async (tmpDir) => {
// Given
Expand Down
44 changes: 25 additions & 19 deletions packages/app/src/cli/models/extensions/extension-instance.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {BaseConfigType, MAX_EXTENSION_HANDLE_LENGTH, MAX_UID_LENGTH} from './schemas.js'
import {FunctionConfigType} from './specifications/function.js'
import {ExtensionFeature, ExtensionSpecification} from './specification.js'
import {DevSessionWatchConfig, ExtensionFeature, ExtensionSpecification} from './specification.js'
import {SingleWebhookSubscriptionType} from './specifications/app_config_webhook_schemas/webhooks_schema.js'
import {ExtensionBuildOptions, bundleFunctionExtension} from '../../services/build/extension.js'
import {bundleThemeExtension} from '../../services/extensions/bundle.js'
Expand Down Expand Up @@ -277,20 +277,15 @@ export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfi
return [this.entrySourceFilePath]
}

// Custom paths to be watched in a dev session
// Return undefiend if there aren't custom configured paths (everything is watched)
// If there are, include some default paths.
get devSessionCustomWatchPaths() {
const config = this.configuration as unknown as FunctionConfigType
if (!config.build || !config.build.watch) return undefined

const watchPaths = [config.build.watch].flat().map((path) => joinPath(this.directory, path))

watchPaths.push(joinPath(this.directory, 'locales', '**.json'))
watchPaths.push(joinPath(this.directory, '**', '!(.)*.graphql'))
watchPaths.push(joinPath(this.directory, '**.toml'))
// Custom watch configuration for dev sessions
// Return undefined to watch everything (default for 'extension' experience)
// Return a config with empty paths to watch nothing (default for 'configuration' experience)
get devSessionWatchConfig(): DevSessionWatchConfig | undefined {
if (this.specification.devSessionWatchConfig) {
return this.specification.devSessionWatchConfig(this)
}

return watchPaths
return this.specification.experience === 'configuration' ? {paths: []} : undefined
}

async watchConfigurationPaths() {
Expand Down Expand Up @@ -436,20 +431,31 @@ export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfi
watchedFiles(): string[] {
const watchedFiles: string[] = []

// Add extension directory files based on devSessionCustomWatchPaths or all files
const patterns = this.devSessionCustomWatchPaths ?? ['**/*']
const defaultIgnore = [
'**/node_modules/**',
'**/.git/**',
'**/*.test.*',
'**/dist/**',
'**/*.swp',
'**/generated/**',
'**/.gitignore',
]
const watchConfig = this.devSessionWatchConfig

const patterns = watchConfig?.paths ?? ['**/*']
const ignore = watchConfig?.ignore ?? defaultIgnore
const files = patterns.flatMap((pattern) =>
globSync(pattern, {
cwd: this.directory,
absolute: true,
followSymbolicLinks: false,
ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/*.swp', '**/generated/**'],
ignore,
}),
)
watchedFiles.push(...files.flat())

// Add imported files from outside the extension directory unless custom watch paths are defined
if (!this.devSessionCustomWatchPaths) {
// Add imported files from outside the extension directory unless custom watch config is defined
if (!watchConfig) {
const importedFiles = this.scanImports()
watchedFiles.push(...importedFiles)
}
Expand Down
16 changes: 16 additions & 0 deletions packages/app/src/cli/models/extensions/specification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,20 @@ export interface ExtensionSpecification<TConfiguration extends BaseConfigType =
* Copy static assets from the extension directory to the output path
*/
copyStaticAssets?: (configuration: TConfiguration, directory: string, outputPath: string) => Promise<void>

/**
* Custom watch configuration for dev sessions.
* Return a DevSessionWatchConfig with paths to watch and optionally paths to ignore,
* or undefined to watch all files in the extension directory.
*/
devSessionWatchConfig?: (extension: ExtensionInstance<TConfiguration>) => DevSessionWatchConfig | undefined
}

export interface DevSessionWatchConfig {
/** Absolute paths or globs to watch */
paths: string[]
/** Glob patterns to ignore. When provided, replaces the default ignore list entirely. */
ignore?: string[]
}

/**
Expand Down Expand Up @@ -294,6 +308,7 @@ export function createContractBasedModuleSpecification<TConfiguration extends Ba
| 'clientSteps'
| 'experience'
| 'transformRemoteToLocal'
| 'devSessionWatchConfig'
>,
) {
return createExtensionSpecification({
Expand All @@ -305,6 +320,7 @@ export function createContractBasedModuleSpecification<TConfiguration extends Ba
clientSteps: spec.clientSteps,
uidStrategy: spec.uidStrategy,
transformRemoteToLocal: spec.transformRemoteToLocal,
devSessionWatchConfig: spec.devSessionWatchConfig,
deployConfig: async (config, directory) => {
let parsedConfig = configWithoutFirstClassFields(config)
if (spec.appModuleFeatures().includes('localization')) {
Expand Down
29 changes: 27 additions & 2 deletions packages/app/src/cli/models/extensions/specifications/admin.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,33 @@
import {createContractBasedModuleSpecification} from '../specification.js'
import {createExtensionSpecification} from '../specification.js'
import {BaseConfigType, ZodSchemaType} from '../schemas.js'
import {zod} from '@shopify/cli-kit/node/schema'
import {joinPath} from '@shopify/cli-kit/node/path'

const adminSpecificationSpec = createContractBasedModuleSpecification({
const AdminSchema = zod.object({
admin: zod
.object({
static_root: zod.string().optional(),
})
.optional(),
})

type AdminConfigType = zod.infer<typeof AdminSchema> & BaseConfigType

const adminSpecificationSpec = createExtensionSpecification<AdminConfigType>({
identifier: 'admin',
uidStrategy: 'single',
experience: 'configuration',
schema: AdminSchema as ZodSchemaType<AdminConfigType>,
deployConfig: async (config, _) => {
return {admin: config.admin}
},
devSessionWatchConfig: (extension) => {
const staticRoot = extension.configuration.admin?.static_root
if (!staticRoot) return {paths: []}

const path = joinPath(extension.directory, staticRoot, '**/*')
return {paths: [path], ignore: []}
},
transformRemoteToLocal: (remoteContent) => {
return {
admin: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,18 @@ const functionSpec = createExtensionSpecification({
appModuleFeatures: (_) => ['function'],
buildConfig: {mode: 'function'},
getOutputRelativePath: (_extension: ExtensionInstance<FunctionConfigType>) => joinPath('dist', 'index.wasm'),
devSessionWatchConfig: (extension: ExtensionInstance<FunctionConfigType>) => {
const config = extension.configuration
if (!config.build || !config.build.watch) return undefined

const paths = [config.build.watch].flat().map((path) => joinPath(extension.directory, path))

paths.push(joinPath(extension.directory, 'locales', '**.json'))
paths.push(joinPath(extension.directory, '**', '!(.)*.graphql'))
paths.push(joinPath(extension.directory, '**.toml'))

return {paths}
},
clientSteps: [
{
lifecycle: 'deploy',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -274,15 +274,7 @@ describe('file-watcher events', () => {

// Then
expect(watchSpy).toHaveBeenCalledWith([joinPath(dir, '/shopify.app.toml'), joinPath(dir, '/extensions')], {
ignored: [
'**/node_modules/**',
'**/.git/**',
'**/*.test.*',
'**/dist/**',
'**/*.swp',
'**/generated/**',
'**/.gitignore',
],
ignored: ['**/node_modules/**', '**/.git/**'],
ignoreInitial: true,
persistent: true,
})
Expand Down
10 changes: 1 addition & 9 deletions packages/app/src/cli/services/dev/app-events/file-watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,15 +118,7 @@ export class FileWatcher {
// Create new watcher
const {default: chokidar} = await import('chokidar')
this.watcher = chokidar.watch(watchPaths, {
ignored: [
'**/node_modules/**',
'**/.git/**',
'**/*.test.*',
'**/dist/**',
'**/*.swp',
'**/generated/**',
'**/.gitignore',
],
ignored: ['**/node_modules/**', '**/.git/**'],
persistent: true,
ignoreInitial: true,
})
Expand Down
Loading