Skip to content

Commit 0993a7c

Browse files
Add typed JSON output to theme profile
1 parent 3d5d370 commit 0993a7c

9 files changed

Lines changed: 445 additions & 81 deletions

File tree

‎packages/cli/README.md‎

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

90589058
This command will open a web page with the Speedscope profiler detailing the time spent executing Liquid on the given
90599059
page.
9060+
9061+
Output from `--json` conforms to the `ThemeProfileResult` schema.
9062+
9063+
Use `--json-schema` to print the result, error, and event schemas.
9064+
9065+
```json
9066+
{
9067+
"type": "object",
9068+
"properties": {
9069+
"$schema": {
9070+
"type": "string",
9071+
"const": "https://www.speedscope.app/file-format-schema.json"
9072+
},
9073+
"shared": {
9074+
"$ref": "#/definitions/ProfileShared"
9075+
},
9076+
"profiles": {
9077+
"type": "array",
9078+
"items": {
9079+
"anyOf": [
9080+
{
9081+
"$ref": "#/definitions/EventedProfile"
9082+
},
9083+
{
9084+
"$ref": "#/definitions/SampledProfile"
9085+
}
9086+
]
9087+
}
9088+
},
9089+
"name": {
9090+
"type": "string"
9091+
},
9092+
"exporter": {
9093+
"type": "string"
9094+
},
9095+
"activeProfileIndex": {
9096+
"type": "number"
9097+
}
9098+
},
9099+
"required": [
9100+
"$schema",
9101+
"shared",
9102+
"profiles"
9103+
],
9104+
"additionalProperties": true,
9105+
"title": "ThemeProfileResult",
9106+
"definitions": {
9107+
"ProfileFrame": {
9108+
"type": "object",
9109+
"properties": {
9110+
"name": {
9111+
"type": "string"
9112+
},
9113+
"file": {
9114+
"type": "string"
9115+
},
9116+
"line": {
9117+
"type": "number"
9118+
},
9119+
"col": {
9120+
"type": "number"
9121+
}
9122+
},
9123+
"required": [
9124+
"name"
9125+
],
9126+
"additionalProperties": true
9127+
},
9128+
"ProfileEvent": {
9129+
"type": "object",
9130+
"properties": {
9131+
"type": {
9132+
"type": "string",
9133+
"enum": [
9134+
"O",
9135+
"C"
9136+
]
9137+
},
9138+
"at": {
9139+
"type": "number"
9140+
},
9141+
"frame": {
9142+
"type": "number"
9143+
}
9144+
},
9145+
"required": [
9146+
"type",
9147+
"at",
9148+
"frame"
9149+
],
9150+
"additionalProperties": true
9151+
},
9152+
"ProfileShared": {
9153+
"type": "object",
9154+
"properties": {
9155+
"frames": {
9156+
"type": "array",
9157+
"items": {
9158+
"$ref": "#/definitions/ProfileFrame"
9159+
}
9160+
}
9161+
},
9162+
"required": [
9163+
"frames"
9164+
],
9165+
"additionalProperties": true
9166+
},
9167+
"EventedProfile": {
9168+
"type": "object",
9169+
"properties": {
9170+
"name": {
9171+
"type": "string"
9172+
},
9173+
"unit": {
9174+
"type": "string",
9175+
"enum": [
9176+
"bytes",
9177+
"microseconds",
9178+
"milliseconds",
9179+
"nanoseconds",
9180+
"none",
9181+
"seconds"
9182+
]
9183+
},
9184+
"startValue": {
9185+
"type": "number"
9186+
},
9187+
"endValue": {
9188+
"type": "number"
9189+
},
9190+
"type": {
9191+
"type": "string",
9192+
"const": "evented"
9193+
},
9194+
"events": {
9195+
"type": "array",
9196+
"items": {
9197+
"$ref": "#/definitions/ProfileEvent"
9198+
}
9199+
}
9200+
},
9201+
"required": [
9202+
"name",
9203+
"unit",
9204+
"startValue",
9205+
"endValue",
9206+
"type",
9207+
"events"
9208+
],
9209+
"additionalProperties": true
9210+
},
9211+
"SampledProfile": {
9212+
"type": "object",
9213+
"properties": {
9214+
"name": {
9215+
"$ref": "#/definitions/EventedProfile/properties/name"
9216+
},
9217+
"unit": {
9218+
"$ref": "#/definitions/EventedProfile/properties/unit"
9219+
},
9220+
"startValue": {
9221+
"$ref": "#/definitions/EventedProfile/properties/startValue"
9222+
},
9223+
"endValue": {
9224+
"$ref": "#/definitions/EventedProfile/properties/endValue"
9225+
},
9226+
"type": {
9227+
"type": "string",
9228+
"const": "sampled"
9229+
},
9230+
"samples": {
9231+
"type": "array",
9232+
"items": {
9233+
"type": "array",
9234+
"items": {
9235+
"type": "number"
9236+
}
9237+
}
9238+
},
9239+
"weights": {
9240+
"type": "array",
9241+
"items": {
9242+
"type": "number"
9243+
}
9244+
}
9245+
},
9246+
"required": [
9247+
"name",
9248+
"unit",
9249+
"startValue",
9250+
"endValue",
9251+
"type",
9252+
"samples",
9253+
"weights"
9254+
],
9255+
"additionalProperties": true
9256+
}
9257+
},
9258+
"$schema": "http://json-schema.org/draft-07/schema#"
9259+
}
9260+
```
90609261
```
90619262
90629263
## `shopify theme publish`

‎packages/cli/oclif.manifest.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10937,7 +10937,7 @@
1093710937
"args": {
1093810938
},
1093910939
"customPluginName": "@shopify/theme",
10940-
"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.",
10940+
"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```",
1094110941
"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.",
1094210942
"enableJsonFlag": false,
1094310943
"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: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import Profile from './profile.js'
2+
import {themeProfileJsonOutputSchema, type ThemeProfileResult} from '../../services/profile/types.js'
3+
import {expect, test, vi} from 'vitest'
4+
import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output'
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+
test('exposes Speedscope evented and sampled profiles while retaining extension fields', () => {
33+
expect(Profile.jsonOutputSchema).toBe(themeProfileJsonOutputSchema)
34+
expect(Profile.flags.json).toBeDefined()
35+
expect(JSON.parse(themeProfileJsonOutputSchema.encode(profile))).toEqual(profile)
36+
const sampled: ThemeProfileResult = {
37+
...profile,
38+
profiles: [
39+
{type: 'sampled', name: 'Liquid', unit: 'none', startValue: 0, endValue: 10, samples: [[0]], weights: [10]},
40+
],
41+
}
42+
expect(JSON.parse(themeProfileJsonOutputSchema.encode(sampled))).toEqual(sampled)
43+
expect(() => themeProfileJsonOutputSchema.validate({...profile, shared: {frames: [{name: 123}]}})).toThrow()
44+
expect(() =>
45+
themeProfileJsonOutputSchema.validate({...profile, profiles: [{...profile.profiles[0], unit: 'invalid'}]}),
46+
).toThrow()
47+
})
48+
49+
test.each(['profile', 'empty', 'failure', 'invalid result'])(
50+
'keeps %s JSON on stdout and progress events on stderr',
51+
async (mode) => {
52+
const {default: StreamProfile} = await import('./profile.js')
53+
const {findOrSelectTheme} = await import('../../utilities/theme-selector.js')
54+
const {render} = await import('../../utilities/theme-environment/storefront-renderer.js')
55+
const {ensureAuthenticatedThemes} = await import('@shopify/cli-kit/node/session')
56+
const {openURL} = await import('@shopify/cli-kit/node/system')
57+
const {runWithCommandEventsForCommand} = await import('@shopify/cli-kit/node/command-events')
58+
const {Config} = await import('@oclif/core')
59+
const config = new Config({root: __dirname})
60+
await config.load()
61+
vi.mocked(ensureAuthenticatedThemes).mockResolvedValue({storeFqdn: 'shop.myshopify.com', token: 'token'})
62+
vi.mocked(findOrSelectTheme).mockResolvedValue({
63+
id: 1,
64+
name: 'Dawn',
65+
role: 'live',
66+
processing: false,
67+
createdAtRuntime: false,
68+
})
69+
const result = mode === 'empty' ? {...profile, profiles: [], shared: {frames: []}} : profile
70+
// Keep unusual whitespace and upstream field order to catch accidental reserialization.
71+
const source = mode === 'invalid result' ? '{"profiles":false}' : ` ${JSON.stringify(result, null, 4)}\n`
72+
vi.mocked(render).mockResolvedValue(new Response(source, {status: 200}))
73+
if (mode === 'failure') vi.mocked(render).mockRejectedValue(new Error('Network error'))
74+
await withCapturedStandardStreams(async ({stdout, stderr}) => {
75+
const operation = runWithCommandEventsForCommand(['--json'], () =>
76+
new StreamProfile(['--store=shop.myshopify.com', '--json'], config).run(),
77+
)
78+
if (mode === 'failure') await expect(operation).rejects.toThrow('Network error')
79+
else if (mode === 'invalid result') await expect(operation).rejects.toThrow()
80+
else await operation
81+
expect(openURL).not.toHaveBeenCalled()
82+
const failed = mode === 'failure' || mode === 'invalid result'
83+
expect(stdout()).toBe(failed ? '' : `${source}\n`)
84+
if (!failed) expect(JSON.parse(stdout())).toEqual(result)
85+
const events = stderr()
86+
.trim()
87+
.split('\n')
88+
.map((line) => JSON.parse(line))
89+
expect(events.map((event) => event.status)).toEqual(failed ? ['started'] : ['started', 'completed'])
90+
expect(events.every((event) => event.type === 'progress')).toBe(true)
91+
})
92+
},
93+
)

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

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
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'
910
import {InferredFlags} from '@oclif/core/interfaces'
1011
import {AdminSession} from '@shopify/cli-kit/node/session'
1112

1213
type ProfileFlags = InferredFlags<typeof Profile.flags>
1314
export default class Profile extends ThemeCommand {
15+
static get jsonOutputSchema() {
16+
return themeProfileJsonOutputSchema
17+
}
18+
1419
static summary = 'Profile the Liquid rendering of a theme page.'
1520

1621
static usage = ['theme profile', 'theme profile --url /products/classic-leather-jacket']
@@ -54,21 +59,17 @@ export default class Profile extends ThemeCommand {
5459
}
5560
const theme = await findOrSelectTheme(adminSession, filter)
5661

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)
62+
const title = `Generating Liquid profile for ${adminSession.storeFqdn} ${flags.url}`
63+
const task = async () => {
64+
const result = await profile(
65+
adminSession,
66+
theme.id.toString(),
67+
flags.url,
68+
themeAccessPassword,
69+
flags['store-password'],
70+
)
71+
await renderThemeProfileResult(result, flags.json ? 'json' : 'text')
72+
}
73+
await renderTasksToStdErr([{title, task}])
7374
}
7475
}

0 commit comments

Comments
 (0)