@shopify/cli-kit 3.90.1 → 3.91.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/private/node/constants.d.ts +1 -1
- package/dist/private/node/constants.js +1 -1
- package/dist/private/node/constants.js.map +1 -1
- package/dist/private/node/otel-metrics.js +4 -1
- package/dist/private/node/otel-metrics.js.map +1 -1
- package/dist/public/common/string.d.ts +7 -0
- package/dist/public/common/string.js +10 -1
- package/dist/public/common/string.js.map +1 -1
- package/dist/public/common/version.d.ts +1 -1
- package/dist/public/common/version.js +1 -1
- package/dist/public/common/version.js.map +1 -1
- package/dist/public/node/analytics.js +2 -0
- package/dist/public/node/analytics.js.map +1 -1
- package/dist/public/node/context/fqdn.d.ts +8 -0
- package/dist/public/node/context/fqdn.js +14 -0
- package/dist/public/node/context/fqdn.js.map +1 -1
- package/dist/public/node/context/local.d.ts +3 -3
- package/dist/public/node/context/local.js +4 -4
- package/dist/public/node/context/local.js.map +1 -1
- package/dist/public/node/doctor/framework.d.ts +162 -0
- package/dist/public/node/doctor/framework.js +355 -0
- package/dist/public/node/doctor/framework.js.map +1 -0
- package/dist/public/node/doctor/reporter.d.ts +10 -0
- package/dist/public/node/doctor/reporter.js +90 -0
- package/dist/public/node/doctor/reporter.js.map +1 -0
- package/dist/public/node/doctor/types.d.ts +21 -0
- package/dist/public/node/doctor/types.js +2 -0
- package/dist/public/node/doctor/types.js.map +1 -0
- package/dist/public/node/error-handler.js +5 -1
- package/dist/public/node/error-handler.js.map +1 -1
- package/dist/public/node/system.d.ts +54 -0
- package/dist/public/node/system.js +146 -2
- package/dist/public/node/system.js.map +1 -1
- package/dist/public/node/themes/api.js +0 -1
- package/dist/public/node/themes/api.js.map +1 -1
- package/dist/public/node/themes/types.d.ts +2 -1
- package/dist/public/node/themes/types.js.map +1 -1
- package/dist/public/node/ui.js +3 -0
- package/dist/public/node/ui.js.map +1 -1
- package/dist/public/node/vendor/otel-js/export/InstantaneousMetricReader.js +5 -7
- package/dist/public/node/vendor/otel-js/export/InstantaneousMetricReader.js.map +1 -1
- package/dist/public/node/vendor/otel-js/utils/throttle.js +6 -0
- package/dist/public/node/vendor/otel-js/utils/throttle.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import type { DoctorContext, TestResult } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Result from running a CLI command.
|
|
4
|
+
*/
|
|
5
|
+
interface CommandResult {
|
|
6
|
+
/** The full command that was run */
|
|
7
|
+
command: string;
|
|
8
|
+
/** Exit code (0 = success) */
|
|
9
|
+
exitCode: number;
|
|
10
|
+
/** Standard output */
|
|
11
|
+
stdout: string;
|
|
12
|
+
/** Standard error */
|
|
13
|
+
stderr: string;
|
|
14
|
+
/** Combined output (stdout + stderr) */
|
|
15
|
+
output: string;
|
|
16
|
+
/** Whether the command succeeded (exitCode === 0) */
|
|
17
|
+
success: boolean;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Base class for doctor test suites.
|
|
21
|
+
*
|
|
22
|
+
* Write tests using the test() method:.
|
|
23
|
+
*
|
|
24
|
+
* ```typescript
|
|
25
|
+
* export default class MyTests extends DoctorSuite {
|
|
26
|
+
* static description = 'My test suite'
|
|
27
|
+
*
|
|
28
|
+
* tests() {
|
|
29
|
+
* this.test('basic case', async () => {
|
|
30
|
+
* const result = await this.run('shopify theme init')
|
|
31
|
+
* this.assertSuccess(result)
|
|
32
|
+
* })
|
|
33
|
+
*
|
|
34
|
+
* this.test('error case', async () => {
|
|
35
|
+
* const result = await this.run('shopify theme init --invalid')
|
|
36
|
+
* this.assertError(result, /unknown flag/)
|
|
37
|
+
* })
|
|
38
|
+
* }
|
|
39
|
+
* }
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export declare abstract class DoctorSuite<TContext extends DoctorContext = DoctorContext> {
|
|
43
|
+
static description: string;
|
|
44
|
+
protected context: TContext;
|
|
45
|
+
private assertions;
|
|
46
|
+
private registeredTests;
|
|
47
|
+
/**
|
|
48
|
+
* Run the entire test suite.
|
|
49
|
+
*
|
|
50
|
+
* @param context - The doctor context for this suite run.
|
|
51
|
+
*/
|
|
52
|
+
runSuite(context: TContext): Promise<TestResult[]>;
|
|
53
|
+
/**
|
|
54
|
+
* Register a test with a name and function.
|
|
55
|
+
*
|
|
56
|
+
* @param name - The test name.
|
|
57
|
+
* @param fn - The async test function.
|
|
58
|
+
*/
|
|
59
|
+
protected test(name: string, fn: () => Promise<void>): void;
|
|
60
|
+
/**
|
|
61
|
+
* Override this method to register tests using this.test().
|
|
62
|
+
*/
|
|
63
|
+
protected tests(): void;
|
|
64
|
+
/**
|
|
65
|
+
* Run a CLI command and return the result.
|
|
66
|
+
*
|
|
67
|
+
* @param command - The CLI command to run.
|
|
68
|
+
* @param options - Optional cwd and env overrides.
|
|
69
|
+
* @example
|
|
70
|
+
* const result = await this.run('shopify theme init my-theme')
|
|
71
|
+
* const result = await this.run('shopify theme push --json')
|
|
72
|
+
*/
|
|
73
|
+
protected run(command: string, options?: {
|
|
74
|
+
cwd?: string;
|
|
75
|
+
env?: {
|
|
76
|
+
[key: string]: string;
|
|
77
|
+
};
|
|
78
|
+
}): Promise<CommandResult>;
|
|
79
|
+
/**
|
|
80
|
+
* Run a command without capturing output (for interactive commands).
|
|
81
|
+
* Returns only success/failure.
|
|
82
|
+
*
|
|
83
|
+
* @param command - The CLI command to run.
|
|
84
|
+
* @param options - Optional cwd and env overrides.
|
|
85
|
+
*/
|
|
86
|
+
protected runInteractive(command: string, options?: {
|
|
87
|
+
cwd?: string;
|
|
88
|
+
env?: {
|
|
89
|
+
[key: string]: string;
|
|
90
|
+
};
|
|
91
|
+
}): Promise<CommandResult>;
|
|
92
|
+
/**
|
|
93
|
+
* Assert that a command succeeded (exit code 0).
|
|
94
|
+
*
|
|
95
|
+
* @param result - The command result to check.
|
|
96
|
+
* @param message - Optional custom assertion message.
|
|
97
|
+
*/
|
|
98
|
+
protected assertSuccess(result: CommandResult, message?: string): void;
|
|
99
|
+
/**
|
|
100
|
+
* Assert that a command failed with an error matching the pattern.
|
|
101
|
+
*
|
|
102
|
+
* @param result - The command result to check.
|
|
103
|
+
* @param pattern - Optional regex or string pattern to match against output.
|
|
104
|
+
* @param message - Optional custom assertion message.
|
|
105
|
+
*/
|
|
106
|
+
protected assertError(result: CommandResult, pattern?: RegExp | string, message?: string): void;
|
|
107
|
+
/**
|
|
108
|
+
* Assert that a file exists and optionally matches content.
|
|
109
|
+
*
|
|
110
|
+
* @param path - The file path to check.
|
|
111
|
+
* @param contentPattern - Optional regex or string to match file content.
|
|
112
|
+
* @param message - Optional custom assertion message.
|
|
113
|
+
*/
|
|
114
|
+
protected assertFile(path: string, contentPattern?: RegExp | string, message?: string): Promise<void>;
|
|
115
|
+
/**
|
|
116
|
+
* Assert that a file does not exist.
|
|
117
|
+
*
|
|
118
|
+
* @param path - The file path to check.
|
|
119
|
+
* @param message - Optional custom assertion message.
|
|
120
|
+
*/
|
|
121
|
+
protected assertNoFile(path: string, message?: string): Promise<void>;
|
|
122
|
+
/**
|
|
123
|
+
* Assert that a directory exists.
|
|
124
|
+
*
|
|
125
|
+
* @param path - The directory path to check.
|
|
126
|
+
* @param message - Optional custom assertion message.
|
|
127
|
+
*/
|
|
128
|
+
protected assertDirectory(path: string, message?: string): Promise<void>;
|
|
129
|
+
/**
|
|
130
|
+
* Assert that output contains a pattern.
|
|
131
|
+
*
|
|
132
|
+
* @param result - The command result to check.
|
|
133
|
+
* @param pattern - Regex or string pattern to match against output.
|
|
134
|
+
* @param message - Optional custom assertion message.
|
|
135
|
+
*/
|
|
136
|
+
protected assertOutput(result: CommandResult, pattern: RegExp | string, message?: string): void;
|
|
137
|
+
/**
|
|
138
|
+
* Assert that output contains valid JSON and optionally validate it.
|
|
139
|
+
*
|
|
140
|
+
* @param result - The command result to parse.
|
|
141
|
+
* @param validator - Optional function to validate the parsed JSON.
|
|
142
|
+
* @param message - Optional custom assertion message.
|
|
143
|
+
*/
|
|
144
|
+
protected assertJson<T = unknown>(result: CommandResult, validator?: (json: T) => boolean, message?: string): T | undefined;
|
|
145
|
+
/**
|
|
146
|
+
* Assert a boolean condition.
|
|
147
|
+
*
|
|
148
|
+
* @param condition - The boolean condition to assert.
|
|
149
|
+
* @param message - The assertion description.
|
|
150
|
+
*/
|
|
151
|
+
protected assert(condition: boolean, message: string): void;
|
|
152
|
+
/**
|
|
153
|
+
* Assert two values are equal.
|
|
154
|
+
*
|
|
155
|
+
* @param actual - The actual value.
|
|
156
|
+
* @param expected - The expected value.
|
|
157
|
+
* @param message - The assertion description.
|
|
158
|
+
*/
|
|
159
|
+
protected assertEqual<T>(actual: T, expected: T, message: string): void;
|
|
160
|
+
private hasFailures;
|
|
161
|
+
}
|
|
162
|
+
export {};
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import { fileExists, readFile } from '../fs.js';
|
|
2
|
+
import { isAbsolutePath, joinPath, relativePath } from '../path.js';
|
|
3
|
+
import { execCommand, captureCommandWithExitCode } from '../system.js';
|
|
4
|
+
/**
|
|
5
|
+
* Base class for doctor test suites.
|
|
6
|
+
*
|
|
7
|
+
* Write tests using the test() method:.
|
|
8
|
+
*
|
|
9
|
+
* ```typescript
|
|
10
|
+
* export default class MyTests extends DoctorSuite {
|
|
11
|
+
* static description = 'My test suite'
|
|
12
|
+
*
|
|
13
|
+
* tests() {
|
|
14
|
+
* this.test('basic case', async () => {
|
|
15
|
+
* const result = await this.run('shopify theme init')
|
|
16
|
+
* this.assertSuccess(result)
|
|
17
|
+
* })
|
|
18
|
+
*
|
|
19
|
+
* this.test('error case', async () => {
|
|
20
|
+
* const result = await this.run('shopify theme init --invalid')
|
|
21
|
+
* this.assertError(result, /unknown flag/)
|
|
22
|
+
* })
|
|
23
|
+
* }
|
|
24
|
+
* }
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export class DoctorSuite {
|
|
28
|
+
constructor() {
|
|
29
|
+
this.assertions = [];
|
|
30
|
+
this.registeredTests = [];
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Run the entire test suite.
|
|
34
|
+
*
|
|
35
|
+
* @param context - The doctor context for this suite run.
|
|
36
|
+
*/
|
|
37
|
+
async runSuite(context) {
|
|
38
|
+
this.context = context;
|
|
39
|
+
this.registeredTests = [];
|
|
40
|
+
const results = [];
|
|
41
|
+
// Call tests() to register tests via this.test()
|
|
42
|
+
this.tests();
|
|
43
|
+
// Run all registered tests
|
|
44
|
+
for (const registeredTest of this.registeredTests) {
|
|
45
|
+
this.assertions = [];
|
|
46
|
+
const startTime = Date.now();
|
|
47
|
+
try {
|
|
48
|
+
// eslint-disable-next-line no-await-in-loop
|
|
49
|
+
await registeredTest.fn();
|
|
50
|
+
results.push({
|
|
51
|
+
name: registeredTest.name,
|
|
52
|
+
status: this.hasFailures() ? 'failed' : 'passed',
|
|
53
|
+
duration: Date.now() - startTime,
|
|
54
|
+
assertions: [...this.assertions],
|
|
55
|
+
});
|
|
56
|
+
// eslint-disable-next-line no-catch-all/no-catch-all
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
results.push({
|
|
60
|
+
name: registeredTest.name,
|
|
61
|
+
status: 'failed',
|
|
62
|
+
duration: Date.now() - startTime,
|
|
63
|
+
assertions: [...this.assertions],
|
|
64
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return results;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Register a test with a name and function.
|
|
72
|
+
*
|
|
73
|
+
* @param name - The test name.
|
|
74
|
+
* @param fn - The async test function.
|
|
75
|
+
*/
|
|
76
|
+
test(name, fn) {
|
|
77
|
+
this.registeredTests.push({ name, fn });
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Override this method to register tests using this.test().
|
|
81
|
+
*/
|
|
82
|
+
tests() {
|
|
83
|
+
// Subclasses override this to register tests
|
|
84
|
+
}
|
|
85
|
+
// ============================================
|
|
86
|
+
// Command execution
|
|
87
|
+
// ============================================
|
|
88
|
+
/**
|
|
89
|
+
* Run a CLI command and return the result.
|
|
90
|
+
*
|
|
91
|
+
* @param command - The CLI command to run.
|
|
92
|
+
* @param options - Optional cwd and env overrides.
|
|
93
|
+
* @example
|
|
94
|
+
* const result = await this.run('shopify theme init my-theme')
|
|
95
|
+
* const result = await this.run('shopify theme push --json')
|
|
96
|
+
*/
|
|
97
|
+
async run(command, options) {
|
|
98
|
+
const cwd = options?.cwd ?? this.context.workingDirectory;
|
|
99
|
+
const result = await captureCommandWithExitCode(command, { cwd, env: options?.env });
|
|
100
|
+
return {
|
|
101
|
+
command,
|
|
102
|
+
exitCode: result.exitCode,
|
|
103
|
+
stdout: result.stdout,
|
|
104
|
+
stderr: result.stderr,
|
|
105
|
+
output: String(result.stdout) + String(result.stderr),
|
|
106
|
+
success: result.exitCode === 0,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Run a command without capturing output (for interactive commands).
|
|
111
|
+
* Returns only success/failure.
|
|
112
|
+
*
|
|
113
|
+
* @param command - The CLI command to run.
|
|
114
|
+
* @param options - Optional cwd and env overrides.
|
|
115
|
+
*/
|
|
116
|
+
async runInteractive(command, options) {
|
|
117
|
+
const cwd = options?.cwd ?? this.context.workingDirectory;
|
|
118
|
+
let exitCode = 0;
|
|
119
|
+
try {
|
|
120
|
+
await execCommand(command, { cwd, env: options?.env, stdin: 'inherit' });
|
|
121
|
+
// eslint-disable-next-line no-catch-all/no-catch-all
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
exitCode = 1;
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
command,
|
|
128
|
+
exitCode,
|
|
129
|
+
stdout: '',
|
|
130
|
+
stderr: '',
|
|
131
|
+
output: '',
|
|
132
|
+
success: exitCode === 0,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
// ============================================
|
|
136
|
+
// Assertions
|
|
137
|
+
// ============================================
|
|
138
|
+
/**
|
|
139
|
+
* Assert that a command succeeded (exit code 0).
|
|
140
|
+
*
|
|
141
|
+
* @param result - The command result to check.
|
|
142
|
+
* @param message - Optional custom assertion message.
|
|
143
|
+
*/
|
|
144
|
+
assertSuccess(result, message) {
|
|
145
|
+
this.assertions.push({
|
|
146
|
+
description: message ?? `Command succeeded: ${result.command}`,
|
|
147
|
+
passed: result.success,
|
|
148
|
+
expected: 'exit code 0',
|
|
149
|
+
actual: `exit code ${result.exitCode}`,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Assert that a command failed with an error matching the pattern.
|
|
154
|
+
*
|
|
155
|
+
* @param result - The command result to check.
|
|
156
|
+
* @param pattern - Optional regex or string pattern to match against output.
|
|
157
|
+
* @param message - Optional custom assertion message.
|
|
158
|
+
*/
|
|
159
|
+
assertError(result, pattern, message) {
|
|
160
|
+
const failed = !result.success;
|
|
161
|
+
if (pattern) {
|
|
162
|
+
const regex = typeof pattern === 'string' ? new RegExp(pattern) : pattern;
|
|
163
|
+
const matches = regex.test(result.output);
|
|
164
|
+
let actualValue;
|
|
165
|
+
if (!failed) {
|
|
166
|
+
actualValue = 'command succeeded';
|
|
167
|
+
}
|
|
168
|
+
else if (matches) {
|
|
169
|
+
actualValue = 'matched';
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
actualValue = `output: ${result.output.slice(0, 200)}`;
|
|
173
|
+
}
|
|
174
|
+
this.assertions.push({
|
|
175
|
+
description: message ?? `Command failed with expected error: ${pattern}`,
|
|
176
|
+
passed: failed && matches,
|
|
177
|
+
expected: `failure with error matching ${pattern}`,
|
|
178
|
+
actual: actualValue,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
this.assertions.push({
|
|
183
|
+
description: message ?? `Command failed: ${result.command}`,
|
|
184
|
+
passed: failed,
|
|
185
|
+
expected: 'non-zero exit code',
|
|
186
|
+
actual: `exit code ${result.exitCode}`,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Assert that a file exists and optionally matches content.
|
|
192
|
+
*
|
|
193
|
+
* @param path - The file path to check.
|
|
194
|
+
* @param contentPattern - Optional regex or string to match file content.
|
|
195
|
+
* @param message - Optional custom assertion message.
|
|
196
|
+
*/
|
|
197
|
+
async assertFile(path, contentPattern, message) {
|
|
198
|
+
const fullPath = isAbsolutePath(path) ? path : joinPath(this.context.workingDirectory, path);
|
|
199
|
+
const displayPath = relativePath(this.context.workingDirectory, fullPath);
|
|
200
|
+
const exists = await fileExists(fullPath);
|
|
201
|
+
if (!exists) {
|
|
202
|
+
this.assertions.push({
|
|
203
|
+
description: message ?? `File exists: ${displayPath}`,
|
|
204
|
+
passed: false,
|
|
205
|
+
expected: 'file exists',
|
|
206
|
+
actual: 'file not found',
|
|
207
|
+
});
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (contentPattern) {
|
|
211
|
+
const content = await readFile(fullPath);
|
|
212
|
+
const regex = typeof contentPattern === 'string' ? new RegExp(contentPattern) : contentPattern;
|
|
213
|
+
const matches = regex.test(content);
|
|
214
|
+
this.assertions.push({
|
|
215
|
+
description: message ?? `File ${displayPath} matches ${contentPattern}`,
|
|
216
|
+
passed: matches,
|
|
217
|
+
expected: `content matching ${contentPattern}`,
|
|
218
|
+
actual: matches ? 'matched' : `content: ${content.slice(0, 200)}...`,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
this.assertions.push({
|
|
223
|
+
description: message ?? `File exists: ${displayPath}`,
|
|
224
|
+
passed: true,
|
|
225
|
+
expected: 'file exists',
|
|
226
|
+
actual: 'file exists',
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Assert that a file does not exist.
|
|
232
|
+
*
|
|
233
|
+
* @param path - The file path to check.
|
|
234
|
+
* @param message - Optional custom assertion message.
|
|
235
|
+
*/
|
|
236
|
+
async assertNoFile(path, message) {
|
|
237
|
+
const fullPath = isAbsolutePath(path) ? path : joinPath(this.context.workingDirectory, path);
|
|
238
|
+
const displayPath = relativePath(this.context.workingDirectory, fullPath);
|
|
239
|
+
const exists = await fileExists(fullPath);
|
|
240
|
+
this.assertions.push({
|
|
241
|
+
description: message ?? `File does not exist: ${displayPath}`,
|
|
242
|
+
passed: !exists,
|
|
243
|
+
expected: 'file does not exist',
|
|
244
|
+
actual: exists ? 'file exists' : 'file does not exist',
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Assert that a directory exists.
|
|
249
|
+
*
|
|
250
|
+
* @param path - The directory path to check.
|
|
251
|
+
* @param message - Optional custom assertion message.
|
|
252
|
+
*/
|
|
253
|
+
async assertDirectory(path, message) {
|
|
254
|
+
const fullPath = isAbsolutePath(path) ? path : joinPath(this.context.workingDirectory, path);
|
|
255
|
+
const displayPath = relativePath(this.context.workingDirectory, fullPath);
|
|
256
|
+
const exists = await fileExists(fullPath);
|
|
257
|
+
this.assertions.push({
|
|
258
|
+
description: message ?? `Directory exists: ${displayPath}`,
|
|
259
|
+
passed: exists,
|
|
260
|
+
expected: 'directory exists',
|
|
261
|
+
actual: exists ? 'directory exists' : 'directory not found',
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Assert that output contains a pattern.
|
|
266
|
+
*
|
|
267
|
+
* @param result - The command result to check.
|
|
268
|
+
* @param pattern - Regex or string pattern to match against output.
|
|
269
|
+
* @param message - Optional custom assertion message.
|
|
270
|
+
*/
|
|
271
|
+
assertOutput(result, pattern, message) {
|
|
272
|
+
const regex = typeof pattern === 'string' ? new RegExp(pattern) : pattern;
|
|
273
|
+
const matches = regex.test(result.output);
|
|
274
|
+
this.assertions.push({
|
|
275
|
+
description: message ?? `Output matches ${pattern}`,
|
|
276
|
+
passed: matches,
|
|
277
|
+
expected: `output matching ${pattern}`,
|
|
278
|
+
actual: matches ? 'matched' : `output: ${result.output.slice(0, 200)}`,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Assert that output contains valid JSON and optionally validate it.
|
|
283
|
+
*
|
|
284
|
+
* @param result - The command result to parse.
|
|
285
|
+
* @param validator - Optional function to validate the parsed JSON.
|
|
286
|
+
* @param message - Optional custom assertion message.
|
|
287
|
+
*/
|
|
288
|
+
assertJson(result, validator, message) {
|
|
289
|
+
try {
|
|
290
|
+
const json = JSON.parse(result.stdout);
|
|
291
|
+
if (validator) {
|
|
292
|
+
const valid = validator(json);
|
|
293
|
+
this.assertions.push({
|
|
294
|
+
description: message ?? 'Output is valid JSON matching validator',
|
|
295
|
+
passed: valid,
|
|
296
|
+
expected: 'valid JSON matching validator',
|
|
297
|
+
actual: valid ? 'matched' : 'validator returned false',
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
this.assertions.push({
|
|
302
|
+
description: message ?? 'Output is valid JSON',
|
|
303
|
+
passed: true,
|
|
304
|
+
expected: 'valid JSON',
|
|
305
|
+
actual: 'valid JSON',
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
return json;
|
|
309
|
+
// eslint-disable-next-line no-catch-all/no-catch-all
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
this.assertions.push({
|
|
313
|
+
description: message ?? 'Output is valid JSON',
|
|
314
|
+
passed: false,
|
|
315
|
+
expected: 'valid JSON',
|
|
316
|
+
actual: `invalid JSON: ${result.stdout.slice(0, 100)}`,
|
|
317
|
+
});
|
|
318
|
+
return undefined;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Assert a boolean condition.
|
|
323
|
+
*
|
|
324
|
+
* @param condition - The boolean condition to assert.
|
|
325
|
+
* @param message - The assertion description.
|
|
326
|
+
*/
|
|
327
|
+
assert(condition, message) {
|
|
328
|
+
this.assertions.push({
|
|
329
|
+
description: message,
|
|
330
|
+
passed: condition,
|
|
331
|
+
expected: 'true',
|
|
332
|
+
actual: String(condition),
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Assert two values are equal.
|
|
337
|
+
*
|
|
338
|
+
* @param actual - The actual value.
|
|
339
|
+
* @param expected - The expected value.
|
|
340
|
+
* @param message - The assertion description.
|
|
341
|
+
*/
|
|
342
|
+
assertEqual(actual, expected, message) {
|
|
343
|
+
this.assertions.push({
|
|
344
|
+
description: message,
|
|
345
|
+
passed: actual === expected,
|
|
346
|
+
expected: String(expected),
|
|
347
|
+
actual: String(actual),
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
hasFailures() {
|
|
351
|
+
return this.assertions.some((assertion) => !assertion.passed);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
DoctorSuite.description = 'Doctor test suite';
|
|
355
|
+
//# sourceMappingURL=framework.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"framework.js","sourceRoot":"","sources":["../../../../src/public/node/doctor/framework.ts"],"names":[],"mappings":"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","sourcesContent":["import {fileExists, readFile} from '../fs.js'\nimport {isAbsolutePath, joinPath, relativePath} from '../path.js'\nimport {execCommand, captureCommandWithExitCode} from '../system.js'\nimport type {DoctorContext, TestResult, AssertionResult} from './types.js'\n\n/**\n * Result from running a CLI command.\n */\ninterface CommandResult {\n /** The full command that was run */\n command: string\n /** Exit code (0 = success) */\n exitCode: number\n /** Standard output */\n stdout: string\n /** Standard error */\n stderr: string\n /** Combined output (stdout + stderr) */\n output: string\n /** Whether the command succeeded (exitCode === 0) */\n success: boolean\n}\n\n/**\n * A registered test with its name and function.\n */\ninterface RegisteredTest {\n name: string\n fn: () => Promise<void>\n}\n\n/**\n * Base class for doctor test suites.\n *\n * Write tests using the test() method:.\n *\n * ```typescript\n * export default class MyTests extends DoctorSuite {\n * static description = 'My test suite'\n *\n * tests() {\n * this.test('basic case', async () => {\n * const result = await this.run('shopify theme init')\n * this.assertSuccess(result)\n * })\n *\n * this.test('error case', async () => {\n * const result = await this.run('shopify theme init --invalid')\n * this.assertError(result, /unknown flag/)\n * })\n * }\n * }\n * ```\n */\nexport abstract class DoctorSuite<TContext extends DoctorContext = DoctorContext> {\n static description = 'Doctor test suite'\n\n protected context!: TContext\n private assertions: AssertionResult[] = []\n private registeredTests: RegisteredTest[] = []\n\n /**\n * Run the entire test suite.\n *\n * @param context - The doctor context for this suite run.\n */\n async runSuite(context: TContext): Promise<TestResult[]> {\n this.context = context\n this.registeredTests = []\n const results: TestResult[] = []\n\n // Call tests() to register tests via this.test()\n this.tests()\n\n // Run all registered tests\n for (const registeredTest of this.registeredTests) {\n this.assertions = []\n const startTime = Date.now()\n\n try {\n // eslint-disable-next-line no-await-in-loop\n await registeredTest.fn()\n\n results.push({\n name: registeredTest.name,\n status: this.hasFailures() ? 'failed' : 'passed',\n duration: Date.now() - startTime,\n assertions: [...this.assertions],\n })\n // eslint-disable-next-line no-catch-all/no-catch-all\n } catch (error) {\n results.push({\n name: registeredTest.name,\n status: 'failed',\n duration: Date.now() - startTime,\n assertions: [...this.assertions],\n error: error instanceof Error ? error : new Error(String(error)),\n })\n }\n }\n\n return results\n }\n\n /**\n * Register a test with a name and function.\n *\n * @param name - The test name.\n * @param fn - The async test function.\n */\n protected test(name: string, fn: () => Promise<void>): void {\n this.registeredTests.push({name, fn})\n }\n\n /**\n * Override this method to register tests using this.test().\n */\n protected tests(): void {\n // Subclasses override this to register tests\n }\n\n // ============================================\n // Command execution\n // ============================================\n\n /**\n * Run a CLI command and return the result.\n *\n * @param command - The CLI command to run.\n * @param options - Optional cwd and env overrides.\n * @example\n * const result = await this.run('shopify theme init my-theme')\n * const result = await this.run('shopify theme push --json')\n */\n protected async run(\n command: string,\n options?: {cwd?: string; env?: {[key: string]: string}},\n ): Promise<CommandResult> {\n const cwd = options?.cwd ?? this.context.workingDirectory\n const result = await captureCommandWithExitCode(command, {cwd, env: options?.env})\n\n return {\n command,\n exitCode: result.exitCode,\n stdout: result.stdout,\n stderr: result.stderr,\n output: String(result.stdout) + String(result.stderr),\n success: result.exitCode === 0,\n }\n }\n\n /**\n * Run a command without capturing output (for interactive commands).\n * Returns only success/failure.\n *\n * @param command - The CLI command to run.\n * @param options - Optional cwd and env overrides.\n */\n protected async runInteractive(\n command: string,\n options?: {cwd?: string; env?: {[key: string]: string}},\n ): Promise<CommandResult> {\n const cwd = options?.cwd ?? this.context.workingDirectory\n let exitCode = 0\n\n try {\n await execCommand(command, {cwd, env: options?.env, stdin: 'inherit'})\n // eslint-disable-next-line no-catch-all/no-catch-all\n } catch {\n exitCode = 1\n }\n\n return {\n command,\n exitCode,\n stdout: '',\n stderr: '',\n output: '',\n success: exitCode === 0,\n }\n }\n\n // ============================================\n // Assertions\n // ============================================\n\n /**\n * Assert that a command succeeded (exit code 0).\n *\n * @param result - The command result to check.\n * @param message - Optional custom assertion message.\n */\n protected assertSuccess(result: CommandResult, message?: string): void {\n this.assertions.push({\n description: message ?? `Command succeeded: ${result.command}`,\n passed: result.success,\n expected: 'exit code 0',\n actual: `exit code ${result.exitCode}`,\n })\n }\n\n /**\n * Assert that a command failed with an error matching the pattern.\n *\n * @param result - The command result to check.\n * @param pattern - Optional regex or string pattern to match against output.\n * @param message - Optional custom assertion message.\n */\n protected assertError(result: CommandResult, pattern?: RegExp | string, message?: string): void {\n const failed = !result.success\n\n if (pattern) {\n const regex = typeof pattern === 'string' ? new RegExp(pattern) : pattern\n const matches = regex.test(result.output)\n let actualValue: string\n if (!failed) {\n actualValue = 'command succeeded'\n } else if (matches) {\n actualValue = 'matched'\n } else {\n actualValue = `output: ${result.output.slice(0, 200)}`\n }\n this.assertions.push({\n description: message ?? `Command failed with expected error: ${pattern}`,\n passed: failed && matches,\n expected: `failure with error matching ${pattern}`,\n actual: actualValue,\n })\n } else {\n this.assertions.push({\n description: message ?? `Command failed: ${result.command}`,\n passed: failed,\n expected: 'non-zero exit code',\n actual: `exit code ${result.exitCode}`,\n })\n }\n }\n\n /**\n * Assert that a file exists and optionally matches content.\n *\n * @param path - The file path to check.\n * @param contentPattern - Optional regex or string to match file content.\n * @param message - Optional custom assertion message.\n */\n protected async assertFile(path: string, contentPattern?: RegExp | string, message?: string): Promise<void> {\n const fullPath = isAbsolutePath(path) ? path : joinPath(this.context.workingDirectory, path)\n const displayPath = relativePath(this.context.workingDirectory, fullPath)\n const exists = await fileExists(fullPath)\n\n if (!exists) {\n this.assertions.push({\n description: message ?? `File exists: ${displayPath}`,\n passed: false,\n expected: 'file exists',\n actual: 'file not found',\n })\n return\n }\n\n if (contentPattern) {\n const content = await readFile(fullPath)\n const regex = typeof contentPattern === 'string' ? new RegExp(contentPattern) : contentPattern\n const matches = regex.test(content)\n this.assertions.push({\n description: message ?? `File ${displayPath} matches ${contentPattern}`,\n passed: matches,\n expected: `content matching ${contentPattern}`,\n actual: matches ? 'matched' : `content: ${content.slice(0, 200)}...`,\n })\n } else {\n this.assertions.push({\n description: message ?? `File exists: ${displayPath}`,\n passed: true,\n expected: 'file exists',\n actual: 'file exists',\n })\n }\n }\n\n /**\n * Assert that a file does not exist.\n *\n * @param path - The file path to check.\n * @param message - Optional custom assertion message.\n */\n protected async assertNoFile(path: string, message?: string): Promise<void> {\n const fullPath = isAbsolutePath(path) ? path : joinPath(this.context.workingDirectory, path)\n const displayPath = relativePath(this.context.workingDirectory, fullPath)\n const exists = await fileExists(fullPath)\n this.assertions.push({\n description: message ?? `File does not exist: ${displayPath}`,\n passed: !exists,\n expected: 'file does not exist',\n actual: exists ? 'file exists' : 'file does not exist',\n })\n }\n\n /**\n * Assert that a directory exists.\n *\n * @param path - The directory path to check.\n * @param message - Optional custom assertion message.\n */\n protected async assertDirectory(path: string, message?: string): Promise<void> {\n const fullPath = isAbsolutePath(path) ? path : joinPath(this.context.workingDirectory, path)\n const displayPath = relativePath(this.context.workingDirectory, fullPath)\n const exists = await fileExists(fullPath)\n this.assertions.push({\n description: message ?? `Directory exists: ${displayPath}`,\n passed: exists,\n expected: 'directory exists',\n actual: exists ? 'directory exists' : 'directory not found',\n })\n }\n\n /**\n * Assert that output contains a pattern.\n *\n * @param result - The command result to check.\n * @param pattern - Regex or string pattern to match against output.\n * @param message - Optional custom assertion message.\n */\n protected assertOutput(result: CommandResult, pattern: RegExp | string, message?: string): void {\n const regex = typeof pattern === 'string' ? new RegExp(pattern) : pattern\n const matches = regex.test(result.output)\n this.assertions.push({\n description: message ?? `Output matches ${pattern}`,\n passed: matches,\n expected: `output matching ${pattern}`,\n actual: matches ? 'matched' : `output: ${result.output.slice(0, 200)}`,\n })\n }\n\n /**\n * Assert that output contains valid JSON and optionally validate it.\n *\n * @param result - The command result to parse.\n * @param validator - Optional function to validate the parsed JSON.\n * @param message - Optional custom assertion message.\n */\n protected assertJson<T = unknown>(\n result: CommandResult,\n validator?: (json: T) => boolean,\n message?: string,\n ): T | undefined {\n try {\n const json = JSON.parse(result.stdout) as T\n if (validator) {\n const valid = validator(json)\n this.assertions.push({\n description: message ?? 'Output is valid JSON matching validator',\n passed: valid,\n expected: 'valid JSON matching validator',\n actual: valid ? 'matched' : 'validator returned false',\n })\n } else {\n this.assertions.push({\n description: message ?? 'Output is valid JSON',\n passed: true,\n expected: 'valid JSON',\n actual: 'valid JSON',\n })\n }\n return json\n // eslint-disable-next-line no-catch-all/no-catch-all\n } catch {\n this.assertions.push({\n description: message ?? 'Output is valid JSON',\n passed: false,\n expected: 'valid JSON',\n actual: `invalid JSON: ${result.stdout.slice(0, 100)}`,\n })\n return undefined\n }\n }\n\n /**\n * Assert a boolean condition.\n *\n * @param condition - The boolean condition to assert.\n * @param message - The assertion description.\n */\n protected assert(condition: boolean, message: string): void {\n this.assertions.push({\n description: message,\n passed: condition,\n expected: 'true',\n actual: String(condition),\n })\n }\n\n /**\n * Assert two values are equal.\n *\n * @param actual - The actual value.\n * @param expected - The expected value.\n * @param message - The assertion description.\n */\n protected assertEqual<T>(actual: T, expected: T, message: string): void {\n this.assertions.push({\n description: message,\n passed: actual === expected,\n expected: String(expected),\n actual: String(actual),\n })\n }\n\n private hasFailures(): boolean {\n return this.assertions.some((assertion) => !assertion.passed)\n }\n}\n"]}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { TestResult } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Initialize the reporter with a base path for truncating file paths in output.
|
|
4
|
+
* Call this before running tests to enable path truncation.
|
|
5
|
+
*/
|
|
6
|
+
export declare function initReporter(basePath: string): void;
|
|
7
|
+
export declare function reportSuiteStart(suiteName: string, description: string): void;
|
|
8
|
+
export declare function reportTestStart(testName: string): void;
|
|
9
|
+
export declare function reportTestResult(result: TestResult): void;
|
|
10
|
+
export declare function reportSummary(results: TestResult[]): void;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import colors from '../colors.js';
|
|
2
|
+
import { outputInfo } from '../output.js';
|
|
3
|
+
import { relativizePath } from '../path.js';
|
|
4
|
+
const log = (message) => outputInfo(message);
|
|
5
|
+
// Reporter context for path
|
|
6
|
+
let reporterBasePath;
|
|
7
|
+
/**
|
|
8
|
+
* Initialize the reporter with a base path for truncating file paths in output.
|
|
9
|
+
* Call this before running tests to enable path truncation.
|
|
10
|
+
*/
|
|
11
|
+
export function initReporter(basePath) {
|
|
12
|
+
reporterBasePath = basePath;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Truncate absolute paths to be relative to the base path.
|
|
16
|
+
* Looks for paths in common patterns like "File exists: /path/to/file"
|
|
17
|
+
*/
|
|
18
|
+
function truncatePaths(text) {
|
|
19
|
+
if (!reporterBasePath)
|
|
20
|
+
return text;
|
|
21
|
+
// Match absolute paths
|
|
22
|
+
// relativizePath will convert paths under reporterBasePath to relative paths
|
|
23
|
+
// and keep other paths unchanged
|
|
24
|
+
const absolutePathPattern = /\/[^\s,)]+/g;
|
|
25
|
+
return text.replace(absolutePathPattern, (path) => {
|
|
26
|
+
return relativizePath(path, reporterBasePath);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
export function reportSuiteStart(suiteName, description) {
|
|
30
|
+
log('');
|
|
31
|
+
log(colors.bold(colors.cyan(`Suite: ${suiteName}`)));
|
|
32
|
+
log(colors.dim(description));
|
|
33
|
+
}
|
|
34
|
+
export function reportTestStart(testName) {
|
|
35
|
+
log(colors.bold(colors.blue(`Running: ${testName}`)));
|
|
36
|
+
}
|
|
37
|
+
export function reportTestResult(result) {
|
|
38
|
+
const durationStr = `(${(result.duration / 1000).toFixed(2)}s)`;
|
|
39
|
+
if (result.status === 'passed') {
|
|
40
|
+
log(colors.bold(colors.green(`PASSED: ${result.name} ${colors.dim(durationStr)}`)));
|
|
41
|
+
for (const line of formatAssertions(result.assertions)) {
|
|
42
|
+
log(line);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
else if (result.status === 'failed') {
|
|
46
|
+
log(colors.red(`FAILED: ${result.name} ${colors.dim(durationStr)}`));
|
|
47
|
+
for (const line of formatAssertions(result.assertions)) {
|
|
48
|
+
log(line);
|
|
49
|
+
}
|
|
50
|
+
if (result.error) {
|
|
51
|
+
log(colors.red(` Error: ${truncatePaths(result.error.message)}`));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
log(colors.yellow(`SKIPPED: ${result.name}`));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export function reportSummary(results) {
|
|
59
|
+
const passed = results.filter((result) => result.status === 'passed').length;
|
|
60
|
+
const failed = results.filter((result) => result.status === 'failed').length;
|
|
61
|
+
const skipped = results.filter((result) => result.status === 'skipped').length;
|
|
62
|
+
const total = results.length;
|
|
63
|
+
const totalDuration = results.reduce((sum, result) => sum + result.duration, 0);
|
|
64
|
+
log('');
|
|
65
|
+
log(colors.bold('─'.repeat(40)));
|
|
66
|
+
if (failed > 0) {
|
|
67
|
+
log(colors.red(colors.bold(`Doctor Complete: ${failed}/${total} tests failed`)));
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
log(colors.green(colors.bold(`Doctor Complete: ${passed}/${total} tests passed`)));
|
|
71
|
+
}
|
|
72
|
+
log(` Passed: ${colors.green(String(passed))}`);
|
|
73
|
+
log(` Failed: ${colors.red(String(failed))}`);
|
|
74
|
+
if (skipped > 0) {
|
|
75
|
+
log(` Skipped: ${colors.yellow(String(skipped))}`);
|
|
76
|
+
}
|
|
77
|
+
log(` Total time: ${colors.dim(`${(totalDuration / 1000).toFixed(2)}s`)}`);
|
|
78
|
+
}
|
|
79
|
+
function formatAssertions(assertions) {
|
|
80
|
+
return assertions.map((assertion) => {
|
|
81
|
+
if (assertion.passed) {
|
|
82
|
+
return colors.green(` [OK] ${assertion.description}`);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
const details = ` (expected: ${assertion.expected}, actual: ${assertion.actual})`;
|
|
86
|
+
return colors.red(` [FAIL] ${assertion.description}${details}`);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
//# sourceMappingURL=reporter.js.map
|