From 4b5422117afecc259ae8b7537e3bd811a33099af Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Thu, 24 Sep 2026 11:19:16 +0200 Subject: [PATCH] Add typed JSON output to theme push --- packages/cli/README.md | 106 ++++++++ packages/cli/oclif.manifest.json | 2 +- .../rules/json-output-command-exceptions.js | 1 - .../theme/src/cli/commands/theme/push.test.ts | 80 ++++++ packages/theme/src/cli/commands/theme/push.ts | 25 +- .../theme/transfer-environments.test.ts | 109 ++++++++ packages/theme/src/cli/services/push.test.ts | 1 + packages/theme/src/cli/services/push.ts | 233 ++++-------------- .../src/cli/services/push/execute.test.ts | 89 +++++++ .../src/cli/services/push/result.test.ts | 156 ++++++++++++ .../theme/src/cli/services/push/result.ts | 102 ++++++++ packages/theme/src/cli/services/push/types.ts | 49 ++++ packages/theme/src/cli/utilities/errors.ts | 6 + .../utilities/theme-transfer-events.test.ts | 111 +++++++++ packages/theme/src/cli/utilities/theme-ui.ts | 25 +- .../theme/src/cli/utilities/theme-uploader.ts | 4 +- 16 files changed, 901 insertions(+), 198 deletions(-) create mode 100644 packages/theme/src/cli/commands/theme/push.test.ts create mode 100644 packages/theme/src/cli/commands/theme/transfer-environments.test.ts create mode 100644 packages/theme/src/cli/services/push/execute.test.ts create mode 100644 packages/theme/src/cli/services/push/result.test.ts create mode 100644 packages/theme/src/cli/services/push/result.ts create mode 100644 packages/theme/src/cli/services/push/types.ts create mode 100644 packages/theme/src/cli/utilities/theme-transfer-events.test.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index e5fffbc55bd..3cb6d98ad4f 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -9329,6 +9329,112 @@ DESCRIPTION } } ``` + + + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `ThemePushJsonResult` schema. + + ```json + { + "anyOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "success", + "failed" + ] + }, + "environment": { + "type": "string" + }, + "theme": { + "$ref": "#/definitions/ThemePushTheme" + } + }, + "required": [ + "status", + "theme" + ], + "additionalProperties": false + }, + { + "type": "array", + "items": { + "type": "object", + "properties": { + "status": { + "$ref": "#/definitions/ThemePushJsonResult/anyOf/0/properties/status" + }, + "environment": { + "type": "string" + }, + "theme": { + "$ref": "#/definitions/ThemePushTheme" + } + }, + "required": [ + "status", + "environment", + "theme" + ], + "additionalProperties": false + } + } + ], + "title": "ThemePushJsonResult", + "definitions": { + "ThemePushTheme": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "name": { + "type": "string" + }, + "role": { + "type": "string" + }, + "shop": { + "type": "string" + }, + "editor_url": { + "type": "string" + }, + "preview_url": { + "type": "string" + }, + "warning": { + "type": "string" + }, + "errors": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "required": [ + "id", + "name", + "role", + "shop", + "editor_url", + "preview_url" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` ``` ## `shopify theme rename` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index eaaa4a4665c..85a43c4cbfb 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -11336,7 +11336,7 @@ "args": { }, "customPluginName": "@shopify/theme", - "description": "Uploads your local theme files to Shopify, overwriting the remote version if specified.\n\n If no theme is specified, then you're prompted to select the theme to overwrite from the list of the themes in your store.\n\n You can run this command only in a directory that matches the \"default Shopify theme folder structure\" (https://shopify.dev/docs/themes/tools/cli#directory-structure).\n\n This command returns the following information:\n\n - A link to the \"editor\" (https://shopify.dev/docs/themes/tools/online-editor) for the theme in the Shopify admin.\n - A \"preview link\" (https://help.shopify.com/manual/online-store/themes/adding-themes#share-a-theme-preview-with-others) that you can share with others.\n\n If you use the `--json` flag, then theme information is returned in JSON format, which can be used as a machine-readable input for scripts or continuous integration.\n\n Sample output:\n\n ```json\n {\n \"theme\": {\n \"id\": 108267175958,\n \"name\": \"MyTheme\",\n \"role\": \"unpublished\",\n \"shop\": \"mystore.myshopify.com\",\n \"editor_url\": \"https://mystore.myshopify.com/admin/themes/108267175958/editor\",\n \"preview_url\": \"https://mystore.myshopify.com/?preview_theme_id=108267175958\"\n }\n }\n ```\n ", + "description": "Uploads your local theme files to Shopify, overwriting the remote version if specified.\n\n If no theme is specified, then you're prompted to select the theme to overwrite from the list of the themes in your store.\n\n You can run this command only in a directory that matches the \"default Shopify theme folder structure\" (https://shopify.dev/docs/themes/tools/cli#directory-structure).\n\n This command returns the following information:\n\n - A link to the \"editor\" (https://shopify.dev/docs/themes/tools/online-editor) for the theme in the Shopify admin.\n - A \"preview link\" (https://help.shopify.com/manual/online-store/themes/adding-themes#share-a-theme-preview-with-others) that you can share with others.\n\n If you use the `--json` flag, then theme information is returned in JSON format, which can be used as a machine-readable input for scripts or continuous integration.\n\n Sample output:\n\n ```json\n {\n \"theme\": {\n \"id\": 108267175958,\n \"name\": \"MyTheme\",\n \"role\": \"unpublished\",\n \"shop\": \"mystore.myshopify.com\",\n \"editor_url\": \"https://mystore.myshopify.com/admin/themes/108267175958/editor\",\n \"preview_url\": \"https://mystore.myshopify.com/?preview_theme_id=108267175958\"\n }\n }\n ```\n \n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ThemePushJsonResult` schema.\n\n```json\n{\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\n \"success\",\n \"failed\"\n ]\n },\n \"environment\": {\n \"type\": \"string\"\n },\n \"theme\": {\n \"$ref\": \"#/definitions/ThemePushTheme\"\n }\n },\n \"required\": [\n \"status\",\n \"theme\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"$ref\": \"#/definitions/ThemePushJsonResult/anyOf/0/properties/status\"\n },\n \"environment\": {\n \"type\": \"string\"\n },\n \"theme\": {\n \"$ref\": \"#/definitions/ThemePushTheme\"\n }\n },\n \"required\": [\n \"status\",\n \"environment\",\n \"theme\"\n ],\n \"additionalProperties\": false\n }\n }\n ],\n \"title\": \"ThemePushJsonResult\",\n \"definitions\": {\n \"ThemePushTheme\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"number\"\n },\n \"name\": {\n \"type\": \"string\"\n },\n \"role\": {\n \"type\": \"string\"\n },\n \"shop\": {\n \"type\": \"string\"\n },\n \"editor_url\": {\n \"type\": \"string\"\n },\n \"preview_url\": {\n \"type\": \"string\"\n },\n \"warning\": {\n \"type\": \"string\"\n },\n \"errors\": {\n \"type\": \"object\",\n \"additionalProperties\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n }\n },\n \"required\": [\n \"id\",\n \"name\",\n \"role\",\n \"shop\",\n \"editor_url\",\n \"preview_url\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Uploads your local theme files to Shopify, overwriting the remote version if specified.\n\n If no theme is specified, then you're prompted to select the theme to overwrite from the list of the themes in your store.\n\n You can run this command only in a directory that matches the [default Shopify theme folder structure](https://shopify.dev/docs/themes/tools/cli#directory-structure).\n\n This command returns the following information:\n\n - A link to the [editor](https://shopify.dev/docs/themes/tools/online-editor) for the theme in the Shopify admin.\n - A [preview link](https://help.shopify.com/manual/online-store/themes/adding-themes#share-a-theme-preview-with-others) that you can share with others.\n\n If you use the `--json` flag, then theme information is returned in JSON format, which can be used as a machine-readable input for scripts or continuous integration.\n\n Sample output:\n\n ```json\n {\n \"theme\": {\n \"id\": 108267175958,\n \"name\": \"MyTheme\",\n \"role\": \"unpublished\",\n \"shop\": \"mystore.myshopify.com\",\n \"editor_url\": \"https://mystore.myshopify.com/admin/themes/108267175958/editor\",\n \"preview_url\": \"https://mystore.myshopify.com/?preview_theme_id=108267175958\"\n }\n }\n ```\n ", "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 73d9381d6dc..25b409c59cf 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -50,7 +50,6 @@ const commandExceptions = [ 'packages/theme/src/cli/commands/theme/preview.ts', 'packages/theme/src/cli/commands/theme/profile.ts', 'packages/theme/src/cli/commands/theme/pull.ts', - 'packages/theme/src/cli/commands/theme/push.ts', 'packages/theme/src/cli/commands/theme/share.ts', // Streaming commands without a single finite result. diff --git a/packages/theme/src/cli/commands/theme/push.test.ts b/packages/theme/src/cli/commands/theme/push.test.ts new file mode 100644 index 00000000000..5bc74f3f6e6 --- /dev/null +++ b/packages/theme/src/cli/commands/theme/push.test.ts @@ -0,0 +1,80 @@ +import Push from './push.js' +import {executeThemePush} from '../../services/push.js' +import {checkThemeBeforePush} from '../../services/push/result.js' +import {themePushJsonOutputSchema} from '../../services/push/types.js' +import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {describe, expect, test, vi} from 'vitest' +import {Config} from '@oclif/core' +import {AbortError} from '@shopify/cli-kit/node/error' + +vi.mock('../../services/push.js') +vi.mock('../../services/push/result.js', async (importOriginal) => ({ + ...(await importOriginal()), + checkThemeBeforePush: vi.fn(), +})) + +const session = {storeFqdn: 'test.myshopify.com', token: 'token'} + +describe('theme push JSON', () => { + test('exposes the schema in help and retains JSON and inherited flags', () => { + expect(Push.jsonOutputSchema).toBe(themePushJsonOutputSchema) + expect(Push.description).toContain('ThemePushJsonResult') + expect(Push.flags.json).toBeDefined() + expect(Push.baseFlags).toHaveProperty('json-schema') + }) + + test('executes and writes the result through the real encoder', async () => { + vi.mocked(executeThemePush).mockResolvedValue({ + theme: { + id: 1, + name: 'Theme', + role: 'unpublished', + shop: session.storeFqdn, + editor_url: 'editor', + preview_url: 'preview', + }, + published: false, + hasErrors: false, + errors: {}, + }) + const command = new Push([], new Config({root: '.'})) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await command.command({json: true} as never, session, false) + + expect(JSON.parse(stdout())).toEqual({ + status: 'success', + theme: { + id: 1, + name: 'Theme', + role: 'unpublished', + shop: session.storeFqdn, + editor_url: 'editor', + preview_url: 'preview', + }, + }) + expect(stderr()).toBe('') + }) + }) + + test('retains cancellation without emitting a success result', async () => { + vi.mocked(executeThemePush).mockResolvedValue(undefined) + const command = new Push([], new Config({root: '.'})) + await withCapturedStandardStreams(async ({stdout}) => { + await command.command({json: true} as never, session, false) + + expect(stdout()).toBe('') + }) + }) + + test('propagates strict failures without pushing or writing a result', async () => { + const failure = new AbortError('Theme check failed. Please fix the errors before pushing.') + vi.mocked(checkThemeBeforePush).mockRejectedValueOnce(failure) + const command = new Push([], new Config({root: '.'})) + await withCapturedStandardStreams(async ({stdout}) => { + await expect(command.command({json: true, strict: true} as never, session, false)).rejects.toBe(failure) + + expect(executeThemePush).not.toHaveBeenCalled() + expect(stdout()).toBe('') + }) + }) +}) diff --git a/packages/theme/src/cli/commands/theme/push.ts b/packages/theme/src/cli/commands/theme/push.ts index c87c7dcedb7..0eb519700cd 100644 --- a/packages/theme/src/cli/commands/theme/push.ts +++ b/packages/theme/src/cli/commands/theme/push.ts @@ -1,6 +1,12 @@ import {globFlags, themeFlags} from '../../flags.js' import ThemeCommand from '../../utilities/theme-command.js' -import {push} from '../../services/push.js' +import {executeThemePush} from '../../services/push.js' +import {themePushJsonOutputSchema} from '../../services/push/types.js' +import { + checkThemeBeforePush, + renderThemePushResult, + renderThemePushEnvironmentResults, +} from '../../services/push/result.js' import {Flags} from '@oclif/core' import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' import {recordTiming} from '@shopify/cli-kit/node/analytics' @@ -13,6 +19,10 @@ import type {NonTTYFlagRequirement} from '@shopify/cli-kit/node/base-command' type PushFlags = InferredFlags export default class Push extends ThemeCommand { + static get jsonOutputSchema() { + return themePushJsonOutputSchema + } + static summary = 'Uploads your local theme files to the connected store, overwriting the remote version if specified.' static usage = ['theme push', 'theme push --unpublished --json'] @@ -137,7 +147,8 @@ export default class Push extends ThemeCommand { context?: {stdout?: Writable; stderr?: Writable}, ) { recordTiming('theme-command:push') - await push( + await checkThemeBeforePush(flags) + const result = await executeThemePush( { ...flags, allowLive: flags['allow-live'], @@ -148,7 +159,17 @@ export default class Push extends ThemeCommand { multiEnvironment, context, ) + if (result && !(flags.json && multiEnvironment)) renderThemePushResult(result, flags.json ? 'json' : 'text') recordTiming('theme-command:push') + return result + } + + protected collectsEnvironmentResults(flags: {json?: boolean}): boolean { + return Boolean(flags.json) + } + + protected renderEnvironmentResults(results: {environment: string; result: unknown}[]): void { + renderThemePushEnvironmentResults(results) } protected storeAuthScopes(): string[] { diff --git a/packages/theme/src/cli/commands/theme/transfer-environments.test.ts b/packages/theme/src/cli/commands/theme/transfer-environments.test.ts new file mode 100644 index 00000000000..66e81d73105 --- /dev/null +++ b/packages/theme/src/cli/commands/theme/transfer-environments.test.ts @@ -0,0 +1,109 @@ +import Push from './push.js' +import {executeThemePush} from '../../services/push.js' +import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {loadEnvironment} from '@shopify/cli-kit/node/environments' +import {ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' +import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs' +import {runWithCommandEvents, renderCommandEventAsJson} from '@shopify/cli-kit/node/command-events' +import {Config} from '@oclif/core' +import {expect, test, vi} from 'vitest' + +vi.mock('../../services/push.js') +vi.mock('@shopify/cli-kit/node/environments') +vi.mock('@shopify/cli-kit/node/session') +vi.mock('@shopify/cli-kit/node/metadata') + +class TestPush extends Push { + public parse = vi.fn() +} + +// Exercise real environment orchestration, presenter, encoder and streams. +test.each(['none', 'partial', 'total'] as const)( + 'collects environment successes in requested order with %s failures', + async (failures) => { + await inTemporaryDirectory(async (path) => { + vi.mocked(loadEnvironment).mockImplementation(async (name) => ({ + store: name === 'second' ? 'second.myshopify.com' : 'first.myshopify.com', + password: 'password', + path, + theme: '1', + })) + vi.mocked(ensureAuthenticatedThemes).mockImplementation(async (store) => ({storeFqdn: store, token: 'token'})) + let releaseFirst: () => void = () => {} + const secondStarted = new Promise((resolve) => { + releaseFirst = resolve + }) + const executionOrder: string[] = [] + vi.mocked(executeThemePush).mockImplementation(async (flags, session) => { + const environment = flags.environment![0]! + if (environment === 'first') await secondStarted + if (environment === 'second') releaseFirst() + executionOrder.push(environment) + if (failures === 'total' || (failures === 'partial' && environment === 'second')) + throw new Error('upload failed') + return { + environment, + theme: { + id: 1, + name: environment, + role: 'unpublished', + shop: session!.storeFqdn, + editor_url: 'editor', + preview_url: 'preview', + }, + published: false, + hasErrors: false, + errors: {}, + } + }) + const command = new TestPush([], new Config({root: path})) + const flags = {json: true, force: true, environment: ['first', 'second', 'third']} + vi.spyOn(command, 'parse').mockResolvedValue({flags, args: {}} as never) + const exitCode = process.exitCode + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runWithCommandEvents({outputMode: 'json', sink: renderCommandEventAsJson}, () => command.run()) + + const results = JSON.parse(stdout()) + expect(results.map((result: {environment: string}) => result.environment)).toEqual( + failures === 'total' ? [] : ['first', ...(failures === 'none' ? ['second'] : []), 'third'], + ) + expect(executionOrder).toEqual(['second', 'first', 'third']) + expect(process.exitCode).toBe(exitCode) + const events = stderr().trim() + ? stderr() + .trim() + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)) + : [] + expect(events.filter((event) => event.level === 'error')).toHaveLength( + {none: 0, partial: 1, total: 3}[failures], + ) + }) + }) + }, +) + +test('returns an empty array when every environment is invalid', async () => { + vi.mocked(loadEnvironment).mockResolvedValue({}) + const command = new TestPush([], new Config({root: '.'})) + vi.spyOn(command, 'parse').mockResolvedValue({ + flags: {json: true, force: true, environment: ['first', 'second']}, + args: {}, + } as never) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runWithCommandEvents({outputMode: 'json', sink: renderCommandEventAsJson}, () => command.run()) + + expect(JSON.parse(stdout())).toEqual([]) + expect(executeThemePush).not.toHaveBeenCalled() + expect( + stderr() + .trim() + .split('\n') + .map((line) => JSON.parse(line)), + ).toEqual([ + expect.objectContaining({type: 'diagnostic', level: 'warning'}), + expect.objectContaining({type: 'diagnostic', level: 'warning'}), + ]) + }) +}) diff --git a/packages/theme/src/cli/services/push.test.ts b/packages/theme/src/cli/services/push.test.ts index 596aa5e1b31..f89b9d936e3 100644 --- a/packages/theme/src/cli/services/push.test.ts +++ b/packages/theme/src/cli/services/push.test.ts @@ -104,6 +104,7 @@ describe('push', () => { // Then expect(outputResult).toHaveBeenCalledWith( JSON.stringify({ + status: 'failed', theme: { id: 1, name: 'Theme', diff --git a/packages/theme/src/cli/services/push.ts b/packages/theme/src/cli/services/push.ts index ff2c42b517c..1f23015b429 100644 --- a/packages/theme/src/cli/services/push.ts +++ b/packages/theme/src/cli/services/push.ts @@ -1,25 +1,18 @@ -/* eslint-disable tsdoc/syntax */ +import {themePushResultSchema} from './push/types.js' +import {checkThemeBeforePush, renderThemePushResult} from './push/result.js' import {hasRequiredThemeDirectories, mountThemeFileSystem} from '../utilities/theme-fs.js' import {uploadTheme} from '../utilities/theme-uploader.js' -import {ensureDirectoryConfirmed, themeComponent} from '../utilities/theme-ui.js' +import {ensureDirectoryConfirmed} from '../utilities/theme-ui.js' import {DevelopmentThemeManager} from '../utilities/development-theme-manager.js' import {findOrSelectTheme} from '../utilities/theme-selector.js' import {Role} from '../utilities/theme-selector/fetch.js' import {configureCLIEnvironment} from '../utilities/cli-config.js' -import {runThemeCheck} from '../commands/theme/check.js' import {ensureThemeStore} from '../utilities/theme-store.js' import {ensureListingExists} from '../utilities/theme-listing.js' import {AdminSession, ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' import {themeCreate, fetchChecksums, themePublish} from '@shopify/cli-kit/node/themes/api' -import {Result, Theme} from '@shopify/cli-kit/node/themes/types' -import {outputResult} from '@shopify/cli-kit/node/output' -import { - renderConfirmationPrompt, - RenderConfirmationPromptOptions, - renderError, - renderSuccess, - renderWarning, -} from '@shopify/cli-kit/node/ui' +import {Theme} from '@shopify/cli-kit/node/themes/types' +import {renderConfirmationPrompt, RenderConfirmationPromptOptions, renderError} from '@shopify/cli-kit/node/ui' import {themeEditorUrl, themePreviewUrl} from '@shopify/cli-kit/node/themes/urls' import {cwd, resolvePath} from '@shopify/cli-kit/node/path' import { @@ -28,16 +21,14 @@ import { promptThemeName, UNPUBLISHED_THEME_ROLE, } from '@shopify/cli-kit/node/themes/utils' -import {AbortError} from '@shopify/cli-kit/node/error' -import {Severity} from '@shopify/theme-check-node' -import {recordError, recordTiming} from '@shopify/cli-kit/node/analytics' +import {recordTiming} from '@shopify/cli-kit/node/analytics' +import {commandEventOutputMode, emitCommandEvent} from '@shopify/cli-kit/node/command-events' import {Writable} from 'stream' interface PushOptions { path: string nodelete?: boolean - json?: boolean force?: boolean publish?: boolean ignore?: string[] @@ -48,20 +39,6 @@ interface PushOptions { listing?: string } -interface JsonOutput { - environment?: string - theme: { - id: number - name: string - role: string - shop: string - editor_url: string - preview_url: string - warning?: string - errors?: Result['errors'] - } -} - export interface PushFlags { /** The path to your theme directory. */ path?: string @@ -132,8 +109,8 @@ export interface PushFlags { * * @param flags - The flags for the push operation. */ -export async function push( - flags: PushFlags, +export async function executeThemePush( + flags: Omit, adminSession?: AdminSession, multiEnvironment?: boolean, context?: {stdout?: Writable; stderr?: Writable}, @@ -144,23 +121,6 @@ export async function push( const session = adminSession ?? (await ensureAuthenticatedThemes(ensureThemeStore({store: flags.store}), flags.password)) - if (flags.strict) { - const outputType = flags.json ? 'json' : 'text' - const {offenses} = await runThemeCheck(flags.path ?? cwd(), outputType) - - if (offenses.length > 0) { - const errorOffenses = offenses.filter((offense) => offense.severity === Severity.ERROR) - if (errorOffenses.length > 0) { - throw recordError( - new AbortError( - environment - ? `[${environment}] Theme check failed. Please fix the errors before pushing.` - : 'Theme check failed. Please fix the errors before pushing.', - ), - ) - } - } - } recordTiming('theme-service:push:setup') configureCLIEnvironment({ @@ -189,7 +149,7 @@ export async function push( recordTiming('theme-service:push:setup') - await executePush( + return executePush( selectedTheme, session, { @@ -197,7 +157,6 @@ export async function push( environment, force, ignore: flags.ignore ?? [], - json: flags.json ?? false, multiEnvironment, nodelete: flags.nodelete ?? false, only: flags.only ?? [], @@ -245,57 +204,13 @@ async function executePush( await themePublish(theme.id, session) } - await handlePushOutput(uploadResults, theme, session, options) -} - -/** - * Checks if there are any upload errors in the results. - * - * @param results - The map of upload results. - * @returns {boolean} - Returns true if there are any upload errors, otherwise false. - */ -function hasUploadErrors(results: Map): boolean { - for (const [_key, result] of results.entries()) { - if (!result.success) { - return true - } + const errors: Record = {} + for (const [key, result] of uploadResults) { + if (!result.success && result.errors?.asset) errors[key] = result.errors.asset } - return false -} -/** - * Handles the output based on the push operation results. - * - * @param results - The map of upload results. - * @param theme - The theme being pushed. - * @param session - The admin session for the theme. - * @param options - The options for the push operation. - */ -async function handlePushOutput( - results: Map, - theme: Theme, - session: AdminSession, - options: PushOptions, -) { - if (options.json) { - handleJsonOutput(theme, session, results, options.environment) - } else if (options.publish) { - handlePublishOutput(session, results, options.environment) - } else { - handleOutput(theme, session, results, options.environment) - } -} - -/** - * Handles the JSON output for the push operation. - * - * @param theme - The theme being pushed. - * @param session - The admin session for the theme. - * @param results - The map of upload results. - */ -function handleJsonOutput(theme: Theme, session: AdminSession, results: Map, environment?: string) { - const output: JsonOutput = { - environment, + return themePushResultSchema.parse({ + environment: options.environment, theme: { id: theme.id, name: theme.name, @@ -304,86 +219,10 @@ function handleJsonOutput(theme: Theme, session: AdminSession, results: Map = {} - for (const [key, result] of results.entries()) { - if (!result.success && result.errors?.asset) { - errors[key] = result.errors.asset - } - } - if (Object.keys(errors).length > 0) { - output.theme.errors = errors - } - } - outputResult(JSON.stringify(output)) -} - -/** - * Handles the output for the publish operation. - * - * @param session - The admin session for the theme. - * @param results - The map of upload results. - */ -function handlePublishOutput(session: AdminSession, results: Map, environment?: string) { - const header = environment ? [{subdued: `Environment: ${environment}\n\n`}] : [] - - const hasErrors = hasUploadErrors(results) - if (hasErrors) { - renderWarning({ - body: [...header, `Your theme was published with errors and is now live at https://${session.storeFqdn}`], - }) - } else { - renderSuccess({body: [...header, `Your theme is now live at https://${session.storeFqdn}`]}) - } -} - -/** - * Handles the output for the push operation. - * - * @param theme - The theme being pushed. - * @param session - The admin session for the theme. - * @param results - The map of upload results. - */ -function handleOutput(theme: Theme, session: AdminSession, results: Map, environment?: string) { - const header = environment ? [{subdued: `Environment: ${environment}\n\n`}] : [] - - const hasErrors = hasUploadErrors(results) - const nextSteps = [ - [ - { - link: { - label: 'View your theme', - url: themePreviewUrl(theme, session), - }, - }, - ], - [ - { - link: { - label: 'Customize your theme at the theme editor', - url: themeEditorUrl(theme, session), - }, - }, - ], - ] - - if (hasErrors) { - renderWarning({ - body: [...header, 'The theme', ...themeComponent(theme), 'was pushed with errors'], - nextSteps, - }) - } else { - renderSuccess({ - body: [...header, 'The theme', ...themeComponent(theme), 'was pushed successfully.'], - nextSteps, - }) - } + published: options.publish ?? false, + hasErrors: [...uploadResults.values()].some((result) => !result.success), + errors, + }) } export async function createOrSelectTheme( @@ -439,13 +278,19 @@ async function confirmPushToTheme( } if (multiEnvironment) { - renderError({ - headline: `Environment: ${environment}`, - body: [ - `Can't push theme files to the live theme on ${storeFqdn}`, - 'Use the --allow-live flag to push to a live theme.', - ], - }) + const body = [ + `Can't push theme files to the live theme on ${storeFqdn}`, + 'Use the --allow-live flag to push to a live theme.', + ] + if (commandEventOutputMode() === 'json') { + emitCommandEvent({ + type: 'diagnostic', + level: 'error', + message: `Environment: ${environment}\n${body.join('\n')}`, + }) + } else { + renderError({headline: `Environment: ${environment}`, body}) + } return false } @@ -459,3 +304,17 @@ async function confirmPushToTheme( } return true } + +/** Compatibility adapter for callers of the exported theme API. */ +export async function push( + flags: PushFlags, + adminSession?: AdminSession, + multiEnvironment?: boolean, + context?: {stdout?: Writable; stderr?: Writable}, +): Promise { + const session = + adminSession ?? (await ensureAuthenticatedThemes(ensureThemeStore({store: flags.store}), flags.password)) + await checkThemeBeforePush(flags, true) + const result = await executeThemePush(flags, session, multiEnvironment, context) + if (result) renderThemePushResult(result, flags.json ? 'json' : 'text') +} diff --git a/packages/theme/src/cli/services/push/execute.test.ts b/packages/theme/src/cli/services/push/execute.test.ts new file mode 100644 index 00000000000..65b548efa2c --- /dev/null +++ b/packages/theme/src/cli/services/push/execute.test.ts @@ -0,0 +1,89 @@ +import {executeThemePush} from '../push.js' +import {findOrSelectTheme} from '../../utilities/theme-selector.js' +import {uploadTheme} from '../../utilities/theme-uploader.js' +import {Operation} from '@shopify/cli-kit/node/themes/types' +import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs' +import {buildTheme} from '@shopify/cli-kit/node/themes/factories' +import {fetchChecksums, themePublish} from '@shopify/cli-kit/node/themes/api' +import {renderConfirmationPrompt} from '@shopify/cli-kit/node/ui' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' +import {describe, expect, test, vi} from 'vitest' + +vi.mock('../../utilities/theme-selector.js') +vi.mock('../../utilities/theme-uploader.js') +vi.mock('@shopify/cli-kit/node/themes/api') +vi.mock('@shopify/cli-kit/node/ui', async (importOriginal) => ({ + ...(await importOriginal()), + renderConfirmationPrompt: vi.fn(), +})) + +const session = {storeFqdn: 'test.myshopify.com', token: 'token'} + +describe('push execution', () => { + test('returns transfer facts without rendering a final result', async () => { + await inTemporaryDirectory(async (path) => { + vi.mocked(findOrSelectTheme).mockResolvedValue(buildTheme({id: 1, name: 'Theme', role: 'unpublished'})!) + vi.mocked(fetchChecksums).mockResolvedValue([]) + vi.mocked(uploadTheme).mockImplementation((_theme, _session, _checksums, fileSystem) => ({ + workPromise: Promise.resolve(), + uploadResults: new Map([ + [ + 'assets/theme.css', + {key: 'assets/theme.css', operation: Operation.Upload, success: false, errors: {asset: ['bad CSS']}}, + ], + ['assets/valid.js', {key: 'assets/valid.js', operation: Operation.Upload, success: true}], + ]), + renderThemeSyncProgress: async () => { + await fileSystem.ready() + }, + })) + const output = mockAndCaptureOutput() + output.clear() + const result = await executeThemePush({path, force: true, publish: true, environment: ['staging']}, session) + expect(result).toEqual({ + environment: 'staging', + theme: { + id: 1, + name: 'Theme', + role: 'unpublished', + shop: session.storeFqdn, + editor_url: 'https://test.myshopify.com/admin/themes/1/editor', + preview_url: 'https://test.myshopify.com?preview_theme_id=1', + }, + published: true, + hasErrors: true, + errors: {'assets/theme.css': ['bad CSS']}, + }) + expect(themePublish).toHaveBeenCalledWith(1, session) + expect(output.output()).toBe('') + expect(output.info()).not.toContain('was pushed') + }) + }) + + test('does not upload or publish after live-theme confirmation is declined', async () => { + await inTemporaryDirectory(async (path) => { + vi.mocked(findOrSelectTheme).mockResolvedValue(buildTheme({id: 1, name: 'Theme', role: 'live'})!) + vi.mocked(renderConfirmationPrompt).mockResolvedValue(false) + await expect(executeThemePush({path, force: true, publish: true}, session)).resolves.toBeUndefined() + expect(uploadTheme).not.toHaveBeenCalled() + expect(themePublish).not.toHaveBeenCalled() + }) + }) + + test('propagates publish failures without producing a result', async () => { + await inTemporaryDirectory(async (path) => { + vi.mocked(findOrSelectTheme).mockResolvedValue(buildTheme({id: 1, name: 'Theme', role: 'unpublished'})!) + vi.mocked(fetchChecksums).mockResolvedValue([]) + vi.mocked(uploadTheme).mockImplementation((_theme, _session, _checksums, fileSystem) => ({ + workPromise: Promise.resolve(), + uploadResults: new Map(), + renderThemeSyncProgress: async () => { + await fileSystem.ready() + }, + })) + const failure = new Error('publish failed') + vi.mocked(themePublish).mockRejectedValue(failure) + await expect(executeThemePush({path, force: true, publish: true}, session)).rejects.toBe(failure) + }) + }) +}) diff --git a/packages/theme/src/cli/services/push/result.test.ts b/packages/theme/src/cli/services/push/result.test.ts new file mode 100644 index 00000000000..98c335ebf25 --- /dev/null +++ b/packages/theme/src/cli/services/push/result.test.ts @@ -0,0 +1,156 @@ +import { + renderThemePushResult, + themePushJsonResult, + checkThemeBeforePush, + renderThemePushEnvironmentResults, +} from './result.js' +import {themePushJsonOutputSchema, type ThemePushResult} from './types.js' +import {renderThrownError} from '../../utilities/errors.js' +import {runThemeCheck} from '../../commands/theme/check.js' +import {describe, expect, test, vi} from 'vitest' +import {runWithCommandEvents, renderCommandEventAsJson} from '@shopify/cli-kit/node/command-events' +import {mockAndCaptureOutput, withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {Severity, SourceCodeType} from '@shopify/theme-check-node' + +vi.mock('../../commands/theme/check.js') + +function pushResult(overrides: Partial = {}): ThemePushResult { + return { + theme: { + id: 1, + name: 'Theme', + role: 'unpublished', + shop: 'test.myshopify.com', + editor_url: 'https://test.myshopify.com/admin/themes/1/editor', + preview_url: 'https://test.myshopify.com?preview_theme_id=1', + }, + published: false, + hasErrors: false, + errors: {}, + ...overrides, + } +} + +describe('push result', () => { + test('writes compact JSON with a success status and omits absent environment and error fields', () => { + expect(themePushJsonOutputSchema.encode(themePushJsonResult(pushResult()))).toBe( + '{"status":"success","theme":{"id":1,"name":"Theme","role":"unpublished","shop":"test.myshopify.com","editor_url":"https://test.myshopify.com/admin/themes/1/editor","preview_url":"https://test.myshopify.com?preview_theme_id=1"}}', + ) + }) + + test('preserves environment, warning, asset errors and their order', () => { + const result = pushResult({ + environment: 'staging', + hasErrors: true, + errors: {'assets/z.css': ['bad CSS'], 'layout/a.liquid': []}, + }) + expect(themePushJsonOutputSchema.encode(themePushJsonResult(result))).toBe( + '{"status":"failed","environment":"staging","theme":{"id":1,"name":"Theme","role":"unpublished","shop":"test.myshopify.com","editor_url":"https://test.myshopify.com/admin/themes/1/editor","preview_url":"https://test.myshopify.com?preview_theme_id=1","warning":"[staging] The theme \'Theme\' was pushed with errors","errors":{"assets/z.css":["bad CSS"],"layout/a.liquid":[]}}}', + ) + }) + + test('warns without an errors field for failed uploads without asset errors', () => { + const encoded = JSON.parse(themePushJsonOutputSchema.encode(themePushJsonResult(pushResult({hasErrors: true})))) + expect(encoded.status).toBe('failed') + expect(encoded.theme.warning).toBe("The theme 'Theme' was pushed with errors") + expect(encoded.theme).not.toHaveProperty('errors') + }) + + test('reports each environment independently without an overall success status', async () => { + await withCapturedStandardStreams(async ({stdout}) => { + renderThemePushEnvironmentResults([ + {environment: 'production', result: pushResult()}, + {environment: 'staging', result: pushResult({hasErrors: true})}, + {environment: 'cancelled', result: undefined}, + ]) + expect( + JSON.parse(stdout()).map(({environment, status}: {environment: string; status: string}) => ({ + environment, + status, + })), + ).toEqual([ + {environment: 'production', status: 'success'}, + {environment: 'staging', status: 'failed'}, + ]) + }) + }) + + test('requires environment identity for array entries', () => { + expect(() => themePushJsonOutputSchema.validate([themePushJsonResult(pushResult())])).toThrow() + expect(themePushJsonOutputSchema.encode([])).toBe('[]') + }) + + test.each([{id: '1'}, {name: null}, {role: false}, {errors: {file: 'error'}}])( + 'rejects malformed theme fields %j', + (fields) => { + expect(() => + themePushJsonOutputSchema.validate({status: 'success', theme: {...pushResult().theme, ...fields}}), + ).toThrow() + }, + ) + + test.each([false, true])('keeps JSON unchanged when publish is %s', (published) => { + expect(themePushJsonResult(pushResult({published}))).toEqual(themePushJsonResult(pushResult())) + }) + + test.each([ + [false, false, 'was pushed successfully.'], + [false, true, 'was pushed with errors'], + [true, false, 'Your theme is now live at https://test.myshopify.com'], + [true, true, 'Your theme was published with errors and is now live at https://test.myshopify.com'], + ] as const)('preserves text (published=%s, errors=%s)', (published, hasErrors, message) => { + const output = mockAndCaptureOutput() + output.clear() + renderThemePushResult(pushResult({published, hasErrors, environment: 'staging'}), 'text') + const text = `${output.info()}${output.warn()}` + expect(text.replace(/│/g, '').replace(/\s+/g, ' ')).toContain(message) + expect(text).toContain('Environment: staging') + }) + + test('writes one result to stdout and typed upload diagnostics to stderr', async () => { + const result = pushResult({hasErrors: true, errors: {'assets/theme.css': ['bad CSS']}}) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runWithCommandEvents({outputMode: 'json', sink: renderCommandEventAsJson}, () => { + renderThrownError('assets/theme.css', new Error('bad CSS')) + renderThemePushResult(result, 'json') + }) + + expect(stdout()).toBe(`${themePushJsonOutputSchema.encode(themePushJsonResult(result))}\n`) + expect(JSON.parse(stderr())).toMatchObject({ + type: 'diagnostic', + level: 'error', + message: 'assets/theme.css\nbad CSS', + }) + }) + }) + + test.each([ + [Severity.WARNING, 'warning'], + [Severity.INFO, 'info'], + ] as const)('keeps strict-check severity %s off stdout', async (severity, level) => { + vi.mocked(runThemeCheck).mockResolvedValue({ + offenses: [ + { + severity, + message: 'warning', + type: SourceCodeType.LiquidHtml, + check: 'check', + uri: 'file:///theme.liquid', + start: {index: 0, line: 0, character: 0}, + end: {index: 1, line: 0, character: 1}, + }, + ], + theme: [], + }) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runWithCommandEvents({outputMode: 'json', sink: renderCommandEventAsJson}, async () => { + await checkThemeBeforePush({strict: true, json: true, path: '/theme'}) + renderThemePushResult(pushResult(), 'json') + }) + + expect(runThemeCheck).toHaveBeenCalledWith('/theme', 'silent') + expect(JSON.parse(stdout())).toEqual(themePushJsonResult(pushResult())) + expect(JSON.parse(stderr())).toMatchObject({type: 'diagnostic', level, code: 'check'}) + }) + }) +}) diff --git a/packages/theme/src/cli/services/push/result.ts b/packages/theme/src/cli/services/push/result.ts new file mode 100644 index 00000000000..daa9352a6a9 --- /dev/null +++ b/packages/theme/src/cli/services/push/result.ts @@ -0,0 +1,102 @@ +import { + themePushJsonOutputSchema, + themePushResultSchema, + type ThemePushResult, + type ThemePushJsonResult, +} from './types.js' +import {themeComponent} from '../../utilities/theme-ui.js' +import {PushFlags} from '../push.js' +import {runThemeCheck} from '../../commands/theme/check.js' +import {outputResult} from '@shopify/cli-kit/node/output' +import {renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui' +import {emitCommandEvent} from '@shopify/cli-kit/node/command-events' +import {cwd} from '@shopify/cli-kit/node/path' +import {AbortError} from '@shopify/cli-kit/node/error' +import {recordError} from '@shopify/cli-kit/node/analytics' +import {Severity} from '@shopify/theme-check-node' + +export function themePushJsonResult(result: ThemePushResult): ThemePushJsonResult { + const {environment, theme, hasErrors, errors} = result + return { + status: hasErrors ? 'failed' : 'success', + environment, + theme: { + ...theme, + ...(hasErrors + ? {warning: `${environment ? `[${environment}] ` : ''}The theme '${theme.name}' was pushed with errors`} + : {}), + ...(Object.keys(errors).length > 0 ? {errors} : {}), + }, + } +} + +export function renderThemePushResult(result: ThemePushResult, format: 'text' | 'json'): void { + if (format === 'json') { + outputResult(themePushJsonOutputSchema.encode(themePushJsonResult(result))) + return + } + + const {environment, theme, hasErrors, published} = result + const header = environment ? [{subdued: `Environment: ${environment}\n\n`}] : [] + const render = hasErrors ? renderWarning : renderSuccess + if (published) { + render({ + body: [ + ...header, + hasErrors + ? `Your theme was published with errors and is now live at https://${theme.shop}` + : `Your theme is now live at https://${theme.shop}`, + ], + }) + return + } + + render({ + body: [ + ...header, + 'The theme', + ...themeComponent(theme), + hasErrors ? 'was pushed with errors' : 'was pushed successfully.', + ], + nextSteps: [ + [{link: {label: 'View your theme', url: theme.preview_url}}], + [{link: {label: 'Customize your theme at the theme editor', url: theme.editor_url}}], + ], + }) +} + +export async function checkThemeBeforePush(flags: PushFlags, legacyOutput = false): Promise { + if (!flags.strict) return + const environment = flags.environment?.[0] + const jsonFormat = legacyOutput ? 'json' : 'silent' + const outputFormat = flags.json ? jsonFormat : 'text' + const {offenses} = await runThemeCheck(flags.path ?? cwd(), outputFormat) + if (flags.json && !legacyOutput) { + for (const offense of offenses) { + emitCommandEvent({ + type: 'diagnostic', + level: ({[Severity.ERROR]: 'error', [Severity.WARNING]: 'warning', [Severity.INFO]: 'info'} as const)[ + offense.severity + ], + code: offense.check, + message: `${environment ? `[${environment}] ` : ''}${offense.uri}: ${offense.message}`, + }) + } + } + if (offenses.some((offense) => offense.severity === Severity.ERROR)) { + throw recordError( + new AbortError( + `${environment ? `[${environment}] ` : ''}Theme check failed. Please fix the errors before pushing.`, + ), + ) + } +} + +export function renderThemePushEnvironmentResults(results: {environment: string; result: unknown}[]): void { + const output = results.flatMap(({environment, result}) => + result === undefined + ? [] + : [{...themePushJsonResult({...themePushResultSchema.parse(result), environment}), environment}], + ) + outputResult(themePushJsonOutputSchema.encode(output)) +} diff --git a/packages/theme/src/cli/services/push/types.ts b/packages/theme/src/cli/services/push/types.ts new file mode 100644 index 00000000000..298da3eb0a2 --- /dev/null +++ b/packages/theme/src/cli/services/push/types.ts @@ -0,0 +1,49 @@ +import {ThemeMutationSuccessSchema} from '../theme-mutation/status.js' +import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' + +const ThemePushThemeSchema = zod.object({ + id: zod.number(), + name: zod.string(), + role: zod.string(), + shop: zod.string(), + editor_url: zod.string(), + preview_url: zod.string(), +}) + +const ThemePushJsonThemeSchema = ThemePushThemeSchema.extend({ + warning: zod.string().optional(), + errors: zod.record(zod.array(zod.string())).optional(), +}) + +const ThemePushJsonResultSchema = zod.object({ + status: zod.union([ThemeMutationSuccessSchema.shape.status, zod.literal('failed')]), + environment: zod.string().optional(), + theme: ThemePushJsonThemeSchema, +}) + +const outputSchema = defineJsonOutputSchema({ + name: 'ThemePushJsonResult', + schema: zod.union([ + ThemePushJsonResultSchema, + zod.array(ThemePushJsonResultSchema.extend({environment: zod.string()})), + ]), + definitions: {ThemePushTheme: ThemePushJsonThemeSchema}, +}) + +export const themePushJsonOutputSchema: typeof outputSchema = { + ...outputSchema, + // Preserve the compact JSON emitted by theme push. + encode: (result: InferJsonOutputSchema) => JSON.stringify(outputSchema.validate(result)), +} + +export const themePushResultSchema = zod.object({ + environment: zod.string().optional(), + theme: ThemePushThemeSchema, + published: zod.boolean(), + hasErrors: zod.boolean(), + errors: zod.record(zod.array(zod.string())), +}) + +export type ThemePushResult = zod.infer +export type ThemePushJsonResult = zod.infer diff --git a/packages/theme/src/cli/utilities/errors.ts b/packages/theme/src/cli/utilities/errors.ts index 7fef4661b07..03d98510df5 100644 --- a/packages/theme/src/cli/utilities/errors.ts +++ b/packages/theme/src/cli/utilities/errors.ts @@ -1,3 +1,4 @@ +import {commandEventOutputMode, emitCommandEvent} from '@shopify/cli-kit/node/command-events' import {AbortError} from '@shopify/cli-kit/node/error' import {outputDebug} from '@shopify/cli-kit/node/output' import {recordError} from '@shopify/cli-kit/node/analytics' @@ -12,6 +13,11 @@ import {createError as createH3Error, type H3Error} from 'h3' export function renderThrownError(headline: string, error: Error | AbortError) { recordError(error) + if (commandEventOutputMode() === 'json') { + emitCommandEvent({type: 'diagnostic', level: 'error', message: `${headline}\n${error.message}`}) + return + } + if (error instanceof AbortError) { error.message = `${headline}\n${error.message}` renderFatalError(error) diff --git a/packages/theme/src/cli/utilities/theme-transfer-events.test.ts b/packages/theme/src/cli/utilities/theme-transfer-events.test.ts new file mode 100644 index 00000000000..2e4b566bc6e --- /dev/null +++ b/packages/theme/src/cli/utilities/theme-transfer-events.test.ts @@ -0,0 +1,111 @@ +import {uploadTheme} from './theme-uploader.js' +import {mountThemeFileSystem} from './theme-fs.js' +import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {inTemporaryDirectory, mkdir, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import {bulkUploadThemeAssets, deleteThemeAssets} from '@shopify/cli-kit/node/themes/api' +import {Operation} from '@shopify/cli-kit/node/themes/types' +import {buildTheme} from '@shopify/cli-kit/node/themes/factories' +import {runWithCommandEvents, renderCommandEventAsJson} from '@shopify/cli-kit/node/command-events' +import {expect, test, vi} from 'vitest' + +vi.mock('@shopify/cli-kit/node/themes/api') + +test('reports upload polling as updates until the upload finishes', async () => { + await inTemporaryDirectory(async (path) => { + await mkdir(joinPath(path, 'assets')) + await writeFile(joinPath(path, 'assets/theme.css'), 'body {}') + let finishUpload!: () => void + const pendingUpload = new Promise((resolve) => { + finishUpload = resolve + }) + let startedUpload!: () => void + const uploadStarted = new Promise((resolve) => { + startedUpload = resolve + }) + vi.mocked(bulkUploadThemeAssets).mockImplementation(async (_themeId, assets) => { + if (!assets.some((asset) => asset.key === 'assets/theme.css')) return [] + startedUpload() + await pendingUpload + return [{key: 'assets/theme.css', operation: Operation.Upload, success: true}] + }) + vi.mocked(deleteThemeAssets).mockResolvedValue([]) + const sink = vi.fn() + vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout']}) + const rendering = runWithCommandEvents({outputMode: 'json', sink}, async () => { + const upload = uploadTheme( + buildTheme({id: 1, name: 'Theme', role: 'unpublished'})!, + {storeFqdn: 'test.myshopify.com', token: 'token'}, + [], + mountThemeFileSystem(path), + ) + await upload.renderThemeSyncProgress() + }) + + try { + await uploadStarted + await vi.advanceTimersByTimeAsync(2000) + const events = sink.mock.calls.map(([event]) => event).filter((event) => event.type === 'progress') + expect(events.map((event) => event.status)).toEqual(['started', 'updated', 'updated']) + expect(new Set(events.map((event) => event.operation)).size).toBe(1) + expect(events[0].message).toBe('Uploading files to remote theme [0%]') + finishUpload() + await rendering + const uploadEvents = sink.mock.calls + .map(([event]) => event) + .filter((event) => event.operation === events[0].operation) + expect(uploadEvents.map((event) => event.status)).toEqual(['started', 'updated', 'updated', 'completed']) + expect(uploadEvents.at(-1).message).toBe('Uploading files to remote theme [100%]') + } finally { + finishUpload() + vi.clearAllTimers() + vi.useRealTimers() + await rendering + } + }) +}) + +test('uploads real local files with typed progress and failure diagnostics on stderr', async () => { + await inTemporaryDirectory(async (path) => { + await mkdir(joinPath(path, 'assets')) + await writeFile(joinPath(path, 'assets/theme.css'), 'body {}') + const theme = buildTheme({id: 1, name: 'Theme', role: 'unpublished'})! + vi.mocked(bulkUploadThemeAssets).mockResolvedValue([ + {key: 'assets/theme.css', operation: Operation.Upload, success: false, errors: {asset: ['bad CSS']}}, + ]) + vi.mocked(deleteThemeAssets).mockResolvedValue([]) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runWithCommandEvents({outputMode: 'json', sink: renderCommandEventAsJson}, async () => { + const upload = uploadTheme( + theme, + {storeFqdn: 'test.myshopify.com', token: 'token'}, + [], + mountThemeFileSystem(path), + ) + await upload.renderThemeSyncProgress() + expect(upload.uploadResults.get('assets/theme.css')?.success).toBe(false) + }) + + expect(stdout()).toBe('') + const events = stderr() + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'progress', + status: 'started', + message: expect.stringContaining('Uploading files to remote theme'), + }), + expect.objectContaining({ + type: 'progress', + status: 'completed', + message: expect.stringContaining('Cleaning your remote theme'), + }), + expect.objectContaining({type: 'diagnostic', level: 'error', message: 'assets/theme.css\nbad CSS'}), + ]), + ) + }) + }) +}) diff --git a/packages/theme/src/cli/utilities/theme-ui.ts b/packages/theme/src/cli/utilities/theme-ui.ts index 3782a550327..ca5276df80d 100644 --- a/packages/theme/src/cli/utilities/theme-ui.ts +++ b/packages/theme/src/cli/utilities/theme-ui.ts @@ -1,10 +1,11 @@ +import {commandEventOutputMode, emitCommandEvent} from '@shopify/cli-kit/node/command-events' import {recordEvent} from '@shopify/cli-kit/node/analytics' import {Theme} from '@shopify/cli-kit/node/themes/types' import {LIVE_THEME_ROLE} from '@shopify/cli-kit/node/themes/utils' import {Task, renderConfirmationPrompt, renderError, renderTasks, renderWarning} from '@shopify/cli-kit/node/ui' import {Writable} from 'stream' -export function themeComponent(theme: Theme) { +export function themeComponent(theme: Pick) { return [ `'${theme.name}'`, { @@ -30,14 +31,26 @@ export async function ensureDirectoryConfirmed( } if (multiEnvironment) { - renderError({ - headline: environment ? `Environment: ${environment}` : '', - body: message, - }) + if (commandEventOutputMode() === 'json') { + emitCommandEvent({ + type: 'diagnostic', + level: 'error', + message: `${environment ? `Environment: ${environment}\n` : ''}${message}`, + }) + } else { + renderError({ + headline: environment ? `Environment: ${environment}` : '', + body: message, + }) + } return false } - renderWarning({body: message}) + if (commandEventOutputMode() === 'json') { + emitCommandEvent({type: 'diagnostic', level: 'warning', message}) + } else { + renderWarning({body: message}) + } if (!process.stdout.isTTY) { return true diff --git a/packages/theme/src/cli/utilities/theme-uploader.ts b/packages/theme/src/cli/utilities/theme-uploader.ts index 49522d71faf..fd80330d105 100644 --- a/packages/theme/src/cli/utilities/theme-uploader.ts +++ b/packages/theme/src/cli/utilities/theme-uploader.ts @@ -130,7 +130,7 @@ function createIntervalTask({ const addNextCheck = () => { tasks.push({ title: titleGetter(), - task: async () => { + task: async (_context, task) => { const result = await Promise.race([ promise, new Promise((resolve) => setTimeout(() => resolve('timeout'), updateInterval)), @@ -138,6 +138,8 @@ function createIntervalTask({ if (result === 'timeout') { addNextCheck() + } else { + task.title = titleGetter() } }, })