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/cli/README.md b/packages/cli/README.md index 69e204c34a6..13e58c87648 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -15544,6 +15544,424 @@ 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 + { + "anyOf": [ + { + "$ref": "#/definitions/ThemeEnvironment/anyOf/0/properties/result" + }, + { + "type": "object", + "properties": { + "environments": { + "type": "array", + "items": { + "$ref": "#/definitions/ThemeEnvironment" + } + } + }, + "required": [ + "environments" + ], + "additionalProperties": false + } + ], + "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 + }, + "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": "number" + } + }, + "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" + }, + "code": { + "type": "string", + "minLength": 1, + "description": "A stable error code, included only when known." + }, + "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": { + "description": "Selected domain details, preserving native API payloads such as GraphQL errors, extensions, and data." + } + }, + "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" + }, + "code": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/code" + }, + "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" + }, + "code": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/code" + }, + "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#" + } + ``` ``` ## `shopify theme publish` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index fb40c330c23..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.", + "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/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..2516a640f5b --- /dev/null +++ b/packages/theme/src/cli/commands/theme/profile.test.ts @@ -0,0 +1,125 @@ +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') +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', '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') + 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, + }) + 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 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() + } + }) + }, +) + +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 ada34ef65b7..3ad85efb86f 100644 --- a/packages/theme/src/cli/commands/theme/profile.ts +++ b/packages/theme/src/cli/commands/theme/profile.ts @@ -1,16 +1,23 @@ 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 {outputResult} from '@shopify/cli-kit/node/output' 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' +import type {ThemeEnvironmentResult} from '../../services/json-output/schema.js' 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'] @@ -43,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 @@ -54,21 +61,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}` + let result: Awaited> | undefined + const task = async () => { + 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 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 6a7bd22c94d..90ec431ba69 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 @@ -85,12 +90,23 @@ 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')) // 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 +122,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 +130,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 +152,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 +164,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..46e9475ee9d 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 {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' 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: themeProfileResultSchema.parse(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..4a12493fec2 --- /dev/null +++ b/packages/theme/src/cli/services/profile/types.ts @@ -0,0 +1,65 @@ +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. +// 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 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().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: themeProfileResultSchema, + project: (value) => value, + definitions: { + ProfileFrame: ProfileFrameSchema, + ProfileEvent: ProfileEventSchema, + ProfileShared: ProfileSharedSchema, + EventedProfile: EventedProfileSchema, + SampledProfile: SampledProfileSchema, + }, +}) + +export type ThemeProfileResult = zod.infer