Skip to content

Commit 511a2de

Browse files
committed
chore: address comments and adds tests
1 parent 2b31382 commit 511a2de

File tree

2 files changed

+317
-23
lines changed

2 files changed

+317
-23
lines changed
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
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+
})

packages/cli-kit/src/public/node/custom-oclif-loader.ts

Lines changed: 41 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import {fileExistsSync} from './fs.js'
2-
import {cwd, joinPath, sniffForPath} from './path.js'
3-
import {isDevelopment} from './context/local.js'
4-
import {execaSync} from 'execa'
5-
import {Config} from '@oclif/core'
6-
import {Options} from '@oclif/core/interfaces'
1+
import { fileExistsSync } from './fs.js'
2+
import { cwd, joinPath, sniffForPath } from './path.js'
3+
import { isDevelopment } from './context/local.js'
4+
import { execaSync } from 'execa'
5+
import { Config } from '@oclif/core'
6+
import { Options } from '@oclif/core/interfaces'
77

88
export class ShopifyConfig extends Config {
99
constructor(options: Options) {
@@ -38,24 +38,42 @@ export class ShopifyConfig extends Config {
3838
async load(): Promise<void> {
3939
await super.load()
4040

41-
if (isDevelopment()) {
42-
// Let OCLIF load all commands first, then manually replace bundled hydrogen
43-
// commands with external ones after loading completes.
44-
const externalHydrogenPlugin = Array.from(this.plugins.values()).find(
45-
(plugin) => plugin.name === '@shopify/cli-hydrogen' && !plugin.isRoot,
46-
)
47-
48-
if (externalHydrogenPlugin) {
49-
for (const command of externalHydrogenPlugin.commands) {
50-
if (command.id.startsWith('hydrogen')) {
51-
// @ts-expect-error: _commands is private but we need to replace bundled commands
52-
if (this._commands.has(command.id)) {
53-
// @ts-expect-error: _commands is private but we need to replace bundled commands
54-
this._commands.set(command.id, command)
55-
}
56-
}
57-
}
41+
if (!isDevelopment()) return
42+
43+
// Let OCLIF load all commands first, then manually replace bundled hydrogen
44+
// commands with external ones after loading completes.
45+
const externalHydrogenPlugin = Array.from(this.plugins.values()).find(
46+
(plugin) => plugin.name === '@shopify/cli-hydrogen' && !plugin.isRoot,
47+
)
48+
49+
if (!externalHydrogenPlugin) return
50+
51+
if (typeof (this as Record<string, unknown>)._commands === 'undefined') {
52+
throw new Error('ShopifyConfig: oclif internals changed. _commands is no longer available.')
53+
}
54+
55+
// Extract _commands once to avoid repeated @ts-expect-error suppressions.
56+
// @ts-expect-error: _commands is private but we need to replace bundled commands
57+
const internalCommands = this._commands as Map<string, unknown>
58+
59+
// Delete all bundled hydrogen command entries (canonical IDs, aliases, and hidden aliases)
60+
// before reloading from the external plugin. This mirrors oclif's own insertLegacyPlugins
61+
// pattern and ensures alias entries don't continue pointing to the bundled version.
62+
for (const command of externalHydrogenPlugin.commands) {
63+
if (!command.id.startsWith('hydrogen')) continue
64+
internalCommands.delete(command.id)
65+
const allAliases = [
66+
...(command.aliases ?? []),
67+
...((command as { hiddenAliases?: string[] }).hiddenAliases ?? []),
68+
]
69+
for (const alias of allAliases) {
70+
internalCommands.delete(alias)
5871
}
5972
}
73+
74+
// Let oclif's own loadCommands re-insert commands with proper alias and permutation
75+
// handling, mirroring the insertLegacyPlugins pattern used for legacy plugins.
76+
// @ts-expect-error: loadCommands is private but handles aliases/permutations correctly
77+
this.loadCommands(externalHydrogenPlugin)
6078
}
6179
}

0 commit comments

Comments
 (0)