Skip to content

Commit 964fa3f

Browse files
Add typed JSON output to theme profile
1 parent 60fc145 commit 964fa3f

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
@@ -5709,6 +5709,207 @@ DESCRIPTION
57095709

57105710
This command will open a web page with the Speedscope profiler detailing the time spent executing Liquid on the given
57115711
page.
5712+
5713+
Output from `--json` conforms to the `ThemeProfileResult` schema.
5714+
5715+
Use `--json-schema` to print the result, error, and event schemas.
5716+
5717+
```json
5718+
{
5719+
"type": "object",
5720+
"properties": {
5721+
"$schema": {
5722+
"type": "string",
5723+
"const": "https://www.speedscope.app/file-format-schema.json"
5724+
},
5725+
"shared": {
5726+
"$ref": "#/definitions/ProfileShared"
5727+
},
5728+
"profiles": {
5729+
"type": "array",
5730+
"items": {
5731+
"anyOf": [
5732+
{
5733+
"$ref": "#/definitions/EventedProfile"
5734+
},
5735+
{
5736+
"$ref": "#/definitions/SampledProfile"
5737+
}
5738+
]
5739+
}
5740+
},
5741+
"name": {
5742+
"type": "string"
5743+
},
5744+
"exporter": {
5745+
"type": "string"
5746+
},
5747+
"activeProfileIndex": {
5748+
"type": "number"
5749+
}
5750+
},
5751+
"required": [
5752+
"$schema",
5753+
"shared",
5754+
"profiles"
5755+
],
5756+
"additionalProperties": true,
5757+
"title": "ThemeProfileResult",
5758+
"definitions": {
5759+
"ProfileFrame": {
5760+
"type": "object",
5761+
"properties": {
5762+
"name": {
5763+
"type": "string"
5764+
},
5765+
"file": {
5766+
"type": "string"
5767+
},
5768+
"line": {
5769+
"type": "number"
5770+
},
5771+
"col": {
5772+
"type": "number"
5773+
}
5774+
},
5775+
"required": [
5776+
"name"
5777+
],
5778+
"additionalProperties": true
5779+
},
5780+
"ProfileEvent": {
5781+
"type": "object",
5782+
"properties": {
5783+
"type": {
5784+
"type": "string",
5785+
"enum": [
5786+
"O",
5787+
"C"
5788+
]
5789+
},
5790+
"at": {
5791+
"type": "number"
5792+
},
5793+
"frame": {
5794+
"type": "number"
5795+
}
5796+
},
5797+
"required": [
5798+
"type",
5799+
"at",
5800+
"frame"
5801+
],
5802+
"additionalProperties": true
5803+
},
5804+
"ProfileShared": {
5805+
"type": "object",
5806+
"properties": {
5807+
"frames": {
5808+
"type": "array",
5809+
"items": {
5810+
"$ref": "#/definitions/ProfileFrame"
5811+
}
5812+
}
5813+
},
5814+
"required": [
5815+
"frames"
5816+
],
5817+
"additionalProperties": true
5818+
},
5819+
"EventedProfile": {
5820+
"type": "object",
5821+
"properties": {
5822+
"name": {
5823+
"type": "string"
5824+
},
5825+
"unit": {
5826+
"type": "string",
5827+
"enum": [
5828+
"bytes",
5829+
"microseconds",
5830+
"milliseconds",
5831+
"nanoseconds",
5832+
"none",
5833+
"seconds"
5834+
]
5835+
},
5836+
"startValue": {
5837+
"type": "number"
5838+
},
5839+
"endValue": {
5840+
"type": "number"
5841+
},
5842+
"type": {
5843+
"type": "string",
5844+
"const": "evented"
5845+
},
5846+
"events": {
5847+
"type": "array",
5848+
"items": {
5849+
"$ref": "#/definitions/ProfileEvent"
5850+
}
5851+
}
5852+
},
5853+
"required": [
5854+
"name",
5855+
"unit",
5856+
"startValue",
5857+
"endValue",
5858+
"type",
5859+
"events"
5860+
],
5861+
"additionalProperties": true
5862+
},
5863+
"SampledProfile": {
5864+
"type": "object",
5865+
"properties": {
5866+
"name": {
5867+
"$ref": "#/definitions/EventedProfile/properties/name"
5868+
},
5869+
"unit": {
5870+
"$ref": "#/definitions/EventedProfile/properties/unit"
5871+
},
5872+
"startValue": {
5873+
"$ref": "#/definitions/EventedProfile/properties/startValue"
5874+
},
5875+
"endValue": {
5876+
"$ref": "#/definitions/EventedProfile/properties/endValue"
5877+
},
5878+
"type": {
5879+
"type": "string",
5880+
"const": "sampled"
5881+
},
5882+
"samples": {
5883+
"type": "array",
5884+
"items": {
5885+
"type": "array",
5886+
"items": {
5887+
"type": "number"
5888+
}
5889+
}
5890+
},
5891+
"weights": {
5892+
"type": "array",
5893+
"items": {
5894+
"type": "number"
5895+
}
5896+
}
5897+
},
5898+
"required": [
5899+
"name",
5900+
"unit",
5901+
"startValue",
5902+
"endValue",
5903+
"type",
5904+
"samples",
5905+
"weights"
5906+
],
5907+
"additionalProperties": true
5908+
}
5909+
},
5910+
"$schema": "http://json-schema.org/draft-07/schema#"
5911+
}
5912+
```
57125913
```
57135914
57145915
## `shopify theme publish`

‎packages/cli/oclif.manifest.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10625,7 +10625,7 @@
1062510625
"args": {
1062610626
},
1062710627
"customPluginName": "@shopify/theme",
10628-
"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.",
10628+
"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```",
1062910629
"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.",
1063010630
"enableJsonFlag": false,
1063110631
"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
@@ -85,7 +85,6 @@ const commandExceptions = [
8585
'packages/theme/src/cli/commands/theme/open.ts',
8686
'packages/theme/src/cli/commands/theme/package.ts',
8787
'packages/theme/src/cli/commands/theme/preview.ts',
88-
'packages/theme/src/cli/commands/theme/profile.ts',
8988
'packages/theme/src/cli/commands/theme/publish.ts',
9089
'packages/theme/src/cli/commands/theme/pull.ts',
9190
'packages/theme/src/cli/commands/theme/push.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)