Skip to content

Commit 6c472bf

Browse files
committed
Async exec + convention refactors
1. Replace `execSync` with a `promisify(exec)` to prevent extension hang 2. Rename files to fit our conventions 3. Remove the `_` prefix from private variables 4. Move the spec file next to the profiler file
1 parent 5ae9d48 commit 6c472bf

File tree

4 files changed

+83
-73
lines changed

4 files changed

+83
-73
lines changed

packages/vscode-extension/src/node/test/liquid_profiler.spec.ts renamed to packages/vscode-extension/src/node/LiquidProfiler.spec.ts

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { describe, it, expect, vi, beforeEach } from 'vitest';
2-
import { LiquidProfiler } from '../liquid_profiler';
3-
import { execSync } from 'node:child_process';
1+
import { exec } from './utils';
2+
import { beforeEach, describe, expect, it, vi } from 'vitest';
43
import { ExtensionContext } from 'vscode';
4+
import { fetchProfileContents, LiquidProfiler } from './LiquidProfiler';
55

66
// Mock the vscode namespace
77
vi.mock('vscode', () => ({
@@ -19,8 +19,8 @@ vi.mock('vscode', () => ({
1919
}));
2020

2121
// Mock child_process
22-
vi.mock('node:child_process', () => ({
23-
execSync: vi.fn(),
22+
vi.mock('./utils', () => ({
23+
exec: vi.fn(),
2424
}));
2525

2626
describe('LiquidProfiler', () => {
@@ -35,23 +35,21 @@ describe('LiquidProfiler', () => {
3535
profiler = new LiquidProfiler(mockContext);
3636
});
3737

38-
describe('getProfileContents', () => {
39-
it('successfully retrieves profile content', () => {
38+
describe('fetchProfileContents', () => {
39+
it('successfully retrieves profile content', async () => {
4040
const mockJson = '{"profiles":[{"events":[]}]}';
41-
vi.mocked(execSync).mockReturnValue(Buffer.from(mockJson));
41+
vi.mocked(exec).mockReturnValue(Promise.resolve({ stdout: mockJson, stderr: '' }));
4242

43-
const result = LiquidProfiler['getProfileContents']('http://example.com');
43+
const result = await fetchProfileContents('http://example.com');
4444
expect(result).toBe(mockJson);
4545
});
4646

47-
it('handles CLI errors gracefully', () => {
47+
it('handles CLI errors gracefully', async () => {
4848
const mockError = new Error('CLI Error');
4949
(mockError as any).stderr = 'Command failed';
50-
vi.mocked(execSync).mockImplementation(() => {
51-
throw mockError;
52-
});
50+
vi.mocked(exec).mockReturnValue(Promise.reject(mockError));
5351

54-
const result = LiquidProfiler['getProfileContents']('http://example.com');
52+
const result = await fetchProfileContents('http://example.com');
5553
expect(result).toContain('Error loading preview');
5654
expect(result).toContain('Command failed');
5755
});

packages/vscode-extension/src/node/liquid_profiler.ts renamed to packages/vscode-extension/src/node/LiquidProfiler.ts

Lines changed: 61 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
1+
import { exec as execCb } from 'node:child_process';
2+
import * as path from 'node:path';
3+
import { promisify } from 'node:util';
14
import {
5+
DecorationOptions,
26
Disposable,
37
ExtensionContext,
8+
Position,
9+
Range,
410
Uri,
511
ViewColumn,
612
WebviewPanel,
713
window,
814
workspace,
9-
Range,
10-
DecorationOptions,
11-
Position,
1215
} from 'vscode';
13-
import * as path from 'node:path';
14-
import { execSync } from 'node:child_process';
16+
17+
const exec = promisify(execCb);
1518

1619
const SHOPIFY_CLI_COMMAND = 'shopify theme profile';
1720

@@ -41,71 +44,71 @@ export class LiquidProfiler {
4144
borderRadius: '3px',
4245
});
4346

44-
private _panel: WebviewPanel | undefined;
45-
private _disposables: Disposable[] = [];
46-
private _decorations = new Map<string, DecorationOptions[]>();
47-
private _context: ExtensionContext;
47+
private panel: WebviewPanel | undefined;
48+
private disposables: Disposable[] = [];
49+
private decorations = new Map<string, DecorationOptions[]>();
50+
private context: ExtensionContext;
4851

4952
constructor(context: ExtensionContext) {
50-
this._context = context;
53+
this.context = context;
5154
}
5255

5356
public async showProfileForUrl(url: string) {
54-
const profile = LiquidProfiler.getProfileContents(url);
57+
const profile = await fetchProfileContents(url);
5558
await this.processAndShowDecorations(profile);
5659
await this.showWebviewPanelForProfile(profile, url);
5760
}
5861

5962
private async showWebviewPanelForProfile(profile: string, url: string) {
6063
const column = ViewColumn.Beside;
6164

62-
if (this._panel) {
63-
this._panel.reveal(column);
64-
this._panel.title = `Liquid Profile: ${url}`;
65-
this._panel.webview.html = '';
65+
if (this.panel) {
66+
this.panel.reveal(column);
67+
this.panel.title = `Liquid Profile: ${url}`;
68+
this.panel.webview.html = '';
6669
} else {
67-
this._panel = window.createWebviewPanel('liquidProfile', `Liquid Profile: ${url}`, column, {
70+
this.panel = window.createWebviewPanel('liquidProfile', `Liquid Profile: ${url}`, column, {
6871
enableScripts: true,
6972
// Allow files in the user's workspace (.tmp directory) to be used as local resources
7073
localResourceRoots: [
7174
...(workspace.workspaceFolders
7275
? workspace.workspaceFolders.map((folder) => folder.uri)
7376
: []),
74-
Uri.file(this._context.asAbsolutePath(path.join('dist', 'node', 'speedscope'))),
77+
Uri.file(this.context.asAbsolutePath(path.join('dist', 'node', 'speedscope'))),
7578
],
7679
});
77-
this._panel.onDidDispose(() => this.dispose(), null, this._disposables);
80+
this.panel.onDidDispose(() => this.dispose(), null, this.disposables);
7881
}
7982

80-
this._panel.webview.html = await this._getSpeedscopeHtml(profile);
83+
this.panel.webview.html = await this.getSpeedscopeHtml(profile);
8184
}
8285

83-
private _getSpeedscopeWebviewUri(fileName: string): Uri {
86+
private getSpeedscopeWebviewUri(fileName: string): Uri {
8487
const filePath = path.join('dist', 'node', 'speedscope', fileName);
85-
return this._panel!.webview.asWebviewUri(Uri.file(this._context.asAbsolutePath(filePath)));
88+
return this.panel!.webview.asWebviewUri(Uri.file(this.context.asAbsolutePath(filePath)));
8689
}
8790

88-
private async _getSpeedscopeHtml(profileContents: string) {
91+
private async getSpeedscopeHtml(profileContents: string) {
8992
const indexHtmlPath = Uri.file(
90-
this._context.asAbsolutePath(path.join('dist', 'node', 'speedscope', 'index.html')),
93+
this.context.asAbsolutePath(path.join('dist', 'node', 'speedscope', 'index.html')),
9194
);
9295
let htmlContent = Buffer.from(await workspace.fs.readFile(indexHtmlPath)).toString('utf8');
9396

9497
// Convert local resource paths to vscode-resource URIs, and replace the paths in the HTML content
95-
const cssUri = this._getSpeedscopeWebviewUri('source-code-pro.52b1676f.css');
98+
const cssUri = this.getSpeedscopeWebviewUri('source-code-pro.52b1676f.css');
9699
htmlContent = htmlContent.replace('source-code-pro.52b1676f.css', cssUri.toString());
97100

98-
const resetCssUri = this._getSpeedscopeWebviewUri('reset.8c46b7a1.css');
101+
const resetCssUri = this.getSpeedscopeWebviewUri('reset.8c46b7a1.css');
99102
htmlContent = htmlContent.replace('reset.8c46b7a1.css', resetCssUri.toString());
100103

101-
const jsUri = this._getSpeedscopeWebviewUri('speedscope.6f107512.js');
104+
const jsUri = this.getSpeedscopeWebviewUri('speedscope.6f107512.js');
102105
htmlContent = htmlContent.replace('speedscope.6f107512.js', jsUri.toString());
103106

104107
// Put the profile JSON in a tmp file, and replace the profile URL in the HTML content.
105108
const tmpDir = workspace.workspaceFolders?.[0].uri.fsPath;
106109
const tmpFile = path.join(tmpDir!, '.tmp', 'profile.json');
107110
await workspace.fs.writeFile(Uri.file(tmpFile), Buffer.from(profileContents));
108-
const tmpUri = this._panel!.webview.asWebviewUri(Uri.file(tmpFile));
111+
const tmpUri = this.panel!.webview.asWebviewUri(Uri.file(tmpFile));
109112
htmlContent = htmlContent.replace(
110113
'<body>',
111114
`<body><script>window.location.hash = "profileURL=${encodeURIComponent(
@@ -117,14 +120,14 @@ export class LiquidProfiler {
117120
}
118121

119122
public dispose() {
120-
this._panel?.dispose();
121-
while (this._disposables.length) {
122-
const disposable = this._disposables.pop();
123+
this.panel?.dispose();
124+
while (this.disposables.length) {
125+
const disposable = this.disposables.pop();
123126
if (disposable) {
124127
disposable.dispose();
125128
}
126129
}
127-
this._panel = undefined;
130+
this.panel = undefined;
128131
}
129132

130133
/**
@@ -174,7 +177,7 @@ export class LiquidProfiler {
174177
console.log('[Liquid Profiler] Processing profile results for decorations');
175178

176179
// Clear existing decorations
177-
this._decorations.clear();
180+
this.decorations.clear();
178181
const visibleEditorsToClear = window.visibleTextEditors;
179182
for (const editor of visibleEditorsToClear) {
180183
editor.setDecorations(this.fileDecorationType, []);
@@ -215,7 +218,7 @@ export class LiquidProfiler {
215218
};
216219

217220
// Store the file-level decoration.
218-
this._decorations.set(uri.fsPath, [decoration]);
221+
this.decorations.set(uri.fsPath, [decoration]);
219222

220223
const visibleEditors = window.visibleTextEditors;
221224
// Store the paths it's been applied to in a set.
@@ -279,9 +282,9 @@ export class LiquidProfiler {
279282
};
280283

281284
// Store the decoration in a map where the key is the file path and the value is an array of decorations
282-
const fileDecorations = this._decorations.get(uri.fsPath) || [];
285+
const fileDecorations = this.decorations.get(uri.fsPath) || [];
283286
fileDecorations.push(decoration);
284-
this._decorations.set(uri.fsPath, fileDecorations);
287+
this.decorations.set(uri.fsPath, fileDecorations);
285288
} catch (err) {
286289
console.error(
287290
`[Liquid Profiler] Error creating line decoration for ${frame.file}:${frame.line}:`,
@@ -294,15 +297,15 @@ export class LiquidProfiler {
294297
const visibleEditors = window.visibleTextEditors;
295298
for (const editor of visibleEditors) {
296299
// Get stored decorations for this file
297-
const lineDecorations = this._decorations.get(editor.document.uri.fsPath) || [];
300+
const lineDecorations = this.decorations.get(editor.document.uri.fsPath) || [];
298301
editor.setDecorations(this.lineDecorationType, lineDecorations);
299302
}
300303

301304
// Add listener for active editor changes
302-
this._context.subscriptions.push(
305+
this.context.subscriptions.push(
303306
window.onDidChangeActiveTextEditor((editor) => {
304307
if (editor) {
305-
const decorations = this._decorations.get(editor.document.uri.fsPath);
308+
const decorations = this.decorations.get(editor.document.uri.fsPath);
306309
if (decorations) {
307310
editor.setDecorations(this.lineDecorationType, decorations);
308311
} else {
@@ -328,28 +331,30 @@ export class LiquidProfiler {
328331
// Slow: Red
329332
return '#f44336';
330333
}
334+
}
331335

332-
private static getProfileContents(url: string) {
333-
try {
334-
console.log('[Liquid Profiler] Attempting to load preview for URL:', url);
335-
const result = execSync(`${SHOPIFY_CLI_COMMAND} --url=${url} --json`, { stdio: 'pipe' });
336-
// Remove all characters leading up to the first {
337-
const content = result.toString().replace(/^[^{]+/, '');
338-
console.log(`[Liquid Profiler] Successfully retrieved preview content ${content}`);
339-
return content;
340-
} catch (error) {
341-
console.error('[Liquid Profiler] Error loading preview:', error);
342-
if (error instanceof Error) {
343-
// If there's stderr output, it will be in error.stderr
344-
const errorMessage = (error as any).stderr?.toString() || error.message;
345-
console.error('[Liquid Profiler] Error details:', errorMessage);
346-
return `<div style="color: red; padding: 20px;">
336+
export async function fetchProfileContents(url: string) {
337+
try {
338+
console.log('[Liquid Profiler] Attempting to load preview for URL:', url);
339+
const { stdout: result, stderr } = await exec(`${SHOPIFY_CLI_COMMAND} --url=${url} --json`);
340+
if (stderr) console.error(stderr);
341+
342+
// Remove all characters leading up to the first {
343+
const content = result.toString().replace(/^[^{]+/, '');
344+
console.log(`[Liquid Profiler] Successfully retrieved preview content ${content}`);
345+
return content;
346+
} catch (error) {
347+
console.error('[Liquid Profiler] Error loading preview:', error);
348+
if (error instanceof Error) {
349+
// If there's stderr output, it will be in error.stderr
350+
const errorMessage = (error as any).stderr?.toString() || error.message;
351+
console.error('[Liquid Profiler] Error details:', errorMessage);
352+
return `<div style="color: red; padding: 20px;">
347353
<h3>Error loading preview:</h3>
348354
<pre>${errorMessage}</pre>
349355
</div>`;
350-
}
351-
console.error('[Liquid Profiler] Unexpected error type:', typeof error);
352-
return '<div style="color: red; padding: 20px;">An unexpected error occurred</div>';
353356
}
357+
console.error('[Liquid Profiler] Unexpected error type:', typeof error);
358+
return '<div style="color: red; padding: 20px;">An unexpected error occurred</div>';
354359
}
355360
}

packages/vscode-extension/src/node/extension.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { FileStat, FileTuple, path as pathUtils } from '@shopify/theme-check-common';
2+
import Config from 'conf';
23
import * as path from 'node:path';
3-
import { commands, ExtensionContext, languages, Uri, workspace, window } from 'vscode';
4+
import { commands, ExtensionContext, languages, Uri, window, workspace } from 'vscode';
45
import {
56
DocumentSelector,
67
LanguageClient,
@@ -11,8 +12,7 @@ import {
1112
import { documentSelectors } from '../common/constants';
1213
import LiquidFormatter from '../common/formatter';
1314
import { vscodePrettierFormat } from './formatter';
14-
import { LiquidProfiler } from './liquid_profiler';
15-
import Config from 'conf';
15+
import { LiquidProfiler } from './LiquidProfiler';
1616

1717
const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms));
1818

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { exec as execCb } from 'child_process';
2+
import { promisify } from 'util';
3+
4+
export const exec = promisify(execCb) as (
5+
command: string,
6+
options?: { encoding: BufferEncoding },
7+
) => Promise<{ stdout: string; stderr: string }>;

0 commit comments

Comments
 (0)