|
| 1 | +import {ShopifyConfig} from './custom-oclif-loader.js' |
| 2 | +import {isDevelopment} from './context/local.js' |
| 3 | +import {fileExistsSync} from './fs.js' |
| 4 | +import {cwd, joinPath, sniffForPath} from './path.js' |
| 5 | +import {execaSync} from 'execa' |
| 6 | +import {describe, test, expect, vi, beforeEach} from 'vitest' |
| 7 | +import type {Config as OclifConfig} from '@oclif/core' |
| 8 | +import type {Options} from '@oclif/core/interfaces' |
| 9 | + |
| 10 | +vi.mock('./context/local.js') |
| 11 | +vi.mock('./fs.js') |
| 12 | +vi.mock('./path.js') |
| 13 | +vi.mock('execa') |
| 14 | + |
| 15 | +// Provide a controllable base class so tests can inspect `plugins`, `_commands`, |
| 16 | +// and whether `loadCommands` was invoked without depending on real oclif internals. |
| 17 | +vi.mock('@oclif/core', () => { |
| 18 | + class Config { |
| 19 | + plugins = new Map<string, unknown>() |
| 20 | + _commands = new Map<string, unknown>() |
| 21 | + loadCommandsCalls: unknown[] = [] |
| 22 | + |
| 23 | + constructor(_options: unknown) {} |
| 24 | + |
| 25 | + async load(): Promise<void> {} |
| 26 | + |
| 27 | + loadCommands(plugin: unknown): void { |
| 28 | + this.loadCommandsCalls.push(plugin) |
| 29 | + } |
| 30 | + } |
| 31 | + |
| 32 | + return {Config} |
| 33 | +}) |
| 34 | + |
| 35 | +// Convenience type so tests can reach mock-only properties without ts-expect-error on every line. |
| 36 | +type MockConfig = { |
| 37 | + plugins: Map<string, unknown> |
| 38 | + _commands: Map<string, unknown> | undefined |
| 39 | + loadCommandsCalls: unknown[] |
| 40 | +} |
| 41 | + |
| 42 | +function asMock(config: ShopifyConfig): MockConfig { |
| 43 | + return config as unknown as MockConfig |
| 44 | +} |
| 45 | + |
| 46 | +describe('ShopifyConfig', () => { |
| 47 | + beforeEach(() => { |
| 48 | + vi.mocked(isDevelopment).mockReturnValue(false) |
| 49 | + vi.mocked(cwd).mockReturnValue('/workspace') |
| 50 | + vi.mocked(sniffForPath).mockReturnValue(undefined) |
| 51 | + vi.mocked(joinPath).mockImplementation((...args: string[]) => args.join('/')) |
| 52 | + vi.mocked(fileExistsSync).mockReturnValue(false) |
| 53 | + delete process.env.IGNORE_HYDROGEN_MONOREPO |
| 54 | + }) |
| 55 | + |
| 56 | + describe('constructor', () => { |
| 57 | + test('does not set pluginAdditions when not in dev mode', () => { |
| 58 | + const options = {root: '/workspace'} as Options |
| 59 | + new ShopifyConfig(options) |
| 60 | + expect((options as {pluginAdditions?: unknown}).pluginAdditions).toBeUndefined() |
| 61 | + expect(options.ignoreManifest).toBeUndefined() |
| 62 | + }) |
| 63 | + |
| 64 | + test('sets pluginAdditions and ignoreManifest when package.json exists in dev mode', () => { |
| 65 | + vi.mocked(isDevelopment).mockReturnValue(true) |
| 66 | + vi.mocked(fileExistsSync).mockReturnValue(true) |
| 67 | + |
| 68 | + const options = {root: '/workspace'} as Options |
| 69 | + new ShopifyConfig(options) |
| 70 | + |
| 71 | + expect((options as {pluginAdditions?: unknown}).pluginAdditions).toEqual({ |
| 72 | + core: ['@shopify/cli-hydrogen'], |
| 73 | + path: '/workspace', |
| 74 | + }) |
| 75 | + expect(options.ignoreManifest).toBe(true) |
| 76 | + }) |
| 77 | + |
| 78 | + test('does not set pluginAdditions when package.json is absent in dev mode', () => { |
| 79 | + vi.mocked(isDevelopment).mockReturnValue(true) |
| 80 | + vi.mocked(fileExistsSync).mockReturnValue(false) |
| 81 | + |
| 82 | + const options = {root: '/workspace'} as Options |
| 83 | + new ShopifyConfig(options) |
| 84 | + |
| 85 | + expect((options as {pluginAdditions?: unknown}).pluginAdditions).toBeUndefined() |
| 86 | + }) |
| 87 | + |
| 88 | + test('uses sniffForPath result over cwd when available', () => { |
| 89 | + vi.mocked(isDevelopment).mockReturnValue(true) |
| 90 | + vi.mocked(sniffForPath).mockReturnValue('/sniffed/path') |
| 91 | + vi.mocked(fileExistsSync).mockReturnValue(true) |
| 92 | + |
| 93 | + const options = {root: '/workspace'} as Options |
| 94 | + new ShopifyConfig(options) |
| 95 | + |
| 96 | + expect((options as {pluginAdditions?: unknown}).pluginAdditions).toMatchObject({path: '/sniffed/path'}) |
| 97 | + }) |
| 98 | + |
| 99 | + test('runs npm prefix when cwd matches hydrogen monorepo pattern', () => { |
| 100 | + vi.mocked(isDevelopment).mockReturnValue(true) |
| 101 | + vi.mocked(cwd).mockReturnValue('/home/user/shopify/hydrogen/packages/cli') |
| 102 | + vi.mocked(execaSync).mockReturnValue({stdout: '/home/user/shopify/hydrogen'} as unknown as ReturnType<typeof execaSync>) |
| 103 | + vi.mocked(fileExistsSync).mockReturnValue(true) |
| 104 | + |
| 105 | + const options = {root: '/workspace'} as Options |
| 106 | + new ShopifyConfig(options) |
| 107 | + |
| 108 | + expect(execaSync).toHaveBeenCalledWith('npm', ['prefix']) |
| 109 | + expect((options as {pluginAdditions?: unknown}).pluginAdditions).toMatchObject({path: '/home/user/shopify/hydrogen'}) |
| 110 | + }) |
| 111 | + |
| 112 | + test('also matches hydrogen/hydrogen CI path pattern', () => { |
| 113 | + vi.mocked(isDevelopment).mockReturnValue(true) |
| 114 | + vi.mocked(cwd).mockReturnValue('/runner/hydrogen/hydrogen/packages/cli') |
| 115 | + vi.mocked(execaSync).mockReturnValue({stdout: '/runner/hydrogen/hydrogen'} as unknown as ReturnType<typeof execaSync>) |
| 116 | + vi.mocked(fileExistsSync).mockReturnValue(true) |
| 117 | + |
| 118 | + new ShopifyConfig({root: '/workspace'} as Options) |
| 119 | + |
| 120 | + expect(execaSync).toHaveBeenCalledWith('npm', ['prefix']) |
| 121 | + }) |
| 122 | + |
| 123 | + test('skips npm prefix when IGNORE_HYDROGEN_MONOREPO is set', () => { |
| 124 | + vi.mocked(isDevelopment).mockReturnValue(true) |
| 125 | + vi.mocked(cwd).mockReturnValue('/home/user/shopify/hydrogen/packages/cli') |
| 126 | + vi.mocked(fileExistsSync).mockReturnValue(true) |
| 127 | + process.env.IGNORE_HYDROGEN_MONOREPO = '1' |
| 128 | + |
| 129 | + new ShopifyConfig({root: '/workspace'} as Options) |
| 130 | + |
| 131 | + expect(execaSync).not.toHaveBeenCalled() |
| 132 | + }) |
| 133 | + }) |
| 134 | + |
| 135 | + describe('load()', () => { |
| 136 | + test('does not replace commands when not in dev mode', async () => { |
| 137 | + vi.mocked(isDevelopment).mockReturnValue(false) |
| 138 | + |
| 139 | + const config = new ShopifyConfig({root: '/workspace'} as Options) |
| 140 | + const hydrogenPlugin = {name: '@shopify/cli-hydrogen', isRoot: false, commands: [{id: 'hydrogen:dev', aliases: [], hiddenAliases: []}]} |
| 141 | + asMock(config).plugins.set('@shopify/cli-hydrogen', hydrogenPlugin) |
| 142 | + asMock(config)._commands!.set('hydrogen:dev', {bundled: true}) |
| 143 | + |
| 144 | + await config.load() |
| 145 | + |
| 146 | + expect(asMock(config)._commands!.has('hydrogen:dev')).toBe(true) |
| 147 | + expect(asMock(config).loadCommandsCalls).toHaveLength(0) |
| 148 | + }) |
| 149 | + |
| 150 | + test('does not replace commands when no external hydrogen plugin is present', async () => { |
| 151 | + vi.mocked(isDevelopment).mockReturnValue(true) |
| 152 | + |
| 153 | + const config = new ShopifyConfig({root: '/workspace'} as Options) |
| 154 | + asMock(config)._commands!.set('hydrogen:dev', {bundled: true}) |
| 155 | + |
| 156 | + await config.load() |
| 157 | + |
| 158 | + expect(asMock(config)._commands!.has('hydrogen:dev')).toBe(true) |
| 159 | + expect(asMock(config).loadCommandsCalls).toHaveLength(0) |
| 160 | + }) |
| 161 | + |
| 162 | + test('does not replace commands when the hydrogen plugin is the root plugin', async () => { |
| 163 | + vi.mocked(isDevelopment).mockReturnValue(true) |
| 164 | + |
| 165 | + const config = new ShopifyConfig({root: '/workspace'} as Options) |
| 166 | + asMock(config).plugins.set('@shopify/cli-hydrogen', {name: '@shopify/cli-hydrogen', isRoot: true, commands: []}) |
| 167 | + asMock(config)._commands!.set('hydrogen:dev', {bundled: true}) |
| 168 | + |
| 169 | + await config.load() |
| 170 | + |
| 171 | + expect(asMock(config)._commands!.has('hydrogen:dev')).toBe(true) |
| 172 | + expect(asMock(config).loadCommandsCalls).toHaveLength(0) |
| 173 | + }) |
| 174 | + |
| 175 | + test('removes bundled hydrogen commands and reloads from external plugin', async () => { |
| 176 | + vi.mocked(isDevelopment).mockReturnValue(true) |
| 177 | + |
| 178 | + const config = new ShopifyConfig({root: '/workspace'} as Options) |
| 179 | + const externalPlugin = { |
| 180 | + name: '@shopify/cli-hydrogen', |
| 181 | + isRoot: false, |
| 182 | + commands: [ |
| 183 | + {id: 'hydrogen:dev', aliases: ['h:dev'], hiddenAliases: ['hydrogen:develop']}, |
| 184 | + {id: 'hydrogen:build', aliases: [], hiddenAliases: undefined}, |
| 185 | + ], |
| 186 | + } |
| 187 | + asMock(config).plugins.set('@shopify/cli-hydrogen', externalPlugin) |
| 188 | + |
| 189 | + // Populate _commands with bundled versions of hydrogen commands plus an unrelated one |
| 190 | + asMock(config)._commands!.set('hydrogen:dev', {bundled: true}) |
| 191 | + asMock(config)._commands!.set('h:dev', {bundled: true}) |
| 192 | + asMock(config)._commands!.set('hydrogen:develop', {bundled: true}) |
| 193 | + asMock(config)._commands!.set('hydrogen:build', {bundled: true}) |
| 194 | + asMock(config)._commands!.set('app:dev', {bundled: true}) |
| 195 | + |
| 196 | + await config.load() |
| 197 | + |
| 198 | + // All bundled hydrogen entries (canonical + aliases + hidden aliases) are gone |
| 199 | + expect(asMock(config)._commands!.has('hydrogen:dev')).toBe(false) |
| 200 | + expect(asMock(config)._commands!.has('h:dev')).toBe(false) |
| 201 | + expect(asMock(config)._commands!.has('hydrogen:develop')).toBe(false) |
| 202 | + expect(asMock(config)._commands!.has('hydrogen:build')).toBe(false) |
| 203 | + |
| 204 | + // Non-hydrogen commands are untouched |
| 205 | + expect(asMock(config)._commands!.has('app:dev')).toBe(true) |
| 206 | + |
| 207 | + // loadCommands is called exactly once with the external plugin |
| 208 | + expect(asMock(config).loadCommandsCalls).toHaveLength(1) |
| 209 | + expect(asMock(config).loadCommandsCalls[0]).toBe(externalPlugin) |
| 210 | + }) |
| 211 | + |
| 212 | + test('only removes commands whose id starts with "hydrogen"', async () => { |
| 213 | + vi.mocked(isDevelopment).mockReturnValue(true) |
| 214 | + |
| 215 | + const config = new ShopifyConfig({root: '/workspace'} as Options) |
| 216 | + const externalPlugin = { |
| 217 | + name: '@shopify/cli-hydrogen', |
| 218 | + isRoot: false, |
| 219 | + // A non-hydrogen-prefixed command shipped by the hydrogen plugin |
| 220 | + commands: [{id: 'app:generate:route', aliases: [], hiddenAliases: []}], |
| 221 | + } |
| 222 | + asMock(config).plugins.set('@shopify/cli-hydrogen', externalPlugin) |
| 223 | + asMock(config)._commands!.set('app:generate:route', {bundled: true}) |
| 224 | + |
| 225 | + await config.load() |
| 226 | + |
| 227 | + // The command is not hydrogen-prefixed so it must not be removed |
| 228 | + expect(asMock(config)._commands!.has('app:generate:route')).toBe(true) |
| 229 | + }) |
| 230 | + |
| 231 | + test('throws a descriptive error when _commands is unavailable, catching future oclif API changes', async () => { |
| 232 | + vi.mocked(isDevelopment).mockReturnValue(true) |
| 233 | + |
| 234 | + const config = new ShopifyConfig({root: '/workspace'} as Options) |
| 235 | + asMock(config).plugins.set('@shopify/cli-hydrogen', { |
| 236 | + name: '@shopify/cli-hydrogen', |
| 237 | + isRoot: false, |
| 238 | + commands: [], |
| 239 | + }) |
| 240 | + |
| 241 | + // Simulate oclif removing the private _commands property |
| 242 | + asMock(config)._commands = undefined |
| 243 | + |
| 244 | + await expect(config.load()).rejects.toThrow( |
| 245 | + 'ShopifyConfig: oclif internals changed. _commands is no longer available.', |
| 246 | + ) |
| 247 | + }) |
| 248 | + }) |
| 249 | + |
| 250 | + // These tests use the REAL @oclif/core (via vi.importActual) so they will fail |
| 251 | + // if oclif removes or renames the private APIs that ShopifyConfig depends on. |
| 252 | + // The mock above intentionally replaces oclif for logic isolation; this block |
| 253 | + // provides the missing contract check against the installed package version. |
| 254 | + describe('oclif API contract', () => { |
| 255 | + test('Config still has a loadCommands method on its prototype', async () => { |
| 256 | + const {Config: RealConfig} = await vi.importActual<typeof import('@oclif/core')>('@oclif/core') |
| 257 | + |
| 258 | + // ShopifyConfig calls this.loadCommands(plugin) via @ts-expect-error. |
| 259 | + // If oclif removes or renames this method, this assertion will catch it. |
| 260 | + expect(typeof (RealConfig as unknown as {prototype: Record<string, unknown>}).prototype.loadCommands).toBe( |
| 261 | + 'function', |
| 262 | + ) |
| 263 | + }) |
| 264 | + |
| 265 | + test('Config instances still have a _commands own property after construction', async () => { |
| 266 | + const {Config: RealConfig} = await vi.importActual<typeof import('@oclif/core')>('@oclif/core') |
| 267 | + |
| 268 | + // _commands is a class field initialized in the constructor, so it appears as an |
| 269 | + // own property on every instance even before load() is called. |
| 270 | + // ShopifyConfig reads and mutates this._commands as a Map — if oclif renames or |
| 271 | + // restructures it, this assertion will fail. |
| 272 | + const instance = new (RealConfig as new (options: {root: string}) => OclifConfig)({root: process.cwd()}) |
| 273 | + expect(Object.prototype.hasOwnProperty.call(instance, '_commands')).toBe(true) |
| 274 | + }) |
| 275 | + }) |
| 276 | +}) |
0 commit comments