Skip to content

Commit 12d1ef8

Browse files
fix(cli): Fix crash when cancelling follow-up feature selection during plugin creation (vendurehq#4371)
1 parent 44cf9ba commit 12d1ef8

File tree

2 files changed

+144
-7
lines changed

2 files changed

+144
-7
lines changed
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
// Cancel symbol to simulate user pressing Ctrl+C / Escape in prompts
4+
const CANCEL_SYMBOL = Symbol('clack:cancel');
5+
6+
// Mock @clack/prompts
7+
vi.mock('@clack/prompts', () => ({
8+
intro: vi.fn(),
9+
cancel: vi.fn(),
10+
isCancel: vi.fn((value: unknown) => value === CANCEL_SYMBOL),
11+
select: vi.fn(),
12+
text: vi.fn(),
13+
spinner: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })),
14+
log: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() },
15+
}));
16+
17+
// Mock shared prompts
18+
vi.mock('../../../shared/shared-prompts', () => ({
19+
analyzeProject: vi.fn(),
20+
selectPlugin: vi.fn(),
21+
}));
22+
23+
// Mock VendureConfigRef
24+
vi.mock('../../../shared/vendure-config-ref', () => ({
25+
VendureConfigRef: vi.fn(),
26+
}));
27+
28+
// Mock ast-utils
29+
vi.mock('../../../utilities/ast-utils', () => ({
30+
createFile: vi.fn(),
31+
getPluginClasses: vi.fn(() => []),
32+
addImportsToFile: vi.fn(),
33+
}));
34+
35+
// Mock utils
36+
vi.mock('../../../utilities/utils', () => ({
37+
pauseForPromptDisplay: vi.fn().mockResolvedValue(undefined),
38+
withInteractiveTimeout: vi.fn((fn: () => Promise<any>) => fn()),
39+
isRunningInTsNode: vi.fn(() => false),
40+
}));
41+
42+
// Mock fs-extra
43+
vi.mock('fs-extra', () => ({
44+
existsSync: vi.fn(() => false),
45+
default: { existsSync: vi.fn(() => false) },
46+
}));
47+
48+
import { select, text } from '@clack/prompts';
49+
50+
import { analyzeProject } from '../../../shared/shared-prompts';
51+
import { VendureConfigRef } from '../../../shared/vendure-config-ref';
52+
import { createFile } from '../../../utilities/ast-utils';
53+
54+
import { createNewPlugin } from './create-new-plugin';
55+
56+
function setupMocks() {
57+
// Re-apply VendureConfigRef mock each time (vi.restoreAllMocks clears it)
58+
vi.mocked(VendureConfigRef).mockImplementation((() => ({
59+
addToPluginsArray: vi.fn(),
60+
sourceFile: {
61+
getProject: vi.fn(() => ({ save: vi.fn().mockResolvedValue(undefined) })),
62+
},
63+
})) as any);
64+
65+
const mockPluginClass = {
66+
rename: vi.fn(),
67+
getName: vi.fn(() => 'TestFeaturePlugin'),
68+
getSourceFile: vi.fn(() => ({})),
69+
};
70+
71+
const mockImportDecl = { setModuleSpecifier: vi.fn() };
72+
const mockVarDecl = { rename: vi.fn().mockReturnThis(), set: vi.fn() };
73+
74+
const mockPluginFile = {
75+
getClass: vi.fn((name: string) => (name === 'TemplatePlugin' ? mockPluginClass : undefined)),
76+
getImportDeclaration: vi.fn(() => mockImportDecl),
77+
organizeImports: vi.fn(),
78+
};
79+
const mockTypesFile = {
80+
getClass: vi.fn(() => undefined),
81+
organizeImports: vi.fn(),
82+
};
83+
const mockConstantsFile = {
84+
getClass: vi.fn(() => undefined),
85+
getVariableDeclaration: vi.fn(() => mockVarDecl),
86+
organizeImports: vi.fn(),
87+
};
88+
89+
vi.mocked(analyzeProject).mockResolvedValue({
90+
project: { save: vi.fn().mockResolvedValue(undefined) } as any,
91+
config: undefined,
92+
vendureTsConfig: '/tmp/tsconfig.json',
93+
} as any);
94+
95+
vi.mocked(createFile)
96+
.mockReturnValueOnce(mockPluginFile as any)
97+
.mockReturnValueOnce(mockTypesFile as any)
98+
.mockReturnValueOnce(mockConstantsFile as any);
99+
100+
// text is called twice in interactive mode: plugin name, then plugin location
101+
vi.mocked(text).mockResolvedValueOnce('test-feature').mockResolvedValueOnce('/tmp/plugins/test-feature');
102+
}
103+
104+
describe('createNewPlugin', () => {
105+
beforeEach(() => {
106+
vi.clearAllMocks();
107+
});
108+
109+
afterEach(() => {
110+
vi.restoreAllMocks();
111+
});
112+
113+
describe('follow-up feature selection', () => {
114+
it('should not throw when cancelling the follow-up feature selection', async () => {
115+
setupMocks();
116+
// Simulate user pressing Ctrl+C/Escape during "Add features to plugin?" prompt
117+
vi.mocked(select).mockResolvedValueOnce(CANCEL_SYMBOL);
118+
119+
// With the bug, this throws:
120+
// "TypeError: Cannot read properties of undefined (reading 'id')"
121+
// because the cancel check doesn't prevent falling through to the else branch
122+
const result = await createNewPlugin();
123+
124+
expect(result).toBeDefined();
125+
expect(result.project).toBeDefined();
126+
expect(result.modifiedSourceFiles).toBeDefined();
127+
});
128+
129+
it('should exit cleanly when user selects "no" (finish)', async () => {
130+
setupMocks();
131+
vi.mocked(select).mockResolvedValueOnce('no');
132+
133+
const result = await createNewPlugin();
134+
135+
expect(result).toBeDefined();
136+
expect(result.project).toBeDefined();
137+
});
138+
});
139+
});

packages/cli/src/commands/add/plugin/create-new-plugin.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -134,15 +134,13 @@ export async function createNewPlugin(
134134
});
135135
});
136136

137-
if (isCancel(featureType)) {
138-
done = true;
139-
}
140-
if (featureType === 'no') {
137+
if (isCancel(featureType) || featureType === 'no') {
141138
done = true;
142139
} else {
143-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
144-
const command = followUpCommands.find(c => c.id === featureType)!;
145-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
140+
const command = followUpCommands.find(c => c.id === featureType);
141+
if (!command) {
142+
break;
143+
}
146144
try {
147145
const result = await command.run({ plugin });
148146
allModifiedSourceFiles = result.modifiedSourceFiles;

0 commit comments

Comments
 (0)