From a2ca82642042d5bb14d97a46785a2630c78a6f42 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Thu, 24 Sep 2026 11:18:41 +0200 Subject: [PATCH 1/4] Add typed JSON output to theme profile --- packages/cli/README.md | 201 ++++++++++++++++++ packages/cli/oclif.manifest.json | 2 +- .../rules/json-output-command-exceptions.js | 1 - .../src/cli/commands/theme/profile.test.ts | 93 ++++++++ .../theme/src/cli/commands/theme/profile.ts | 35 +-- .../theme/src/cli/services/profile.test.ts | 21 +- packages/theme/src/cli/services/profile.ts | 56 +---- .../theme/src/cli/services/profile/result.ts | 58 +++++ .../theme/src/cli/services/profile/types.ts | 59 +++++ 9 files changed, 445 insertions(+), 81 deletions(-) create mode 100644 packages/theme/src/cli/commands/theme/profile.test.ts create mode 100644 packages/theme/src/cli/services/profile/result.ts create mode 100644 packages/theme/src/cli/services/profile/types.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index 69e204c34a6..27a601c91b1 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -15544,6 +15544,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 fb40c330c23..8b9e1e2c41d 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -12271,7 +12271,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 b57bef4ffd6..18e26068614 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -8,7 +8,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', // App Security commands, launched before adopting typed JSON output. Remove entries as they adopt it. 'packages/app/src/cli/commands/app/security/check.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 6a7bd22c94d..e3efc3523e3 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' @@ -20,7 +21,9 @@ vi.mock('../utilities/theme-environment/crawler-signature.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'} @@ -41,7 +44,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( @@ -64,7 +69,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 @@ -90,7 +95,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') @@ -106,7 +111,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"}') @@ -114,7 +119,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( @@ -136,7 +141,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$/)) @@ -148,7 +153,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 From 311a4de18da3a31e6a3cb97098581596379eef7f Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 14:07:35 +0200 Subject: [PATCH 2/4] Align theme profile environment JSON with conventions --- .changeset/theme-profile-environments.md | 5 +++ .../src/cli/commands/theme/profile.test.ts | 10 +++++ .../theme/src/cli/commands/theme/profile.ts | 24 ++++++----- packages/theme/src/cli/services/profile.ts | 4 +- .../theme/src/cli/services/profile/types.ts | 40 +++++++++++-------- 5 files changed, 55 insertions(+), 28 deletions(-) create mode 100644 .changeset/theme-profile-environments.md diff --git a/.changeset/theme-profile-environments.md b/.changeset/theme-profile-environments.md new file mode 100644 index 00000000000..473f9a8d34d --- /dev/null +++ b/.changeset/theme-profile-environments.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli': major +--- + +Wrap explicit environment results in `theme profile --json` while preserving native Speedscope profiles. diff --git a/packages/theme/src/cli/commands/theme/profile.test.ts b/packages/theme/src/cli/commands/theme/profile.test.ts index 2a2d77a4757..bf6504f31b1 100644 --- a/packages/theme/src/cli/commands/theme/profile.test.ts +++ b/packages/theme/src/cli/commands/theme/profile.test.ts @@ -91,3 +91,13 @@ test.each(['profile', 'empty', 'failure', 'invalid result'])( }) }, ) + +test('wraps requested environment results while preserving the native profile payload', () => { + const result = {environments: [{environment: 'development', result: profile}]} + expect(JSON.parse(themeProfileJsonOutputSchema.encode(result))).toEqual(result) + expect(() => + themeProfileJsonOutputSchema.validate({ + environments: [{environment: 'development', result: profile, unexpected: true}], + }), + ).toThrow() +}) diff --git a/packages/theme/src/cli/commands/theme/profile.ts b/packages/theme/src/cli/commands/theme/profile.ts index 3c04dd3c58f..ebfc26ad7ac 100644 --- a/packages/theme/src/cli/commands/theme/profile.ts +++ b/packages/theme/src/cli/commands/theme/profile.ts @@ -6,9 +6,11 @@ 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 {outputResult} from '@shopify/cli-kit/node/output' import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' import {InferredFlags} from '@oclif/core/interfaces' import {AdminSession} from '@shopify/cli-kit/node/session' +import type {ThemeEnvironmentResult} from '../../services/json-output/schema.js' type ProfileFlags = InferredFlags export default class Profile extends ThemeCommand { @@ -48,7 +50,7 @@ export default class Profile extends ThemeCommand { static multiEnvironmentsFlags: RequiredFlags = null - async command(flags: ProfileFlags, adminSession: AdminSession) { + async command(flags: ProfileFlags, adminSession: AdminSession, multiEnvironment = false) { const {password: themeAccessPassword} = flags let filter @@ -60,16 +62,20 @@ export default class Profile extends ThemeCommand { const theme = await findOrSelectTheme(adminSession, filter) const title = `Generating Liquid profile for ${adminSession.storeFqdn} ${flags.url}` + let result: Awaited> | undefined const task = async () => { - const result = await profile( - adminSession, - theme.id.toString(), - flags.url, - themeAccessPassword, - flags['store-password'], - ) - await renderThemeProfileResult(result, flags.json ? 'json' : 'text') + result = await profile(adminSession, theme.id.toString(), flags.url, themeAccessPassword, flags['store-password']) + if (!multiEnvironment || !flags.json) await renderThemeProfileResult(result, flags.json ? 'json' : 'text') } await renderTasksToStdErr([{title, task}]) + return result?.result + } + + protected collectsEnvironmentResults(flags: Partial): boolean { + return Boolean(flags.json) + } + + protected renderEnvironmentResults(environments: ThemeEnvironmentResult[]): void { + outputResult(themeProfileJsonOutputSchema.encode({environments})) } } diff --git a/packages/theme/src/cli/services/profile.ts b/packages/theme/src/cli/services/profile.ts index dad140e2deb..46e9475ee9d 100644 --- a/packages/theme/src/cli/services/profile.ts +++ b/packages/theme/src/cli/services/profile.ts @@ -1,4 +1,4 @@ -import {themeProfileJsonOutputSchema} from './profile/types.js' +import {themeProfileResultSchema} 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' @@ -42,5 +42,5 @@ export async function profile( const profileJson = await response.text() - return {result: themeProfileJsonOutputSchema.validate(JSON.parse(profileJson)), source: profileJson} + return {result: themeProfileResultSchema.parse(JSON.parse(profileJson)), source: profileJson} } diff --git a/packages/theme/src/cli/services/profile/types.ts b/packages/theme/src/cli/services/profile/types.ts index ee82abc97a5..105f3259821 100644 --- a/packages/theme/src/cli/services/profile/types.ts +++ b/packages/theme/src/cli/services/profile/types.ts @@ -1,4 +1,4 @@ -import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {defineThemeJsonOutputSchema} from '../json-output/schema.js' import {zod} from '@shopify/cli-kit/node/schema' // Speedscope owns this format; allow upstream extensions at every object boundary. @@ -7,15 +7,15 @@ const ProfileFrameSchema = zod .object({ name: zod.string(), file: zod.string().optional(), - line: zod.number().optional(), - col: zod.number().optional(), + line: zod.number().int().nonnegative().optional(), + col: zod.number().int().nonnegative().optional(), }) .passthrough() const ProfileEventSchema = zod .object({ type: zod.enum(['O', 'C']), at: zod.number(), - frame: zod.number(), + frame: zod.number().int().nonnegative(), }) .passthrough() const ProfileBaseSchema = zod.object({ @@ -30,23 +30,29 @@ const EventedProfileSchema = ProfileBaseSchema.extend({ }).passthrough() const SampledProfileSchema = ProfileBaseSchema.extend({ type: zod.literal('sampled'), - samples: zod.array(zod.array(zod.number())), + samples: zod.array(zod.array(zod.number().int().nonnegative())), weights: zod.array(zod.number()), }).passthrough() const ProfileSharedSchema = zod.object({frames: zod.array(ProfileFrameSchema)}).passthrough() -export const themeProfileJsonOutputSchema = defineJsonOutputSchema({ +export const themeProfileResultSchema = 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().int().nonnegative().optional(), + }) + .passthrough() + .describe( + 'The native Speedscope file format, defined by its $schema URL. Original keys and extension fields are preserved.', + ) + +export const themeProfileJsonOutputSchema = defineThemeJsonOutputSchema({ 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(), + schema: themeProfileResultSchema, + project: (value) => value, definitions: { ProfileFrame: ProfileFrameSchema, ProfileEvent: ProfileEventSchema, @@ -56,4 +62,4 @@ export const themeProfileJsonOutputSchema = defineJsonOutputSchema({ }, }) -export type ThemeProfileResult = InferJsonOutputSchema +export type ThemeProfileResult = zod.infer From a8e253417b28f0f16323c29bc4618e128b94d94c Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 14:16:43 +0200 Subject: [PATCH 3/4] Refresh theme profile JSON schema documentation --- packages/cli/README.md | 285 ++++++++++++++++++++++++++----- packages/cli/oclif.manifest.json | 2 +- 2 files changed, 248 insertions(+), 39 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 27a601c91b1..6cd4a7cbb30 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -15551,44 +15551,26 @@ DESCRIPTION ```json { - "type": "object", - "properties": { - "$schema": { - "type": "string", - "const": "https://www.speedscope.app/file-format-schema.json" - }, - "shared": { - "$ref": "#/definitions/ProfileShared" + "anyOf": [ + { + "$ref": "#/definitions/ThemeEnvironment/anyOf/0/properties/result" }, - "profiles": { - "type": "array", - "items": { - "anyOf": [ - { - "$ref": "#/definitions/EventedProfile" - }, - { - "$ref": "#/definitions/SampledProfile" + { + "type": "object", + "properties": { + "environments": { + "type": "array", + "items": { + "$ref": "#/definitions/ThemeEnvironment" } - ] - } - }, - "name": { - "type": "string" - }, - "exporter": { - "type": "string" - }, - "activeProfileIndex": { - "type": "number" + } + }, + "required": [ + "environments" + ], + "additionalProperties": false } - }, - "required": [ - "$schema", - "shared", - "profiles" ], - "additionalProperties": true, "title": "ThemeProfileResult", "definitions": { "ProfileFrame": { @@ -15601,10 +15583,12 @@ DESCRIPTION "type": "string" }, "line": { - "type": "number" + "type": "integer", + "minimum": 0 }, "col": { - "type": "number" + "type": "integer", + "minimum": 0 } }, "required": [ @@ -15626,7 +15610,8 @@ DESCRIPTION "type": "number" }, "frame": { - "type": "number" + "type": "integer", + "minimum": 0 } }, "required": [ @@ -15719,7 +15704,8 @@ DESCRIPTION "items": { "type": "array", "items": { - "type": "number" + "type": "integer", + "minimum": 0 } } }, @@ -15740,6 +15726,229 @@ DESCRIPTION "weights" ], "additionalProperties": true + }, + "ThemeEnvironment": { + "anyOf": [ + { + "type": "object", + "properties": { + "environment": { + "type": "string" + }, + "result": { + "anyOf": [ + { + "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": "integer", + "minimum": 0 + } + }, + "required": [ + "$schema", + "shared", + "profiles" + ], + "additionalProperties": true, + "description": "The native Speedscope file format, defined by its $schema URL. Original keys and extension fields are preserved." + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "cancelled" + } + }, + "required": [ + "status" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "environment", + "result" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "environment": { + "type": "string" + }, + "error": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "abort" + }, + "message": { + "type": "string" + }, + "tryMessage": { + "type": "string" + }, + "nextSteps": { + "type": "array", + "items": { + "type": "string" + } + }, + "customSections": { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "body": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + } + ] + } + }, + "required": [ + "body" + ], + "additionalProperties": false + } + }, + "details": {} + }, + "required": [ + "type", + "message" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "bug" + }, + "message": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message" + }, + "tryMessage": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage" + }, + "nextSteps": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps" + }, + "customSections": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections" + }, + "details": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details" + }, + "stack": { + "type": "string" + } + }, + "required": [ + "type", + "message" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "external" + }, + "message": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message" + }, + "tryMessage": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage" + }, + "nextSteps": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps" + }, + "customSections": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections" + }, + "details": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details" + }, + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type", + "message", + "command", + "args" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "environment", + "error" + ], + "additionalProperties": false + } + ] } }, "$schema": "http://json-schema.org/draft-07/schema#" diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 8b9e1e2c41d..38dacbc2f57 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -12271,7 +12271,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.\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```", + "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 \"anyOf\": [\n {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/0/properties/result\"\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"environments\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/ThemeEnvironment\"\n }\n }\n },\n \"required\": [\n \"environments\"\n ],\n \"additionalProperties\": false\n }\n ],\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\": \"integer\",\n \"minimum\": 0\n },\n \"col\": {\n \"type\": \"integer\",\n \"minimum\": 0\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\": \"integer\",\n \"minimum\": 0\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\": \"integer\",\n \"minimum\": 0\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 \"ThemeEnvironment\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"result\": {\n \"anyOf\": [\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\": \"integer\",\n \"minimum\": 0\n }\n },\n \"required\": [\n \"$schema\",\n \"shared\",\n \"profiles\"\n ],\n \"additionalProperties\": true,\n \"description\": \"The native Speedscope file format, defined by its $schema URL. Original keys and extension fields are preserved.\"\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"cancelled\"\n }\n },\n \"required\": [\n \"status\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"required\": [\n \"environment\",\n \"result\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"error\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"abort\"\n },\n \"message\": {\n \"type\": \"string\"\n },\n \"tryMessage\": {\n \"type\": \"string\"\n },\n \"nextSteps\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"customSections\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"title\": {\n \"type\": \"string\"\n },\n \"body\": {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n }\n ]\n }\n },\n \"required\": [\n \"body\"\n ],\n \"additionalProperties\": false\n }\n },\n \"details\": {}\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"bug\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details\"\n },\n \"stack\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"external\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details\"\n },\n \"command\": {\n \"type\": \"string\"\n },\n \"args\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"type\",\n \"message\",\n \"command\",\n \"args\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"required\": [\n \"environment\",\n \"error\"\n ],\n \"additionalProperties\": false\n }\n ]\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": { From 04883cc639dbc58c9e6a960341b4e2d3d648bb66 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Thu, 8 Oct 2026 10:39:08 +0200 Subject: [PATCH 4/4] Refresh JSON schema documentation after rebase --- packages/cli/README.md | 30 ++++++---- packages/cli/oclif.manifest.json | 2 +- .../src/cli/commands/theme/profile.test.ts | 60 +++++++++++++------ .../theme/src/cli/commands/theme/profile.ts | 4 -- .../theme/src/cli/services/profile.test.ts | 11 ++++ .../theme/src/cli/services/profile/types.ts | 10 ++-- 6 files changed, 77 insertions(+), 40 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 6cd4a7cbb30..13e58c87648 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -15583,12 +15583,10 @@ DESCRIPTION "type": "string" }, "line": { - "type": "integer", - "minimum": 0 + "type": "number" }, "col": { - "type": "integer", - "minimum": 0 + "type": "number" } }, "required": [ @@ -15610,8 +15608,7 @@ DESCRIPTION "type": "number" }, "frame": { - "type": "integer", - "minimum": 0 + "type": "number" } }, "required": [ @@ -15704,8 +15701,7 @@ DESCRIPTION "items": { "type": "array", "items": { - "type": "integer", - "minimum": 0 + "type": "number" } } }, @@ -15767,8 +15763,7 @@ DESCRIPTION "type": "string" }, "activeProfileIndex": { - "type": "integer", - "minimum": 0 + "type": "number" } }, "required": [ @@ -15819,6 +15814,11 @@ DESCRIPTION "message": { "type": "string" }, + "code": { + "type": "string", + "minLength": 1, + "description": "A stable error code, included only when known." + }, "tryMessage": { "type": "string" }, @@ -15859,7 +15859,9 @@ DESCRIPTION "additionalProperties": false } }, - "details": {} + "details": { + "description": "Selected domain details, preserving native API payloads such as GraphQL errors, extensions, and data." + } }, "required": [ "type", @@ -15877,6 +15879,9 @@ DESCRIPTION "message": { "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message" }, + "code": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/code" + }, "tryMessage": { "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage" }, @@ -15909,6 +15914,9 @@ DESCRIPTION "message": { "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message" }, + "code": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/code" + }, "tryMessage": { "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage" }, diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 38dacbc2f57..3e414b30b4c 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -12271,7 +12271,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.\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 \"anyOf\": [\n {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/0/properties/result\"\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"environments\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/ThemeEnvironment\"\n }\n }\n },\n \"required\": [\n \"environments\"\n ],\n \"additionalProperties\": false\n }\n ],\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\": \"integer\",\n \"minimum\": 0\n },\n \"col\": {\n \"type\": \"integer\",\n \"minimum\": 0\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\": \"integer\",\n \"minimum\": 0\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\": \"integer\",\n \"minimum\": 0\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 \"ThemeEnvironment\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"result\": {\n \"anyOf\": [\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\": \"integer\",\n \"minimum\": 0\n }\n },\n \"required\": [\n \"$schema\",\n \"shared\",\n \"profiles\"\n ],\n \"additionalProperties\": true,\n \"description\": \"The native Speedscope file format, defined by its $schema URL. Original keys and extension fields are preserved.\"\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"cancelled\"\n }\n },\n \"required\": [\n \"status\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"required\": [\n \"environment\",\n \"result\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"error\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"abort\"\n },\n \"message\": {\n \"type\": \"string\"\n },\n \"tryMessage\": {\n \"type\": \"string\"\n },\n \"nextSteps\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"customSections\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"title\": {\n \"type\": \"string\"\n },\n \"body\": {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n }\n ]\n }\n },\n \"required\": [\n \"body\"\n ],\n \"additionalProperties\": false\n }\n },\n \"details\": {}\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"bug\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details\"\n },\n \"stack\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"external\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details\"\n },\n \"command\": {\n \"type\": \"string\"\n },\n \"args\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"type\",\n \"message\",\n \"command\",\n \"args\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"required\": [\n \"environment\",\n \"error\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "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 \"anyOf\": [\n {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/0/properties/result\"\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"environments\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/ThemeEnvironment\"\n }\n }\n },\n \"required\": [\n \"environments\"\n ],\n \"additionalProperties\": false\n }\n ],\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 \"ThemeEnvironment\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"result\": {\n \"anyOf\": [\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 \"description\": \"The native Speedscope file format, defined by its $schema URL. Original keys and extension fields are preserved.\"\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"cancelled\"\n }\n },\n \"required\": [\n \"status\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"required\": [\n \"environment\",\n \"result\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"error\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"abort\"\n },\n \"message\": {\n \"type\": \"string\"\n },\n \"code\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"A stable error code, included only when known.\"\n },\n \"tryMessage\": {\n \"type\": \"string\"\n },\n \"nextSteps\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"customSections\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"title\": {\n \"type\": \"string\"\n },\n \"body\": {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n }\n ]\n }\n },\n \"required\": [\n \"body\"\n ],\n \"additionalProperties\": false\n }\n },\n \"details\": {\n \"description\": \"Selected domain details, preserving native API payloads such as GraphQL errors, extensions, and data.\"\n }\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"bug\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message\"\n },\n \"code\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/code\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details\"\n },\n \"stack\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"external\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message\"\n },\n \"code\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/code\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details\"\n },\n \"command\": {\n \"type\": \"string\"\n },\n \"args\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"type\",\n \"message\",\n \"command\",\n \"args\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"required\": [\n \"environment\",\n \"error\"\n ],\n \"additionalProperties\": false\n }\n ]\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/theme/src/cli/commands/theme/profile.test.ts b/packages/theme/src/cli/commands/theme/profile.test.ts index bf6504f31b1..2516a640f5b 100644 --- a/packages/theme/src/cli/commands/theme/profile.test.ts +++ b/packages/theme/src/cli/commands/theme/profile.test.ts @@ -1,7 +1,10 @@ import Profile from './profile.js' import {themeProfileJsonOutputSchema, type ThemeProfileResult} from '../../services/profile/types.js' +import * as themeStorage from '../../services/local-storage.js' import {expect, test, vi} from 'vitest' import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs' +import {LocalStorage} from '@shopify/cli-kit/node/local-storage' vi.mock('../../utilities/theme-selector.js') vi.mock('../../utilities/theme-environment/storefront-renderer.js') @@ -46,7 +49,7 @@ test('exposes Speedscope evented and sampled profiles while retaining extension ).toThrow() }) -test.each(['profile', 'empty', 'failure', 'invalid result'])( +test.each(['profile', 'empty', 'unknown location', 'fractional location', 'failure', 'invalid result'])( 'keeps %s JSON on stdout and progress events on stderr', async (mode) => { const {default: StreamProfile} = await import('./profile.js') @@ -66,28 +69,47 @@ test.each(['profile', 'empty', 'failure', 'invalid result'])( processing: false, createdAtRuntime: false, }) - const result = mode === 'empty' ? {...profile, profiles: [], shared: {frames: []}} : profile + let result = profile + if (mode === 'empty') result = {...profile, profiles: [], shared: {frames: []}} + if (mode === 'unknown location' || mode === 'fractional location') { + const position = mode === 'unknown location' ? -1 : 0.5 + result = {...profile, shared: {frames: [{name: 'layout', line: position, col: position}]}} + } // 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) + await inTemporaryDirectory(async (directory) => { + const storage = new LocalStorage({cwd: directory}) + const {getThemeStore, setThemeStore} = themeStorage + const getStore = vi.spyOn(themeStorage, 'getThemeStore').mockImplementation(() => getThemeStore(storage)) + const setStore = vi + .spyOn(themeStorage, 'setThemeStore') + .mockImplementation((store) => setThemeStore(store, storage)) + try { + 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) + }) + expect(storage.get('themeStore')).toBe('shop.myshopify.com') + } finally { + getStore.mockRestore() + setStore.mockRestore() + } }) }, ) diff --git a/packages/theme/src/cli/commands/theme/profile.ts b/packages/theme/src/cli/commands/theme/profile.ts index ebfc26ad7ac..3ad85efb86f 100644 --- a/packages/theme/src/cli/commands/theme/profile.ts +++ b/packages/theme/src/cli/commands/theme/profile.ts @@ -71,10 +71,6 @@ export default class Profile extends ThemeCommand { return result?.result } - protected collectsEnvironmentResults(flags: Partial): boolean { - return Boolean(flags.json) - } - protected renderEnvironmentResults(environments: ThemeEnvironmentResult[]): void { outputResult(themeProfileJsonOutputSchema.encode({environments})) } diff --git a/packages/theme/src/cli/services/profile.test.ts b/packages/theme/src/cli/services/profile.test.ts index e3efc3523e3..90ec431ba69 100644 --- a/packages/theme/src/cli/services/profile.test.ts +++ b/packages/theme/src/cli/services/profile.test.ts @@ -90,6 +90,17 @@ describe('profile', () => { expect(htmlContent).toContain('speedscope') }) + test.each([-1, 0.5])('opens native profiles with source position %s', async (position) => { + const data = {...mockProfileData, shared: {frames: [{name: 'layout', line: position, col: position}]}} + vi.mocked(render).mockResolvedValue(new Response(JSON.stringify(data), {status: 200})) + + const result = await profile(mockAdminSession, themeId, urlPath) + expect(result.result).toEqual(data) + await renderThemeProfileResult(result, 'text') + + expect(openURL).toHaveBeenCalledWith(expect.stringMatching(/^file:\/\/.*\.html$/)) + }) + test('throws error when fetch fails', async () => { // Given vi.mocked(render).mockRejectedValue(new Error('Network error')) diff --git a/packages/theme/src/cli/services/profile/types.ts b/packages/theme/src/cli/services/profile/types.ts index 105f3259821..4a12493fec2 100644 --- a/packages/theme/src/cli/services/profile/types.ts +++ b/packages/theme/src/cli/services/profile/types.ts @@ -7,15 +7,15 @@ const ProfileFrameSchema = zod .object({ name: zod.string(), file: zod.string().optional(), - line: zod.number().int().nonnegative().optional(), - col: zod.number().int().nonnegative().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().int().nonnegative(), + frame: zod.number(), }) .passthrough() const ProfileBaseSchema = zod.object({ @@ -30,7 +30,7 @@ const EventedProfileSchema = ProfileBaseSchema.extend({ }).passthrough() const SampledProfileSchema = ProfileBaseSchema.extend({ type: zod.literal('sampled'), - samples: zod.array(zod.array(zod.number().int().nonnegative())), + samples: zod.array(zod.array(zod.number())), weights: zod.array(zod.number()), }).passthrough() const ProfileSharedSchema = zod.object({frames: zod.array(ProfileFrameSchema)}).passthrough() @@ -42,7 +42,7 @@ export const themeProfileResultSchema = zod profiles: zod.array(zod.discriminatedUnion('type', [EventedProfileSchema, SampledProfileSchema])), name: zod.string().optional(), exporter: zod.string().optional(), - activeProfileIndex: zod.number().int().nonnegative().optional(), + activeProfileIndex: zod.number().optional(), }) .passthrough() .describe(