diff --git a/packages/cli/README.md b/packages/cli/README.md index 3e4f7b11695..458505672bc 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -9100,6 +9100,207 @@ DESCRIPTION This command will open a web page with the Speedscope profiler detailing the time spent executing Liquid on the given page. + + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `ThemeProfileResult` schema. + + ```json + { + "type": "object", + "properties": { + "$schema": { + "type": "string", + "const": "https://www.speedscope.app/file-format-schema.json" + }, + "shared": { + "$ref": "#/definitions/ProfileShared" + }, + "profiles": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/EventedProfile" + }, + { + "$ref": "#/definitions/SampledProfile" + } + ] + } + }, + "name": { + "type": "string" + }, + "exporter": { + "type": "string" + }, + "activeProfileIndex": { + "type": "number" + } + }, + "required": [ + "$schema", + "shared", + "profiles" + ], + "additionalProperties": true, + "title": "ThemeProfileResult", + "definitions": { + "ProfileFrame": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "file": { + "type": "string" + }, + "line": { + "type": "number" + }, + "col": { + "type": "number" + } + }, + "required": [ + "name" + ], + "additionalProperties": true + }, + "ProfileEvent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "O", + "C" + ] + }, + "at": { + "type": "number" + }, + "frame": { + "type": "number" + } + }, + "required": [ + "type", + "at", + "frame" + ], + "additionalProperties": true + }, + "ProfileShared": { + "type": "object", + "properties": { + "frames": { + "type": "array", + "items": { + "$ref": "#/definitions/ProfileFrame" + } + } + }, + "required": [ + "frames" + ], + "additionalProperties": true + }, + "EventedProfile": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "unit": { + "type": "string", + "enum": [ + "bytes", + "microseconds", + "milliseconds", + "nanoseconds", + "none", + "seconds" + ] + }, + "startValue": { + "type": "number" + }, + "endValue": { + "type": "number" + }, + "type": { + "type": "string", + "const": "evented" + }, + "events": { + "type": "array", + "items": { + "$ref": "#/definitions/ProfileEvent" + } + } + }, + "required": [ + "name", + "unit", + "startValue", + "endValue", + "type", + "events" + ], + "additionalProperties": true + }, + "SampledProfile": { + "type": "object", + "properties": { + "name": { + "$ref": "#/definitions/EventedProfile/properties/name" + }, + "unit": { + "$ref": "#/definitions/EventedProfile/properties/unit" + }, + "startValue": { + "$ref": "#/definitions/EventedProfile/properties/startValue" + }, + "endValue": { + "$ref": "#/definitions/EventedProfile/properties/endValue" + }, + "type": { + "type": "string", + "const": "sampled" + }, + "samples": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "number" + } + } + }, + "weights": { + "type": "array", + "items": { + "type": "number" + } + } + }, + "required": [ + "name", + "unit", + "startValue", + "endValue", + "type", + "samples", + "weights" + ], + "additionalProperties": true + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` ``` ## `shopify theme publish` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 54047683870..aa15ad08230 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -10951,7 +10951,7 @@ "args": { }, "customPluginName": "@shopify/theme", - "description": "Profile the Shopify Liquid on a given page.\n\n This command will open a web page with the Speedscope profiler detailing the time spent executing Liquid on the given page.", + "description": "Profile the Shopify Liquid on a given page.\n\n This command will open a web page with the Speedscope profiler detailing the time spent executing Liquid on the given page.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ThemeProfileResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"$schema\": {\n \"type\": \"string\",\n \"const\": \"https://www.speedscope.app/file-format-schema.json\"\n },\n \"shared\": {\n \"$ref\": \"#/definitions/ProfileShared\"\n },\n \"profiles\": {\n \"type\": \"array\",\n \"items\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/EventedProfile\"\n },\n {\n \"$ref\": \"#/definitions/SampledProfile\"\n }\n ]\n }\n },\n \"name\": {\n \"type\": \"string\"\n },\n \"exporter\": {\n \"type\": \"string\"\n },\n \"activeProfileIndex\": {\n \"type\": \"number\"\n }\n },\n \"required\": [\n \"$schema\",\n \"shared\",\n \"profiles\"\n ],\n \"additionalProperties\": true,\n \"title\": \"ThemeProfileResult\",\n \"definitions\": {\n \"ProfileFrame\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\n \"type\": \"string\"\n },\n \"file\": {\n \"type\": \"string\"\n },\n \"line\": {\n \"type\": \"number\"\n },\n \"col\": {\n \"type\": \"number\"\n }\n },\n \"required\": [\n \"name\"\n ],\n \"additionalProperties\": true\n },\n \"ProfileEvent\": {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"enum\": [\n \"O\",\n \"C\"\n ]\n },\n \"at\": {\n \"type\": \"number\"\n },\n \"frame\": {\n \"type\": \"number\"\n }\n },\n \"required\": [\n \"type\",\n \"at\",\n \"frame\"\n ],\n \"additionalProperties\": true\n },\n \"ProfileShared\": {\n \"type\": \"object\",\n \"properties\": {\n \"frames\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/ProfileFrame\"\n }\n }\n },\n \"required\": [\n \"frames\"\n ],\n \"additionalProperties\": true\n },\n \"EventedProfile\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\n \"type\": \"string\"\n },\n \"unit\": {\n \"type\": \"string\",\n \"enum\": [\n \"bytes\",\n \"microseconds\",\n \"milliseconds\",\n \"nanoseconds\",\n \"none\",\n \"seconds\"\n ]\n },\n \"startValue\": {\n \"type\": \"number\"\n },\n \"endValue\": {\n \"type\": \"number\"\n },\n \"type\": {\n \"type\": \"string\",\n \"const\": \"evented\"\n },\n \"events\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/ProfileEvent\"\n }\n }\n },\n \"required\": [\n \"name\",\n \"unit\",\n \"startValue\",\n \"endValue\",\n \"type\",\n \"events\"\n ],\n \"additionalProperties\": true\n },\n \"SampledProfile\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\n \"$ref\": \"#/definitions/EventedProfile/properties/name\"\n },\n \"unit\": {\n \"$ref\": \"#/definitions/EventedProfile/properties/unit\"\n },\n \"startValue\": {\n \"$ref\": \"#/definitions/EventedProfile/properties/startValue\"\n },\n \"endValue\": {\n \"$ref\": \"#/definitions/EventedProfile/properties/endValue\"\n },\n \"type\": {\n \"type\": \"string\",\n \"const\": \"sampled\"\n },\n \"samples\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"number\"\n }\n }\n },\n \"weights\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"number\"\n }\n }\n },\n \"required\": [\n \"name\",\n \"unit\",\n \"startValue\",\n \"endValue\",\n \"type\",\n \"samples\",\n \"weights\"\n ],\n \"additionalProperties\": true\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Profile the Shopify Liquid on a given page.\n\n This command will open a web page with the Speedscope profiler detailing the time spent executing Liquid on the given page.", "enableJsonFlag": false, "flags": { diff --git a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js index 63be3b4b0c2..51600867362 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -46,7 +46,6 @@ const commandExceptions = [ 'packages/theme/src/cli/commands/theme/init.ts', 'packages/theme/src/cli/commands/theme/metafields/pull.ts', 'packages/theme/src/cli/commands/theme/package.ts', - 'packages/theme/src/cli/commands/theme/profile.ts', // Streaming commands without a single finite result. 'packages/app/src/cli/commands/app/dev.ts', diff --git a/packages/theme/src/cli/commands/theme/profile.test.ts b/packages/theme/src/cli/commands/theme/profile.test.ts new file mode 100644 index 00000000000..2a2d77a4757 --- /dev/null +++ b/packages/theme/src/cli/commands/theme/profile.test.ts @@ -0,0 +1,93 @@ +import Profile from './profile.js' +import {themeProfileJsonOutputSchema, type ThemeProfileResult} from '../../services/profile/types.js' +import {expect, test, vi} from 'vitest' +import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' + +vi.mock('../../utilities/theme-selector.js') +vi.mock('../../utilities/theme-environment/storefront-renderer.js') +vi.mock('../../utilities/theme-environment/dev-server-session.js') +vi.mock('../../utilities/theme-environment/storefront-session.js') +vi.mock('@shopify/cli-kit/node/session') +vi.mock('@shopify/cli-kit/node/system') + +const profile: ThemeProfileResult = { + $schema: 'https://www.speedscope.app/file-format-schema.json', + shared: {frames: [{name: 'layout', file: 'layout/theme.liquid', line: 1, col: 0, extension: false}]}, + profiles: [ + { + type: 'evented', + name: 'Liquid', + unit: 'microseconds', + startValue: 0, + endValue: 10, + events: [ + {type: 'O', at: 0, frame: 0}, + {type: 'C', at: 10, frame: 0}, + ], + }, + ], + extension: {value: null}, +} + +test('exposes Speedscope evented and sampled profiles while retaining extension fields', () => { + expect(Profile.jsonOutputSchema).toBe(themeProfileJsonOutputSchema) + expect(Profile.flags.json).toBeDefined() + expect(JSON.parse(themeProfileJsonOutputSchema.encode(profile))).toEqual(profile) + const sampled: ThemeProfileResult = { + ...profile, + profiles: [ + {type: 'sampled', name: 'Liquid', unit: 'none', startValue: 0, endValue: 10, samples: [[0]], weights: [10]}, + ], + } + expect(JSON.parse(themeProfileJsonOutputSchema.encode(sampled))).toEqual(sampled) + expect(() => themeProfileJsonOutputSchema.validate({...profile, shared: {frames: [{name: 123}]}})).toThrow() + expect(() => + themeProfileJsonOutputSchema.validate({...profile, profiles: [{...profile.profiles[0], unit: 'invalid'}]}), + ).toThrow() +}) + +test.each(['profile', 'empty', 'failure', 'invalid result'])( + 'keeps %s JSON on stdout and progress events on stderr', + async (mode) => { + const {default: StreamProfile} = await import('./profile.js') + const {findOrSelectTheme} = await import('../../utilities/theme-selector.js') + const {render} = await import('../../utilities/theme-environment/storefront-renderer.js') + const {ensureAuthenticatedThemes} = await import('@shopify/cli-kit/node/session') + const {openURL} = await import('@shopify/cli-kit/node/system') + const {runWithCommandEventsForCommand} = await import('@shopify/cli-kit/node/command-events') + const {Config} = await import('@oclif/core') + const config = new Config({root: __dirname}) + await config.load() + vi.mocked(ensureAuthenticatedThemes).mockResolvedValue({storeFqdn: 'shop.myshopify.com', token: 'token'}) + vi.mocked(findOrSelectTheme).mockResolvedValue({ + id: 1, + name: 'Dawn', + role: 'live', + processing: false, + createdAtRuntime: false, + }) + const result = mode === 'empty' ? {...profile, profiles: [], shared: {frames: []}} : profile + // Keep unusual whitespace and upstream field order to catch accidental reserialization. + const source = mode === 'invalid result' ? '{"profiles":false}' : ` ${JSON.stringify(result, null, 4)}\n` + vi.mocked(render).mockResolvedValue(new Response(source, {status: 200})) + if (mode === 'failure') vi.mocked(render).mockRejectedValue(new Error('Network error')) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + const operation = runWithCommandEventsForCommand(['--json'], () => + new StreamProfile(['--store=shop.myshopify.com', '--json'], config).run(), + ) + if (mode === 'failure') await expect(operation).rejects.toThrow('Network error') + else if (mode === 'invalid result') await expect(operation).rejects.toThrow() + else await operation + expect(openURL).not.toHaveBeenCalled() + const failed = mode === 'failure' || mode === 'invalid result' + expect(stdout()).toBe(failed ? '' : `${source}\n`) + if (!failed) expect(JSON.parse(stdout())).toEqual(result) + const events = stderr() + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + expect(events.map((event) => event.status)).toEqual(failed ? ['started', 'failed'] : ['started', 'completed']) + expect(events.every((event) => event.type === 'progress')).toBe(true) + }) + }, +) diff --git a/packages/theme/src/cli/commands/theme/profile.ts b/packages/theme/src/cli/commands/theme/profile.ts index ada34ef65b7..3c04dd3c58f 100644 --- a/packages/theme/src/cli/commands/theme/profile.ts +++ b/packages/theme/src/cli/commands/theme/profile.ts @@ -1,16 +1,21 @@ import {themeFlags} from '../../flags.js' import ThemeCommand, {RequiredFlags} from '../../utilities/theme-command.js' import {profile} from '../../services/profile.js' +import {renderThemeProfileResult} from '../../services/profile/result.js' +import {themeProfileJsonOutputSchema} from '../../services/profile/types.js' import {findOrSelectTheme} from '../../utilities/theme-selector.js' import {renderTasksToStdErr} from '../../utilities/theme-ui.js' import {Flags} from '@oclif/core' import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' -import {Task} from '@shopify/cli-kit/node/ui' import {InferredFlags} from '@oclif/core/interfaces' import {AdminSession} from '@shopify/cli-kit/node/session' type ProfileFlags = InferredFlags export default class Profile extends ThemeCommand { + static get jsonOutputSchema() { + return themeProfileJsonOutputSchema + } + static summary = 'Profile the Liquid rendering of a theme page.' static usage = ['theme profile', 'theme profile --url /products/classic-leather-jacket'] @@ -54,21 +59,17 @@ export default class Profile extends ThemeCommand { } const theme = await findOrSelectTheme(adminSession, filter) - const tasks: Task[] = [ - { - title: `Generating Liquid profile for ${adminSession.storeFqdn} ${flags.url}`, - task: async () => { - await profile( - adminSession, - theme.id.toString(), - flags.url, - flags.json, - themeAccessPassword, - flags['store-password'], - ) - }, - }, - ] - await renderTasksToStdErr(tasks) + const title = `Generating Liquid profile for ${adminSession.storeFqdn} ${flags.url}` + const task = async () => { + const result = await profile( + adminSession, + theme.id.toString(), + flags.url, + themeAccessPassword, + flags['store-password'], + ) + await renderThemeProfileResult(result, flags.json ? 'json' : 'text') + } + await renderTasksToStdErr([{title, task}]) } } diff --git a/packages/theme/src/cli/services/profile.test.ts b/packages/theme/src/cli/services/profile.test.ts index 48c07d6c481..c00942ec7b4 100644 --- a/packages/theme/src/cli/services/profile.test.ts +++ b/packages/theme/src/cli/services/profile.test.ts @@ -1,4 +1,5 @@ import {profile} from './profile.js' +import {renderThemeProfileResult} from './profile/result.js' import {render} from '../utilities/theme-environment/storefront-renderer.js' import {ensureAuthenticatedStorefront} from '@shopify/cli-kit/node/session' import {openURL} from '@shopify/cli-kit/node/system' @@ -18,7 +19,9 @@ vi.mock('../utilities/theme-environment/storefront-renderer.js') describe('profile', () => { const mockProfileData = { name: 'test-profile', - data: 'sample-data', + $schema: 'https://www.speedscope.app/file-format-schema.json', + profiles: [], + shared: {frames: []}, } const mockToken = 'mock-token' const mockAdminSession = {token: mockToken, storeFqdn: 'test-store.myshopify.com'} @@ -39,7 +42,9 @@ describe('profile', () => { test('outputs JSON to stdout when asJson is true', async () => { // When - await profile(mockAdminSession, themeId, urlPath, true, undefined, undefined) + const result = await profile(mockAdminSession, themeId, urlPath) + expect(result.result).toEqual(mockProfileData) + await renderThemeProfileResult(result, 'json') // Then expect(render).toHaveBeenCalledWith( @@ -62,7 +67,7 @@ describe('profile', () => { test('opens profile in browser when asJson is false', async () => { // When - await profile(mockAdminSession, themeId, urlPath, false, undefined, undefined) + await renderThemeProfileResult(await profile(mockAdminSession, themeId, urlPath), 'text') // Then // Verify fetch was called correctly @@ -88,7 +93,7 @@ describe('profile', () => { vi.mocked(render).mockRejectedValue(new Error('Network error')) // When - const result = profile(mockAdminSession, themeId, urlPath, true, undefined, undefined) + const result = profile(mockAdminSession, themeId, urlPath) // Then await expect(result).rejects.toThrow('Network error') @@ -104,7 +109,7 @@ describe('profile', () => { ) // When - const result = profile(mockAdminSession, themeId, urlPath, true, undefined, undefined) + const result = profile(mockAdminSession, themeId, urlPath) // Then await expect(result).rejects.toThrow('Bad response: 404: {"error":"Some error message"}') @@ -112,7 +117,7 @@ describe('profile', () => { test('throws error when a password is used', async () => { // When - const result = profile(mockAdminSession, themeId, urlPath, true, 'shpat_hello', undefined) + const result = profile(mockAdminSession, themeId, urlPath, 'shpat_hello') // Then await expect(result).rejects.toThrow( @@ -134,7 +139,7 @@ describe('profile', () => { vi.mocked(openURL).mockResolvedValue(true) // When - await profile(mockAdminSession, themeId, urlPath, false, undefined, undefined) + await renderThemeProfileResult(await profile(mockAdminSession, themeId, urlPath), 'text') // Then expect(openURL).toHaveBeenCalledWith(expect.stringMatching(/^file:\/\/\/\/wsl\$\/Ubuntu.*\.html$/)) @@ -146,7 +151,7 @@ describe('profile', () => { vi.mocked(openURL).mockResolvedValue(true) // When - await profile(mockAdminSession, themeId, urlPath, false, undefined, undefined) + await renderThemeProfileResult(await profile(mockAdminSession, themeId, urlPath), 'text') // Then expect(openURL).toHaveBeenCalledWith(expect.stringMatching(/^file:\/\/.*\.html$/)) diff --git a/packages/theme/src/cli/services/profile.ts b/packages/theme/src/cli/services/profile.ts index 7e55bbad6b5..dad140e2deb 100644 --- a/packages/theme/src/cli/services/profile.ts +++ b/packages/theme/src/cli/services/profile.ts @@ -1,20 +1,15 @@ -import {resolveAssetPath} from '../utilities/asset-path.js' +import {themeProfileJsonOutputSchema} from './profile/types.js' import {render} from '../utilities/theme-environment/storefront-renderer.js' import {fetchDevServerSession} from '../utilities/theme-environment/dev-server-session.js' import {ensureValidPassword} from '../utilities/theme-environment/storefront-password-prompt.js' import {isStorefrontPasswordProtected} from '../utilities/theme-environment/storefront-session.js' -import {openURL} from '@shopify/cli-kit/node/system' -import {joinPath} from '@shopify/cli-kit/node/path' import {AdminSession} from '@shopify/cli-kit/node/session' -import {writeFile, tempDirectory} from '@shopify/cli-kit/node/fs' -import {outputResult, outputDebug} from '@shopify/cli-kit/node/output' import {AbortError} from '@shopify/cli-kit/node/error' export async function profile( adminSession: AdminSession, themeId: string, url: string, - asJson: boolean, themeAccessPassword?: string, storefrontPassword?: string, ) { @@ -47,52 +42,5 @@ export async function profile( const profileJson = await response.text() - if (asJson) { - // Print the JSON - outputResult(profileJson) - } else { - await openProfile(profileJson) - } -} - -async function openProfile(profileJson: string) { - // Adapted from https://github.com/jlfwong/speedscope/blob/146477a8508a6d2da697cb0ea0a426ba81b3e8dc/bin/cli.js#L63 - - // on Windows Subsystem for Linux, we need to prefix file paths with the - // WSL distro name to get them to open in the browser. - const distroName = process.env.WSL_DISTRO_NAME - const wslPrefix = distroName ? `//wsl$/${distroName}` : '' - - let urlToOpen = await resolveAssetPath('speedscope', 'index.html') - outputDebug(`[Theme Profile] Resolved URL to open: ${urlToOpen}`) - - const filename = 'liquid-profile' - const sourceBase64 = Buffer.from(profileJson).toString('base64') - const jsSource = `speedscope.loadFileFromBase64(${JSON.stringify(filename)}, ${JSON.stringify(sourceBase64)})` - - const filePrefix = `speedscope-${Number(new Date())}-${process.pid}` - let jsPath = joinPath(tempDirectory(), `${filePrefix}.js`) - outputDebug(`[Theme Profile] writing JS file to: ${jsPath}`) - await writeFile(jsPath, jsSource) - outputDebug(`[Theme Profile] JS file created successfully: ${jsPath}`) - - urlToOpen = `${wslPrefix}${urlToOpen}` - jsPath = `${wslPrefix}${jsPath}` - - // For some silly reason, the OS X open command ignores any query parameters or hash parameters - // passed as part of the URL. To get around this weird issue, we'll create a local HTML file - // that just redirects. - urlToOpen += `#localProfilePath=${jsPath}` - - let htmlPath = joinPath(tempDirectory(), `${filePrefix}.html`) - outputDebug(`[Theme Profile] writing HTML file to: ${htmlPath}`) - await writeFile(htmlPath, ``) - outputDebug(`[Theme Profile] HTML file created successfully: ${htmlPath}`) - - htmlPath = `${wslPrefix}${htmlPath}` - - urlToOpen = `file://${htmlPath}` - outputDebug(`[Theme Profile] Opening URL: ${urlToOpen}`) - const opened = await openURL(urlToOpen) - outputDebug(`[Theme Profile] URL opened successfully: ${opened}`) + return {result: themeProfileJsonOutputSchema.validate(JSON.parse(profileJson)), source: profileJson} } diff --git a/packages/theme/src/cli/services/profile/result.ts b/packages/theme/src/cli/services/profile/result.ts new file mode 100644 index 00000000000..28b68d6c158 --- /dev/null +++ b/packages/theme/src/cli/services/profile/result.ts @@ -0,0 +1,58 @@ +import {themeProfileJsonOutputSchema, type ThemeProfileResult} from './types.js' +import {resolveAssetPath} from '../../utilities/asset-path.js' +import {openURL} from '@shopify/cli-kit/node/system' +import {joinPath} from '@shopify/cli-kit/node/path' +import {writeFile, tempDirectory} from '@shopify/cli-kit/node/fs' +import {outputResult, outputDebug} from '@shopify/cli-kit/node/output' + +export async function renderThemeProfileResult( + {result, source}: {result: ThemeProfileResult; source: string}, + format: 'text' | 'json', +): Promise { + themeProfileJsonOutputSchema.validate(result) + // The server owns the wire format. Keep its whitespace, member order and extension fields. + if (format === 'json') outputResult(source) + else await openProfile(source) +} + +async function openProfile(profileJson: string) { + // Adapted from https://github.com/jlfwong/speedscope/blob/146477a8508a6d2da697cb0ea0a426ba81b3e8dc/bin/cli.js#L63 + + // on Windows Subsystem for Linux, we need to prefix file paths with the + // WSL distro name to get them to open in the browser. + const distroName = process.env.WSL_DISTRO_NAME + const wslPrefix = distroName ? `//wsl$/${distroName}` : '' + + let urlToOpen = await resolveAssetPath('speedscope', 'index.html') + outputDebug(`[Theme Profile] Resolved URL to open: ${urlToOpen}`) + + const filename = 'liquid-profile' + const sourceBase64 = Buffer.from(profileJson).toString('base64') + const jsSource = `speedscope.loadFileFromBase64(${JSON.stringify(filename)}, ${JSON.stringify(sourceBase64)})` + + const filePrefix = `speedscope-${Number(new Date())}-${process.pid}` + let jsPath = joinPath(tempDirectory(), `${filePrefix}.js`) + outputDebug(`[Theme Profile] writing JS file to: ${jsPath}`) + await writeFile(jsPath, jsSource) + outputDebug(`[Theme Profile] JS file created successfully: ${jsPath}`) + + urlToOpen = `${wslPrefix}${urlToOpen}` + jsPath = `${wslPrefix}${jsPath}` + + // For some silly reason, the OS X open command ignores any query parameters or hash parameters + // passed as part of the URL. To get around this weird issue, we'll create a local HTML file + // that just redirects. + urlToOpen += `#localProfilePath=${jsPath}` + + let htmlPath = joinPath(tempDirectory(), `${filePrefix}.html`) + outputDebug(`[Theme Profile] writing HTML file to: ${htmlPath}`) + await writeFile(htmlPath, ``) + outputDebug(`[Theme Profile] HTML file created successfully: ${htmlPath}`) + + htmlPath = `${wslPrefix}${htmlPath}` + + urlToOpen = `file://${htmlPath}` + outputDebug(`[Theme Profile] Opening URL: ${urlToOpen}`) + const opened = await openURL(urlToOpen) + outputDebug(`[Theme Profile] URL opened successfully: ${opened}`) +} diff --git a/packages/theme/src/cli/services/profile/types.ts b/packages/theme/src/cli/services/profile/types.ts new file mode 100644 index 00000000000..ee82abc97a5 --- /dev/null +++ b/packages/theme/src/cli/services/profile/types.ts @@ -0,0 +1,59 @@ +import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' + +// Speedscope owns this format; allow upstream extensions at every object boundary. +// See assets/speedscope/file-format-schema.json for the bundled viewer's contract. +const ProfileFrameSchema = zod + .object({ + name: zod.string(), + file: zod.string().optional(), + line: zod.number().optional(), + col: zod.number().optional(), + }) + .passthrough() +const ProfileEventSchema = zod + .object({ + type: zod.enum(['O', 'C']), + at: zod.number(), + frame: zod.number(), + }) + .passthrough() +const ProfileBaseSchema = zod.object({ + name: zod.string(), + unit: zod.enum(['bytes', 'microseconds', 'milliseconds', 'nanoseconds', 'none', 'seconds']), + startValue: zod.number(), + endValue: zod.number(), +}) +const EventedProfileSchema = ProfileBaseSchema.extend({ + type: zod.literal('evented'), + events: zod.array(ProfileEventSchema), +}).passthrough() +const SampledProfileSchema = ProfileBaseSchema.extend({ + type: zod.literal('sampled'), + samples: zod.array(zod.array(zod.number())), + weights: zod.array(zod.number()), +}).passthrough() +const ProfileSharedSchema = zod.object({frames: zod.array(ProfileFrameSchema)}).passthrough() + +export const themeProfileJsonOutputSchema = defineJsonOutputSchema({ + name: 'ThemeProfileResult', + schema: zod + .object({ + $schema: zod.literal('https://www.speedscope.app/file-format-schema.json'), + shared: ProfileSharedSchema, + profiles: zod.array(zod.discriminatedUnion('type', [EventedProfileSchema, SampledProfileSchema])), + name: zod.string().optional(), + exporter: zod.string().optional(), + activeProfileIndex: zod.number().optional(), + }) + .passthrough(), + definitions: { + ProfileFrame: ProfileFrameSchema, + ProfileEvent: ProfileEventSchema, + ProfileShared: ProfileSharedSchema, + EventedProfile: EventedProfileSchema, + SampledProfile: SampledProfileSchema, + }, +}) + +export type ThemeProfileResult = InferJsonOutputSchema