Skip to content

Commit 42d0894

Browse files
Add typed JSON output to theme profile
1 parent 1e151c5 commit 42d0894

10 files changed

Lines changed: 463 additions & 81 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
'@shopify/cli': minor
3+
---
4+
Expose a discoverable Speedscope result schema for theme profile --json.

‎packages/cli/README.md‎

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8627,6 +8627,207 @@ DESCRIPTION
86278627

86288628
This command will open a web page with the Speedscope profiler detailing the time spent executing Liquid on the given
86298629
page.
8630+
8631+
Output from `--json` conforms to the `ThemeProfileResult` schema.
8632+
8633+
Use `--json-schema` to print the result, error, and event schemas.
8634+
8635+
```json
8636+
{
8637+
"type": "object",
8638+
"properties": {
8639+
"$schema": {
8640+
"type": "string",
8641+
"const": "https://www.speedscope.app/file-format-schema.json"
8642+
},
8643+
"shared": {
8644+
"$ref": "#/definitions/ProfileShared"
8645+
},
8646+
"profiles": {
8647+
"type": "array",
8648+
"items": {
8649+
"anyOf": [
8650+
{
8651+
"$ref": "#/definitions/EventedProfile"
8652+
},
8653+
{
8654+
"$ref": "#/definitions/SampledProfile"
8655+
}
8656+
]
8657+
}
8658+
},
8659+
"name": {
8660+
"type": "string"
8661+
},
8662+
"exporter": {
8663+
"type": "string"
8664+
},
8665+
"activeProfileIndex": {
8666+
"type": "number"
8667+
}
8668+
},
8669+
"required": [
8670+
"$schema",
8671+
"shared",
8672+
"profiles"
8673+
],
8674+
"additionalProperties": true,
8675+
"title": "ThemeProfileResult",
8676+
"definitions": {
8677+
"ProfileFrame": {
8678+
"type": "object",
8679+
"properties": {
8680+
"name": {
8681+
"type": "string"
8682+
},
8683+
"file": {
8684+
"type": "string"
8685+
},
8686+
"line": {
8687+
"type": "number"
8688+
},
8689+
"col": {
8690+
"type": "number"
8691+
}
8692+
},
8693+
"required": [
8694+
"name"
8695+
],
8696+
"additionalProperties": true
8697+
},
8698+
"ProfileEvent": {
8699+
"type": "object",
8700+
"properties": {
8701+
"type": {
8702+
"type": "string",
8703+
"enum": [
8704+
"O",
8705+
"C"
8706+
]
8707+
},
8708+
"at": {
8709+
"type": "number"
8710+
},
8711+
"frame": {
8712+
"type": "number"
8713+
}
8714+
},
8715+
"required": [
8716+
"type",
8717+
"at",
8718+
"frame"
8719+
],
8720+
"additionalProperties": true
8721+
},
8722+
"ProfileShared": {
8723+
"type": "object",
8724+
"properties": {
8725+
"frames": {
8726+
"type": "array",
8727+
"items": {
8728+
"$ref": "#/definitions/ProfileFrame"
8729+
}
8730+
}
8731+
},
8732+
"required": [
8733+
"frames"
8734+
],
8735+
"additionalProperties": true
8736+
},
8737+
"EventedProfile": {
8738+
"type": "object",
8739+
"properties": {
8740+
"name": {
8741+
"type": "string"
8742+
},
8743+
"unit": {
8744+
"type": "string",
8745+
"enum": [
8746+
"bytes",
8747+
"microseconds",
8748+
"milliseconds",
8749+
"nanoseconds",
8750+
"none",
8751+
"seconds"
8752+
]
8753+
},
8754+
"startValue": {
8755+
"type": "number"
8756+
},
8757+
"endValue": {
8758+
"type": "number"
8759+
},
8760+
"type": {
8761+
"type": "string",
8762+
"const": "evented"
8763+
},
8764+
"events": {
8765+
"type": "array",
8766+
"items": {
8767+
"$ref": "#/definitions/ProfileEvent"
8768+
}
8769+
}
8770+
},
8771+
"required": [
8772+
"name",
8773+
"unit",
8774+
"startValue",
8775+
"endValue",
8776+
"type",
8777+
"events"
8778+
],
8779+
"additionalProperties": true
8780+
},
8781+
"SampledProfile": {
8782+
"type": "object",
8783+
"properties": {
8784+
"name": {
8785+
"$ref": "#/definitions/EventedProfile/properties/name"
8786+
},
8787+
"unit": {
8788+
"$ref": "#/definitions/EventedProfile/properties/unit"
8789+
},
8790+
"startValue": {
8791+
"$ref": "#/definitions/EventedProfile/properties/startValue"
8792+
},
8793+
"endValue": {
8794+
"$ref": "#/definitions/EventedProfile/properties/endValue"
8795+
},
8796+
"type": {
8797+
"type": "string",
8798+
"const": "sampled"
8799+
},
8800+
"samples": {
8801+
"type": "array",
8802+
"items": {
8803+
"type": "array",
8804+
"items": {
8805+
"type": "number"
8806+
}
8807+
}
8808+
},
8809+
"weights": {
8810+
"type": "array",
8811+
"items": {
8812+
"type": "number"
8813+
}
8814+
}
8815+
},
8816+
"required": [
8817+
"name",
8818+
"unit",
8819+
"startValue",
8820+
"endValue",
8821+
"type",
8822+
"samples",
8823+
"weights"
8824+
],
8825+
"additionalProperties": true
8826+
}
8827+
},
8828+
"$schema": "http://json-schema.org/draft-07/schema#"
8829+
}
8830+
```
86308831
```
86318832
86328833
## `shopify theme publish`

‎packages/cli/oclif.manifest.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10942,7 +10942,7 @@
1094210942
"args": {
1094310943
},
1094410944
"customPluginName": "@shopify/theme",
10945-
"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.",
10945+
"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\nOutput from `--json` conforms to the `ThemeProfileResult` schema.\n\nUse `--json-schema` to print the result, error, and event schemas.\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```",
1094610946
"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.",
1094710947
"enableJsonFlag": false,
1094810948
"flags": {

‎packages/eslint-plugin-cli/rules/json-output-command-exceptions.js‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,6 @@ const commandExceptions = [
6363
'packages/theme/src/cli/commands/theme/init.ts',
6464
'packages/theme/src/cli/commands/theme/metafields/pull.ts',
6565
'packages/theme/src/cli/commands/theme/package.ts',
66-
'packages/theme/src/cli/commands/theme/profile.ts',
6766

6867
// Streaming commands without a single finite result.
6968
'packages/app/src/cli/commands/app/demo/watcher.ts',
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import Profile from './profile.js'
2+
import {themeProfileJsonOutputSchema, type ThemeProfileResult} from '../../services/profile/types.js'
3+
import {captureStandardStreams} from '../../utilities/testing/streams.js'
4+
import {afterEach, expect, test, vi} from 'vitest'
5+
6+
vi.mock('../../utilities/theme-selector.js')
7+
vi.mock('../../utilities/theme-environment/storefront-renderer.js')
8+
vi.mock('../../utilities/theme-environment/dev-server-session.js')
9+
vi.mock('../../utilities/theme-environment/storefront-session.js')
10+
vi.mock('@shopify/cli-kit/node/session')
11+
vi.mock('@shopify/cli-kit/node/system')
12+
13+
const profile: ThemeProfileResult = {
14+
$schema: 'https://www.speedscope.app/file-format-schema.json',
15+
shared: {frames: [{name: 'layout', file: 'layout/theme.liquid', line: 1, col: 0, extension: false}]},
16+
profiles: [
17+
{
18+
type: 'evented',
19+
name: 'Liquid',
20+
unit: 'microseconds',
21+
startValue: 0,
22+
endValue: 10,
23+
events: [
24+
{type: 'O', at: 0, frame: 0},
25+
{type: 'C', at: 10, frame: 0},
26+
],
27+
},
28+
],
29+
extension: {value: null},
30+
}
31+
32+
afterEach(() => vi.unstubAllEnvs())
33+
34+
test('exposes Speedscope evented and sampled profiles while retaining extension fields', () => {
35+
expect(Profile.jsonOutputSchema).toBe(themeProfileJsonOutputSchema)
36+
expect(Profile.flags.json).toBeDefined()
37+
expect(JSON.parse(themeProfileJsonOutputSchema.encode(profile))).toEqual(profile)
38+
const sampled: ThemeProfileResult = {
39+
...profile,
40+
profiles: [
41+
{type: 'sampled', name: 'Liquid', unit: 'none', startValue: 0, endValue: 10, samples: [[0]], weights: [10]},
42+
],
43+
}
44+
expect(JSON.parse(themeProfileJsonOutputSchema.encode(sampled))).toEqual(sampled)
45+
expect(() => themeProfileJsonOutputSchema.validate({...profile, shared: {frames: [{name: 123}]}})).toThrow()
46+
expect(() =>
47+
themeProfileJsonOutputSchema.validate({...profile, profiles: [{...profile.profiles[0], unit: 'invalid'}]}),
48+
).toThrow()
49+
})
50+
51+
test.each(['profile', 'empty', 'failure', 'invalid result'])(
52+
'keeps %s JSON on stdout and progress events on stderr',
53+
async (mode) => {
54+
vi.stubEnv('SHOPIFY_UNIT_TEST', 'false')
55+
vi.resetModules()
56+
const {default: StreamProfile} = await import('./profile.js')
57+
const {findOrSelectTheme} = await import('../../utilities/theme-selector.js')
58+
const {render} = await import('../../utilities/theme-environment/storefront-renderer.js')
59+
const {ensureAuthenticatedThemes} = await import('@shopify/cli-kit/node/session')
60+
const {openURL} = await import('@shopify/cli-kit/node/system')
61+
const {runWithCommandEventsForCommand} = await import('@shopify/cli-kit/node/command-events')
62+
const {Config} = await import('@oclif/core')
63+
const config = new Config({root: __dirname})
64+
await config.load()
65+
vi.mocked(ensureAuthenticatedThemes).mockResolvedValue({storeFqdn: 'shop.myshopify.com', token: 'token'})
66+
vi.mocked(findOrSelectTheme).mockResolvedValue({
67+
id: 1,
68+
name: 'Dawn',
69+
role: 'live',
70+
processing: false,
71+
createdAtRuntime: false,
72+
})
73+
const result = mode === 'empty' ? {...profile, profiles: [], shared: {frames: []}} : profile
74+
// Keep unusual whitespace and upstream field order to catch accidental reserialization.
75+
const source = mode === 'invalid result' ? '{"profiles":false}' : ` ${JSON.stringify(result, null, 4)}\n`
76+
vi.mocked(render).mockResolvedValue(new Response(source, {status: 200}))
77+
if (mode === 'failure') vi.mocked(render).mockRejectedValue(new Error('Network error'))
78+
const streams = captureStandardStreams()
79+
try {
80+
const operation = runWithCommandEventsForCommand(['--json'], () =>
81+
new StreamProfile(['--store=shop.myshopify.com', '--json'], config).run(),
82+
)
83+
if (mode === 'failure') await expect(operation).rejects.toThrow('Network error')
84+
else if (mode === 'invalid result') await expect(operation).rejects.toThrow()
85+
else await operation
86+
} finally {
87+
streams.restore()
88+
}
89+
expect(openURL).not.toHaveBeenCalled()
90+
const failed = mode === 'failure' || mode === 'invalid result'
91+
expect(streams.stdout()).toBe(failed ? '' : `${source}\n`)
92+
if (!failed) expect(JSON.parse(streams.stdout())).toEqual(result)
93+
const events = streams
94+
.stderr()
95+
.trim()
96+
.split('\n')
97+
.map((line) => JSON.parse(line))
98+
expect(events.map((event) => event.status)).toEqual(failed ? ['started'] : ['started', 'completed'])
99+
expect(events.every((event) => event.type === 'progress')).toBe(true)
100+
},
101+
)

‎packages/theme/src/cli/commands/theme/profile.ts‎

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,23 @@
11
import {themeFlags} from '../../flags.js'
22
import ThemeCommand, {RequiredFlags} from '../../utilities/theme-command.js'
33
import {profile} from '../../services/profile.js'
4+
import {renderThemeProfileResult} from '../../services/profile/result.js'
5+
import {themeProfileJsonOutputSchema} from '../../services/profile/types.js'
46
import {findOrSelectTheme} from '../../utilities/theme-selector.js'
57
import {renderTasksToStdErr} from '../../utilities/theme-ui.js'
68
import {Flags} from '@oclif/core'
79
import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'
8-
import {Task} from '@shopify/cli-kit/node/ui'
10+
import {renderSingleTask} from '@shopify/cli-kit/node/ui'
11+
import {outputContent} from '@shopify/cli-kit/node/output'
912
import {InferredFlags} from '@oclif/core/interfaces'
1013
import {AdminSession} from '@shopify/cli-kit/node/session'
1114

1215
type ProfileFlags = InferredFlags<typeof Profile.flags>
1316
export default class Profile extends ThemeCommand {
17+
static get jsonOutputSchema() {
18+
return themeProfileJsonOutputSchema
19+
}
20+
1421
static summary = 'Profile the Liquid rendering of a theme page.'
1522

1623
static usage = ['theme profile', 'theme profile --url /products/classic-leather-jacket']
@@ -54,21 +61,21 @@ export default class Profile extends ThemeCommand {
5461
}
5562
const theme = await findOrSelectTheme(adminSession, filter)
5663

57-
const tasks: Task[] = [
58-
{
59-
title: `Generating Liquid profile for ${adminSession.storeFqdn} ${flags.url}`,
60-
task: async () => {
61-
await profile(
62-
adminSession,
63-
theme.id.toString(),
64-
flags.url,
65-
flags.json,
66-
themeAccessPassword,
67-
flags['store-password'],
68-
)
69-
},
70-
},
71-
]
72-
await renderTasksToStdErr(tasks)
64+
const title = `Generating Liquid profile for ${adminSession.storeFqdn} ${flags.url}`
65+
const task = async () => {
66+
const result = await profile(
67+
adminSession,
68+
theme.id.toString(),
69+
flags.url,
70+
themeAccessPassword,
71+
flags['store-password'],
72+
)
73+
await renderThemeProfileResult(result, flags.json ? 'json' : 'text')
74+
}
75+
if (flags.json) {
76+
await renderSingleTask({title: outputContent`${title}`, task})
77+
} else {
78+
await renderTasksToStdErr([{title, task}])
79+
}
7380
}
7481
}

0 commit comments

Comments
 (0)