diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 30839954c0a..b4e31e02578 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -8834,6 +8834,15 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_ENVIRONMENT" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/theme-dev.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-j, --json", + "value": "''", + "description": "Output the result as JSON. Automatically disables color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_JSON" + }, { "filePath": "docs-shopify.dev/commands/interfaces/theme-dev.interface.ts", "syntaxKind": "PropertySignature", @@ -8880,7 +8889,7 @@ "environmentValue": "SHOPIFY_FLAG_IGNORE" } ], - "value": "export interface themedev {\n /**\n * Allow development on a live theme.\n * @environment SHOPIFY_FLAG_ALLOW_LIVE\n */\n '-a, --allow-live'?: ''\n\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The environment to apply to the current command.\n * @environment SHOPIFY_FLAG_ENVIRONMENT\n */\n '-e, --environment '?: string\n\n /**\n * Controls the visibility of the error overlay when an theme asset upload fails:\n- silent Prevents the error overlay from appearing.\n- default Displays the error overlay.\n \n * @environment SHOPIFY_FLAG_ERROR_OVERLAY\n */\n '--error-overlay '?: string\n\n /**\n * Set which network interface the web server listens on. The default value is 127.0.0.1.\n * @environment SHOPIFY_FLAG_HOST\n */\n '--host '?: string\n\n /**\n * Skip hot reloading any files that match the specified pattern.\n * @environment SHOPIFY_FLAG_IGNORE\n */\n '-x, --ignore '?: string\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * The listing preset to use for multi-preset themes. Applies preset files from listings/[preset-name] directory.\n * @environment SHOPIFY_FLAG_LISTING\n */\n '--listing '?: string\n\n /**\n * The live reload mode switches the server behavior when a file is modified:\n- hot-reload Hot reloads local changes to CSS and sections (default)\n- full-page Always refreshes the entire page\n- off Deactivate live reload\n * @environment SHOPIFY_FLAG_LIVE_RELOAD\n */\n '--live-reload '?: string\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * Prevents files from being deleted in the remote theme when a file has been deleted locally. This applies to files that are deleted while the command is running, and files that have been deleted locally before the command is run.\n * @environment SHOPIFY_FLAG_NODELETE\n */\n '-n, --nodelete'?: ''\n\n /**\n * The file path or URL. The file path is to a file that you want updated on idle. The URL path is where you want a webhook posted to report on file changes.\n * @environment SHOPIFY_FLAG_NOTIFY\n */\n '--notify '?: string\n\n /**\n * Hot reload only files that match the specified pattern.\n * @environment SHOPIFY_FLAG_ONLY\n */\n '-o, --only '?: string\n\n /**\n * Automatically launch the theme preview in your default web browser.\n * @environment SHOPIFY_FLAG_OPEN\n */\n '--open'?: ''\n\n /**\n * Password generated from the Theme Access app or an Admin API token.\n * @environment SHOPIFY_CLI_THEME_TOKEN\n */\n '--password '?: string\n\n /**\n * The path where you want to run the command. Defaults to the current working directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Local port to serve theme preview from. Must be between 1 and 65535.\n * @environment SHOPIFY_FLAG_PORT\n */\n '--port '?: string\n\n /**\n * How to resolve JSON conflicts when --theme-editor-sync is enabled. Use keep-local to keep local files, keep-remote to keep remote files, or abort to fail instead of prompting.\n * @environment SHOPIFY_FLAG_RECONCILIATION_STRATEGY\n */\n '--reconciliation-strategy '?: string\n\n /**\n * Inject the standard events inspector into storefront HTML.\n * @environment SHOPIFY_FLAG_STANDARD_EVENTS_INSPECTOR\n */\n '--standard-events-inspector'?: ''\n\n /**\n * Store URL. It can be the store prefix (example) or the full myshopify.com URL (example.myshopify.com, https://example.myshopify.com).\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\n\n /**\n * The password for storefronts with password protection.\n * @environment SHOPIFY_FLAG_STORE_PASSWORD\n */\n '--store-password '?: string\n\n /**\n * Theme ID or name of the remote theme.\n * @environment SHOPIFY_FLAG_THEME_ID\n */\n '-t, --theme '?: string\n\n /**\n * Synchronize Theme Editor updates in the local theme files.\n * @environment SHOPIFY_FLAG_THEME_EDITOR_SYNC\n */\n '--theme-editor-sync'?: ''\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + "value": "export interface themedev {\n /**\n * Allow development on a live theme.\n * @environment SHOPIFY_FLAG_ALLOW_LIVE\n */\n '-a, --allow-live'?: ''\n\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The environment to apply to the current command.\n * @environment SHOPIFY_FLAG_ENVIRONMENT\n */\n '-e, --environment '?: string\n\n /**\n * Controls the visibility of the error overlay when an theme asset upload fails:\n- silent Prevents the error overlay from appearing.\n- default Displays the error overlay.\n \n * @environment SHOPIFY_FLAG_ERROR_OVERLAY\n */\n '--error-overlay '?: string\n\n /**\n * Set which network interface the web server listens on. The default value is 127.0.0.1.\n * @environment SHOPIFY_FLAG_HOST\n */\n '--host '?: string\n\n /**\n * Skip hot reloading any files that match the specified pattern.\n * @environment SHOPIFY_FLAG_IGNORE\n */\n '-x, --ignore '?: string\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * The listing preset to use for multi-preset themes. Applies preset files from listings/[preset-name] directory.\n * @environment SHOPIFY_FLAG_LISTING\n */\n '--listing '?: string\n\n /**\n * The live reload mode switches the server behavior when a file is modified:\n- hot-reload Hot reloads local changes to CSS and sections (default)\n- full-page Always refreshes the entire page\n- off Deactivate live reload\n * @environment SHOPIFY_FLAG_LIVE_RELOAD\n */\n '--live-reload '?: string\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * Prevents files from being deleted in the remote theme when a file has been deleted locally. This applies to files that are deleted while the command is running, and files that have been deleted locally before the command is run.\n * @environment SHOPIFY_FLAG_NODELETE\n */\n '-n, --nodelete'?: ''\n\n /**\n * The file path or URL. The file path is to a file that you want updated on idle. The URL path is where you want a webhook posted to report on file changes.\n * @environment SHOPIFY_FLAG_NOTIFY\n */\n '--notify '?: string\n\n /**\n * Hot reload only files that match the specified pattern.\n * @environment SHOPIFY_FLAG_ONLY\n */\n '-o, --only '?: string\n\n /**\n * Automatically launch the theme preview in your default web browser.\n * @environment SHOPIFY_FLAG_OPEN\n */\n '--open'?: ''\n\n /**\n * Password generated from the Theme Access app or an Admin API token.\n * @environment SHOPIFY_CLI_THEME_TOKEN\n */\n '--password '?: string\n\n /**\n * The path where you want to run the command. Defaults to the current working directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Local port to serve theme preview from. Must be between 1 and 65535.\n * @environment SHOPIFY_FLAG_PORT\n */\n '--port '?: string\n\n /**\n * How to resolve JSON conflicts when --theme-editor-sync is enabled. Use keep-local to keep local files, keep-remote to keep remote files, or abort to fail instead of prompting.\n * @environment SHOPIFY_FLAG_RECONCILIATION_STRATEGY\n */\n '--reconciliation-strategy '?: string\n\n /**\n * Inject the standard events inspector into storefront HTML.\n * @environment SHOPIFY_FLAG_STANDARD_EVENTS_INSPECTOR\n */\n '--standard-events-inspector'?: ''\n\n /**\n * Store URL. It can be the store prefix (example) or the full myshopify.com URL (example.myshopify.com, https://example.myshopify.com).\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\n\n /**\n * The password for storefronts with password protection.\n * @environment SHOPIFY_FLAG_STORE_PASSWORD\n */\n '--store-password '?: string\n\n /**\n * Theme ID or name of the remote theme.\n * @environment SHOPIFY_FLAG_THEME_ID\n */\n '-t, --theme '?: string\n\n /**\n * Synchronize Theme Editor updates in the local theme files.\n * @environment SHOPIFY_FLAG_THEME_EDITOR_SYNC\n */\n '--theme-editor-sync'?: ''\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, "themeduplicate": { diff --git a/docs/cli/dev-instance-agent-workflow.md b/docs/cli/dev-instance-agent-workflow.md index 50c251820bc..a4b9dc6c6bd 100644 --- a/docs/cli/dev-instance-agent-workflow.md +++ b/docs/cli/dev-instance-agent-workflow.md @@ -84,3 +84,12 @@ The log includes theme dev messages, file synchronization results, request lines errors, and setup progress. Theme records omit the redundant `source` field. Authentication before the session and fatal CLI errors reported after it are outside this log. + +With `--json`, the session writes identical JSONL bytes to stdout and the log. +Each record contains `timestamp` and `message`, without `type`, `stream`, or +`source`. Request and file synchronization messages omit terminal timestamps, +decoration, and alignment spaces. Request paths are not truncated in JSON mode. +Setup diagnostics use stderr; fatal CLI errors use the standard JSON +error envelope on stdout and are not added to the log. There is no final success +result. Use `--json --no-input` for unattended runs and `--json-schema` to inspect +the stream record and error schemas. diff --git a/docs/cli/json-output.md b/docs/cli/json-output.md index 9f2aaf4fd3e..9eb7e584f04 100644 --- a/docs/cli/json-output.md +++ b/docs/cli/json-output.md @@ -257,6 +257,11 @@ the session never changes the running process's log. Setup prompts still use stderr. Use `--json --no-input` for unattended runs. `app dev --json-schema` describes the stream's event and fatal error formats. This streaming contract does not change the single-document rule for finite commands. +`shopify theme dev --json` also mirrors its session log to stdout as JSONL, with +`timestamp` and `message` in each record. Theme records omit `source` because all +messages belong to the same session. Setup diagnostics and fatal errors follow +the same stream separation as `app dev`. + Long-lived commands that produce an open-ended event stream don't have one finite result. Track these exemptions in `packages/eslint-plugin-cli/rules/json-output-command-exceptions.js`, in its streaming section. Add the command's repository-relative path: diff --git a/packages/cli/README.md b/packages/cli/README.md index a3da2d36a6a..bb25c259bba 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -13286,10 +13286,10 @@ Uploads the current theme as a development theme to the connected store, then pr ``` USAGE $ shopify theme dev [-a] [--auth-alias ] [-e ...] [--error-overlay silent|default] [--host - ] [-x ...] [--json-schema] [--listing ] [--live-reload hot-reload|full-page|off] [--no-color] - [--no-input] [-n] [--notify ] [-o ...] [--open] [--password ] [--path ] [--port ] - [--reconciliation-strategy keep-local|keep-remote|abort --theme-editor-sync] [--standard-events-inspector] [-s - ] [--store-password ] [-t ] [--verbose] + ] [-x ...] [-j] [--json-schema] [--listing ] [--live-reload hot-reload|full-page|off] + [--no-color] [--no-input] [-n] [--notify ] [-o ...] [--open] [--password ] [--path ] + [--port ] [--reconciliation-strategy keep-local|keep-remote|abort --theme-editor-sync] + [--standard-events-inspector] [-s ] [--store-password ] [-t ] [--verbose] FLAGS -a, --allow-live @@ -13300,6 +13300,10 @@ FLAGS The environment to apply to the current command. [env: SHOPIFY_FLAG_ENVIRONMENT] + -j, --json + Output the result as JSON. Automatically disables color output. + [env: SHOPIFY_FLAG_JSON] + -n, --nodelete Prevents files from being deleted in the remote theme when a file has been deleted locally. This applies to files that are deleted while the command is running, and files that have been deleted locally before the command is run. @@ -13438,6 +13442,47 @@ DESCRIPTION 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). + + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `DevInstanceLogEntry` schema. + + ```json + { + "type": "object", + "properties": { + "timestamp": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$", + "description": "A UTC ISO 8601 instant with whole seconds and the Z timezone marker." + }, + "source": { + "type": "string", + "description": "What produced the line, for example a process prefix like web or extensions, when needed." + }, + "level": { + "type": "string", + "enum": [ + "debug", + "info", + "warning", + "error" + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "timestamp", + "message" + ], + "additionalProperties": false, + "title": "DevInstanceLogEntry", + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` ``` ## `shopify theme duplicate` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 1c8e81bed44..7ed5825ba30 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -10930,7 +10930,7 @@ "args": { }, "customPluginName": "@shopify/theme", - "description": "\n Uploads the current theme as the specified theme, or a \"development theme\" (https://shopify.dev/docs/themes/tools/cli#development-themes), to a store so you can preview it.\n\nThis command returns the following information:\n\n- A link to your development theme at http://127.0.0.1:9292. This URL can hot reload local changes to CSS and sections, or refresh the entire page when a file changes, enabling you to preview changes in real time using the store's data.\n\n You can specify a different network interface and port using `--host` and `--port`.\n\n- A link to the \"editor\" (https://shopify.dev/docs/themes/tools/online-editor) for the theme in the Shopify admin.\n\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 other developers.\n\nIf you already have a development theme for your current environment, then this command replaces the development theme with your local theme. You can override this using the `--theme-editor-sync` flag.\n\n> Note: You can't preview checkout customizations using http://127.0.0.1:9292.\n\nDevelopment themes are deleted when you run `shopify auth logout`. If you need a preview link that can be used after you log out, then you should \"share\" (https://shopify.dev/docs/api/shopify-cli/theme/theme-share) your theme or \"push\" (https://shopify.dev/docs/api/shopify-cli/theme/theme-push) to an unpublished theme on your store.\n\nYou 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).", + "description": "\n Uploads the current theme as the specified theme, or a \"development theme\" (https://shopify.dev/docs/themes/tools/cli#development-themes), to a store so you can preview it.\n\nThis command returns the following information:\n\n- A link to your development theme at http://127.0.0.1:9292. This URL can hot reload local changes to CSS and sections, or refresh the entire page when a file changes, enabling you to preview changes in real time using the store's data.\n\n You can specify a different network interface and port using `--host` and `--port`.\n\n- A link to the \"editor\" (https://shopify.dev/docs/themes/tools/online-editor) for the theme in the Shopify admin.\n\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 other developers.\n\nIf you already have a development theme for your current environment, then this command replaces the development theme with your local theme. You can override this using the `--theme-editor-sync` flag.\n\n> Note: You can't preview checkout customizations using http://127.0.0.1:9292.\n\nDevelopment themes are deleted when you run `shopify auth logout`. If you need a preview link that can be used after you log out, then you should \"share\" (https://shopify.dev/docs/api/shopify-cli/theme/theme-share) your theme or \"push\" (https://shopify.dev/docs/api/shopify-cli/theme/theme-push) to an unpublished theme on your store.\n\nYou 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\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `DevInstanceLogEntry` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"timestamp\": {\n \"type\": \"string\",\n \"format\": \"date-time\",\n \"pattern\": \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}Z$\",\n \"description\": \"A UTC ISO 8601 instant with whole seconds and the Z timezone marker.\"\n },\n \"source\": {\n \"type\": \"string\",\n \"description\": \"What produced the line, for example a process prefix like web or extensions, when needed.\"\n },\n \"level\": {\n \"type\": \"string\",\n \"enum\": [\n \"debug\",\n \"info\",\n \"warning\",\n \"error\"\n ]\n },\n \"message\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"timestamp\",\n \"message\"\n ],\n \"additionalProperties\": false,\n \"title\": \"DevInstanceLogEntry\",\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "\n Uploads the current theme as the specified theme, or a [development theme](https://shopify.dev/docs/themes/tools/cli#development-themes), to a store so you can preview it.\n\nThis command returns the following information:\n\n- A link to your development theme at http://127.0.0.1:9292. This URL can hot reload local changes to CSS and sections, or refresh the entire page when a file changes, enabling you to preview changes in real time using the store's data.\n\n You can specify a different network interface and port using `--host` and `--port`.\n\n- A link to the [editor](https://shopify.dev/docs/themes/tools/online-editor) for the theme in the Shopify admin.\n\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 other developers.\n\nIf you already have a development theme for your current environment, then this command replaces the development theme with your local theme. You can override this using the `--theme-editor-sync` flag.\n\n> Note: You can't preview checkout customizations using http://127.0.0.1:9292.\n\nDevelopment themes are deleted when you run `shopify auth logout`. If you need a preview link that can be used after you log out, then you should [share](https://shopify.dev/docs/api/shopify-cli/theme/theme-share) your theme or [push](https://shopify.dev/docs/api/shopify-cli/theme/theme-push) to an unpublished theme on your store.\n\nYou 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).", "enableJsonFlag": false, "flags": { @@ -10998,6 +10998,15 @@ "name": "ignore", "type": "option" }, + "json": { + "allowNo": false, + "char": "j", + "description": "Output the result as JSON. Automatically disables color output.", + "env": "SHOPIFY_FLAG_JSON", + "hidden": false, + "name": "json", + "type": "boolean" + }, "json-schema": { "allowNo": false, "description": "Print the command's JSON schemas.", diff --git a/packages/eslint-plugin-cli/rules/command-json-output.test.js b/packages/eslint-plugin-cli/rules/command-json-output.test.js index 06d80aedf1a..cacbd61c322 100644 --- a/packages/eslint-plugin-cli/rules/command-json-output.test.js +++ b/packages/eslint-plugin-cli/rules/command-json-output.test.js @@ -43,12 +43,12 @@ ruleTester.run('command-json-output', rule, { }, { name: 'allow-listed streaming command', - filename: '/repo/packages/app/src/cli/commands/app/dev.ts', + filename: '/repo/packages/app/src/cli/commands/app/graphiql.ts', code: 'export default class Dev extends Command {}', }, { name: 'omitted exceptions preserve the default baseline', - filename: '/repo/packages/app/src/cli/commands/app/dev.ts', + filename: '/repo/packages/app/src/cli/commands/app/graphiql.ts', options: [{}], code: 'export default class Dev extends Command {}', }, @@ -92,14 +92,14 @@ ruleTester.run('command-json-output', rule, { }, { name: 'custom exceptions replace the default baseline', - filename: '/repo/packages/app/src/cli/commands/app/dev.ts', + filename: '/repo/packages/app/src/cli/commands/app/graphiql.ts', options: [{exceptions: ['packages/cli/src/commands/hydrogen/dev.ts']}], code: 'export default class Dev extends Command {}', errors: [{messageId: 'missingJsonOutputSchema'}, {messageId: 'missingJsonFlag'}], }, { name: 'empty exceptions remove the streaming command exemption', - filename: '/repo/packages/app/src/cli/commands/app/dev.ts', + filename: '/repo/packages/app/src/cli/commands/app/graphiql.ts', options: [{exceptions: []}], code: 'export default class Dev extends Command {}', errors: [{messageId: 'missingJsonOutputSchema'}, {messageId: 'missingJsonFlag'}], @@ -122,7 +122,7 @@ ruleTester.run('command-json-output', rule, { }, { name: 'new subcommand of an allow-listed streaming command', - filename: '/repo/packages/app/src/cli/commands/app/dev/status.ts', + filename: '/repo/packages/app/src/cli/commands/app/graphiql/status.ts', code: 'export default class DevStatus extends Command {}', errors: [{messageId: 'missingJsonOutputSchema'}, {messageId: 'missingJsonFlag'}], }, 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 a2165527d7a..0c2cac2489e 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -11,12 +11,10 @@ const commandExceptions = [ 'packages/app/src/cli/commands/app/security/review.ts', // Streaming commands without a single finite result. - 'packages/app/src/cli/commands/app/dev.ts', 'packages/app/src/cli/commands/app/graphiql.ts', 'packages/app/src/cli/commands/app/logs.ts', 'packages/store/src/cli/commands/store/graphiql.ts', 'packages/theme/src/cli/commands/theme/console.ts', - 'packages/theme/src/cli/commands/theme/dev.ts', 'packages/theme/src/cli/commands/theme/language-server.ts', ] diff --git a/packages/theme/src/cli/commands/theme/dev.test.ts b/packages/theme/src/cli/commands/theme/dev.test.ts new file mode 100644 index 00000000000..283444d8dbd --- /dev/null +++ b/packages/theme/src/cli/commands/theme/dev.test.ts @@ -0,0 +1,102 @@ +import Dev from './dev.js' +import {dev} from '../../services/dev.js' +import {findOrSelectTheme} from '../../utilities/theme-selector.js' +import {ensureLiveThemeConfirmed} from '../../utilities/theme-ui.js' +import {devInstanceLogOutputSchema} from '@shopify/cli-kit/node/dev-instance' +import {launchCLI} from '@shopify/cli-kit/node/cli-launcher' +import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs' +import {ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' +import {loadEnvironment} from '@shopify/cli-kit/node/environments' +import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events' +import {AbortError} from '@shopify/cli-kit/node/error' +import {Config} from '@oclif/core' +import {afterEach, expect, test, vi} from 'vitest' + +vi.mock('../../services/dev.js') +vi.mock('../../services/metafields-pull.js') +vi.mock('../../utilities/theme-selector.js') +vi.mock('../../utilities/theme-ui.js') +vi.mock('../../utilities/theme-store.js', () => ({ensureThemeStore: ({store}: {store: string}) => store})) +vi.mock('@shopify/cli-kit/node/session') +vi.mock('@shopify/cli-kit/node/environments') + +afterEach(() => vi.unstubAllEnvs()) + +const session = {storeFqdn: 'fixture.myshopify.com', token: 'fixture'} +const theme = {id: 123, name: 'Fixture', role: 'development', processing: false, createdAtRuntime: false} + +test('declares the shared stream schema and JSON flags without changing the Markdown description', () => { + expect(Dev.jsonOutputSchema).toBe(devInstanceLogOutputSchema) + expect(Dev.flags.json.char).toBe('j') + expect(Dev.flags.json.env).toBe('SHOPIFY_FLAG_JSON') + expect(Dev.baseFlags).toHaveProperty('json-schema') + expect(Dev.description).toContain('DevInstanceLogEntry') + expect(Dev.descriptionWithMarkdown).not.toContain('DevInstanceLogEntry') +}) + +test('prints the stream schema without authentication or starting dev', async () => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await launchCLI({ + moduleURL: new URL('../../../../../cli/package.json', import.meta.url).href, + argv: ['theme', 'dev', '--json-schema'], + lazyCommandLoader: async (id) => (id === 'theme:dev' ? Dev : undefined), + }) + const {definitions} = JSON.parse(stdout()) + expect(definitions.Result.required).toEqual(['timestamp', 'message']) + expect(definitions.Result.properties).not.toHaveProperty('type') + expect(definitions.Error).toBeDefined() + expect(stderr()).toBe('') + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + expect(dev).not.toHaveBeenCalled() + }) +}) + +test.each(['--json', '-j', 'env', 'named environment', 'configured environment', 'no input'])( + 'passes JSON mode to the service for %s without collecting a finite result', + async (source) => { + await inTemporaryDirectory(async (directory) => { + vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(session) + vi.mocked(findOrSelectTheme).mockResolvedValue(theme) + vi.mocked(ensureLiveThemeConfirmed).mockResolvedValue(true) + vi.mocked(loadEnvironment).mockResolvedValue({ + store: session.storeFqdn, + password: 'fixture', + theme: '123', + ...(source === 'configured environment' ? {json: true} : {}), + }) + const config = new Config({root: __dirname}) + await config.load() + const argv = ['--path', directory, '--store', session.storeFqdn, '--password', 'fixture', '--theme', '123'] + if (source === 'env') vi.stubEnv('SHOPIFY_FLAG_JSON', '1') + else if (source !== 'configured environment') argv.push(source.startsWith('-') ? source : '--json') + if (source.endsWith('environment')) argv.push('--environment', 'local') + if (source === 'no input') argv.push('--no-input') + + await withCapturedStandardStreams(async ({stdout}) => { + await runWithCommandEventsForCommand(argv, () => new Dev(argv, config).run()) + expect(dev).toHaveBeenCalledOnce() + expect(dev).toHaveBeenCalledWith(expect.objectContaining({json: true, directory, theme})) + expect(stdout()).toBe('') + }) + }) + }, +) + +test('renders fatal errors through the CLI JSON handler', async () => { + await inTemporaryDirectory(async (directory) => { + vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(session) + vi.mocked(findOrSelectTheme).mockResolvedValue(theme) + vi.mocked(ensureLiveThemeConfirmed).mockResolvedValue(true) + vi.mocked(dev).mockRejectedValue(new AbortError('Fixture startup failure')) + await withCapturedStandardStreams(async ({stdout}) => { + await expect( + Dev.run( + ['--path', directory, '--store', session.storeFqdn, '--password', 'fixture', '--theme', '123', '--json'], + import.meta.url, + ), + ).rejects.toThrow() + expect(JSON.parse(stdout())).toEqual({error: {type: 'abort', message: 'Fixture startup failure'}}) + }) + }) +}) diff --git a/packages/theme/src/cli/commands/theme/dev.ts b/packages/theme/src/cli/commands/theme/dev.ts index 7bbd2231120..1a34cb9c4ef 100644 --- a/packages/theme/src/cli/commands/theme/dev.ts +++ b/packages/theme/src/cli/commands/theme/dev.ts @@ -6,7 +6,8 @@ import {findOrSelectTheme} from '../../utilities/theme-selector.js' import {metafieldsPull} from '../../services/metafields-pull.js' import {ensureLiveThemeConfirmed} from '../../utilities/theme-ui.js' import {Flags} from '@oclif/core' -import {globalFlags, portFlag} from '@shopify/cli-kit/node/cli' +import {globalFlags, jsonFlag, portFlag} from '@shopify/cli-kit/node/cli' +import {devInstanceLogOutputSchema} from '@shopify/cli-kit/node/dev-instance' import {Theme} from '@shopify/cli-kit/node/themes/types' import {recordEvent} from '@shopify/cli-kit/node/analytics' import {AdminSession} from '@shopify/cli-kit/node/session' @@ -45,11 +46,14 @@ Development themes are deleted when you run \`shopify auth logout\`. If you need 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).` + static jsonOutputSchema = devInstanceLogOutputSchema + static description = this.descriptionForHelp() static flags = { ...globalFlags, ...themeFlags, + ...jsonFlag, host: Flags.string({ description: 'Set which network interface the web server listens on. The default value is 127.0.0.1.', env: 'SHOPIFY_FLAG_HOST', @@ -202,6 +206,7 @@ You can run this command only in a directory that matches the [default Shopify t ignore, only, notify: flags.notify, + json: flags.json, }) await metafieldsPull({ @@ -214,4 +219,9 @@ You can run this command only in a directory that matches the [default Shopify t silent: true, }) } + + protected collectsEnvironmentResults(): boolean { + // A dev session streams records instead of returning an environment batch. + return false + } } diff --git a/packages/theme/src/cli/services/dev.integration.test.ts b/packages/theme/src/cli/services/dev.integration.test.ts index 1f5dbec683b..17fede273de 100644 --- a/packages/theme/src/cli/services/dev.integration.test.ts +++ b/packages/theme/src/cli/services/dev.integration.test.ts @@ -6,61 +6,136 @@ import {fileURLToPath} from 'node:url' const bootstrapUrl = new URL('../../../../cli/src/bootstrap.ts', import.meta.url).href const serviceUrl = new URL('./dev.ts', import.meta.url).href +const cliPackageUrl = new URL('../../../../cli/package.json', import.meta.url).href const loaderUrl = new URL('../../../test/fixtures/dev-instance-loader.js', import.meta.url).href const tsconfigPath = fileURLToPath(new URL('../../../../app/test/fixtures/tsconfig.json', import.meta.url)) -test.skipIf(process.platform === 'win32').each([ - {signal: 'SIGINT', stage: 'running'}, - {signal: 'SIGTERM', stage: 'running'}, - {signal: 'SIGINT', stage: 'crawler'}, - {signal: 'SIGTERM', stage: 'session'}, -])( - '$signal during $stage exits on the first signal and releases ownership', - {timeout: 60000}, - async ({signal, stage}) => { - await inTemporaryDirectory(async (directory) => { - const script = ` +test.skipIf(process.platform === 'win32').each( + [ + {signal: 'SIGINT', stage: 'running'}, + {signal: 'SIGTERM', stage: 'running'}, + {signal: 'SIGINT', stage: 'crawler'}, + {signal: 'SIGTERM', stage: 'session'}, + ].flatMap((scenario) => [false, true].map((json) => ({...scenario, json}))), +)('$signal during $stage exits and releases ownership, json=$json', {timeout: 60000}, async ({signal, stage, json}) => { + await inTemporaryDirectory(async (directory) => { + const script = ` await import(${JSON.stringify(bootstrapUrl)}) const {dev} = await import(${JSON.stringify(serviceUrl)}) await dev({ directory: ${JSON.stringify(directory)}, store: 'fixture.myshopify.com', adminSession: {storeFqdn: 'fixture.myshopify.com', token: 'fixture'}, commandConfig: {}, theme: {id: 123, name: 'Fixture', role: 'development'}, - open: false, force: false, noDelete: false, ignore: [], only: [], + json: ${json}, open: false, force: false, noDelete: false, ignore: [], only: [], 'standard-events-inspector': false, 'theme-editor-sync': false, 'live-reload': 'hot-reload', 'error-overlay': 'default', }) ` - const result = await captureOutputWithExitCode( - process.execPath, - ['--loader', 'ts-node/esm', '--loader', loaderUrl, '--input-type=module', '--eval', script], - { - env: { - ...process.env, - NODE_NO_WARNINGS: '1', - SHOPIFY_UNIT_TEST: '0', - SHOPIFY_CLI_ENV: 'development', - SHOPIFY_CLI_NO_ANALYTICS: '1', - CI: '1', - TS_NODE_PROJECT: tsconfigPath, - THEME_DEV_TEST_SIGNAL: signal, - THEME_DEV_TEST_STAGE: stage, - }, + const result = await captureOutputWithExitCode( + process.execPath, + ['--loader', 'ts-node/esm', '--loader', loaderUrl, '--input-type=module', '--eval', script], + { + stripFinalNewline: false, + env: { + ...process.env, + NODE_NO_WARNINGS: '1', + SHOPIFY_UNIT_TEST: '0', + SHOPIFY_CLI_ENV: 'development', + SHOPIFY_CLI_NO_ANALYTICS: '1', + CI: '1', + TS_NODE_PROJECT: tsconfigPath, + THEME_DEV_TEST_SIGNAL: signal, + THEME_DEV_TEST_STAGE: stage, }, - ) - expect(result.exitCode, result.stderr).toBe(0) - const paths = devInstancePaths({command: 'theme dev', directory, key: 'fixture.myshopify.com:123'}) - const messages = (await readFile(paths.logFile)) - .trim() - .split('\n') - .filter(Boolean) - .map((line) => JSON.parse(line).message) - expect(messages.filter((message: string) => message.startsWith('line '))).toEqual( - stage === 'running' ? Array.from({length: 1000}, (_, index) => `line ${index}`) : [], - ) - if (stage === 'running') expect(messages.at(-1)).toBe('Stopped') - await expect(fileExists(paths.infoFile)).resolves.toBe(false) - await expect(fileExists(paths.lockFile)).resolves.toBe(false) + }, + ) + expect(result.exitCode, result.stderr).toBe(0) + const paths = devInstancePaths({command: 'theme dev', directory, key: 'fixture.myshopify.com:123'}) + const log = await readFile(paths.logFile) + expect(result.stdout).toBe(json ? log : '') + if (json) expect(result.stderr).toBe('') + const messages = log + .trim() + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line).message) + expect(messages.filter((message: string) => message.startsWith('line '))).toEqual( + stage === 'running' ? Array.from({length: 1000}, (_, index) => `line ${index}`) : [], + ) + if (stage === 'running') expect(messages.at(-1)).toBe('Stopped') + await expect(fileExists(paths.infoFile)).resolves.toBe(false) + await expect(fileExists(paths.lockFile)).resolves.toBe(false) + }) +}) + +test('drains logs and reports fatal preview failures through the CLI JSON handler', {timeout: 60000}, async () => { + await inTemporaryDirectory(async (directory) => { + const script = ` + await import(${JSON.stringify(bootstrapUrl)}) + const {dev} = await import(${JSON.stringify(serviceUrl)}) + const {default: BaseCommand} = await import('@shopify/cli-kit/node/base-command') + const {jsonFlag} = await import('@shopify/cli-kit/node/cli') + const {readDevInstance} = await import('@shopify/cli-kit/node/dev-instance') + const {setTimeout: delay} = await import('node:timers/promises') + setTimeout(() => process.exit(2), 20000) + class PreviewCommand extends BaseCommand { + static flags = jsonFlag + async run() { + await this.parse(PreviewCommand) + const running = dev({ + directory: ${JSON.stringify(directory)}, store: 'fixture.myshopify.com', + adminSession: {storeFqdn: 'fixture.myshopify.com', token: 'fixture'}, commandConfig: {}, + theme: {id: 123, name: 'Fixture', role: 'development'}, + json: true, open: false, force: false, noDelete: false, ignore: [], only: [], + 'standard-events-inspector': false, 'theme-editor-sync': false, + 'live-reload': 'off', 'error-overlay': 'default', + }) + const requestPreview = async () => { + const identity = { + command: 'theme dev', directory: ${JSON.stringify(directory)}, key: 'fixture.myshopify.com:123', + } + let info + while ((info = await readDevInstance(identity))?.status !== 'running') await delay(10) + for (let index = 0; index < 6; index++) { + await fetch(info.details.urls.local, {redirect: 'manual'}) + } + } + requestPreview().catch(() => {}) + await running + } + } + await PreviewCommand.run(['--json'], ${JSON.stringify(cliPackageUrl)}).catch(() => process.exit(1)) + ` + const result = await captureOutputWithExitCode( + process.execPath, + ['--loader', 'ts-node/esm', '--loader', loaderUrl, '--input-type=module', '--eval', script], + { + stripFinalNewline: false, + env: { + ...process.env, + NODE_NO_WARNINGS: '1', + SHOPIFY_UNIT_TEST: '0', + SHOPIFY_CLI_ENV: 'development', + SHOPIFY_CLI_NO_ANALYTICS: '1', + CI: '1', + TS_NODE_PROJECT: tsconfigPath, + THEME_DEV_TEST_STAGE: 'preview', + }, + }, + ) + expect(result.exitCode, result.stderr).toBe(1) + const lines = result.stdout.trimEnd().split('\n') + expect(JSON.parse(lines.pop()!)).toMatchObject({ + error: {type: 'abort', message: expect.stringContaining('Theme ID mismatch: expected 123 but got 456.')}, }) - }, -) + const paths = devInstancePaths({command: 'theme dev', directory, key: 'fixture.myshopify.com:123'}) + const log = await readFile(paths.logFile) + expect(`${lines.join('\n')}\n`).toBe(log) + const messages = lines.map((line) => JSON.parse(line).message) + expect(messages.filter((message: string) => message.startsWith('line '))).toEqual( + Array.from({length: 1000}, (_, index) => `line ${index}`), + ) + await expect(fileExists(paths.infoFile)).resolves.toBe(false) + await expect(fileExists(paths.lockFile)).resolves.toBe(false) + }) +}) diff --git a/packages/theme/src/cli/services/dev.test.ts b/packages/theme/src/cli/services/dev.test.ts index ac859069c85..800ebdeff99 100644 --- a/packages/theme/src/cli/services/dev.test.ts +++ b/packages/theme/src/cli/services/dev.test.ts @@ -422,7 +422,12 @@ describe('dev() Ctrl-C analytics', () => { ) }) - test.each([false, true])('restores stdin after stopping with input available: %s', async (acceptsInput) => { + test.each([ + {acceptsInput: false, json: false}, + {acceptsInput: true, json: false}, + {acceptsInput: true, json: true}, + ])('restores stdin with input=$acceptsInput and json=$json', async ({acceptsInput, json}) => { + const usesInput = acceptsInput && !json vi.mocked(terminalSupportsPrompting).mockReturnValue(acceptsInput) const listenerCount = process.stdin.listenerCount('keypress') const emitKeypressEventsSpy = vi.spyOn(readline, 'emitKeypressEvents') @@ -432,11 +437,11 @@ describe('dev() Ctrl-C analytics', () => { Object.defineProperty(process.stdin, 'setRawMode', {configurable: true, value: setRawMode}) try { - const devPromise = dev(baseOptions) + const devPromise = dev({...baseOptions, json}) await vi.waitFor(() => expect(setupDevServer).toHaveBeenCalled()) - if (acceptsInput) { + if (usesInput) { await vi.waitFor(() => expect(setRawMode).toHaveBeenCalledWith(true)) process.stdin.emit('keypress', '\u0003', {ctrl: true, name: 'c'}) } else { @@ -444,7 +449,7 @@ describe('dev() Ctrl-C analytics', () => { } await devPromise - if (acceptsInput) { + if (usesInput) { expect(emitKeypressEventsSpy).toHaveBeenCalled() expect(setRawMode.mock.calls).toEqual([[true], [false]]) } else { diff --git a/packages/theme/src/cli/services/dev.ts b/packages/theme/src/cli/services/dev.ts index 959864ccc18..ed2731b67de 100644 --- a/packages/theme/src/cli/services/dev.ts +++ b/packages/theme/src/cli/services/dev.ts @@ -57,6 +57,7 @@ interface DevOptions { only: string[] notify?: string listing?: string + json?: boolean } export async function dev(options: DevOptions) { @@ -95,6 +96,7 @@ export async function dev(options: DevOptions) { command: 'theme dev', directory: options.directory, key: `${options.store}:${options.theme.id}`, + logOutput: options.json ? process.stdout : undefined, details: { store: options.store, themeId: options.theme.id, @@ -111,10 +113,13 @@ export async function dev(options: DevOptions) { process.on('SIGTERM', stop) instance.abortSignal.addEventListener('abort', onInstanceError, {once: true}) try { - await withThemeDevOutput(instance, () => - runDev(options, instance, controller).catch((error: unknown) => { - if (!controller.signal.aborted || error !== controller.signal.reason) throw error - }), + await withThemeDevOutput( + instance, + () => + runDev(options, instance, controller).catch((error: unknown) => { + if (!controller.signal.aborted || error !== controller.signal.reason) throw error + }), + options.json, ) instance.abortSignal.throwIfAborted() } finally { @@ -222,7 +227,7 @@ async function runDev(options: DevOptions, instance: DevInstance, controller: Ab options.theme, ctx, ) - const acceptsInput = terminalSupportsPrompting() + const acceptsInput = !options.json && terminalSupportsPrompting() const keypressHandler = createKeypressHandler(urls, ctx, () => controller.abort()) const {promise: stopped, resolve: resolveStopped} = Promise.withResolvers() const onAbort = () => resolveStopped() diff --git a/packages/theme/src/cli/services/dev/output.test.ts b/packages/theme/src/cli/services/dev/output.test.ts index 4896c8eb0f6..62da528754b 100644 --- a/packages/theme/src/cli/services/dev/output.test.ts +++ b/packages/theme/src/cli/services/dev/output.test.ts @@ -1,15 +1,19 @@ import {withThemeDevOutput} from './output.js' import {logRequestLine} from '../../utilities/log-request-line.js' import {renderThrownError} from '../../utilities/errors.js' +import {handleSyncUpdate, mountThemeFileSystem} from '../../utilities/theme-fs.js' +import {pollRemoteJsonChanges} from '../../utilities/theme-environment/theme-polling.js' import {inTemporaryDirectory, readFile} from '@shopify/cli-kit/node/fs' import {withDevInstance} from '@shopify/cli-kit/node/dev-instance' -import {outputInfo} from '@shopify/cli-kit/node/output' +import {outputInfo, unstyled} from '@shopify/cli-kit/node/output' import {renderTasks, renderWarning} from '@shopify/cli-kit/node/ui' import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {bulkUploadThemeAssets, fetchChecksums, fetchThemeAssets} from '@shopify/cli-kit/node/themes/api' +import {buildTheme} from '@shopify/cli-kit/node/themes/factories' +import {Operation} from '@shopify/cli-kit/node/themes/types' import {afterEach, expect, test, vi} from 'vitest' import {createEvent} from 'h3' -import {IncomingMessage, ServerResponse, createServer} from 'node:http' -import {Socket} from 'node:net' +import {createServer} from 'node:http' import {type AddressInfo} from 'node:net' // Vitest omits the console constructor that Ink uses to patch console methods. // eslint-disable-next-line n/prefer-global/console @@ -18,66 +22,130 @@ import {setImmediate} from 'node:timers/promises' afterEach(() => vi.unstubAllGlobals()) -test('records theme messages, task progress, sync errors, and late HTTP request output', async () => { +vi.mock('@shopify/cli-kit/node/themes/api') + +test.each([false, true])('records theme messages, progress, errors, and late HTTP output, json=%s', async (json) => { vi.stubGlobal('console', {...console, Console}) await inTemporaryDirectory(async (directory) => { - await withCapturedStandardStreams(async ({stderr}) => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { let logFile = '' - await withDevInstance({command: 'theme dev', directory}, async (instance) => { - logFile = instance.paths.logFile - await withThemeDevOutput(instance, async () => { - await renderTasks( - [ - { - title: 'Uploading theme files', - task: async () => { - await setImmediate() - outputInfo('Synced » update assets/base.css') - renderWarning({body: 'A dev warning'}) - renderThrownError('Failed to upload assets/base.css.', new Error('Invalid CSS')) - }, - }, - ], - {renderOptions: {patchConsole: true}}, - ) + const requestPath = `/products/test?variant=${'1'.repeat(100)}` + await withDevInstance( + {command: 'theme dev', directory, logOutput: json ? process.stdout : undefined}, + async (instance) => { + logFile = instance.paths.logFile + await withThemeDevOutput( + instance, + async () => { + await renderTasks( + [ + { + title: 'Uploading theme files', + task: async () => { + await setImmediate() + const session = {storeFqdn: 'fixture.myshopify.com', token: 'fixture'} + vi.mocked(bulkUploadThemeAssets).mockResolvedValue([ + {key: 'assets/base.css', operation: Operation.Upload, success: true}, + ]) + await handleSyncUpdate( + new Set(['assets/base.css']), + new Map(), + 'assets/base.css', + '123', + session, + )({ + value: 'body {}', + }) + const fileSystem = mountThemeFileSystem(directory) + await fileSystem.ready() + await fileSystem.write({key: 'templates/removed.json', checksum: 'old', value: '{}'}) + await fileSystem.write({key: 'templates/remote.json', checksum: 'old', value: '{}'}) + await fileSystem.read('templates/remote.json') + vi.mocked(fetchChecksums).mockResolvedValue([{key: 'templates/remote.json', checksum: 'new'}]) + vi.mocked(fetchThemeAssets).mockResolvedValue([ + {key: 'templates/remote.json', checksum: 'new', value: '{}'}, + ]) + await pollRemoteJsonChanges( + buildTheme({id: 123, name: 'Fixture', role: 'development'})!, + session, + [...fileSystem.files.values()], + fileSystem, + {noDelete: false}, + ) + renderWarning({body: 'A dev warning'}) + renderThrownError('Failed to upload assets/base.css.', new Error('Invalid CSS')) + }, + }, + ], + {renderOptions: {patchConsole: true}}, + ) - // HTTP callbacks must retain the session context after setup has returned. - const server = createServer((_request, response) => { - const request = new IncomingMessage(new Socket()) - request.url = '/products/test' - request.method = 'GET' - logRequestLine(createEvent(request, new ServerResponse(request)), {status: 200, headers: new Headers()}, { - type: 'theme', - } as Parameters[2]) - response.end('OK') - }) - try { - await new Promise((resolve, reject) => { - server.once('error', reject) - server.listen(0, '127.0.0.1', resolve) - }) - const {port} = server.address() as AddressInfo - await expect((await fetch(`http://127.0.0.1:${port}`)).text()).resolves.toBe('OK') - } finally { - server.closeAllConnections() - await new Promise((resolve) => server.close(() => resolve())) - } - }) - outputInfo('Outside the dev session') - }) + // HTTP callbacks must retain the session context after setup has returned. + const server = createServer((request, response) => { + logRequestLine( + createEvent(request, response), + { + status: 200, + headers: new Headers( + request.url === '/cart' ? undefined : {'server-timing': 'cfRequestDuration;dur=12.5'}, + ), + }, + { + type: 'theme', + } as Parameters[2], + ) + response.end('OK') + }) + try { + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const {port} = server.address() as AddressInfo + await expect((await fetch(`http://127.0.0.1:${port}${requestPath}`)).text()).resolves.toBe('OK') + await expect((await fetch(`http://127.0.0.1:${port}/cart`)).text()).resolves.toBe('OK') + } finally { + server.closeAllConnections() + await new Promise((resolve) => server.close(() => resolve())) + } + }, + json, + ) + outputInfo('Outside the dev session') + }, + ) const log = await readFile(logFile) - const messages = log + const messageLines = log .trim() .split('\n') .map((line) => JSON.parse(line).message) - .join('\n') - for (const text of ['Synced » update assets/base.css', 'A dev warning', 'Invalid CSS', '/products/test']) { + const messages = messageLines.join('\n') + for (const text of ['update assets/base.css', 'A dev warning', 'Invalid CSS', '/products/test']) { expect(messages).toContain(text) - expect(stderr()).toContain(text) + if (!json) expect(stderr()).toContain(text) + } + if (json) { + expect(messageLines).toEqual( + expect.arrayContaining([ + 'Synced update assets/base.css', + 'Synced download templates/remote.json from remote theme', + 'Synced remove templates/removed.json from local theme', + `Request GET 200 ${requestPath} 13ms`, + 'Request GET 200 /cart', + ]), + ) + } else { + const terminalOutput = unstyled(stderr()) + expect(terminalOutput).toContain(`Request » GET 200 ${requestPath.substring(0, 80)}... 13ms`) + expect(terminalOutput).toContain('Synced » update assets/base.css') + expect(terminalOutput).toContain('Synced » download templates/remote.json from remote theme') + expect(terminalOutput).toContain('Synced » remove templates/removed.json from local theme') } expect(messages).toContain('Uploading theme files') expect(messages).not.toContain('Outside the dev session') + expect(stdout()).toBe(json ? log : '') + if (json) expect(stderr()).toBe('Outside the dev session\n') expect(log).not.toContain('\u001b') expect(log).not.toContain('sessionId') expect(log).not.toContain('"source"') @@ -85,26 +153,36 @@ test('records theme messages, task progress, sync errors, and late HTTP request }) }) -test('preserves session output and restores terminal output after failure', async () => { +test.each([false, true])('preserves output and restores terminal after failure, json=%s', async (json) => { await inTemporaryDirectory(async (directory) => { - await withCapturedStandardStreams(async ({stderr}) => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { // eslint-disable-next-line no-console const originalWarn = console.warn let logFile = '' await expect( - withDevInstance({command: 'theme dev', directory}, async (instance) => { - logFile = instance.paths.logFile - await withThemeDevOutput(instance, async () => { - outputInfo('Before failure') - throw new Error('Setup failed') - }) - }), + withDevInstance( + {command: 'theme dev', directory, logOutput: json ? process.stdout : undefined}, + async (instance) => { + logFile = instance.paths.logFile + await withThemeDevOutput( + instance, + async () => { + outputInfo('Before failure') + throw new Error('Setup failed') + }, + json, + ) + }, + ), ).rejects.toThrow('Setup failed') // eslint-disable-next-line no-console expect(console.warn).toBe(originalWarn) outputInfo('After failure') - expect(JSON.parse(await readFile(logFile))).toEqual({timestamp: expect.any(String), message: 'Before failure'}) - expect(stderr()).toContain('Before failure') + const log = await readFile(logFile) + expect(JSON.parse(log)).toEqual({timestamp: expect.any(String), message: 'Before failure'}) + expect(stdout()).toBe(json ? log : '') + if (json) expect(stderr()).toBe('After failure\n') + else expect(stderr()).toContain('Before failure') expect(stderr()).toContain('After failure') }) }) diff --git a/packages/theme/src/cli/services/dev/output.ts b/packages/theme/src/cli/services/dev/output.ts index 7bda6312bd5..fec16a68da1 100644 --- a/packages/theme/src/cli/services/dev/output.ts +++ b/packages/theme/src/cli/services/dev/output.ts @@ -8,7 +8,7 @@ import {format} from 'node:util' const themeDevOutput = new AsyncLocalStorage() -export async function withThemeDevOutput(instance: DevInstance, run: () => Promise): Promise { +export async function withThemeDevOutput(instance: DevInstance, run: () => Promise, json = false): Promise { const output = instance.tee(new Writable({write: (_chunk, _encoding, next) => next()}), { stringWritesAreMessages: true, }) @@ -22,10 +22,10 @@ export async function withThemeDevOutput(instance: DevInstance, run: () => Pr useConcurrentOutputContext({consoleWarn: writeMessage}, () => runWithCommandEvents( { - outputMode: 'text', + outputMode: json ? 'json' : 'text', sink: (event, options) => { - if (active && event.type === 'progress' && event.message) output.write(event.message) - if (!options?.alreadyRendered) renderCommandEvent(event) + if (active && event.message && (json || event.type === 'progress')) output.write(event.message) + if (!json && !options?.alreadyRendered) renderCommandEvent(event) }, }, run, @@ -51,6 +51,7 @@ export async function withThemeDevOutput(instance: DevInstance, run: () => Pr function writeMessage(message: string) { if (active) output.write(message) + if (active && json) return // Ink can replace the console method while a progress view is active. const warn = console.warn === captureWarn ? originalWarn : console.warn warn(message) diff --git a/packages/theme/src/cli/utilities/log-request-line.ts b/packages/theme/src/cli/utilities/log-request-line.ts index 3373c2cd6c8..69760554d3c 100644 --- a/packages/theme/src/cli/utilities/log-request-line.ts +++ b/packages/theme/src/cli/utilities/log-request-line.ts @@ -1,6 +1,7 @@ import {EXTENSION_CDN_PREFIX, VANITY_CDN_PREFIX} from './theme-environment/proxy.js' import {timestampDateFormat} from '../constants.js' import {outputContent, outputInfo, outputToken} from '@shopify/cli-kit/node/output' +import {commandEventOutputMode} from '@shopify/cli-kit/node/command-events' import {H3Event} from 'h3' import {extname} from '@shopify/cli-kit/node/path' @@ -17,14 +18,19 @@ export function logRequestLine(event: H3Event, response: MinimalResponse, ctx: D if (!shouldLog(event)) return if (ctx.type === 'theme-extension') return - const truncatedPath = - event.path.length > CHARACTER_TRUNCATION_LIMIT - ? `${event.path.substring(0, CHARACTER_TRUNCATION_LIMIT)}...` - : event.path const serverTiming = response.headers.get('server-timing') const requestDuration = serverTiming?.match(/cfRequestDuration;dur=([\d.]+)/)?.[1] const durationString = requestDuration ? `${Math.round(Number(requestDuration))}ms` : '' + if (commandEventOutputMode() === 'json') { + outputInfo(`Request ${event.method} ${response.status} ${event.path}${durationString ? ` ${durationString}` : ''}`) + return + } + + const truncatedPath = + event.path.length > CHARACTER_TRUNCATION_LIMIT + ? `${event.path.substring(0, CHARACTER_TRUNCATION_LIMIT)}...` + : event.path const statusColor = getColorizeStatus(response.status) const eventMethodAligned = event.method.padStart(6) diff --git a/packages/theme/src/cli/utilities/theme-environment/html.ts b/packages/theme/src/cli/utilities/theme-environment/html.ts index 8420869055d..d2b3cf344af 100644 --- a/packages/theme/src/cli/utilities/theme-environment/html.ts +++ b/packages/theme/src/cli/utilities/theme-environment/html.ts @@ -21,7 +21,11 @@ let themeIdMismatchRedirects = 0 /** The maximum number of consecutive theme ID mismatch redirects before aborting */ const MAX_THEME_ID_MISMATCH_REDIRECTS = 5 -export function getHtmlHandler(theme: Theme, ctx: DevServerContext): EventHandler { +export function getHtmlHandler( + theme: Theme, + ctx: DevServerContext, + onFatalError?: (error: AbortError) => void, +): EventHandler { return defineEventHandler((event) => { const [browserPathname = '/', browserSearch = ''] = event.path.split('?') @@ -94,7 +98,12 @@ export function getHtmlHandler(theme: Theme, ctx: DevServerContext): EventHandle if (ctx.session.refresh) { themeIdMismatchRedirects++ if (themeIdMismatchRedirects > MAX_THEME_ID_MISMATCH_REDIRECTS) { - renderFatalError(new AbortError(error.getMessage())) + const fatalError = new AbortError(error.getMessage()) + if (onFatalError) { + onFatalError(fatalError) + return new Response(fatalError.message, {status: 502}) + } + renderFatalError(fatalError) process.exit(1) } diff --git a/packages/theme/src/cli/utilities/theme-environment/theme-environment.ts b/packages/theme/src/cli/utilities/theme-environment/theme-environment.ts index e1b7e9056e1..1b87d80569b 100644 --- a/packages/theme/src/cli/utilities/theme-environment/theme-environment.ts +++ b/packages/theme/src/cli/utilities/theme-environment/theme-environment.ts @@ -29,7 +29,7 @@ export function setupDevServer(theme: Theme, ctx: DevServerContext) { const workPromise = Promise.all([watcherPromise, envSetup.workPromise]).then(() => ctx.localThemeFileSystem.startWatcher(theme.id.toString(), ctx.session), ) - const server = createDevelopmentServer(theme, ctx, workPromise) + const server = createDevelopmentServer(theme, ctx, workPromise, rejectBackgroundJob) return { workPromise, @@ -142,7 +142,12 @@ interface DevelopmentServerInstance { close: () => Promise } -function createDevelopmentServer(theme: Theme, ctx: DevServerContext, initialWork: Promise) { +function createDevelopmentServer( + theme: Theme, + ctx: DevServerContext, + initialWork: Promise, + rejectBackgroundJob: (reason?: unknown) => void, +) { const app = createApp() const allowedOrigins = [ `http://${ctx.options.host}:${ctx.options.port}`, @@ -179,7 +184,7 @@ function createDevelopmentServer(theme: Theme, ctx: DevServerContext, initialWor app.use(getAssetsHandler(theme, ctx)) app.use(getProxyHandler(theme, ctx)) - app.use(getHtmlHandler(theme, ctx)) + app.use(getHtmlHandler(theme, ctx, rejectBackgroundJob)) const server = createServer(toNodeListener(app)) diff --git a/packages/theme/src/cli/utilities/theme-environment/theme-polling.ts b/packages/theme/src/cli/utilities/theme-environment/theme-polling.ts index ce5bad7cf96..d5e2a8ca3ce 100644 --- a/packages/theme/src/cli/utilities/theme-environment/theme-polling.ts +++ b/packages/theme/src/cli/utilities/theme-environment/theme-polling.ts @@ -4,6 +4,7 @@ import {MAX_GRAPHQL_THEME_FILES, timestampDateFormat} from '../../constants.js' import {Checksum, Theme, ThemeFileSystem} from '@shopify/cli-kit/node/themes/types' import {fetchChecksums, fetchThemeAssets} from '@shopify/cli-kit/node/themes/api' import {outputDebug, outputInfo, outputContent, outputToken} from '@shopify/cli-kit/node/output' +import {commandEventOutputMode} from '@shopify/cli-kit/node/command-events' import {AdminSession} from '@shopify/cli-kit/node/session' import {renderFatalError} from '@shopify/cli-kit/node/ui' import {AbortError} from '@shopify/cli-kit/node/error' @@ -160,9 +161,11 @@ async function syncChangedAssets( if (asset) { await localFileSystem.write(asset) outputInfo( - outputContent`• ${timestampDateFormat.format(new Date())} Synced ${outputToken.raw( - '»', - )} ${outputToken.gray(`download ${asset.key} from remote theme`)}`, + commandEventOutputMode() === 'json' + ? `Synced download ${asset.key} from remote theme` + : outputContent`• ${timestampDateFormat.format(new Date())} Synced ${outputToken.raw( + '»', + )} ${outputToken.gray(`download ${asset.key} from remote theme`)}`, ) } }), @@ -185,9 +188,11 @@ export async function deleteRemovedAssets( if (localFileSystem.files.get(file.key)) { return localFileSystem.delete(file.key).then(() => { outputInfo( - outputContent`• ${timestampDateFormat.format(new Date())} Synced ${outputToken.raw( - '»', - )} ${outputToken.gray(`remove ${file.key} from local theme`)}`, + commandEventOutputMode() === 'json' + ? `Synced remove ${file.key} from local theme` + : outputContent`• ${timestampDateFormat.format(new Date())} Synced ${outputToken.raw( + '»', + )} ${outputToken.gray(`remove ${file.key} from local theme`)}`, ) }) } diff --git a/packages/theme/src/cli/utilities/theme-fs.ts b/packages/theme/src/cli/utilities/theme-fs.ts index 381e97ecf40..f2301b2028c 100644 --- a/packages/theme/src/cli/utilities/theme-fs.ts +++ b/packages/theme/src/cli/utilities/theme-fs.ts @@ -10,6 +10,7 @@ import {glob, readFile, ReadOptions, fileExists, mkdir, writeFile, removeFile} f import {joinPath, basename, relativePath} from '@shopify/cli-kit/node/path' import {lookupMimeType, setMimeTypes} from '@shopify/cli-kit/node/mimes' import {outputContent, outputDebug, outputInfo, outputToken, outputWarn} from '@shopify/cli-kit/node/output' +import {commandEventOutputMode} from '@shopify/cli-kit/node/command-events' import {buildThemeAsset} from '@shopify/cli-kit/node/themes/factories' import {recordError} from '@shopify/cli-kit/node/analytics' import {AdminSession} from '@shopify/cli-kit/node/session' @@ -601,7 +602,9 @@ function dirPath(filePath: string) { function outputSyncResult(action: 'update' | 'delete', fileKey: string): void { outputInfo( - outputContent`• ${timestampDateFormat.format(new Date())} Synced ${outputToken.raw('»')} ${action} ${fileKey}`, + commandEventOutputMode() === 'json' + ? `Synced ${action} ${fileKey}` + : outputContent`• ${timestampDateFormat.format(new Date())} Synced ${outputToken.raw('»')} ${action} ${fileKey}`, ) } diff --git a/packages/theme/test/fixtures/dev-instance-dependencies.js b/packages/theme/test/fixtures/dev-instance-dependencies.js index 96337d6ebbc..20ccee1f581 100644 --- a/packages/theme/test/fixtures/dev-instance-dependencies.js +++ b/packages/theme/test/fixtures/dev-instance-dependencies.js @@ -5,13 +5,19 @@ import {outputInfo} from '@shopify/cli-kit/node/output' export const hasRequiredThemeDirectories = async () => true export const ensureDirectoryConfirmed = async () => true -export const mountThemeFileSystem = () => ({}) -export const emptyThemeExtFileSystem = () => ({}) +export {mountThemeFileSystem} from '../../src/cli/utilities/theme-fs.js' +export {emptyThemeExtFileSystem} from '../../src/cli/utilities/theme-fs-empty.js' export const isStorefrontPasswordProtected = async () => false export const ensureValidPassword = async () => undefined export const initializeDevServerSession = async () => { await pauseStartup('session') - return {storeFqdn: 'fixture.myshopify.com', token: 'fixture'} + return { + storeFqdn: 'fixture.myshopify.com', + token: 'fixture', + storefrontToken: '', + sessionCookies: {}, + refresh: async () => {}, + } } export const fetchOrCreateCrawlerSignatureHeaders = async () => pauseStartup('crawler') export const ensureListingExists = async () => {} diff --git a/packages/theme/test/fixtures/dev-instance-loader.js b/packages/theme/test/fixtures/dev-instance-loader.js index 172832a9374..7a420fe8171 100644 --- a/packages/theme/test/fixtures/dev-instance-loader.js +++ b/packages/theme/test/fixtures/dev-instance-loader.js @@ -1,6 +1,8 @@ import {resolve as resolveCliKitSource} from '../../../cli-kit/test/fixtures/cli-kit-source-loader.js' const serviceUrl = new URL('../../src/cli/services/dev.ts', import.meta.url).href +const environmentUrl = new URL('../../src/cli/utilities/theme-environment/theme-environment.ts', import.meta.url).href +const htmlUrl = new URL('../../src/cli/utilities/theme-environment/html.ts', import.meta.url).href const dependencies = new Set([ '../utilities/theme-fs.js', '../utilities/theme-ui.js', @@ -15,6 +17,18 @@ const dependencies = new Set([ // Use the real service and CLI Kit sources; replace only remote theme setup. export function resolve(specifier, context, nextResolve) { + if (process.env.THEME_DEV_TEST_STAGE === 'preview') { + if (context.parentURL === serviceUrl && specifier === '../utilities/theme-environment/theme-environment.js') { + return nextResolve(specifier, context) + } + if ( + (context.parentURL === environmentUrl && + ['../theme-uploader.js', '@shopify/cli-kit/node/themes/api'].includes(specifier)) || + (context.parentURL === htmlUrl && specifier === './storefront-renderer.js') + ) { + return nextResolve(new URL('./dev-instance-preview.js', import.meta.url).href, context) + } + } if (context.parentURL === serviceUrl && dependencies.has(specifier)) { return nextResolve(new URL('./dev-instance-dependencies.js', import.meta.url).href, context) } diff --git a/packages/theme/test/fixtures/dev-instance-preview.js b/packages/theme/test/fixtures/dev-instance-preview.js new file mode 100644 index 00000000000..577584655a3 --- /dev/null +++ b/packages/theme/test/fixtures/dev-instance-preview.js @@ -0,0 +1,16 @@ +import {outputInfo} from '@shopify/cli-kit/node/output' + +export const fetchChecksums = async () => [] +export const uploadTheme = () => ({workPromise: Promise.resolve(), renderThemeSyncProgress: async () => {}}) + +let requests = 0 + +export async function render() { + requests++ + if (requests === 6) { + for (let index = 0; index < 1000; index++) outputInfo(`line ${index}`) + } + return new Response('', { + headers: {'content-type': 'text/html', 'x-request-id': 'fixture'}, + }) +}