diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index a3cc22478a..b2d8a2ab8e 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -1,89 +1,6 @@ { "commands": { "hydrogen:build": { - "aliases": [], - "args": {}, - "description": "Builds a Hydrogen storefront for production.", - "flags": { - "path": { - "description": "The path to the directory of the Hydrogen storefront. Defaults to the current directory where the command is run.", - "env": "SHOPIFY_HYDROGEN_FLAG_PATH", - "name": "path", - "hasDynamicHelp": false, - "multiple": false, - "type": "option" - }, - "sourcemap": { - "description": "Controls whether sourcemaps are generated. Default to `true`. Deactivate `--no-sourcemaps`.", - "env": "SHOPIFY_HYDROGEN_FLAG_SOURCEMAP", - "name": "sourcemap", - "allowNo": true, - "type": "boolean" - }, - "bundle-stats": { - "description": "Show a bundle size summary after building. Defaults to true, use `--no-bundle-stats` to disable.", - "name": "bundle-stats", - "allowNo": true, - "type": "boolean" - }, - "lockfile-check": { - "description": "Checks that there is exactly one valid lockfile in the project. Defaults to `true`. Deactivate with `--no-lockfile-check`.", - "env": "SHOPIFY_HYDROGEN_FLAG_LOCKFILE_CHECK", - "name": "lockfile-check", - "allowNo": true, - "type": "boolean" - }, - "disable-route-warning": { - "description": "Disables any warnings about missing standard routes.", - "env": "SHOPIFY_HYDROGEN_FLAG_DISABLE_ROUTE_WARNING", - "name": "disable-route-warning", - "allowNo": false, - "type": "boolean" - }, - "codegen": { - "description": "Automatically generates GraphQL types for your projectโ€™s Storefront API queries.", - "name": "codegen", - "required": false, - "allowNo": false, - "type": "boolean" - }, - "codegen-config-path": { - "dependsOn": [ - "codegen" - ], - "description": "Specifies a path to a codegen configuration file. Defaults to `/codegen.ts` if this file exists.", - "name": "codegen-config-path", - "required": false, - "hasDynamicHelp": false, - "multiple": false, - "type": "option" - }, - "diff": { - "description": "Applies the current files on top of Hydrogen's starter template in a temporary directory.", - "hidden": true, - "name": "diff", - "required": false, - "allowNo": false, - "type": "boolean" - } - }, - "hasDynamicHelp": false, - "hiddenAliases": [], - "id": "hydrogen:build", - "pluginAlias": "@shopify/cli-hydrogen", - "pluginName": "@shopify/cli-hydrogen", - "pluginType": "core", - "strict": true, - "descriptionWithMarkdown": "Builds a Hydrogen storefront for production. The client and app worker files are compiled to a `/dist` folder in your Hydrogen project directory.", - "isESM": true, - "relativePath": [ - "dist", - "commands", - "hydrogen", - "build.js" - ] - }, - "hydrogen:build-vite": { "aliases": [], "args": {}, "description": "Builds a Hydrogen storefront for production.", @@ -150,22 +67,28 @@ "required": false, "allowNo": false, "type": "boolean" + }, + "bundle-stats": { + "description": "[Classic Remix Compiler] Show a bundle size summary after building. Defaults to true, use `--no-bundle-stats` to disable.", + "name": "bundle-stats", + "allowNo": true, + "type": "boolean" } }, "hasDynamicHelp": false, - "hidden": true, "hiddenAliases": [], - "id": "hydrogen:build-vite", + "id": "hydrogen:build", "pluginAlias": "@shopify/cli-hydrogen", "pluginName": "@shopify/cli-hydrogen", "pluginType": "core", "strict": true, + "descriptionWithMarkdown": "Builds a Hydrogen storefront for production. The client and app worker files are compiled to a `/dist` folder in your Hydrogen project directory.", "isESM": true, "relativePath": [ "dist", "commands", "hydrogen", - "build-vite.js" + "build.js" ] }, "hydrogen:check": { @@ -555,26 +478,23 @@ "multiple": false, "type": "option" }, + "entry": { + "description": "Entry file for the worker. Defaults to `./server`.", + "env": "SHOPIFY_HYDROGEN_FLAG_ENTRY", + "name": "entry", + "hasDynamicHelp": false, + "multiple": false, + "type": "option" + }, "port": { "description": "The port to run the server on. Defaults to 3000.", "env": "SHOPIFY_HYDROGEN_FLAG_PORT", "name": "port", + "required": false, "hasDynamicHelp": false, "multiple": false, "type": "option" }, - "worker": { - "hidden": true, - "name": "worker", - "type": "boolean" - }, - "legacy-runtime": { - "description": "Runs the app in a Node.js sandbox instead of an Oxygen worker.", - "env": "SHOPIFY_HYDROGEN_FLAG_WORKER", - "name": "legacy-runtime", - "allowNo": false, - "type": "boolean" - }, "codegen": { "description": "Automatically generates GraphQL types for your projectโ€™s Storefront API queries.", "name": "codegen", @@ -593,13 +513,6 @@ "multiple": false, "type": "option" }, - "sourcemap": { - "description": "Controls whether sourcemaps are generated. Default to `true`. Deactivate `--no-sourcemaps`.", - "env": "SHOPIFY_HYDROGEN_FLAG_SOURCEMAP", - "name": "sourcemap", - "allowNo": true, - "type": "boolean" - }, "disable-virtual-routes": { "description": "Disable rendering fallback routes when a route file doesn't exist.", "env": "SHOPIFY_HYDROGEN_FLAG_DISABLE_VIRTUAL_ROUTES", @@ -675,170 +588,48 @@ "required": false, "allowNo": false, "type": "boolean" - } - }, - "hasDynamicHelp": false, - "hiddenAliases": [], - "id": "hydrogen:dev", - "pluginAlias": "@shopify/cli-hydrogen", - "pluginName": "@shopify/cli-hydrogen", - "pluginType": "core", - "strict": true, - "descriptionWithMarkdown": "Runs a Hydrogen storefront in a local runtime that emulates an Oxygen worker for development.\n\n If your project is [linked](https://shopify.dev/docs/api/shopify-cli/hydrogen/hydrogen-link) to a Hydrogen storefront, then its environment variables will be loaded with the runtime.", - "isESM": true, - "relativePath": [ - "dist", - "commands", - "hydrogen", - "dev.js" - ] - }, - "hydrogen:dev-vite": { - "aliases": [], - "args": {}, - "description": "Runs Hydrogen storefront in an Oxygen worker for development.", - "flags": { - "path": { - "description": "The path to the directory of the Hydrogen storefront. Defaults to the current directory where the command is run.", - "env": "SHOPIFY_HYDROGEN_FLAG_PATH", - "name": "path", - "hasDynamicHelp": false, - "multiple": false, - "type": "option" - }, - "entry": { - "description": "Entry file for the worker. Defaults to `./server`.", - "env": "SHOPIFY_HYDROGEN_FLAG_ENTRY", - "name": "entry", - "hasDynamicHelp": false, - "multiple": false, - "type": "option" - }, - "port": { - "description": "The port to run the server on. Defaults to 3000.", - "env": "SHOPIFY_HYDROGEN_FLAG_PORT", - "name": "port", - "required": false, - "hasDynamicHelp": false, - "multiple": false, - "type": "option" - }, - "codegen": { - "description": "Automatically generates GraphQL types for your projectโ€™s Storefront API queries.", - "name": "codegen", - "required": false, - "allowNo": false, - "type": "boolean" - }, - "codegen-config-path": { - "dependsOn": [ - "codegen" - ], - "description": "Specifies a path to a codegen configuration file. Defaults to `/codegen.ts` if this file exists.", - "name": "codegen-config-path", - "required": false, - "hasDynamicHelp": false, - "multiple": false, - "type": "option" - }, - "disable-virtual-routes": { - "description": "Disable rendering fallback routes when a route file doesn't exist.", - "env": "SHOPIFY_HYDROGEN_FLAG_DISABLE_VIRTUAL_ROUTES", - "name": "disable-virtual-routes", - "allowNo": false, - "type": "boolean" - }, - "debug": { - "description": "Enables inspector connections to the server with a debugger such as Visual Studio Code or Chrome DevTools.", - "env": "SHOPIFY_HYDROGEN_FLAG_DEBUG", - "name": "debug", - "allowNo": false, - "type": "boolean" - }, - "inspector-port": { - "description": "The port where the inspector is available. Defaults to 9229.", - "env": "SHOPIFY_HYDROGEN_FLAG_INSPECTOR_PORT", - "name": "inspector-port", - "hasDynamicHelp": false, - "multiple": false, - "type": "option" }, "host": { - "description": "Expose the server to the network", + "description": "Expose the server to the local network", "name": "host", "required": false, "allowNo": false, "type": "boolean" }, - "env": { - "description": "Specifies the environment to perform the operation using its handle. Fetch the handle using the `env list` command.", - "exclusive": [ - "env-branch" - ], - "name": "env", - "hasDynamicHelp": false, - "multiple": false, - "type": "option" - }, - "env-branch": { - "deprecated": { - "to": "env", - "message": "--env-branch is deprecated. Use --env instead." - }, - "description": "Specifies the environment to perform the operation using its Git branch name.", - "env": "SHOPIFY_HYDROGEN_ENVIRONMENT_BRANCH", - "name": "env-branch", - "hasDynamicHelp": false, - "multiple": false, - "type": "option" - }, - "disable-version-check": { - "description": "Skip the version check when running `hydrogen dev`", - "name": "disable-version-check", - "required": false, - "allowNo": false, - "type": "boolean" - }, - "diff": { - "description": "Applies the current files on top of Hydrogen's starter template in a temporary directory.", + "worker": { "hidden": true, - "name": "diff", - "required": false, - "allowNo": false, + "name": "worker", "type": "boolean" }, - "customer-account-push__unstable": { - "description": "Use tunneling for local development and push the tunneling domain to admin. Required to use Customer Account API's Oauth flow", - "env": "SHOPIFY_HYDROGEN_FLAG_CUSTOMER_ACCOUNT_PUSH", - "hidden": true, - "name": "customer-account-push__unstable", - "required": false, + "legacy-runtime": { + "description": "[Classic Remix Compiler] Runs the app in a Node.js sandbox instead of an Oxygen worker.", + "env": "SHOPIFY_HYDROGEN_FLAG_LEGACY_RUNTIME", + "name": "legacy-runtime", "allowNo": false, "type": "boolean" }, - "verbose": { - "description": "Outputs more information about the command's execution.", - "env": "SHOPIFY_HYDROGEN_FLAG_VERBOSE", - "name": "verbose", - "required": false, - "allowNo": false, + "sourcemap": { + "description": "[Classic Remix Compiler] Controls whether sourcemaps are generated. Default to `true`. Deactivate `--no-sourcemaps`.", + "env": "SHOPIFY_HYDROGEN_FLAG_SOURCEMAP", + "name": "sourcemap", + "allowNo": true, "type": "boolean" } }, "hasDynamicHelp": false, - "hidden": true, "hiddenAliases": [], - "id": "hydrogen:dev-vite", + "id": "hydrogen:dev", "pluginAlias": "@shopify/cli-hydrogen", "pluginName": "@shopify/cli-hydrogen", "pluginType": "core", "strict": true, + "descriptionWithMarkdown": "Runs a Hydrogen storefront in a local runtime that emulates an Oxygen worker for development.\n\n If your project is [linked](https://shopify.dev/docs/api/shopify-cli/hydrogen/hydrogen-link) to a Hydrogen storefront, then its environment variables will be loaded with the runtime.", "isESM": true, "relativePath": [ "dist", "commands", "hydrogen", - "dev-vite.js" + "dev.js" ] }, "hydrogen:env:list": { @@ -1435,7 +1226,7 @@ }, "legacy-runtime": { "description": "Runs the app in a Node.js sandbox instead of an Oxygen worker.", - "env": "SHOPIFY_HYDROGEN_FLAG_WORKER", + "env": "SHOPIFY_HYDROGEN_FLAG_LEGACY_RUNTIME", "name": "legacy-runtime", "allowNo": false, "type": "boolean" diff --git a/packages/cli/src/commands/hydrogen/build-vite.ts b/packages/cli/src/commands/hydrogen/build-vite.ts deleted file mode 100644 index b5975d5b20..0000000000 --- a/packages/cli/src/commands/hydrogen/build-vite.ts +++ /dev/null @@ -1,179 +0,0 @@ -import Command from '@shopify/cli-kit/node/base-command'; -import {outputWarn, collectLog} from '@shopify/cli-kit/node/output'; -import {fileSize, removeFile} from '@shopify/cli-kit/node/fs'; -import {resolvePath, joinPath} from '@shopify/cli-kit/node/path'; -import {getPackageManager} from '@shopify/cli-kit/node/node-package-manager'; -import {commonFlags, flagsToCamelObject} from '../../lib/flags.js'; -import {checkLockfileStatus} from '../../lib/check-lockfile.js'; -import {findMissingRoutes} from '../../lib/missing-routes.js'; -import {codegen} from '../../lib/codegen.js'; -import {isCI} from '../../lib/is-ci.js'; -import {copyDiffBuild, prepareDiffDirectory} from '../../lib/template-diff.js'; -import {getViteConfig} from '../../lib/vite-config.js'; - -const WORKER_BUILD_SIZE_LIMIT = 5; - -export default class Build extends Command { - static description = 'Builds a Hydrogen storefront for production.'; - static flags = { - ...commonFlags.path, - ...commonFlags.entry, - ...commonFlags.sourcemap, - ...commonFlags.lockfileCheck, - ...commonFlags.disableRouteWarning, - ...commonFlags.codegen, - ...commonFlags.diff, - }; - - static hidden = true; - - async run(): Promise { - const {flags} = await this.parse(Build); - const originalDirectory = flags.path - ? resolvePath(flags.path) - : process.cwd(); - let directory = originalDirectory; - - if (flags.diff) { - directory = await prepareDiffDirectory(originalDirectory, false); - } - - await runViteBuild({ - ...flagsToCamelObject(flags), - useCodegen: flags.codegen, - directory, - }); - - if (flags.diff) { - await copyDiffBuild(directory, originalDirectory); - } - } -} - -type RunBuildOptions = { - entry?: string; - directory?: string; - useCodegen?: boolean; - codegenConfigPath?: string; - sourcemap?: boolean; - disableRouteWarning?: boolean; - assetPath?: string; - bundleStats?: boolean; - lockfileCheck?: boolean; -}; - -export async function runViteBuild({ - entry: ssrEntry, - directory, - useCodegen = false, - codegenConfigPath, - sourcemap = false, - disableRouteWarning = false, - lockfileCheck = true, - assetPath = '/', -}: RunBuildOptions) { - if (!process.env.NODE_ENV) { - process.env.NODE_ENV = 'production'; - } - - const root = directory ?? process.cwd(); - - if (lockfileCheck) { - await checkLockfileStatus(root, isCI()); - } - - const [ - vite, - {userViteConfig, remixConfig, clientOutDir, serverOutDir, serverOutFile}, - ] = await Promise.all([ - // Avoid static imports because this file is imported by `deploy` command, - // which must have a hard dependency on 'vite'. - import('vite'), - getViteConfig(root, ssrEntry), - ]); - - const customLogger = vite.createLogger(); - if (process.env.SHOPIFY_UNIT_TEST) { - // Make logs from Vite visible in tests - customLogger.info = (msg) => collectLog('info', msg); - customLogger.warn = (msg) => collectLog('warn', msg); - customLogger.error = (msg) => collectLog('error', msg); - } - - const serverMinify = userViteConfig.build?.minify ?? true; - const commonConfig = { - root, - mode: process.env.NODE_ENV, - base: assetPath, - customLogger, - }; - - // Client build first - await vite.build({ - ...commonConfig, - build: { - emptyOutDir: true, - copyPublicDir: true, - // Disable client sourcemaps in production - sourcemap: process.env.NODE_ENV !== 'production' && sourcemap, - }, - }); - - console.log(''); - - // Server/SSR build - await vite.build({ - ...commonConfig, - build: { - sourcemap, - ssr: ssrEntry ?? true, - emptyOutDir: false, - copyPublicDir: false, - minify: serverMinify, - }, - }); - - await Promise.all([ - removeFile(joinPath(clientOutDir, '.vite')), - removeFile(joinPath(serverOutDir, '.vite')), - removeFile(joinPath(serverOutDir, 'assets')), - ]); - - if (useCodegen) { - await codegen({ - rootDirectory: root, - appDirectory: remixConfig.appDirectory, - configFilePath: codegenConfigPath, - }); - } - - if (process.env.NODE_ENV !== 'development') { - const sizeMB = (await fileSize(serverOutFile)) / (1024 * 1024); - - if (sizeMB >= WORKER_BUILD_SIZE_LIMIT) { - outputWarn( - `๐Ÿšจ Smaller worker bundles are faster to deploy and run.${ - serverMinify - ? '' - : '\n Minify your bundle by adding `build.minify: true` to vite.config.js.' - }\n Learn more about optimizing your worker bundle file: https://h2o.fyi/debugging/bundle-size\n`, - ); - } - } - - if (!disableRouteWarning) { - const missingRoutes = findMissingRoutes(remixConfig); - if (missingRoutes.length) { - const packageManager = await getPackageManager(root); - const exec = packageManager === 'npm' ? 'npx' : packageManager; - - outputWarn( - `Heads up: Shopify stores have a number of standard routes that arenโ€™t set up yet.\n` + - `Some functionality and backlinks might not work as expected until these are created or redirects are set up.\n` + - `This build is missing ${missingRoutes.length} route${ - missingRoutes.length > 1 ? 's' : '' - }. For more details, run \`${exec} shopify hydrogen check routes\`.\n`, - ); - } - } -} diff --git a/packages/cli/src/commands/hydrogen/build.test.ts b/packages/cli/src/commands/hydrogen/build.test.ts new file mode 100644 index 0000000000..8897af477a --- /dev/null +++ b/packages/cli/src/commands/hydrogen/build.test.ts @@ -0,0 +1,48 @@ +import '../../lib/onboarding/setup-template.mocks.js'; +import {fileExists, inTemporaryDirectory} from '@shopify/cli-kit/node/fs'; +import {describe, it, expect, vi} from 'vitest'; +import {joinPath} from '@shopify/cli-kit/node/path'; +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output'; +import {runBuild} from './build.js'; +import {setupTemplate} from '../../lib/onboarding/index.js'; + +describe('build', () => { + const outputMock = mockAndCaptureOutput(); + + it('builds a Vite project', async () => { + await inTemporaryDirectory(async (tmpDir) => { + await setupTemplate({ + path: tmpDir, + git: true, + language: 'ts', + i18n: 'subfolders', + routes: true, + installDeps: true, + }); + + outputMock.clear(); + vi.stubEnv('NODE_ENV', 'production'); + + await expect(runBuild({directory: tmpDir})).resolves.not.toThrow(); + + const expectedBundlePath = 'dist/server/index.js'; + + const output = outputMock.output(); + expect(output).toMatch(expectedBundlePath); + expect(output).toMatch('building for productio'); + expect(output).toMatch('dist/client/assets/root-'); + expect(output).toMatch('building SSR bundle for productio'); + expect( + fileExists(joinPath(tmpDir, expectedBundlePath)), + ).resolves.toBeTruthy(); + + const kB = Number( + output.match(/dist\/server\/index\.js\s+([\d.]+)\s+kB/)?.[1] || '', + ); + + // Bundle size within 1 MB + expect(kB).toBeGreaterThan(0); + expect(kB).toBeLessThan(1024); + }); + }); +}); diff --git a/packages/cli/src/commands/hydrogen/build.ts b/packages/cli/src/commands/hydrogen/build.ts index bfea047d47..0dbd0e2bbb 100644 --- a/packages/cli/src/commands/hydrogen/build.ts +++ b/packages/cli/src/commands/hydrogen/build.ts @@ -1,46 +1,17 @@ import {Flags} from '@oclif/core'; import Command from '@shopify/cli-kit/node/base-command'; -import { - outputInfo, - outputWarn, - outputContent, - outputToken, -} from '@shopify/cli-kit/node/output'; -import { - fileSize, - copyFile, - rmdir, - glob, - fileExists, - readFile, - writeFile, -} from '@shopify/cli-kit/node/fs'; -import {resolvePath, relativePath, joinPath} from '@shopify/cli-kit/node/path'; +import {resolvePath, joinPath} from '@shopify/cli-kit/node/path'; +import {outputWarn, collectLog} from '@shopify/cli-kit/node/output'; +import {fileSize, removeFile} from '@shopify/cli-kit/node/fs'; import {getPackageManager} from '@shopify/cli-kit/node/node-package-manager'; -import colors from '@shopify/cli-kit/node/colors'; -import { - type RemixConfig, - assertOxygenChecks, - getProjectPaths, - getRemixConfig, - handleRemixImportFail, - type ServerMode, -} from '../../lib/remix-config.js'; import {commonFlags, flagsToCamelObject} from '../../lib/flags.js'; +import {copyDiffBuild, prepareDiffDirectory} from '../../lib/template-diff.js'; +import {hasViteConfig, getViteConfig} from '../../lib/vite-config.js'; import {checkLockfileStatus} from '../../lib/check-lockfile.js'; import {findMissingRoutes} from '../../lib/missing-routes.js'; -import {createRemixLogger, muteRemixLogs} from '../../lib/log.js'; +import {runClassicCompilerBuild} from '../../lib/classic-compiler/build.js'; import {codegen} from '../../lib/codegen.js'; -import { - buildBundleAnalysis, - getBundleAnalysisSummary, -} from '../../lib/bundle/analyzer.js'; import {isCI} from '../../lib/is-ci.js'; -import {copyDiffBuild, prepareDiffDirectory} from '../../lib/template-diff.js'; -import {hasViteConfig} from '../../lib/vite-config.js'; - -const LOG_WORKER_BUILT = '๐Ÿ“ฆ Worker built'; -const WORKER_BUILD_SIZE_LIMIT = 5; export default class Build extends Command { static descriptionWithMarkdown = `Builds a Hydrogen storefront for production. The client and app worker files are compiled to a \`/dist\` folder in your Hydrogen project directory.`; @@ -48,17 +19,20 @@ export default class Build extends Command { static description = 'Builds a Hydrogen storefront for production.'; static flags = { ...commonFlags.path, + ...commonFlags.entry, ...commonFlags.sourcemap, + ...commonFlags.lockfileCheck, + ...commonFlags.disableRouteWarning, + ...commonFlags.codegen, + ...commonFlags.diff, + + // For the classic compiler: 'bundle-stats': Flags.boolean({ description: - 'Show a bundle size summary after building. Defaults to true, use `--no-bundle-stats` to disable.', + '[Classic Remix Compiler] Show a bundle size summary after building. Defaults to true, use `--no-bundle-stats` to disable.', default: true, allowNo: true, }), - ...commonFlags.lockfileCheck, - ...commonFlags.disableRouteWarning, - ...commonFlags.codegen, - ...commonFlags.diff, }; async run(): Promise { @@ -78,11 +52,10 @@ export default class Build extends Command { directory, }; - if (await hasViteConfig(directory ?? process.cwd())) { - const {runViteBuild} = await import('./build-vite.js'); - await runViteBuild(buildParams); - } else { + if (await hasViteConfig(directory)) { await runBuild(buildParams); + } else { + await runClassicCompilerBuild(buildParams); } if (flags.diff) { @@ -96,7 +69,10 @@ export default class Build extends Command { } } +const WORKER_BUILD_SIZE_LIMIT = 5; + type RunBuildOptions = { + entry?: string; directory?: string; useCodegen?: boolean; codegenConfigPath?: string; @@ -108,104 +84,99 @@ type RunBuildOptions = { }; export async function runBuild({ + entry: ssrEntry, directory, useCodegen = false, codegenConfigPath, sourcemap = false, disableRouteWarning = false, - bundleStats = true, lockfileCheck = true, - assetPath, + assetPath = '/', }: RunBuildOptions) { if (!process.env.NODE_ENV) { process.env.NODE_ENV = 'production'; } - if (assetPath) { - process.env.HYDROGEN_ASSET_BASE_URL = assetPath; - } - const {root, buildPath, buildPathClient, buildPathWorkerFile, publicPath} = - getProjectPaths(directory); + const root = directory ?? process.cwd(); if (lockfileCheck) { await checkLockfileStatus(root, isCI()); } - await muteRemixLogs(); - - console.time(LOG_WORKER_BUILT); + const [ + vite, + {userViteConfig, remixConfig, clientOutDir, serverOutDir, serverOutFile}, + ] = await Promise.all([ + // Avoid static imports because this file is imported by `deploy` command, + // which must have a hard dependency on 'vite'. + import('vite'), + getViteConfig(root, ssrEntry), + ]); - outputInfo(`\n๐Ÿ—๏ธ Building in ${process.env.NODE_ENV} mode...`); + const customLogger = vite.createLogger(); + if (process.env.SHOPIFY_UNIT_TEST) { + // Make logs from Vite visible in tests + customLogger.info = (msg) => collectLog('info', msg); + customLogger.warn = (msg) => collectLog('warn', msg); + customLogger.error = (msg) => collectLog('error', msg); + } - const [remixConfig, [{build}, {logThrown}, {createFileWatchCache}]] = - await Promise.all([ - getRemixConfig(root) as Promise, - Promise.all([ - import('@remix-run/dev/dist/compiler/build.js'), - import('@remix-run/dev/dist/compiler/utils/log.js'), - import('@remix-run/dev/dist/compiler/fileWatchCache.js'), - ]).catch(handleRemixImportFail), - rmdir(buildPath, {force: true}), - ]); + const serverMinify = userViteConfig.build?.minify ?? true; + const commonConfig = { + root, + mode: process.env.NODE_ENV, + base: assetPath, + customLogger, + }; - assertOxygenChecks(remixConfig); + // Client build first + await vite.build({ + ...commonConfig, + build: { + emptyOutDir: true, + copyPublicDir: true, + // Disable client sourcemaps in production + sourcemap: process.env.NODE_ENV !== 'production' && sourcemap, + }, + }); + + console.log(''); + + // Server/SSR build + await vite.build({ + ...commonConfig, + build: { + sourcemap, + ssr: ssrEntry ?? true, + emptyOutDir: false, + copyPublicDir: false, + minify: serverMinify, + }, + }); await Promise.all([ - copyPublicFiles(publicPath, buildPathClient), - build({ - config: remixConfig, - options: { - mode: process.env.NODE_ENV as ServerMode, - sourcemap, - }, - logger: createRemixLogger(), - fileWatchCache: createFileWatchCache(), - }).catch((thrown) => { - logThrown(thrown); - if (process.env.SHOPIFY_UNIT_TEST) { - throw thrown; - } else { - process.exit(1); - } - }), - useCodegen && codegen({...remixConfig, configFilePath: codegenConfigPath}), + removeFile(joinPath(clientOutDir, '.vite')), + removeFile(joinPath(serverOutDir, '.vite')), + removeFile(joinPath(serverOutDir, 'assets')), ]); - if (process.env.NODE_ENV !== 'development') { - console.timeEnd(LOG_WORKER_BUILT); - - const bundleAnalysisPath = await buildBundleAnalysis(buildPath); - - const sizeMB = (await fileSize(buildPathWorkerFile)) / (1024 * 1024); - const formattedSize = colors.yellow(sizeMB.toFixed(2) + ' MB'); - - outputInfo( - outputContent` ${colors.dim( - relativePath(root, buildPathWorkerFile), - )} ${ - bundleAnalysisPath - ? outputToken.link(formattedSize, bundleAnalysisPath) - : formattedSize - }\n`, - ); + if (useCodegen) { + await codegen({ + rootDirectory: root, + appDirectory: remixConfig.appDirectory, + configFilePath: codegenConfigPath, + }); + } - if (bundleStats && bundleAnalysisPath) { - outputInfo( - outputContent`${ - (await getBundleAnalysisSummary(buildPathWorkerFile)) || '\n' - }\n โ”‚\n โ””โ”€โ”€โ”€ ${outputToken.link( - 'Complete analysis: ' + bundleAnalysisPath, - bundleAnalysisPath, - )}\n\n`, - ); - } + if (process.env.NODE_ENV !== 'development') { + const sizeMB = (await fileSize(serverOutFile)) / (1024 * 1024); if (sizeMB >= WORKER_BUILD_SIZE_LIMIT) { outputWarn( `๐Ÿšจ Smaller worker bundles are faster to deploy and run.${ - remixConfig.serverMinify + serverMinify ? '' - : '\n Minify your bundle by adding `serverMinify: true` to remix.config.js.' + : '\n Minify your bundle by adding `build.minify: true` to vite.config.js.' }\n Learn more about optimizing your worker bundle file: https://h2o.fyi/debugging/bundle-size\n`, ); } @@ -226,33 +197,4 @@ export async function runBuild({ ); } } - - if (process.env.NODE_ENV !== 'development') { - await cleanClientSourcemaps(buildPathClient); - } -} - -async function cleanClientSourcemaps(buildPathClient: string) { - const bundleFiles = await glob(joinPath(buildPathClient, '**/*.js')); - - await Promise.all( - bundleFiles.map(async (filePath) => { - const file = await readFile(filePath); - return await writeFile( - filePath, - file.replace(/\/\/# sourceMappingURL=.+\.js\.map$/gm, ''), - ); - }), - ); -} - -export async function copyPublicFiles( - publicPath: string, - buildPathClient: string, -) { - if (!(await fileExists(publicPath))) { - return; - } - - return copyFile(publicPath, buildPathClient); } diff --git a/packages/cli/src/commands/hydrogen/deploy.test.ts b/packages/cli/src/commands/hydrogen/deploy.test.ts index cc3d4cc42d..6c1a97c0e7 100644 --- a/packages/cli/src/commands/hydrogen/deploy.test.ts +++ b/packages/cli/src/commands/hydrogen/deploy.test.ts @@ -27,7 +27,7 @@ import { parseToken, } from '@shopify/oxygen-cli/deploy'; import {ciPlatform} from '@shopify/cli-kit/node/context/local'; -import {runViteBuild} from './build-vite.js'; +import {runBuild} from './build.js'; vi.mock('@shopify/oxygen-cli/deploy'); vi.mock('@shopify/cli-kit/node/dot-env'); @@ -35,7 +35,7 @@ vi.mock('@shopify/cli-kit/node/fs'); vi.mock('@shopify/cli-kit/node/context/local'); vi.mock('../../lib/get-oxygen-deployment-data.js'); vi.mock('../../lib/process.js'); -vi.mock('./build-vite.js'); +vi.mock('./build.js'); vi.mock('../../lib/auth.js'); vi.mock('../../lib/shopify-config.js'); vi.mock('../../lib/graphql/admin/link-storefront.js'); @@ -558,7 +558,7 @@ describe('deploy', () => { }); await runDeploy(params); - expect(vi.mocked(runViteBuild)).toHaveBeenCalledWith({ + expect(vi.mocked(runBuild)).toHaveBeenCalledWith({ assetPath: 'some-cool-asset-path', directory: params.path, lockfileCheck: false, diff --git a/packages/cli/src/commands/hydrogen/deploy.ts b/packages/cli/src/commands/hydrogen/deploy.ts index 801349f626..ab052be3bb 100644 --- a/packages/cli/src/commands/hydrogen/deploy.ts +++ b/packages/cli/src/commands/hydrogen/deploy.ts @@ -43,8 +43,8 @@ import {execAsync} from '../../lib/process.js'; import {commonFlags, flagsToCamelObject} from '../../lib/flags.js'; import {getOxygenDeploymentData} from '../../lib/get-oxygen-deployment-data.js'; import {OxygenDeploymentData} from '../../lib/graphql/admin/get-oxygen-data.js'; +import {runClassicCompilerBuild} from '../../lib/classic-compiler/build.js'; import {runBuild} from './build.js'; -import {runViteBuild} from './build-vite.js'; import {getViteConfig} from '../../lib/vite-config.js'; import {prepareDiffDirectory} from '../../lib/template-diff.js'; import {hasRemixConfigFile} from '../../lib/remix-config.js'; @@ -564,7 +564,7 @@ Continue?`.value, outputContent`${colors.whiteBright('Building project...')}`.value, ); - const build = isClassicCompiler ? runBuild : runViteBuild; + const build = isClassicCompiler ? runClassicCompilerBuild : runBuild; await build({ directory: root, diff --git a/packages/cli/src/commands/hydrogen/dev-vite.ts b/packages/cli/src/commands/hydrogen/dev-vite.ts deleted file mode 100644 index 35aa930087..0000000000 --- a/packages/cli/src/commands/hydrogen/dev-vite.ts +++ /dev/null @@ -1,323 +0,0 @@ -import path from 'node:path'; -import {fileURLToPath} from 'node:url'; -import { - enhanceH2Logs, - isH2Verbose, - muteDevLogs, - setH2OVerbose, -} from '../../lib/log.js'; -import { - DEFAULT_APP_PORT, - DEFAULT_INSPECTOR_PORT, - commonFlags, - flagsToCamelObject, - overrideFlag, -} from '../../lib/flags.js'; -import Command from '@shopify/cli-kit/node/base-command'; -import colors from '@shopify/cli-kit/node/colors'; -import {collectLog} from '@shopify/cli-kit/node/output'; -import {type AlertCustomSection, renderSuccess} from '@shopify/cli-kit/node/ui'; -import {AbortError} from '@shopify/cli-kit/node/error'; -import {Flags, Config} from '@oclif/core'; -import {spawnCodegenProcess} from '../../lib/codegen.js'; -import {getAllEnvironmentVariables} from '../../lib/environment-variables.js'; -import {displayDevUpgradeNotice} from './upgrade.js'; -import {prepareDiffDirectory} from '../../lib/template-diff.js'; -import { - getDebugBannerLine, - startTunnelAndPushConfig, - isMockShop, - notifyIssueWithTunnelAndMockShop, - getDevConfigInBackground, - getUtilityBannerlines, - TUNNEL_DOMAIN, -} from '../../lib/dev-shared.js'; -import {getCliCommand} from '../../lib/shell.js'; -import {findPort} from '../../lib/find-port.js'; -import {logRequestLine} from '../../lib/mini-oxygen/common.js'; -import {findHydrogenPlugin, findOxygenPlugin} from '../../lib/vite-config.js'; - -export default class DevVite extends Command { - static description = - 'Runs Hydrogen storefront in an Oxygen worker for development.'; - static flags = { - ...commonFlags.path, - ...commonFlags.entry, - ...overrideFlag(commonFlags.port, { - port: {default: undefined, required: false}, - }), - ...commonFlags.codegen, - 'disable-virtual-routes': Flags.boolean({ - description: - "Disable rendering fallback routes when a route file doesn't exist.", - env: 'SHOPIFY_HYDROGEN_FLAG_DISABLE_VIRTUAL_ROUTES', - }), - ...commonFlags.debug, - ...commonFlags.inspectorPort, - host: Flags.boolean({ - description: 'Expose the server to the network', - default: false, - required: false, - }), - ...commonFlags.env, - ...commonFlags.envBranch, - 'disable-version-check': Flags.boolean({ - description: 'Skip the version check when running `hydrogen dev`', - default: false, - required: false, - }), - ...commonFlags.diff, - ...commonFlags.customerAccountPush, - ...commonFlags.verbose, - }; - - static hidden = true; - - async run(): Promise { - const {flags} = await this.parse(DevVite); - let directory = flags.path ? path.resolve(flags.path) : process.cwd(); - - if (flags.diff) { - directory = await prepareDiffDirectory(directory, true); - } - - await runViteDev({ - ...flagsToCamelObject(flags), - customerAccountPush: flags['customer-account-push__unstable'], - path: directory, - isLocalDev: flags.diff, - cliConfig: this.config, - }); - } -} - -type DevOptions = { - entry?: string; - port?: number; - path?: string; - codegen?: boolean; - host?: boolean; - codegenConfigPath?: string; - disableVirtualRoutes?: boolean; - disableVersionCheck?: boolean; - envBranch?: string; - env?: string; - debug?: boolean; - sourcemap?: boolean; - inspectorPort?: number; - isLocalDev?: boolean; - customerAccountPush?: boolean; - cliConfig: Config; - verbose?: boolean; -}; - -export async function runViteDev({ - entry: ssrEntry, - port: appPort, - path: appPath, - host, - codegen: useCodegen = false, - codegenConfigPath, - disableVirtualRoutes, - envBranch, - env: envHandle, - debug = false, - disableVersionCheck = false, - inspectorPort, - isLocalDev = false, - customerAccountPush: customerAccountPushFlag = false, - cliConfig, - verbose, -}: DevOptions) { - if (!process.env.NODE_ENV) process.env.NODE_ENV = 'development'; - - if (verbose) setH2OVerbose(); - if (!isH2Verbose()) muteDevLogs(); - - const root = appPath ?? process.cwd(); - - const cliCommandPromise = getCliCommand(root); - const backgroundPromise = getDevConfigInBackground( - root, - customerAccountPushFlag, - ); - - const envPromise = backgroundPromise.then(({fetchRemote, localVariables}) => - getAllEnvironmentVariables({ - root, - envBranch, - envHandle, - fetchRemote, - localVariables, - }), - ); - - if (debug && !inspectorPort) { - // The Vite plugin can find and return a port for the inspector - // but we need to print the URLs before the runtime is ready, - // so we find a port early here. - inspectorPort = await findPort(DEFAULT_INSPECTOR_PORT); - } - - const vite = await import('vite'); - - // Allow Vite to read files from the Hydrogen packages in local development. - const fs = isLocalDev - ? {allow: [root, fileURLToPath(new URL('../../../../', import.meta.url))]} - : undefined; - - const customLogger = vite.createLogger(); - if (process.env.SHOPIFY_UNIT_TEST) { - // Make logs from Vite visible in tests - customLogger.info = (msg) => collectLog('info', msg); - customLogger.warn = (msg) => collectLog('warn', msg); - customLogger.error = (msg) => collectLog('error', msg); - } - - const viteServer = await vite.createServer({ - root, - customLogger, - clearScreen: false, - server: {fs, host: host ? true : undefined}, - plugins: [ - { - name: 'hydrogen:cli', - configResolved(config) { - findHydrogenPlugin(config)?.api?.registerPluginOptions({ - disableVirtualRoutes, - }); - - findOxygenPlugin(config)?.api?.registerPluginOptions({ - debug, - entry: ssrEntry, - envPromise: envPromise.then(({allVariables}) => allVariables), - inspectorPort, - logRequestLine, - }); - }, - configureServer: (viteDevServer) => { - if (customerAccountPushFlag) { - viteDevServer.middlewares.use((req, res, next) => { - const host = req.headers.host; - - if (host?.includes(TUNNEL_DOMAIN.ORIGINAL)) { - req.headers.host = host.replace( - TUNNEL_DOMAIN.ORIGINAL, - TUNNEL_DOMAIN.REBRANDED, - ); - } - - next(); - }); - } - }, - }, - ], - }); - - const h2Plugin = findHydrogenPlugin(viteServer.config); - if (!h2Plugin) { - await viteServer.close(); - throw new AbortError( - 'Hydrogen plugin not found.', - 'Add `hydrogen()` plugin to your Vite config.', - ); - } - - const h2PluginOptions = h2Plugin.api?.getPluginOptions?.(); - - const codegenProcess = useCodegen - ? spawnCodegenProcess({ - rootDirectory: root, - configFilePath: codegenConfigPath, - appDirectory: h2PluginOptions?.remixConfig?.appDirectory, - }) - : undefined; - - // handle unhandledRejection so that the process won't exit - process.on('unhandledRejection', (err) => { - console.log('Unhandled Rejection: ', err); - }); - - // Store the port passed by the user in the config. - const publicPort = - appPort ?? viteServer.config.server.port ?? DEFAULT_APP_PORT; - - // TODO -- Need to change Remix' component - // const assetsPort = await findPort(publicPort + 100); - // if (assetsPort) { - // // Note: Set this env before loading Remix config! - // process.env.HYDROGEN_ASSET_BASE_URL = buildAssetsUrl(assetsPort); - // } - - const [tunnel, cliCommand] = await Promise.all([ - backgroundPromise.then(({customerAccountPush, storefrontId}) => - customerAccountPush - ? startTunnelAndPushConfig(root, cliConfig, publicPort, storefrontId) - : undefined, - ), - cliCommandPromise, - viteServer.listen(publicPort), - ]); - - const publicUrl = new URL( - viteServer.resolvedUrls!.local[0] ?? viteServer.resolvedUrls!.network[0]!, - ); - - const finalHost = tunnel?.host || publicUrl.toString() || publicUrl.origin; - - // Start the public facing server with the port passed by the user. - enhanceH2Logs({ - rootDirectory: root, - host: finalHost, - cliCommand, - }); - - const {logInjectedVariables, allVariables} = await envPromise; - - logInjectedVariables(); - console.log(''); - viteServer.printUrls(); - viteServer.bindCLIShortcuts({print: true}); - console.log('\n'); - - const customSections: AlertCustomSection[] = []; - - if (!h2PluginOptions?.disableVirtualRoutes) { - customSections.push({body: getUtilityBannerlines(finalHost)}); - } - - if (debug && inspectorPort) { - customSections.push({ - body: {warn: getDebugBannerLine(inspectorPort)}, - }); - } - - const {storefrontTitle} = await backgroundPromise; - renderSuccess({ - body: [ - `View ${ - storefrontTitle ? colors.cyan(storefrontTitle) : 'Hydrogen' - } app:`, - {link: {url: finalHost}}, - ], - customSections, - }); - - if (!disableVersionCheck) { - displayDevUpgradeNotice({targetPath: root}); - } - - if (customerAccountPushFlag && isMockShop(allVariables)) { - notifyIssueWithTunnelAndMockShop(cliCommand); - } - - return { - getUrl: () => finalHost, - async close() { - codegenProcess?.removeAllListeners('close'); - codegenProcess?.kill('SIGINT'); - await Promise.allSettled([viteServer.close(), tunnel?.cleanup?.()]); - }, - }; -} diff --git a/packages/cli/src/commands/hydrogen/dev.test.ts b/packages/cli/src/commands/hydrogen/dev.test.ts new file mode 100644 index 0000000000..3178a1544a --- /dev/null +++ b/packages/cli/src/commands/hydrogen/dev.test.ts @@ -0,0 +1,48 @@ +import '../../lib/onboarding/setup-template.mocks.js'; +import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs'; +import {describe, it, expect, vi} from 'vitest'; +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output'; +import {runDev} from './dev.js'; +import {setupTemplate} from '../../lib/onboarding/index.js'; + +describe('dev', () => { + const outputMock = mockAndCaptureOutput(); + + it('runs dev in a Vite project', async () => { + await inTemporaryDirectory(async (tmpDir) => { + await setupTemplate({ + path: tmpDir, + git: true, + language: 'ts', + i18n: 'subfolders', + routes: true, + installDeps: true, + }); + + // Clear previous success messages + outputMock.clear(); + vi.stubEnv('NODE_ENV', 'development'); + + const {close, getUrl} = await runDev({ + path: tmpDir, + disableVirtualRoutes: true, + disableVersionCheck: true, + cliConfig: {} as any, + }); + + try { + await vi.waitFor( + () => expect(outputMock.output()).toMatch(/View [^:]+? app:/i), + {timeout: 5000}, + ); + + const response = await fetch(getUrl()); + expect(response.status).toEqual(200); + expect(response.headers.get('content-type')).toEqual('text/html'); + await expect(response.text()).resolves.toMatch('Mock.shop'); + } finally { + await close(); + } + }); + }); +}); diff --git a/packages/cli/src/commands/hydrogen/dev.ts b/packages/cli/src/commands/hydrogen/dev.ts index 9a195f2dfb..4ea23e5f1b 100644 --- a/packages/cli/src/commands/hydrogen/dev.ts +++ b/packages/cli/src/commands/hydrogen/dev.ts @@ -1,60 +1,47 @@ -import fs from 'node:fs/promises'; -import type {ChildProcess} from 'node:child_process'; -import {outputDebug, outputInfo} from '@shopify/cli-kit/node/output'; -import {fileExists} from '@shopify/cli-kit/node/fs'; -import {renderFatalError} from '@shopify/cli-kit/node/ui'; -import {relativePath, resolvePath} from '@shopify/cli-kit/node/path'; -import {copyPublicFiles} from './build.js'; +import {fileURLToPath} from 'node:url'; import { - type RemixConfig, - assertOxygenChecks, - getProjectPaths, - getRemixConfig, - handleRemixImportFail, - type ServerMode, -} from '../../lib/remix-config.js'; -import { - createRemixLogger, enhanceH2Logs, - muteDevLogs, isH2Verbose, + muteDevLogs, setH2OVerbose, } from '../../lib/log.js'; import { DEFAULT_APP_PORT, + DEFAULT_INSPECTOR_PORT, commonFlags, deprecated, flagsToCamelObject, + overrideFlag, } from '../../lib/flags.js'; import Command from '@shopify/cli-kit/node/base-command'; +import colors from '@shopify/cli-kit/node/colors'; +import {resolvePath} from '@shopify/cli-kit/node/path'; +import {collectLog} from '@shopify/cli-kit/node/output'; +import {type AlertCustomSection, renderSuccess} from '@shopify/cli-kit/node/ui'; +import {AbortError} from '@shopify/cli-kit/node/error'; import {Flags, Config} from '@oclif/core'; -import { - type MiniOxygen, - startMiniOxygen, - buildAssetsUrl, -} from '../../lib/mini-oxygen/index.js'; -import {addVirtualRoutes} from '../../lib/virtual-routes.js'; import {spawnCodegenProcess} from '../../lib/codegen.js'; import {getAllEnvironmentVariables} from '../../lib/environment-variables.js'; -import {setupLiveReload} from '../../lib/live-reload.js'; -import {checkRemixVersions} from '../../lib/remix-version-check.js'; import {displayDevUpgradeNotice} from './upgrade.js'; -import {findPort} from '../../lib/find-port.js'; import { - copyShopifyConfig, prepareDiffDirectory, + copyShopifyConfig, } from '../../lib/template-diff.js'; import { + getDebugBannerLine, startTunnelAndPushConfig, - getDevConfigInBackground, isMockShop, notifyIssueWithTunnelAndMockShop, + getDevConfigInBackground, + getUtilityBannerlines, + TUNNEL_DOMAIN, } from '../../lib/dev-shared.js'; import {getCliCommand} from '../../lib/shell.js'; +import {findPort} from '../../lib/find-port.js'; +import {logRequestLine} from '../../lib/mini-oxygen/common.js'; +import {findHydrogenPlugin, findOxygenPlugin} from '../../lib/vite-config.js'; import {hasViteConfig} from '../../lib/vite-config.js'; - -const LOG_REBUILDING = '๐Ÿงฑ Rebuilding...'; -const LOG_REBUILT = '๐Ÿš€ Rebuilt'; +import {runClassicCompilerDev} from '../../lib/classic-compiler/dev.js'; export default class Dev extends Command { static descriptionWithMarkdown = `Runs a Hydrogen storefront in a local runtime that emulates an Oxygen worker for development. @@ -65,11 +52,11 @@ export default class Dev extends Command { 'Runs Hydrogen storefront in an Oxygen worker for development.'; static flags = { ...commonFlags.path, - ...commonFlags.port, - worker: deprecated('--worker', {isBoolean: true}), - ...commonFlags.legacyRuntime, + ...commonFlags.entry, + ...overrideFlag(commonFlags.port, { + port: {default: undefined, required: false}, + }), ...commonFlags.codegen, - ...commonFlags.sourcemap, 'disable-virtual-routes': Flags.boolean({ description: "Disable rendering fallback routes when a route file doesn't exist.", @@ -88,6 +75,28 @@ export default class Dev extends Command { ...commonFlags.diff, ...commonFlags.customerAccountPush, ...commonFlags.verbose, + host: Flags.boolean({ + description: 'Expose the server to the local network', + default: false, + required: false, + }), + + // For the classic compiler: + worker: deprecated('--worker', {isBoolean: true}), + ...overrideFlag(commonFlags.legacyRuntime, { + 'legacy-runtime': { + description: + '[Classic Remix Compiler] ' + + commonFlags.legacyRuntime['legacy-runtime'].description, + }, + }), + ...overrideFlag(commonFlags.sourcemap, { + sourcemap: { + description: + '[Classic Remix Compiler] ' + + commonFlags.sourcemap.sourcemap.description, + }, + }), }; async run(): Promise { @@ -108,11 +117,9 @@ export default class Dev extends Command { cliConfig: this.config, }; - const {close} = (await hasViteConfig(directory ?? process.cwd())) - ? await import('./dev-vite.js').then(({runViteDev}) => - runViteDev(devParams), - ) - : await runDev(devParams); + const {close} = (await hasViteConfig(directory)) + ? await runDev(devParams) + : await runClassicCompilerDev(devParams); // Note: Shopify CLI is hooking into process events and calling process.exit. // This means we are unable to hook into 'beforeExit' or 'SIGINT" events @@ -136,39 +143,40 @@ export default class Dev extends Command { } type DevOptions = { + entry?: string; port?: number; path?: string; codegen?: boolean; - legacyRuntime?: boolean; + host?: boolean; codegenConfigPath?: string; disableVirtualRoutes?: boolean; disableVersionCheck?: boolean; - env?: string; envBranch?: string; + env?: string; debug?: boolean; sourcemap?: boolean; inspectorPort?: number; + isLocalDev?: boolean; customerAccountPush?: boolean; cliConfig: Config; - shouldLiveReload?: boolean; verbose?: boolean; }; export async function runDev({ + entry: ssrEntry, port: appPort, path: appPath, + host, codegen: useCodegen = false, - legacyRuntime = false, codegenConfigPath, disableVirtualRoutes, - env: envHandle, envBranch, + env: envHandle, debug = false, - sourcemap = true, disableVersionCheck = false, inspectorPort, + isLocalDev = false, customerAccountPush: customerAccountPushFlag = false, - shouldLiveReload = true, cliConfig, verbose, }: DevOptions) { @@ -177,271 +185,190 @@ export async function runDev({ if (verbose) setH2OVerbose(); if (!isH2Verbose()) muteDevLogs(); - const {root, publicPath, buildPathClient, buildPathWorkerFile} = - getProjectPaths(appPath); + const root = appPath ?? process.cwd(); - const copyFilesPromise = copyPublicFiles(publicPath, buildPathClient); const cliCommandPromise = getCliCommand(root); - - const reloadConfig = async () => { - const config = (await getRemixConfig(root)) as RemixConfig; - - return disableVirtualRoutes - ? config - : addVirtualRoutes(config).catch((error) => { - // Seen this fail when somehow NPM doesn't publish - // the full 'virtual-routes' directory. - // E.g. https://unpkg.com/browse/@shopify/cli-hydrogen@0.0.0-next-aa15969-20230703072007/dist/virtual-routes/ - outputDebug( - 'Could not add virtual routes: ' + - (error?.stack ?? error?.message ?? error), - ); - - return config; - }); - }; - - const getFilePaths = (file: string) => { - const fileRelative = relativePath(root, file); - return [fileRelative, resolvePath(root, fileRelative)] as const; - }; - - const serverBundleExists = () => fileExists(buildPathWorkerFile); - - if (!appPort) { - appPort = await findPort(DEFAULT_APP_PORT); - } - - const assetsPort = legacyRuntime ? 0 : await findPort(appPort + 100); - if (assetsPort) { - // Note: Set this env before loading Remix config! - process.env.HYDROGEN_ASSET_BASE_URL = await buildAssetsUrl(assetsPort); - } - const backgroundPromise = getDevConfigInBackground( root, customerAccountPushFlag, ); - const tunnelPromise = - cliConfig && - backgroundPromise.then(({customerAccountPush, storefrontId}) => { - if (customerAccountPush) { - return startTunnelAndPushConfig( - root, - cliConfig, - appPort!, - storefrontId, - ); - } - }); - - const remixConfig = await reloadConfig(); - assertOxygenChecks(remixConfig); - const envPromise = backgroundPromise.then(({fetchRemote, localVariables}) => getAllEnvironmentVariables({ root, - fetchRemote, envBranch, envHandle, + fetchRemote, localVariables, }), ); - const [{watch}, {createFileWatchCache}] = await Promise.all([ - import('@remix-run/dev/dist/compiler/watch.js'), - import('@remix-run/dev/dist/compiler/fileWatchCache.js'), - ]).catch(handleRemixImportFail); + if (debug && !inspectorPort) { + // The Vite plugin can find and return a port for the inspector + // but we need to print the URLs before the runtime is ready, + // so we find a port early here. + inspectorPort = await findPort(DEFAULT_INSPECTOR_PORT); + } - let isInitialBuild = true; - let initialBuildDurationMs = 0; - let initialBuildStartTimeMs = Date.now(); + const vite = await import('vite'); - const liveReload = shouldLiveReload - ? await setupLiveReload(remixConfig.dev?.port ?? 8002) + // Allow Vite to read files from the Hydrogen packages in local development. + const fs = isLocalDev + ? {allow: [root, fileURLToPath(new URL('../../../../', import.meta.url))]} : undefined; - let miniOxygen: MiniOxygen; - let codegenProcess: ChildProcess; - async function safeStartMiniOxygen() { - if (miniOxygen) return; - - const {allVariables, logInjectedVariables} = await envPromise; + const customLogger = vite.createLogger(); + if (process.env.SHOPIFY_UNIT_TEST) { + // Make logs from Vite visible in tests + customLogger.info = (msg) => collectLog('info', msg); + customLogger.warn = (msg) => collectLog('warn', msg); + customLogger.error = (msg) => collectLog('error', msg); + } - miniOxygen = await startMiniOxygen( + const viteServer = await vite.createServer({ + root, + customLogger, + clearScreen: false, + server: {fs, host: host ? true : undefined}, + plugins: [ { - root, - debug, - appPort, - assetsPort, - inspectorPort, - watch: !liveReload, - buildPathWorkerFile, - buildPathClient, - env: allVariables, + name: 'hydrogen:cli', + configResolved(config) { + findHydrogenPlugin(config)?.api?.registerPluginOptions({ + disableVirtualRoutes, + }); + + findOxygenPlugin(config)?.api?.registerPluginOptions({ + debug, + entry: ssrEntry, + envPromise: envPromise.then(({allVariables}) => allVariables), + inspectorPort, + logRequestLine, + }); + }, + configureServer: (viteDevServer) => { + if (customerAccountPushFlag) { + viteDevServer.middlewares.use((req, res, next) => { + const host = req.headers.host; + + if (host?.includes(TUNNEL_DOMAIN.ORIGINAL)) { + req.headers.host = host.replace( + TUNNEL_DOMAIN.ORIGINAL, + TUNNEL_DOMAIN.REBRANDED, + ); + } + + next(); + }); + } + }, }, - legacyRuntime, + ], + }); + + const h2Plugin = findHydrogenPlugin(viteServer.config); + if (!h2Plugin) { + await viteServer.close(); + throw new AbortError( + 'Hydrogen plugin not found.', + 'Add `hydrogen()` plugin to your Vite config.', ); + } - logInjectedVariables(); - - const host = (await tunnelPromise)?.host ?? miniOxygen.listeningAt; - - const cliCommand = await cliCommandPromise; - enhanceH2Logs({host, cliCommand, ...remixConfig}); - - const {storefrontTitle} = await backgroundPromise; - - miniOxygen.showBanner({ - appName: storefrontTitle, - headlinePrefix: - initialBuildDurationMs > 0 - ? `Initial build: ${initialBuildDurationMs}ms\n` - : '', - host, - }); + const h2PluginOptions = h2Plugin.api?.getPluginOptions?.(); - if (useCodegen) { - codegenProcess = spawnCodegenProcess({ - ...remixConfig, + const codegenProcess = useCodegen + ? spawnCodegenProcess({ + rootDirectory: root, configFilePath: codegenConfigPath, - }); - } - - checkRemixVersions(); + appDirectory: h2PluginOptions?.remixConfig?.appDirectory, + }) + : undefined; - if (!disableVersionCheck) { - displayDevUpgradeNotice({targetPath: appPath}); - } + // handle unhandledRejection so that the process won't exit + process.on('unhandledRejection', (err) => { + console.log('Unhandled Rejection: ', err); + }); + + // Store the port passed by the user in the config. + const publicPort = + appPort ?? viteServer.config.server.port ?? DEFAULT_APP_PORT; + + // TODO -- Need to change Remix' component + // const assetsPort = await findPort(publicPort + 100); + // if (assetsPort) { + // // Note: Set this env before loading Remix config! + // process.env.HYDROGEN_ASSET_BASE_URL = buildAssetsUrl(assetsPort); + // } + + const [tunnel, cliCommand] = await Promise.all([ + backgroundPromise.then(({customerAccountPush, storefrontId}) => + customerAccountPush + ? startTunnelAndPushConfig(root, cliConfig, publicPort, storefrontId) + : undefined, + ), + cliCommandPromise, + viteServer.listen(publicPort), + ]); + + const publicUrl = new URL( + viteServer.resolvedUrls!.local[0] ?? viteServer.resolvedUrls!.network[0]!, + ); - if (customerAccountPushFlag && isMockShop(allVariables)) { - notifyIssueWithTunnelAndMockShop(cliCommand); - } - } + const finalHost = tunnel?.host || publicUrl.toString() || publicUrl.origin; - const fileWatchCache = createFileWatchCache(); - let skipRebuildLogs = false; + // Start the public facing server with the port passed by the user. + enhanceH2Logs({ + rootDirectory: root, + host: finalHost, + cliCommand, + }); - const closeWatcher = await watch( - { - config: remixConfig, - options: { - mode: process.env.NODE_ENV as ServerMode, - sourcemap, - }, - fileWatchCache, - logger: createRemixLogger(), - }, - { - reloadConfig, - onBuildStart(ctx) { - if (!isInitialBuild && !skipRebuildLogs) { - outputInfo(LOG_REBUILDING); - console.time(LOG_REBUILT); - } - - liveReload?.onBuildStart(ctx); - }, - onBuildManifest: liveReload?.onBuildManifest, - async onBuildFinish(context, duration, succeeded) { - if (isInitialBuild) { - await copyFilesPromise; - initialBuildDurationMs = Date.now() - initialBuildStartTimeMs; - isInitialBuild = false; - } else if (!skipRebuildLogs) { - skipRebuildLogs = false; - console.timeEnd(LOG_REBUILT); - if (!miniOxygen) console.log(''); // New line - } - - if (!miniOxygen && !(await serverBundleExists())) { - return renderFatalError({ - name: 'BuildError', - type: 0, - message: - 'MiniOxygen cannot start because the server bundle has not been generated.', - skipOclifErrorHandling: true, - tryMessage: - 'This is likely due to an error in your app and Remix is unable to compile. Try fixing the app and MiniOxygen will start.', - }); - } + const {logInjectedVariables, allVariables} = await envPromise; - if (succeeded) { - if (!miniOxygen) { - await safeStartMiniOxygen(); - } else if (liveReload) { - await miniOxygen.reload(); - } + logInjectedVariables(); + console.log(''); + viteServer.printUrls(); + viteServer.bindCLIShortcuts({print: true}); + console.log('\n'); - liveReload?.onAppReady(context); - } - }, - async onFileCreated(file: string) { - const [relative, absolute] = getFilePaths(file); - outputInfo(`\n๐Ÿ“„ File created: ${relative}`); - - if (absolute.startsWith(publicPath)) { - await copyPublicFiles( - absolute, - absolute.replace(publicPath, buildPathClient), - ); - } - }, - async onFileChanged(file: string) { - fileWatchCache.invalidateFile(file); - - const [relative, absolute] = getFilePaths(file); - outputInfo(`\n๐Ÿ“„ File changed: ${relative}`); - - if (relative.endsWith('.env')) { - skipRebuildLogs = true; - const {fetchRemote} = await backgroundPromise; - const {allVariables, logInjectedVariables} = - await getAllEnvironmentVariables({ - root, - fetchRemote, - envBranch, - envHandle, - }); + const customSections: AlertCustomSection[] = []; - logInjectedVariables(); + if (!h2PluginOptions?.disableVirtualRoutes) { + customSections.push({body: getUtilityBannerlines(finalHost)}); + } - await miniOxygen.reload({ - env: allVariables, - }); - } - - if (absolute.startsWith(publicPath)) { - await copyPublicFiles( - absolute, - absolute.replace(publicPath, buildPathClient), - ); - } - }, - async onFileDeleted(file: string) { - fileWatchCache.invalidateFile(file); + if (debug && inspectorPort) { + customSections.push({ + body: {warn: getDebugBannerLine(inspectorPort)}, + }); + } - const [relative, absolute] = getFilePaths(file); - outputInfo(`\n๐Ÿ“„ File deleted: ${relative}`); + const {storefrontTitle} = await backgroundPromise; + renderSuccess({ + body: [ + `View ${ + storefrontTitle ? colors.cyan(storefrontTitle) : 'Hydrogen' + } app:`, + {link: {url: finalHost}}, + ], + customSections, + }); + + if (!disableVersionCheck) { + displayDevUpgradeNotice({targetPath: root}); + } - if (absolute.startsWith(publicPath)) { - await fs.unlink(absolute.replace(publicPath, buildPathClient)); - } - }, - }, - ); + if (customerAccountPushFlag && isMockShop(allVariables)) { + notifyIssueWithTunnelAndMockShop(cliCommand); + } return { - getUrl: () => miniOxygen.listeningAt, + getUrl: () => finalHost, async close() { codegenProcess?.removeAllListeners('close'); codegenProcess?.kill('SIGINT'); - await Promise.allSettled([ - closeWatcher(), - miniOxygen?.close(), - Promise.resolve(tunnelPromise).then((tunnel) => tunnel?.cleanup?.()), - ]); + await Promise.allSettled([viteServer.close(), tunnel?.cleanup?.()]); }, }; } diff --git a/packages/cli/src/commands/hydrogen/init.test.ts b/packages/cli/src/commands/hydrogen/init.test.ts index 45cf4c5818..b50459103e 100644 --- a/packages/cli/src/commands/hydrogen/init.test.ts +++ b/packages/cli/src/commands/hydrogen/init.test.ts @@ -1,119 +1,28 @@ -import {fileURLToPath} from 'node:url'; -import {describe, it, expect, vi, beforeEach} from 'vitest'; +import '../../lib/onboarding/setup-template.mocks.js'; +import {describe, it, expect, vi, beforeEach, beforeAll} from 'vitest'; import {runInit} from './init.js'; import {exec} from '@shopify/cli-kit/node/system'; import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output'; -import {readAndParsePackageJson} from '@shopify/cli-kit/node/node-package-manager'; -import { - fileExists, - isDirectory, - readFile, - removeFile, - writeFile, - inTemporaryDirectory, -} from '@shopify/cli-kit/node/fs'; -import {basename, joinPath} from '@shopify/cli-kit/node/path'; +import {fileExists, readFile, removeFile} from '@shopify/cli-kit/node/fs'; +import {temporaryDirectory} from 'tempy'; import {checkHydrogenVersion} from '../../lib/check-version.js'; -import {handleProjectLocation} from '../../lib/onboarding/common.js'; -import glob from 'fast-glob'; -import {getRepoNodeModules, getSkeletonSourceDir} from '../../lib/build.js'; -import {execAsync} from '../../lib/process.js'; -import {createSymlink, remove as rmdir} from 'fs-extra/esm'; import {runCheckRoutes} from './check.js'; import {runCodegen} from './codegen.js'; -import {runViteBuild} from './build-vite.js'; -import {runViteDev} from './dev-vite.js'; -import {runBuild as runClassicBuild} from './build.js'; -import {runDev as runClassicDev} from './dev.js'; -import {renderSelectPrompt} from '@shopify/cli-kit/node/ui'; - -const {renderTasksHook} = vi.hoisted(() => ({renderTasksHook: vi.fn()})); +import {setupTemplate} from '../../lib/onboarding/index.js'; vi.mock('../../lib/check-version.js'); -vi.mock('../../lib/template-downloader.js', async () => ({ - downloadMonorepoTemplates: () => - Promise.resolve({ - version: '', - templatesDir: fileURLToPath( - new URL('../../../../../templates', import.meta.url), - ), - examplesDir: fileURLToPath( - new URL('../../../../../examples', import.meta.url), - ), - }), - downloadExternalRepo: () => - Promise.resolve({ - templateDir: fileURLToPath( - new URL('../../../../../templates/skeleton', import.meta.url), - ), - }), -})); - -vi.mock('@shopify/cli-kit/node/ui', async () => { +vi.mock('../../lib/onboarding/index.js', async () => { const original = await vi.importActual< - typeof import('@shopify/cli-kit/node/ui') - >('@shopify/cli-kit/node/ui'); + typeof import('../../lib/onboarding/index.js') + >('../../lib/onboarding/index.js'); return { ...original, - renderConfirmationPrompt: vi.fn(), - renderSelectPrompt: vi.fn(), - renderTextPrompt: vi.fn(), - renderTasks: vi.fn(async (args) => { - await original.renderTasks(args); - renderTasksHook(); - }), + setupTemplate: vi.fn(original.setupTemplate), }; }); -vi.mock( - '@shopify/cli-kit/node/node-package-manager', - async (importOriginal) => { - const original = await importOriginal< - typeof import('@shopify/cli-kit/node/node-package-manager') - >(); - - return { - ...original, - getPackageManager: () => Promise.resolve('npm'), - packageManagerFromUserAgent: () => 'npm', - installNodeModules: vi.fn(async ({directory}: {directory: string}) => { - // Create lockfile at a later moment to simulate a slow install - renderTasksHook.mockImplementationOnce(async () => { - await writeFile(`${directory}/package-lock.json`, '{}'); - }); - - // "Install" dependencies by linking to monorepo's node_modules - await rmdir(joinPath(directory, 'node_modules')).catch(() => {}); - await createSymlink( - await getRepoNodeModules(), - joinPath(directory, 'node_modules'), - ); - }), - }; - }, -); - -vi.mock('../../lib/onboarding/common.js', async (importOriginal) => { - type ModType = typeof import('../../lib/onboarding/common.js'); - const original = await importOriginal(); - - return Object.keys(original).reduce((acc, item) => { - const key = item as keyof ModType; - const value = original[key]; - if (typeof value === 'function') { - // @ts-ignore - acc[key] = vi.fn(value); - } else { - // @ts-ignore - acc[key] = value; - } - - return acc; - }, {} as ModType); -}); - describe('init', () => { const outputMock = mockAndCaptureOutput(); @@ -124,861 +33,94 @@ describe('init', () => { }); it('checks Hydrogen version', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const showUpgradeMock = vi.fn((param?: string) => ({ - currentVersion: '1.0.0', - newVersion: '1.0.1', - })); - vi.mocked(checkHydrogenVersion).mockResolvedValueOnce(showUpgradeMock); - vi.mocked(handleProjectLocation).mockResolvedValueOnce(undefined); - - const project = await runInit({path: tmpDir, git: false}); - - expect(project).toBeFalsy(); - expect(checkHydrogenVersion).toHaveBeenCalledOnce(); - expect(showUpgradeMock).toHaveBeenCalledWith( - expect.stringContaining('npm create @shopify/hydrogen@latest'), - ); - }); + const showUpgradeMock = vi.fn((param?: string) => ({ + currentVersion: '1.0.0', + newVersion: '1.0.1', + })); + vi.mocked(checkHydrogenVersion).mockResolvedValueOnce(showUpgradeMock); + vi.mocked(setupTemplate).mockResolvedValueOnce(undefined); + + const project = await runInit(); + + expect(project).toBeFalsy(); + expect(checkHydrogenVersion).toHaveBeenCalledOnce(); + expect(showUpgradeMock).toHaveBeenCalledWith( + expect.stringContaining('npm create @shopify/hydrogen@latest'), + ); }); - describe('remote templates', () => { - it('throws for unknown templates', async () => { - const processExit = vi - .spyOn(process, 'exit') - .mockImplementationOnce((() => {}) as any); - - await inTemporaryDirectory(async (tmpDir) => { - await expect( - runInit({ - path: tmpDir, - git: false, - language: 'ts', - template: 'missing-template', - }), - ).resolves.ok; - }); - - // The error message is printed asynchronously - await vi.waitFor(() => expect(outputMock.error()).toMatch('--template')); - - expect(processExit).toHaveBeenCalledWith(1); + it('scaffolds Quickstart project with expected values', async () => { + vi.mocked(setupTemplate).mockResolvedValueOnce(undefined); - processExit.mockRestore(); + await runInit({ + quickstart: true, + installDeps: false, }); - it('creates basic projects', async () => { - await inTemporaryDirectory(async (tmpDir) => { - await runInit({ - path: tmpDir, - git: false, - language: 'ts', - template: 'hello-world', - }); - - const templateFiles = await glob('**/*', { - cwd: getSkeletonSourceDir().replace('skeleton', 'hello-world'), - ignore: ['**/node_modules/**', '**/dist/**'], - }); - const resultFiles = await glob('**/*', {cwd: tmpDir}); - const nonAppFiles = templateFiles.filter( - (item) => !item.startsWith('app/'), - ); - - expect(resultFiles).toEqual(expect.arrayContaining(nonAppFiles)); - - expect(resultFiles).toContain('app/root.tsx'); - expect(resultFiles).toContain('app/entry.client.tsx'); - expect(resultFiles).toContain('app/entry.server.tsx'); - expect(resultFiles).not.toContain('app/components/Layout.tsx'); - - // Skip routes: - expect(resultFiles).not.toContain('app/routes/_index.tsx'); - - await expect(readFile(`${tmpDir}/package.json`)).resolves.toMatch( - `"name": "hello-world"`, - ); - - const output = outputMock.info(); - expect(output).toMatch('success'); - expect(output).not.toMatch('warning'); - expect(output).not.toMatch('Routes'); - expect(output).toMatch(/Language:\s*TypeScript/); - expect(output).toMatch('Next steps'); - expect(output).toMatch( - // Output contains banner characters. USe [^\w]*? to match them. - /Run `cd .*? &&[^\w]*?npm[^\w]*?install[^\w]*?&&[^\w]*?npm[^\w]*?run[^\w]*?dev`/ims, - ); - }); - }); - - it('applies diff for examples', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const exampleName = 'third-party-queries-caching'; - - await runInit({ - path: tmpDir, - git: false, - language: 'ts', - template: exampleName, - }); - - const templatePath = getSkeletonSourceDir(); - const examplePath = templatePath - .replace('templates', 'examples') - .replace('skeleton', exampleName); - - // --- Test file diff - const ignore = ['**/node_modules/**', '**/dist/**']; - const resultFiles = await glob('**/*', {ignore, cwd: tmpDir}); - const exampleFiles = await glob('**/*', {ignore, cwd: examplePath}); - const templateFiles = ( - await glob('**/*', {ignore, cwd: templatePath}) - ).filter((item) => !item.endsWith('CHANGELOG.md')); - - expect(resultFiles).toEqual( - expect.arrayContaining([ - ...new Set([...templateFiles, ...exampleFiles]), - ]), - ); - - // --- Test package.json merge - const templatePkgJson = await readAndParsePackageJson( - `${templatePath}/package.json`, - ); - const examplePkgJson = await readAndParsePackageJson( - `${examplePath}/package.json`, - ); - const resultPkgJson = await readAndParsePackageJson( - `${tmpDir}/package.json`, - ); - - expect(resultPkgJson.name).toMatch(exampleName); - - expect(resultPkgJson.scripts).toEqual( - expect.objectContaining(templatePkgJson.scripts), - ); - - expect(resultPkgJson.dependencies).toEqual( - expect.objectContaining({ - ...templatePkgJson.dependencies, - ...examplePkgJson.dependencies, - '@shopify/cli-hydrogen': - templatePkgJson.dependencies?.['@shopify/cli-hydrogen'], - }), - ); - expect(resultPkgJson.devDependencies).toEqual( - expect.objectContaining({ - ...templatePkgJson.devDependencies, - ...examplePkgJson.devDependencies, - }), - ); - expect(resultPkgJson.peerDependencies).toEqual( - expect.objectContaining({ - ...templatePkgJson.peerDependencies, - ...examplePkgJson.peerDependencies, - }), - ); - - // --- Keeps original tsconfig.json - expect(await readFile(joinPath(templatePath, 'tsconfig.json'))).toEqual( - await readFile(joinPath(tmpDir, 'tsconfig.json')), - ); - }); - }); - - it('transpiles projects to JS', async () => { - await inTemporaryDirectory(async (tmpDir) => { - await runInit({ - path: tmpDir, - git: false, - language: 'js', - template: 'hello-world', - }); - - const templateFiles = await glob('**/*', { - cwd: getSkeletonSourceDir().replace('skeleton', 'hello-world'), - ignore: ['**/node_modules/**', '**/dist/**'], - }); - const resultFiles = await glob('**/*', {cwd: tmpDir}); - - expect(resultFiles).toEqual( - expect.arrayContaining( - templateFiles - .filter((item) => !item.endsWith('.d.ts')) - .map((item) => - item - .replace(/\.ts(x)?$/, '.js$1') - .replace(/tsconfig\.json$/, 'jsconfig.json'), - ), - ), - ); - - // No types but JSDocs: - await expect(readFile(`${tmpDir}/server.js`)).resolves.toMatch( - /export default {\n\s+\/\*\*.*?\*\/\n\s+async fetch\(\s*request,\s*env,\s*executionContext,?\s*\)/s, - ); - - const output = outputMock.info(); - expect(output).toMatch('success'); - expect(output).not.toMatch('warning'); - expect(output).toMatch(/Language:\s*JavaScript/); - }); - }); - - it('creates functional classic Remix projects', async () => { - await inTemporaryDirectory(async (tmpDir) => { - await runInit({ - path: tmpDir, - git: false, - language: 'ts', - template: 'classic-remix', - routes: true, - installDeps: true, - }); - - const templateFiles = await glob('**/*', { - cwd: getSkeletonSourceDir() - .replace('templates', 'examples') - .replace('skeleton', 'classic-remix'), - ignore: ['**/node_modules/**', '**/dist/**'], - }); - const resultFiles = await glob('**/*', {cwd: tmpDir}); - const nonAppFiles = templateFiles.filter( - (item) => !item.startsWith('app/'), - ); - - expect(resultFiles).toEqual(expect.arrayContaining(nonAppFiles)); - expect(resultFiles).not.toContain('vite.config.ts'); - expect(resultFiles).not.toContain('env.d.ts'); - - await expect(readFile(`${tmpDir}/package.json`)).resolves.toMatch( - `"name": "example-classic-remix"`, - ); - - // ---- DEV - outputMock.clear(); - vi.stubEnv('NODE_ENV', 'development'); - - const {close, getUrl} = await runClassicDev({ - path: tmpDir, - disableVirtualRoutes: true, - disableVersionCheck: true, - cliConfig: {} as any, - }); - - try { - await vi.waitFor( - () => expect(outputMock.output()).toMatch('success'), - {timeout: 5000}, - ); - - expect(outputMock.output()).toMatch(/View [^:]+? app:/i); - - await expect( - fileExists(joinPath(tmpDir, 'dist', 'worker', 'index.js')), - ).resolves.toBeTruthy(); - - const response = await fetch(getUrl()); - expect(response.status).toEqual(200); - expect(response.headers.get('content-type')).toEqual('text/html'); - await expect(response.text()).resolves.toMatch('Mock.shop'); - } finally { - await close(); - } - - // ---- BUILD - outputMock.clear(); - vi.stubEnv('NODE_ENV', 'production'); - - await expect( - runClassicBuild({directory: tmpDir}), - ).resolves.not.toThrow(); - - const expectedBundlePath = 'dist/worker/index.js'; - - const output = outputMock.output(); - expect(output).toMatch(expectedBundlePath); - expect( - fileExists(joinPath(tmpDir, expectedBundlePath)), - ).resolves.toBeTruthy(); - - const mb = Number(output.match(/index\.js\s+([\d.]+)\s+MB/)?.[1] || ''); - - // Bundle size within 1 MB - expect(mb).toBeGreaterThan(0); - expect(mb).toBeLessThan(1); - - // Bundle analysis - expect(output).toMatch('Complete analysis: file://'); - - const clientAnalysisPath = 'dist/worker/client-bundle-analyzer.html'; - const workerAnalysisPath = 'dist/worker/worker-bundle-analyzer.html'; - - await expect( - fileExists(joinPath(tmpDir, clientAnalysisPath)), - ).resolves.toBeTruthy(); - - await expect( - fileExists(joinPath(tmpDir, workerAnalysisPath)), - ).resolves.toBeTruthy(); - - await expect( - readFile(joinPath(tmpDir, clientAnalysisPath)), - ).resolves.toMatch(/globalThis\.METAFILE = '.+';/g); - - await expect( - readFile(joinPath(tmpDir, workerAnalysisPath)), - ).resolves.toMatch(/globalThis\.METAFILE = '.+';/g); - }); + expect(setupTemplate).toHaveBeenCalledWith({ + i18n: 'none', + installDeps: false, + language: 'js', + mockShop: true, + path: './hydrogen-quickstart', + routes: true, + shortcut: true, + quickstart: true, + git: true, }); }); - describe('local templates', () => { - it('creates basic projects', async () => { - await inTemporaryDirectory(async (tmpDir) => { - await runInit({ - path: tmpDir, - git: false, - language: 'ts', - mockShop: true, - }); - - const templateFiles = await glob('**/*', { - cwd: getSkeletonSourceDir(), - ignore: ['**/node_modules/**', '**/dist/**'], - }); - const resultFiles = await glob('**/*', {cwd: tmpDir}); - const nonAppFiles = templateFiles.filter( - (item) => !item.startsWith('app/'), - ); - - expect(resultFiles).toEqual(expect.arrayContaining(nonAppFiles)); + describe('project validity', () => { + let tmpDir: string; - expect(resultFiles).toContain('app/root.tsx'); - expect(resultFiles).toContain('app/entry.client.tsx'); - expect(resultFiles).toContain('app/entry.server.tsx'); - expect(resultFiles).toContain('app/components/Layout.tsx'); + beforeAll(async () => { + tmpDir = temporaryDirectory({prefix: 'h2-test-'}); - // Skip routes: - expect(resultFiles).not.toContain('app/routes/_index.tsx'); - - // Not modified: - await expect(readFile(`${tmpDir}/server.ts`)).resolves.toEqual( - await readFile(`${getSkeletonSourceDir()}/server.ts`), - ); - - // Replaces package.json#name - await expect(readFile(`${tmpDir}/package.json`)).resolves.toMatch( - `"name": "${basename(tmpDir)}"`, - ); - - // Creates .env - await expect(readFile(`${tmpDir}/.env`)).resolves.toMatch( - `PUBLIC_STORE_DOMAIN="mock.shop"`, - ); - - const output = outputMock.info(); - expect(output).toMatch('success'); - expect(output).not.toMatch('warning'); - expect(output).toMatch(basename(tmpDir)); - expect(output).not.toMatch('Routes'); - expect(output).toMatch(/Language:\s*TypeScript/); - expect(output).toMatch('Next steps'); - expect(output).toMatch( - // Output contains banner characters. USe [^\w]*? to match them. - /Run `cd .*? &&[^\w]*?npm[^\w]*?install[^\w]*?&&[^\w]*?npm[^\w]*?run[^\w]*?dev`/ims, - ); - }); - }); - - it('creates project prompting for package-manager', async () => { - await inTemporaryDirectory(async (tmpDir) => { - await runInit({ + await expect( + runInit({ path: tmpDir, - git: false, + quickstart: true, language: 'ts', - packageManager: 'unknown', - mockShop: true, - }); - - expect(renderSelectPrompt).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'Select package manager to install dependencies', - }), - ); - }); - }); - - it('creates projects with route files', async () => { - await inTemporaryDirectory(async (tmpDir) => { - await runInit({path: tmpDir, git: false, routes: true, language: 'ts'}); - - const templateFiles = await glob('**/*', { - cwd: getSkeletonSourceDir(), - ignore: ['**/node_modules/**', '**/dist/**'], - }); - - const resultFiles = await glob('**/*', {cwd: tmpDir}); - - expect(resultFiles).toEqual(expect.arrayContaining(templateFiles)); - expect(resultFiles).toContain('app/routes/_index.tsx'); - - // Not modified: - await expect(readFile(`${tmpDir}/server.ts`)).resolves.toEqual( - await readFile(`${getSkeletonSourceDir()}/server.ts`), - ); - - const output = outputMock.info(); - expect(output).toMatch('success'); - expect(output).not.toMatch('warning'); - expect(output).toMatch(basename(tmpDir)); - expect(output).toMatch(/Language:\s*TypeScript/); - expect(output).toMatch('Routes'); - expect(output).toMatch('Home (/ & /:catchAll)'); - expect(output).toMatch('Account (/account/*)'); - }); - }); - - it('transpiles projects to JS', async () => { - await inTemporaryDirectory(async (tmpDir) => { - await runInit({path: tmpDir, git: false, routes: true, language: 'js'}); - - const templateFiles = await glob('**/*', { - cwd: getSkeletonSourceDir(), - ignore: ['**/node_modules/**', '**/dist/**'], - }); - const resultFiles = await glob('**/*', {cwd: tmpDir}); - - expect(resultFiles).toEqual( - expect.arrayContaining( - templateFiles - .filter((item) => !item.endsWith('.d.ts')) - .map((item) => - item - .replace(/\.ts(x)?$/, '.js$1') - .replace(/tsconfig\.json$/, 'jsconfig.json'), - ), - ), - ); - - expect(resultFiles).toContain('app/routes/_index.jsx'); - - // No types but JSDocs: - await expect(readFile(`${tmpDir}/server.js`)).resolves.toMatch( - /export default {\n\s+\/\*\*.*?\*\/\n\s+async fetch\(\s*request,\s*env,\s*executionContext,?\s*\)/s, - ); - - const output = outputMock.info(); - expect(output).toMatch('success'); - expect(output).not.toMatch('warning'); - expect(output).toMatch(basename(tmpDir)); - expect(output).toMatch(/Language:\s*JavaScript/); - expect(output).toMatch('Routes'); - expect(output).toMatch('Home (/ & /:catchAll)'); - expect(output).toMatch('Account (/account/*)'); - }); - }); - - // TODO enable when we support it in Vite - describe.skip('styling libraries', () => { - it('scaffolds Tailwind CSS', async () => { - await inTemporaryDirectory(async (tmpDir) => { - await runInit({ - path: tmpDir, - git: false, - language: 'ts', - styling: 'tailwind', - }); - - // Copies Tailwind file - await expect( - readFile(`${tmpDir}/app/styles/tailwind.css`), - ).resolves.toMatch(/@tailwind base;/); - - // Injects styles in Root - const rootFile = await readFile(`${tmpDir}/app/root.tsx`); - await expect(rootFile).toMatch(/import tailwindCss from/); - await expect(rootFile).toMatch( - /export function links\(\) \{.*?return \[.*\{rel: 'stylesheet', href: tailwindCss\}/ims, - ); - - const output = outputMock.info(); - expect(output).toMatch('success'); - expect(output).not.toMatch('warning'); - expect(output).toMatch(/Styling:\s*Tailwind/); - }); - }); - - it('scaffolds CSS Modules', async () => { - await inTemporaryDirectory(async (tmpDir) => { - await runInit({ - path: tmpDir, - git: false, - language: 'ts', - styling: 'css-modules', - }); - - // Injects the Remix dependency - await expect(readFile(`${tmpDir}/package.json`)).resolves.toMatch( - `"@remix-run/css-bundle": "`, - ); - - // Injects styles in Root - const rootFile = await readFile(`${tmpDir}/app/root.tsx`); - await expect(rootFile).toMatch(/import {cssBundleHref} from/); - await expect(rootFile).toMatch( - /export function links\(\) \{.*?return \[.*\{rel: 'stylesheet', href: cssBundleHref\}/ims, - ); - - const output = outputMock.info(); - expect(output).toMatch('success'); - expect(output).not.toMatch('warning'); - expect(output).toMatch(/Styling:\s*CSS Modules/); - }); - }); - - it('scaffolds Vanilla Extract', async () => { - await inTemporaryDirectory(async (tmpDir) => { - await runInit({ - path: tmpDir, - git: false, - language: 'ts', - styling: 'vanilla-extract', - }); - - // Injects dependencies - const packageJson = await readFile(`${tmpDir}/package.json`); - expect(packageJson).toMatch(/"@remix-run\/css-bundle": "/); - expect(packageJson).toMatch(/"@vanilla-extract\/css": "/); - - // Injects styles in Root - const rootFile = await readFile(`${tmpDir}/app/root.tsx`); - await expect(rootFile).toMatch(/import {cssBundleHref} from/); - await expect(rootFile).toMatch( - /export function links\(\) \{.*?return \[.*\{rel: 'stylesheet', href: cssBundleHref\}/ims, - ); - - const output = outputMock.info(); - expect(output).toMatch('success'); - expect(output).not.toMatch('warning'); - expect(output).toMatch(/Styling:\s*Vanilla Extract/); - }); - }); - }); - - describe('i18n strategies', () => { - it('scaffolds i18n with domains strategy', async () => { - await inTemporaryDirectory(async (tmpDir) => { - await runInit({ - path: tmpDir, - git: false, - language: 'ts', - i18n: 'domains', - routes: true, - }); + }), + ).resolves.not.toThrow(); - const resultFiles = await glob('**/*', {cwd: tmpDir}); - expect(resultFiles).toContain('app/routes/_index.tsx'); - - // Injects styles in Root - const serverFile = await readFile(`${tmpDir}/server.ts`); - expect(serverFile).toMatch(/i18n: getLocaleFromRequest\(request\),/); - expect(serverFile).toMatch(/domain = url.hostname/); - - const output = outputMock.info(); - expect(output).toMatch('success'); - expect(output).not.toMatch('warning'); - expect(output).toMatch(/Markets:\s*Top-level domains/); - }); - }); - - it('scaffolds i18n with subdomains strategy', async () => { - await inTemporaryDirectory(async (tmpDir) => { - await runInit({ - path: tmpDir, - git: false, - language: 'ts', - i18n: 'subdomains', - routes: true, - }); - - const resultFiles = await glob('**/*', {cwd: tmpDir}); - expect(resultFiles).toContain('app/routes/_index.tsx'); - - // Injects styles in Root - const serverFile = await readFile(`${tmpDir}/server.ts`); - expect(serverFile).toMatch(/i18n: getLocaleFromRequest\(request\),/); - expect(serverFile).toMatch(/firstSubdomain = url.hostname/); - - const output = outputMock.info(); - expect(output).toMatch('success'); - expect(output).not.toMatch('warning'); - expect(output).toMatch(/Markets:\s*Subdomains/); - }); - }); - - it('scaffolds i18n with subfolders strategy', async () => { - await inTemporaryDirectory(async (tmpDir) => { - await runInit({ - path: tmpDir, - git: false, - language: 'ts', - i18n: 'subfolders', - routes: true, - }); - - const resultFiles = await glob('**/*', {cwd: tmpDir}); - // Adds locale to the path - expect(resultFiles).toContain('app/routes/($locale)._index.tsx'); - - // Adds ($locale) route - expect(resultFiles).toContain('app/routes/($locale).tsx'); - - // Injects styles in Root - const serverFile = await readFile(`${tmpDir}/server.ts`); - expect(serverFile).toMatch(/i18n: getLocaleFromRequest\(request\),/); - expect(serverFile).toMatch(/url.pathname/); - - const output = outputMock.info(); - expect(output).toMatch('success'); - expect(output).not.toMatch('warning'); - expect(output).toMatch(/Markets:\s*Subfolders/); - }); - }); + return () => removeFile(tmpDir); }); - describe('git', () => { - it('initializes a git repository and creates initial commits', async () => { - await inTemporaryDirectory(async (tmpDir) => { - await runInit({ - path: tmpDir, - git: true, - language: 'js', - styling: 'tailwind', - i18n: 'domains', - routes: true, - installDeps: true, - }); - - expect(isDirectory(`${tmpDir}/.git`)).resolves.toBeTruthy(); - - const {stdout: gitLog} = await execAsync(`git log --oneline`, { - cwd: tmpDir, - }); - - expect(gitLog.split('\n')).toEqual( - expect.arrayContaining([ - expect.stringContaining('Lockfile'), - expect.stringContaining('Generate routes for core functionality'), - // TODO - // expect.stringContaining('Setup Tailwind'), - expect.stringContaining('Setup markets support using domains'), - expect.stringContaining('Scaffold Storefront'), - ]), - ); - }); - }); + it('typechecks the project', async () => { + // This will throw if TSC fails + await expect( + exec('npm', ['run', 'typecheck'], {cwd: tmpDir}), + ).resolves.not.toThrow(); }); - describe('project validity', () => { - it('typechecks the project', async () => { - await inTemporaryDirectory(async (tmpDir) => { - await runInit({ - path: tmpDir, - git: true, - language: 'ts', - styling: 'tailwind', - i18n: 'subfolders', - routes: true, - installDeps: true, - }); - - // This will throw if TSC fails - await expect( - exec('npm', ['run', 'typecheck'], {cwd: tmpDir}), - ).resolves.not.toThrow(); - }); - }); - - it('contains all standard routes', async () => { - await inTemporaryDirectory(async (tmpDir) => { - await runInit({ - path: tmpDir, - git: true, - language: 'ts', - i18n: 'subfolders', - routes: true, - installDeps: true, - }); - - // Clear previous success messages - outputMock.clear(); - - await runCheckRoutes({directory: tmpDir}); - - const output = outputMock.info(); - expect(output).toMatch('success'); - }); - }); - - it('supports codegen', async () => { - await inTemporaryDirectory(async (tmpDir) => { - await runInit({ - path: tmpDir, - git: true, - language: 'ts', - routes: true, - installDeps: true, - }); - - // Clear previous success messages - outputMock.clear(); - - const codegenFile = `${tmpDir}/storefrontapi.generated.d.ts`; - const codegenFromTemplate = await readFile(codegenFile); - expect(codegenFromTemplate).toBeTruthy(); - - await removeFile(codegenFile); - expect(fileExists(codegenFile)).resolves.toBeFalsy(); - - await expect(runCodegen({directory: tmpDir})).resolves.not.toThrow(); - - const output = outputMock.info(); - expect(output).toMatch('success'); - - await expect(readFile(codegenFile)).resolves.toEqual( - codegenFromTemplate, - ); - }); - }); - - it('builds the generated project', async () => { - await inTemporaryDirectory(async (tmpDir) => { - await runInit({ - path: tmpDir, - git: true, - language: 'ts', - styling: 'postcss', - i18n: 'subfolders', - routes: true, - installDeps: true, - }); - - // Clear previous success messages - outputMock.clear(); - vi.stubEnv('NODE_ENV', 'production'); - - await expect( - runViteBuild({directory: tmpDir}), - ).resolves.not.toThrow(); - - const expectedBundlePath = 'dist/server/index.js'; - - const output = outputMock.output(); - expect(output).toMatch(expectedBundlePath); - expect(output).toMatch('building for productio'); - expect(output).toMatch('dist/client/assets/root-'); - expect(output).toMatch('building SSR bundle for productio'); - expect( - fileExists(joinPath(tmpDir, expectedBundlePath)), - ).resolves.toBeTruthy(); - - const kB = Number( - output.match(/dist\/server\/index\.js\s+([\d.]+)\s+kB/)?.[1] || '', - ); - - // Bundle size within 1 MB - expect(kB).toBeGreaterThan(0); - expect(kB).toBeLessThan(1024); - }); - }); - - it('runs dev in the generated project', async () => { - await inTemporaryDirectory(async (tmpDir) => { - await runInit({ - path: tmpDir, - git: true, - language: 'ts', - styling: 'postcss', - i18n: 'subfolders', - routes: true, - installDeps: true, - }); - - // Clear previous success messages - outputMock.clear(); - vi.stubEnv('NODE_ENV', 'development'); - - const {close, getUrl} = await runViteDev({ - path: tmpDir, - disableVirtualRoutes: true, - disableVersionCheck: true, - cliConfig: {} as any, - }); + it('contains all standard routes', async () => { + // Clear previous success messages + outputMock.clear(); - try { - await vi.waitFor( - () => expect(outputMock.output()).toMatch(/View [^:]+? app:/i), - {timeout: 5000}, - ); + await runCheckRoutes({directory: tmpDir}); - const response = await fetch(getUrl()); - expect(response.status).toEqual(200); - expect(response.headers.get('content-type')).toEqual('text/html'); - await expect(response.text()).resolves.toMatch('Mock.shop'); - } finally { - await close(); - } - }); - }); + const output = outputMock.info(); + expect(output).toMatch('success'); }); - describe('Quickstart options', () => { - it('Scaffolds Quickstart project with expected values', async () => { - await inTemporaryDirectory(async (tmpDir) => { - await runInit({ - path: tmpDir, - quickstart: true, - installDeps: false, - }); + it('supports codegen', async () => { + // Clear previous success messages + outputMock.clear(); - const templateFiles = await glob('**/*', { - cwd: getSkeletonSourceDir().replace( - 'skeleton', - 'hydrogen-quickstart', - ), - ignore: ['**/node_modules/**', '**/dist/**'], - }); - const resultFiles = await glob('**/*', {cwd: tmpDir}); - const nonAppFiles = templateFiles.filter( - (item) => !item.startsWith('app/'), - ); + const codegenFile = `${tmpDir}/storefrontapi.generated.d.ts`; + const codegenFromTemplate = await readFile(codegenFile); + expect(codegenFromTemplate).toBeTruthy(); - expect(resultFiles).toEqual(expect.arrayContaining(nonAppFiles)); + await removeFile(codegenFile); + await expect(fileExists(codegenFile)).resolves.toBeFalsy(); - expect(resultFiles).toContain('app/root.jsx'); - expect(resultFiles).toContain('app/entry.client.jsx'); - expect(resultFiles).toContain('app/entry.server.jsx'); - expect(resultFiles).toContain('app/components/Layout.jsx'); - expect(resultFiles).toContain('app/routes/_index.jsx'); - expect(resultFiles).not.toContain('app/routes/($locale)._index.jsx'); + await expect(runCodegen({directory: tmpDir})).resolves.not.toThrow(); - // await expect(readFile(`${tmpDir}/package.json`)).resolves.toMatch( - // `"name": "hello-world"`, - // ); + const output = outputMock.info(); + expect(output).toMatch('success'); - const output = outputMock.info(); - expect(output).not.toMatch('warning'); - expect(output).toMatch('success'); - expect(output).toMatch(/Shopify:\s+Mock.shop/); - expect(output).toMatch(/Language:\s+JavaScript/); - // TODO - // expect(output).toMatch(/Styling:\s+Tailwind/); - expect(output).toMatch('Routes'); - expect(output).toMatch('Next steps'); - }); - }); + await expect(readFile(codegenFile)).resolves.toEqual(codegenFromTemplate); }); }); }); diff --git a/packages/cli/src/commands/hydrogen/init.ts b/packages/cli/src/commands/hydrogen/init.ts index bd90cdd5f1..468d975dc3 100644 --- a/packages/cli/src/commands/hydrogen/init.ts +++ b/packages/cli/src/commands/hydrogen/init.ts @@ -3,7 +3,6 @@ import {fileURLToPath} from 'node:url'; import {packageManagerFromUserAgent} from '@shopify/cli-kit/node/node-package-manager'; import {Flags} from '@oclif/core'; import {AbortError} from '@shopify/cli-kit/node/error'; -import {AbortController} from '@shopify/cli-kit/node/abort'; import { commonFlags, parseProcessFlags, @@ -12,11 +11,7 @@ import { import {checkHydrogenVersion} from '../../lib/check-version.js'; import {I18N_CHOICES, type I18nChoice} from '../../lib/setups/i18n/index.js'; import {supressNodeExperimentalWarnings} from '../../lib/process.js'; -import { - setupRemoteTemplate, - setupLocalStarterTemplate, - type InitOptions, -} from '../../lib/onboarding/index.js'; +import {setupTemplate, type InitOptions} from '../../lib/onboarding/index.js'; import {LANGUAGES} from '../../lib/onboarding/common.js'; const FLAG_MAP = {f: 'force'} as Record; @@ -136,16 +131,5 @@ export async function runInit( ); } - const controller = new AbortController(); - - try { - const template = options.template; - - return template - ? await setupRemoteTemplate({...options, template}, controller) - : await setupLocalStarterTemplate(options, controller); - } catch (error) { - controller.abort(); - throw error; - } + return setupTemplate(options); } diff --git a/packages/cli/src/lib/classic-compiler/build.test.ts b/packages/cli/src/lib/classic-compiler/build.test.ts new file mode 100644 index 0000000000..92979a89bf --- /dev/null +++ b/packages/cli/src/lib/classic-compiler/build.test.ts @@ -0,0 +1,99 @@ +import '../onboarding/setup-template.mocks.js'; +import { + fileExists, + inTemporaryDirectory, + readFile, + glob, +} from '@shopify/cli-kit/node/fs'; +import {describe, it, expect, vi} from 'vitest'; +import {joinPath} from '@shopify/cli-kit/node/path'; +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output'; +import {getSkeletonSourceDir} from '../build.js'; +import {runClassicCompilerBuild} from './build.js'; +import {setupTemplate} from '../onboarding/index.js'; + +describe('Classic Remix Compiler build', () => { + const outputMock = mockAndCaptureOutput(); + + it('builds the project', async () => { + await inTemporaryDirectory(async (tmpDir) => { + await setupTemplate({ + path: tmpDir, + git: false, + language: 'ts', + template: 'classic-remix', + routes: true, + installDeps: true, + }); + + const templateFiles = await glob('**/*', { + cwd: getSkeletonSourceDir() + .replace('templates', 'examples') + .replace('skeleton', 'classic-remix'), + ignore: ['**/node_modules/**', '**/dist/**'], + dot: false, + }); + const resultFiles = await glob('**/*', { + cwd: tmpDir, + dot: false, + ignore: ['**/node_modules/**'], + }); + + const nonAppFiles = templateFiles.filter( + (item) => !item.startsWith('app/'), + ); + + expect(resultFiles).toEqual(expect.arrayContaining(nonAppFiles)); + expect(resultFiles).not.toContain('vite.config.ts'); + expect(resultFiles).not.toContain('env.d.ts'); + + await expect(readFile(`${tmpDir}/package.json`)).resolves.toMatch( + `"name": "example-classic-remix"`, + ); + + // --- BUILD + outputMock.clear(); + vi.stubEnv('NODE_ENV', 'production'); + + await expect( + runClassicCompilerBuild({directory: tmpDir}), + ).resolves.not.toThrow(); + + const expectedBundlePath = 'dist/worker/index.js'; + + const output = outputMock.output(); + expect(output).toMatch(expectedBundlePath); + expect( + fileExists(joinPath(tmpDir, expectedBundlePath)), + ).resolves.toBeTruthy(); + + const mb = Number(output.match(/index\.js\s+([\d.]+)\s+MB/)?.[1] || ''); + + // Bundle size within 1 MB + expect(mb).toBeGreaterThan(0); + expect(mb).toBeLessThan(1); + + // Bundle analysis + expect(output).toMatch('Complete analysis: file://'); + + const clientAnalysisPath = 'dist/worker/client-bundle-analyzer.html'; + const workerAnalysisPath = 'dist/worker/worker-bundle-analyzer.html'; + + await expect( + fileExists(joinPath(tmpDir, clientAnalysisPath)), + ).resolves.toBeTruthy(); + + await expect( + fileExists(joinPath(tmpDir, workerAnalysisPath)), + ).resolves.toBeTruthy(); + + await expect( + readFile(joinPath(tmpDir, clientAnalysisPath)), + ).resolves.toMatch(/globalThis\.METAFILE = '.+';/g); + + await expect( + readFile(joinPath(tmpDir, workerAnalysisPath)), + ).resolves.toMatch(/globalThis\.METAFILE = '.+';/g); + }); + }); +}); diff --git a/packages/cli/src/lib/classic-compiler/build.ts b/packages/cli/src/lib/classic-compiler/build.ts new file mode 100644 index 0000000000..3231836dcb --- /dev/null +++ b/packages/cli/src/lib/classic-compiler/build.ts @@ -0,0 +1,199 @@ +import { + outputInfo, + outputWarn, + outputContent, + outputToken, +} from '@shopify/cli-kit/node/output'; +import { + fileSize, + copyFile, + rmdir, + glob, + fileExists, + readFile, + writeFile, +} from '@shopify/cli-kit/node/fs'; +import {relativePath, joinPath} from '@shopify/cli-kit/node/path'; +import {getPackageManager} from '@shopify/cli-kit/node/node-package-manager'; +import colors from '@shopify/cli-kit/node/colors'; +import { + type RemixConfig, + assertOxygenChecks, + getProjectPaths, + getRemixConfig, + handleRemixImportFail, + type ServerMode, +} from '../remix-config.js'; +import {checkLockfileStatus} from '../check-lockfile.js'; +import {findMissingRoutes} from '../missing-routes.js'; +import {createRemixLogger, muteRemixLogs} from '../log.js'; +import {codegen} from '../codegen.js'; +import { + buildBundleAnalysis, + getBundleAnalysisSummary, +} from '../bundle/analyzer.js'; +import {isCI} from '../is-ci.js'; + +const LOG_WORKER_BUILT = '๐Ÿ“ฆ Worker built'; +const WORKER_BUILD_SIZE_LIMIT = 5; + +type RunBuildOptions = { + directory?: string; + useCodegen?: boolean; + codegenConfigPath?: string; + sourcemap?: boolean; + disableRouteWarning?: boolean; + assetPath?: string; + bundleStats?: boolean; + lockfileCheck?: boolean; +}; + +export async function runClassicCompilerBuild({ + directory, + useCodegen = false, + codegenConfigPath, + sourcemap = false, + disableRouteWarning = false, + bundleStats = true, + lockfileCheck = true, + assetPath, +}: RunBuildOptions) { + if (!process.env.NODE_ENV) { + process.env.NODE_ENV = 'production'; + } + if (assetPath) { + process.env.HYDROGEN_ASSET_BASE_URL = assetPath; + } + + const {root, buildPath, buildPathClient, buildPathWorkerFile, publicPath} = + getProjectPaths(directory); + + if (lockfileCheck) { + await checkLockfileStatus(root, isCI()); + } + + await muteRemixLogs(); + + console.time(LOG_WORKER_BUILT); + + outputInfo(`\n๐Ÿ—๏ธ Building in ${process.env.NODE_ENV} mode...`); + + const [remixConfig, [{build}, {logThrown}, {createFileWatchCache}]] = + await Promise.all([ + getRemixConfig(root) as Promise, + Promise.all([ + import('@remix-run/dev/dist/compiler/build.js'), + import('@remix-run/dev/dist/compiler/utils/log.js'), + import('@remix-run/dev/dist/compiler/fileWatchCache.js'), + ]).catch(handleRemixImportFail), + rmdir(buildPath, {force: true}), + ]); + + assertOxygenChecks(remixConfig); + + await Promise.all([ + copyPublicFiles(publicPath, buildPathClient), + build({ + config: remixConfig, + options: { + mode: process.env.NODE_ENV as ServerMode, + sourcemap, + }, + logger: createRemixLogger(), + fileWatchCache: createFileWatchCache(), + }).catch((thrown) => { + logThrown(thrown); + if (process.env.SHOPIFY_UNIT_TEST) { + throw thrown; + } else { + process.exit(1); + } + }), + useCodegen && codegen({...remixConfig, configFilePath: codegenConfigPath}), + ]); + + if (process.env.NODE_ENV !== 'development') { + console.timeEnd(LOG_WORKER_BUILT); + + const bundleAnalysisPath = await buildBundleAnalysis(buildPath); + + const sizeMB = (await fileSize(buildPathWorkerFile)) / (1024 * 1024); + const formattedSize = colors.yellow(sizeMB.toFixed(2) + ' MB'); + + outputInfo( + outputContent` ${colors.dim( + relativePath(root, buildPathWorkerFile), + )} ${ + bundleAnalysisPath + ? outputToken.link(formattedSize, bundleAnalysisPath) + : formattedSize + }\n`, + ); + + if (bundleStats && bundleAnalysisPath) { + outputInfo( + outputContent`${ + (await getBundleAnalysisSummary(buildPathWorkerFile)) || '\n' + }\n โ”‚\n โ””โ”€โ”€โ”€ ${outputToken.link( + 'Complete analysis: ' + bundleAnalysisPath, + bundleAnalysisPath, + )}\n\n`, + ); + } + + if (sizeMB >= WORKER_BUILD_SIZE_LIMIT) { + outputWarn( + `๐Ÿšจ Smaller worker bundles are faster to deploy and run.${ + remixConfig.serverMinify + ? '' + : '\n Minify your bundle by adding `serverMinify: true` to remix.config.js.' + }\n Learn more about optimizing your worker bundle file: https://h2o.fyi/debugging/bundle-size\n`, + ); + } + } + + if (!disableRouteWarning) { + const missingRoutes = findMissingRoutes(remixConfig); + if (missingRoutes.length) { + const packageManager = await getPackageManager(root); + const exec = packageManager === 'npm' ? 'npx' : packageManager; + + outputWarn( + `Heads up: Shopify stores have a number of standard routes that arenโ€™t set up yet.\n` + + `Some functionality and backlinks might not work as expected until these are created or redirects are set up.\n` + + `This build is missing ${missingRoutes.length} route${ + missingRoutes.length > 1 ? 's' : '' + }. For more details, run \`${exec} shopify hydrogen check routes\`.\n`, + ); + } + } + + if (process.env.NODE_ENV !== 'development') { + await cleanClientSourcemaps(buildPathClient); + } +} + +async function cleanClientSourcemaps(buildPathClient: string) { + const bundleFiles = await glob(joinPath(buildPathClient, '**/*.js')); + + await Promise.all( + bundleFiles.map(async (filePath) => { + const file = await readFile(filePath); + return await writeFile( + filePath, + file.replace(/\/\/# sourceMappingURL=.+\.js\.map$/gm, ''), + ); + }), + ); +} + +export async function copyPublicFiles( + publicPath: string, + buildPathClient: string, +) { + if (!(await fileExists(publicPath))) { + return; + } + + return copyFile(publicPath, buildPathClient); +} diff --git a/packages/cli/src/lib/classic-compiler/dev.test.ts b/packages/cli/src/lib/classic-compiler/dev.test.ts new file mode 100644 index 0000000000..d89855f731 --- /dev/null +++ b/packages/cli/src/lib/classic-compiler/dev.test.ts @@ -0,0 +1,62 @@ +import '../onboarding/setup-template.mocks.js'; +import { + fileExists, + inTemporaryDirectory, + readFile, +} from '@shopify/cli-kit/node/fs'; +import {describe, it, expect, vi} from 'vitest'; +import {joinPath} from '@shopify/cli-kit/node/path'; +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output'; +import {runClassicCompilerDev} from './dev.js'; +import {setupTemplate} from '../onboarding/index.js'; + +describe('Classic Remix Compiler dev', () => { + const outputMock = mockAndCaptureOutput(); + + it('runs dev in project', async () => { + await inTemporaryDirectory(async (tmpDir) => { + await setupTemplate({ + path: tmpDir, + git: false, + language: 'ts', + template: 'classic-remix', + routes: true, + installDeps: true, + }); + + await expect(readFile(`${tmpDir}/package.json`)).resolves.toMatch( + `"name": "example-classic-remix"`, + ); + + // --- DEV + outputMock.clear(); + vi.stubEnv('NODE_ENV', 'development'); + + const {close, getUrl} = await runClassicCompilerDev({ + path: tmpDir, + disableVirtualRoutes: true, + disableVersionCheck: true, + cliConfig: {} as any, + }); + + try { + await vi.waitFor(() => expect(outputMock.output()).toMatch('success'), { + timeout: 5000, + }); + + expect(outputMock.output()).toMatch(/View [^:]+? app:/i); + + await expect( + fileExists(joinPath(tmpDir, 'dist', 'worker', 'index.js')), + ).resolves.toBeTruthy(); + + const response = await fetch(getUrl()); + expect(response.status).toEqual(200); + expect(response.headers.get('content-type')).toEqual('text/html'); + await expect(response.text()).resolves.toMatch('Mock.shop'); + } finally { + await close(); + } + }); + }); +}); diff --git a/packages/cli/src/lib/classic-compiler/dev.ts b/packages/cli/src/lib/classic-compiler/dev.ts new file mode 100644 index 0000000000..94a6821123 --- /dev/null +++ b/packages/cli/src/lib/classic-compiler/dev.ts @@ -0,0 +1,357 @@ +import fs from 'node:fs/promises'; +import type {ChildProcess} from 'node:child_process'; +import {outputDebug, outputInfo} from '@shopify/cli-kit/node/output'; +import {fileExists} from '@shopify/cli-kit/node/fs'; +import {renderFatalError} from '@shopify/cli-kit/node/ui'; +import {relativePath, resolvePath} from '@shopify/cli-kit/node/path'; +import {copyPublicFiles} from './build.js'; +import { + type RemixConfig, + assertOxygenChecks, + getProjectPaths, + getRemixConfig, + handleRemixImportFail, + type ServerMode, +} from '../remix-config.js'; +import { + createRemixLogger, + enhanceH2Logs, + muteDevLogs, + isH2Verbose, + setH2OVerbose, +} from '../log.js'; +import {DEFAULT_APP_PORT} from '../flags.js'; +import {Config} from '@oclif/core'; +import { + type MiniOxygen, + startMiniOxygen, + buildAssetsUrl, +} from '../mini-oxygen/index.js'; +import {addVirtualRoutes} from '../virtual-routes.js'; +import {spawnCodegenProcess} from '../codegen.js'; +import {getAllEnvironmentVariables} from '../environment-variables.js'; +import {setupLiveReload} from '../live-reload.js'; +import {checkRemixVersions} from '../remix-version-check.js'; +import {displayDevUpgradeNotice} from '../../commands/hydrogen/upgrade.js'; +import {findPort} from '../find-port.js'; +import { + startTunnelAndPushConfig, + getDevConfigInBackground, + isMockShop, + notifyIssueWithTunnelAndMockShop, +} from '../dev-shared.js'; +import {getCliCommand} from '../shell.js'; + +const LOG_REBUILDING = '๐Ÿงฑ Rebuilding...'; +const LOG_REBUILT = '๐Ÿš€ Rebuilt'; + +type DevOptions = { + port?: number; + path?: string; + codegen?: boolean; + legacyRuntime?: boolean; + codegenConfigPath?: string; + disableVirtualRoutes?: boolean; + disableVersionCheck?: boolean; + env?: string; + envBranch?: string; + debug?: boolean; + sourcemap?: boolean; + inspectorPort?: number; + customerAccountPush?: boolean; + cliConfig: Config; + shouldLiveReload?: boolean; + verbose?: boolean; +}; + +export async function runClassicCompilerDev({ + port: appPort, + path: appPath, + codegen: useCodegen = false, + legacyRuntime = false, + codegenConfigPath, + disableVirtualRoutes, + env: envHandle, + envBranch, + debug = false, + sourcemap = true, + disableVersionCheck = false, + inspectorPort, + customerAccountPush: customerAccountPushFlag = false, + shouldLiveReload = true, + cliConfig, + verbose, +}: DevOptions) { + if (!process.env.NODE_ENV) process.env.NODE_ENV = 'development'; + + if (verbose) setH2OVerbose(); + if (!isH2Verbose()) muteDevLogs(); + + const {root, publicPath, buildPathClient, buildPathWorkerFile} = + getProjectPaths(appPath); + + const copyFilesPromise = copyPublicFiles(publicPath, buildPathClient); + const cliCommandPromise = getCliCommand(root); + + const reloadConfig = async () => { + const config = (await getRemixConfig(root)) as RemixConfig; + + return disableVirtualRoutes + ? config + : addVirtualRoutes(config).catch((error) => { + // Seen this fail when somehow NPM doesn't publish + // the full 'virtual-routes' directory. + // E.g. https://unpkg.com/browse/@shopify/cli-hydrogen@0.0.0-next-aa15969-20230703072007/dist/virtual-routes/ + outputDebug( + 'Could not add virtual routes: ' + + (error?.stack ?? error?.message ?? error), + ); + + return config; + }); + }; + + const getFilePaths = (file: string) => { + const fileRelative = relativePath(root, file); + return [fileRelative, resolvePath(root, fileRelative)] as const; + }; + + const serverBundleExists = () => fileExists(buildPathWorkerFile); + + if (!appPort) { + appPort = await findPort(DEFAULT_APP_PORT); + } + + const assetsPort = legacyRuntime ? 0 : await findPort(appPort + 100); + if (assetsPort) { + // Note: Set this env before loading Remix config! + process.env.HYDROGEN_ASSET_BASE_URL = await buildAssetsUrl(assetsPort); + } + + const backgroundPromise = getDevConfigInBackground( + root, + customerAccountPushFlag, + ); + + const tunnelPromise = + cliConfig && + backgroundPromise.then(({customerAccountPush, storefrontId}) => { + if (customerAccountPush) { + return startTunnelAndPushConfig( + root, + cliConfig, + appPort!, + storefrontId, + ); + } + }); + + const remixConfig = await reloadConfig(); + assertOxygenChecks(remixConfig); + + const envPromise = backgroundPromise.then(({fetchRemote, localVariables}) => + getAllEnvironmentVariables({ + root, + fetchRemote, + envBranch, + envHandle, + localVariables, + }), + ); + + const [{watch}, {createFileWatchCache}] = await Promise.all([ + import('@remix-run/dev/dist/compiler/watch.js'), + import('@remix-run/dev/dist/compiler/fileWatchCache.js'), + ]).catch(handleRemixImportFail); + + let isInitialBuild = true; + let initialBuildDurationMs = 0; + let initialBuildStartTimeMs = Date.now(); + + const liveReload = shouldLiveReload + ? await setupLiveReload(remixConfig.dev?.port ?? 8002) + : undefined; + + let miniOxygen: MiniOxygen; + let codegenProcess: ChildProcess; + async function safeStartMiniOxygen() { + if (miniOxygen) return; + + const {allVariables, logInjectedVariables} = await envPromise; + + miniOxygen = await startMiniOxygen( + { + root, + debug, + appPort, + assetsPort, + inspectorPort, + watch: !liveReload, + buildPathWorkerFile, + buildPathClient, + env: allVariables, + }, + legacyRuntime, + ); + + logInjectedVariables(); + + const host = (await tunnelPromise)?.host ?? miniOxygen.listeningAt; + + const cliCommand = await cliCommandPromise; + enhanceH2Logs({host, cliCommand, ...remixConfig}); + + const {storefrontTitle} = await backgroundPromise; + + miniOxygen.showBanner({ + appName: storefrontTitle, + headlinePrefix: + initialBuildDurationMs > 0 + ? `Initial build: ${initialBuildDurationMs}ms\n` + : '', + host, + }); + + if (useCodegen) { + codegenProcess = spawnCodegenProcess({ + ...remixConfig, + configFilePath: codegenConfigPath, + }); + } + + checkRemixVersions(); + + if (!disableVersionCheck) { + displayDevUpgradeNotice({targetPath: appPath}); + } + + if (customerAccountPushFlag && isMockShop(allVariables)) { + notifyIssueWithTunnelAndMockShop(cliCommand); + } + } + + const fileWatchCache = createFileWatchCache(); + let skipRebuildLogs = false; + + const closeWatcher = await watch( + { + config: remixConfig, + options: { + mode: process.env.NODE_ENV as ServerMode, + sourcemap, + }, + fileWatchCache, + logger: createRemixLogger(), + }, + { + reloadConfig, + onBuildStart(ctx) { + if (!isInitialBuild && !skipRebuildLogs) { + outputInfo(LOG_REBUILDING); + console.time(LOG_REBUILT); + } + + liveReload?.onBuildStart(ctx); + }, + onBuildManifest: liveReload?.onBuildManifest, + async onBuildFinish(context, duration, succeeded) { + if (isInitialBuild) { + await copyFilesPromise; + initialBuildDurationMs = Date.now() - initialBuildStartTimeMs; + isInitialBuild = false; + } else if (!skipRebuildLogs) { + skipRebuildLogs = false; + console.timeEnd(LOG_REBUILT); + if (!miniOxygen) console.log(''); // New line + } + + if (!miniOxygen && !(await serverBundleExists())) { + return renderFatalError({ + name: 'BuildError', + type: 0, + message: + 'MiniOxygen cannot start because the server bundle has not been generated.', + skipOclifErrorHandling: true, + tryMessage: + 'This is likely due to an error in your app and Remix is unable to compile. Try fixing the app and MiniOxygen will start.', + }); + } + + if (succeeded) { + if (!miniOxygen) { + await safeStartMiniOxygen(); + } else if (liveReload) { + await miniOxygen.reload(); + } + + liveReload?.onAppReady(context); + } + }, + async onFileCreated(file: string) { + const [relative, absolute] = getFilePaths(file); + outputInfo(`\n๐Ÿ“„ File created: ${relative}`); + + if (absolute.startsWith(publicPath)) { + await copyPublicFiles( + absolute, + absolute.replace(publicPath, buildPathClient), + ); + } + }, + async onFileChanged(file: string) { + fileWatchCache.invalidateFile(file); + + const [relative, absolute] = getFilePaths(file); + outputInfo(`\n๐Ÿ“„ File changed: ${relative}`); + + if (relative.endsWith('.env')) { + skipRebuildLogs = true; + const {fetchRemote} = await backgroundPromise; + const {allVariables, logInjectedVariables} = + await getAllEnvironmentVariables({ + root, + fetchRemote, + envBranch, + envHandle, + }); + + logInjectedVariables(); + + await miniOxygen.reload({ + env: allVariables, + }); + } + + if (absolute.startsWith(publicPath)) { + await copyPublicFiles( + absolute, + absolute.replace(publicPath, buildPathClient), + ); + } + }, + async onFileDeleted(file: string) { + fileWatchCache.invalidateFile(file); + + const [relative, absolute] = getFilePaths(file); + outputInfo(`\n๐Ÿ“„ File deleted: ${relative}`); + + if (absolute.startsWith(publicPath)) { + await fs.unlink(absolute.replace(publicPath, buildPathClient)); + } + }, + }, + ); + + return { + getUrl: () => miniOxygen.listeningAt, + async close() { + codegenProcess?.removeAllListeners('close'); + codegenProcess?.kill('SIGINT'); + await Promise.allSettled([ + closeWatcher(), + miniOxygen?.close(), + Promise.resolve(tunnelPromise).then((tunnel) => tunnel?.cleanup?.()), + ]); + }, + }; +} diff --git a/packages/cli/src/lib/file.test.ts b/packages/cli/src/lib/file.test.ts index c722243b37..8588e64376 100644 --- a/packages/cli/src/lib/file.test.ts +++ b/packages/cli/src/lib/file.test.ts @@ -49,29 +49,29 @@ describe('File utils', () => { await mkdir(resolvePath(tmpDir, 'fifth')); await writeFile(resolvePath(tmpDir, 'fifth', 'index.ts'), 'content'); - expect(findFileWithExtension(tmpDir, 'first')).resolves.toEqual({ + await expect(findFileWithExtension(tmpDir, 'first')).resolves.toEqual({ filepath: expect.stringMatching(/first\.js$/), extension: 'js', astType: 'js', }); - expect(findFileWithExtension(tmpDir, 'second')).resolves.toEqual({ + await expect(findFileWithExtension(tmpDir, 'second')).resolves.toEqual({ filepath: expect.stringMatching(/second\.tsx$/), extension: 'tsx', astType: 'tsx', }); - expect(findFileWithExtension(tmpDir, 'third')).resolves.toEqual({ + await expect(findFileWithExtension(tmpDir, 'third')).resolves.toEqual({ filepath: expect.stringMatching(/third\.mjs$/), extension: 'mjs', astType: 'js', }); - expect(findFileWithExtension(tmpDir, 'fourth')).resolves.toEqual({ + await expect(findFileWithExtension(tmpDir, 'fourth')).resolves.toEqual({ filepath: expect.stringMatching(/fourth$/), }); - expect(findFileWithExtension(tmpDir, 'fifth')).resolves.toEqual({ + await expect(findFileWithExtension(tmpDir, 'fifth')).resolves.toEqual({ filepath: expect.stringMatching(/fifth\/index\.ts$/), extension: 'ts', astType: 'ts', diff --git a/packages/cli/src/lib/flags.ts b/packages/cli/src/lib/flags.ts index f2f2b651dc..508263ef5c 100644 --- a/packages/cli/src/lib/flags.ts +++ b/packages/cli/src/lib/flags.ts @@ -28,7 +28,7 @@ export const commonFlags = { 'legacy-runtime': Flags.boolean({ description: 'Runs the app in a Node.js sandbox instead of an Oxygen worker.', - env: 'SHOPIFY_HYDROGEN_FLAG_WORKER', + env: 'SHOPIFY_HYDROGEN_FLAG_LEGACY_RUNTIME', }), }, force: { diff --git a/packages/cli/src/lib/onboarding/index.ts b/packages/cli/src/lib/onboarding/index.ts index b2f0c0dd6a..52dd12c92a 100644 --- a/packages/cli/src/lib/onboarding/index.ts +++ b/packages/cli/src/lib/onboarding/index.ts @@ -1,3 +1,21 @@ -export {setupLocalStarterTemplate} from './local.js'; -export {setupRemoteTemplate} from './remote.js'; -export type {InitOptions} from './common.js'; +import {AbortController} from '@shopify/cli-kit/node/abort'; +import {setupLocalStarterTemplate} from './local.js'; +import {setupRemoteTemplate} from './remote.js'; +import type {InitOptions} from './common.js'; + +export type {InitOptions}; + +export async function setupTemplate(options: InitOptions) { + const controller = new AbortController(); + + try { + const template = options.template; + + return template + ? await setupRemoteTemplate({...options, template}, controller) + : await setupLocalStarterTemplate(options, controller); + } catch (error) { + controller.abort(); + throw error; + } +} diff --git a/packages/cli/src/lib/onboarding/local.test.ts b/packages/cli/src/lib/onboarding/local.test.ts new file mode 100644 index 0000000000..788b8c581f --- /dev/null +++ b/packages/cli/src/lib/onboarding/local.test.ts @@ -0,0 +1,292 @@ +import './setup-template.mocks.js'; +import {describe, it, expect, vi, beforeEach} from 'vitest'; +import {glob} from 'fast-glob'; +import { + inTemporaryDirectory, + isDirectory, + readFile, +} from '@shopify/cli-kit/node/fs'; +import {setupTemplate} from './index.js'; +import {getSkeletonSourceDir} from '../build.js'; +import {basename} from '@shopify/cli-kit/node/path'; +import {renderSelectPrompt} from '@shopify/cli-kit/node/ui'; +import {execAsync} from '../process.js'; +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output'; + +describe('local templates', () => { + const outputMock = mockAndCaptureOutput(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + outputMock.clear(); + }); + + it('creates basic projects', async () => { + await inTemporaryDirectory(async (tmpDir) => { + await setupTemplate({ + path: tmpDir, + git: false, + language: 'ts', + mockShop: true, + }); + + const templateFiles = await glob('**/*', { + cwd: getSkeletonSourceDir(), + ignore: ['**/node_modules/**', '**/dist/**'], + }); + const resultFiles = await glob('**/*', {cwd: tmpDir}); + const nonAppFiles = templateFiles.filter( + (item) => !item.startsWith('app/'), + ); + + expect(resultFiles).toEqual(expect.arrayContaining(nonAppFiles)); + + expect(resultFiles).toContain('app/root.tsx'); + expect(resultFiles).toContain('app/entry.client.tsx'); + expect(resultFiles).toContain('app/entry.server.tsx'); + expect(resultFiles).toContain('app/components/Layout.tsx'); + + // Skip routes: + expect(resultFiles).not.toContain('app/routes/_index.tsx'); + + // Not modified: + await expect(readFile(`${tmpDir}/server.ts`)).resolves.toEqual( + await readFile(`${getSkeletonSourceDir()}/server.ts`), + ); + + // Replaces package.json#name + await expect(readFile(`${tmpDir}/package.json`)).resolves.toMatch( + `"name": "${basename(tmpDir)}"`, + ); + + // Creates .env + await expect(readFile(`${tmpDir}/.env`)).resolves.toMatch( + `PUBLIC_STORE_DOMAIN="mock.shop"`, + ); + + const output = outputMock.info(); + expect(output).toMatch('success'); + expect(output).not.toMatch('warning'); + expect(output).toMatch(basename(tmpDir)); + expect(output).not.toMatch('Routes'); + expect(output).toMatch(/Language:\s*TypeScript/); + expect(output).toMatch('Next steps'); + expect(output).toMatch( + // Output contains banner characters. USe [^\w]*? to match them. + /Run `cd .*? &&[^\w]*?npm[^\w]*?install[^\w]*?&&[^\w]*?npm[^\w]*?run[^\w]*?dev`/ims, + ); + }); + }); + + it('creates project prompting for package-manager', async () => { + await inTemporaryDirectory(async (tmpDir) => { + await setupTemplate({ + path: tmpDir, + git: false, + language: 'ts', + packageManager: 'unknown', + mockShop: true, + }); + + expect(renderSelectPrompt).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Select package manager to install dependencies', + }), + ); + }); + }); + + it('creates projects with route files', async () => { + await inTemporaryDirectory(async (tmpDir) => { + await setupTemplate({ + path: tmpDir, + git: false, + routes: true, + language: 'ts', + }); + + const templateFiles = await glob('**/*', { + cwd: getSkeletonSourceDir(), + ignore: ['**/node_modules/**', '**/dist/**'], + }); + + const resultFiles = await glob('**/*', {cwd: tmpDir}); + + expect(resultFiles).toEqual(expect.arrayContaining(templateFiles)); + expect(resultFiles).toContain('app/routes/_index.tsx'); + + // Not modified: + await expect(readFile(`${tmpDir}/server.ts`)).resolves.toEqual( + await readFile(`${getSkeletonSourceDir()}/server.ts`), + ); + + const output = outputMock.info(); + expect(output).toMatch('success'); + expect(output).not.toMatch('warning'); + expect(output).toMatch(basename(tmpDir)); + expect(output).toMatch(/Language:\s*TypeScript/); + expect(output).toMatch('Routes'); + expect(output).toMatch('Home (/ & /:catchAll)'); + expect(output).toMatch('Account (/account/*)'); + }); + }); + + it('transpiles projects to JS', async () => { + await inTemporaryDirectory(async (tmpDir) => { + await setupTemplate({ + path: tmpDir, + git: false, + routes: true, + language: 'js', + }); + + const templateFiles = await glob('**/*', { + cwd: getSkeletonSourceDir(), + ignore: ['**/node_modules/**', '**/dist/**'], + }); + const resultFiles = await glob('**/*', {cwd: tmpDir}); + + expect(resultFiles).toEqual( + expect.arrayContaining( + templateFiles + .filter((item) => !item.endsWith('.d.ts')) + .map((item) => + item + .replace(/\.ts(x)?$/, '.js$1') + .replace(/tsconfig\.json$/, 'jsconfig.json'), + ), + ), + ); + + expect(resultFiles).toContain('app/routes/_index.jsx'); + + // No types but JSDocs: + await expect(readFile(`${tmpDir}/server.js`)).resolves.toMatch( + /export default {\n\s+\/\*\*.*?\*\/\n\s+async fetch\(\s*request,\s*env,\s*executionContext,?\s*\)/s, + ); + + const output = outputMock.info(); + expect(output).toMatch('success'); + expect(output).not.toMatch('warning'); + expect(output).toMatch(basename(tmpDir)); + expect(output).toMatch(/Language:\s*JavaScript/); + expect(output).toMatch('Routes'); + expect(output).toMatch('Home (/ & /:catchAll)'); + expect(output).toMatch('Account (/account/*)'); + }); + }); + + describe('i18n strategies', () => { + it('scaffolds i18n with domains strategy', async () => { + await inTemporaryDirectory(async (tmpDir) => { + await setupTemplate({ + path: tmpDir, + git: false, + language: 'ts', + i18n: 'domains', + routes: true, + }); + + const resultFiles = await glob('**/*', {cwd: tmpDir}); + expect(resultFiles).toContain('app/routes/_index.tsx'); + + // Injects styles in Root + const serverFile = await readFile(`${tmpDir}/server.ts`); + expect(serverFile).toMatch(/i18n: getLocaleFromRequest\(request\),/); + expect(serverFile).toMatch(/domain = url.hostname/); + + const output = outputMock.info(); + expect(output).toMatch('success'); + expect(output).not.toMatch('warning'); + expect(output).toMatch(/Markets:\s*Top-level domains/); + }); + }); + + it('scaffolds i18n with subdomains strategy', async () => { + await inTemporaryDirectory(async (tmpDir) => { + await setupTemplate({ + path: tmpDir, + git: false, + language: 'ts', + i18n: 'subdomains', + routes: true, + }); + + const resultFiles = await glob('**/*', {cwd: tmpDir}); + expect(resultFiles).toContain('app/routes/_index.tsx'); + + // Injects styles in Root + const serverFile = await readFile(`${tmpDir}/server.ts`); + expect(serverFile).toMatch(/i18n: getLocaleFromRequest\(request\),/); + expect(serverFile).toMatch(/firstSubdomain = url.hostname/); + + const output = outputMock.info(); + expect(output).toMatch('success'); + expect(output).not.toMatch('warning'); + expect(output).toMatch(/Markets:\s*Subdomains/); + }); + }); + + it('scaffolds i18n with subfolders strategy', async () => { + await inTemporaryDirectory(async (tmpDir) => { + await setupTemplate({ + path: tmpDir, + git: false, + language: 'ts', + i18n: 'subfolders', + routes: true, + }); + + const resultFiles = await glob('**/*', {cwd: tmpDir}); + // Adds locale to the path + expect(resultFiles).toContain('app/routes/($locale)._index.tsx'); + + // Adds ($locale) route + expect(resultFiles).toContain('app/routes/($locale).tsx'); + + // Injects styles in Root + const serverFile = await readFile(`${tmpDir}/server.ts`); + expect(serverFile).toMatch(/i18n: getLocaleFromRequest\(request\),/); + expect(serverFile).toMatch(/url.pathname/); + + const output = outputMock.info(); + expect(output).toMatch('success'); + expect(output).not.toMatch('warning'); + expect(output).toMatch(/Markets:\s*Subfolders/); + }); + }); + }); + + describe('git', () => { + it('initializes a git repository and creates initial commits', async () => { + await inTemporaryDirectory(async (tmpDir) => { + await setupTemplate({ + path: tmpDir, + git: true, + language: 'js', + i18n: 'domains', + routes: true, + installDeps: true, + }); + + expect(isDirectory(`${tmpDir}/.git`)).resolves.toBeTruthy(); + + const {stdout: gitLog} = await execAsync(`git log --oneline`, { + cwd: tmpDir, + }); + + expect(gitLog.split('\n')).toEqual( + expect.arrayContaining([ + expect.stringContaining('Lockfile'), + expect.stringContaining('Generate routes for core functionality'), + // TODO + // expect.stringContaining('Setup Tailwind'), + expect.stringContaining('Setup markets support using domains'), + expect.stringContaining('Scaffold Storefront'), + ]), + ); + }); + }); + }); +}); diff --git a/packages/cli/src/lib/onboarding/remote.test.ts b/packages/cli/src/lib/onboarding/remote.test.ts new file mode 100644 index 0000000000..7950a191c7 --- /dev/null +++ b/packages/cli/src/lib/onboarding/remote.test.ts @@ -0,0 +1,202 @@ +import './setup-template.mocks.js'; +import {describe, it, expect, vi, beforeEach} from 'vitest'; +import glob from 'fast-glob'; +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output'; +import {inTemporaryDirectory, readFile} from '@shopify/cli-kit/node/fs'; +import {setupTemplate} from './index.js'; +import {getSkeletonSourceDir} from '../build.js'; +import {readAndParsePackageJson} from '@shopify/cli-kit/node/node-package-manager'; +import {joinPath} from '@shopify/cli-kit/node/path'; + +describe('remote templates', () => { + const outputMock = mockAndCaptureOutput(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + outputMock.clear(); + }); + + it('throws for unknown templates', async () => { + const processExit = vi + .spyOn(process, 'exit') + .mockImplementationOnce((() => {}) as any); + + await inTemporaryDirectory(async (tmpDir) => { + await expect( + setupTemplate({ + path: tmpDir, + git: false, + language: 'ts', + template: 'missing-template', + }), + ).resolves.ok; + }); + + // The error message is printed asynchronously + await vi.waitFor(() => expect(outputMock.error()).toMatch('--template')); + + expect(processExit).toHaveBeenCalledWith(1); + + processExit.mockRestore(); + }); + + it('creates basic projects', async () => { + await inTemporaryDirectory(async (tmpDir) => { + await setupTemplate({ + path: tmpDir, + git: false, + language: 'ts', + template: 'hello-world', + }); + + const templateFiles = await glob('**/*', { + cwd: getSkeletonSourceDir().replace('skeleton', 'hello-world'), + ignore: ['**/node_modules/**', '**/dist/**'], + }); + const resultFiles = await glob('**/*', {cwd: tmpDir}); + const nonAppFiles = templateFiles.filter( + (item) => !item.startsWith('app/'), + ); + + expect(resultFiles).toEqual(expect.arrayContaining(nonAppFiles)); + + expect(resultFiles).toContain('app/root.tsx'); + expect(resultFiles).toContain('app/entry.client.tsx'); + expect(resultFiles).toContain('app/entry.server.tsx'); + expect(resultFiles).not.toContain('app/components/Layout.tsx'); + + // Skip routes: + expect(resultFiles).not.toContain('app/routes/_index.tsx'); + + await expect(readFile(`${tmpDir}/package.json`)).resolves.toMatch( + `"name": "hello-world"`, + ); + + const output = outputMock.info(); + expect(output).toMatch('success'); + expect(output).not.toMatch('warning'); + expect(output).not.toMatch('Routes'); + expect(output).toMatch(/Language:\s*TypeScript/); + expect(output).toMatch('Next steps'); + expect(output).toMatch( + // Output contains banner characters. USe [^\w]*? to match them. + /Run `cd .*? &&[^\w]*?npm[^\w]*?install[^\w]*?&&[^\w]*?npm[^\w]*?run[^\w]*?dev`/ims, + ); + }); + }); + + it('applies diff for examples', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const exampleName = 'third-party-queries-caching'; + + await setupTemplate({ + path: tmpDir, + git: false, + language: 'ts', + template: exampleName, + }); + + const templatePath = getSkeletonSourceDir(); + const examplePath = templatePath + .replace('templates', 'examples') + .replace('skeleton', exampleName); + + // --- Test file diff + const ignore = ['**/node_modules/**', '**/dist/**']; + const resultFiles = await glob('**/*', {ignore, cwd: tmpDir}); + const exampleFiles = await glob('**/*', {ignore, cwd: examplePath}); + const templateFiles = ( + await glob('**/*', {ignore, cwd: templatePath}) + ).filter((item) => !item.endsWith('CHANGELOG.md')); + + expect(resultFiles).toEqual( + expect.arrayContaining([ + ...new Set([...templateFiles, ...exampleFiles]), + ]), + ); + + // --- Test package.json merge + const templatePkgJson = await readAndParsePackageJson( + `${templatePath}/package.json`, + ); + const examplePkgJson = await readAndParsePackageJson( + `${examplePath}/package.json`, + ); + const resultPkgJson = await readAndParsePackageJson( + `${tmpDir}/package.json`, + ); + + expect(resultPkgJson.name).toMatch(exampleName); + + expect(resultPkgJson.scripts).toEqual( + expect.objectContaining(templatePkgJson.scripts), + ); + + expect(resultPkgJson.dependencies).toEqual( + expect.objectContaining({ + ...templatePkgJson.dependencies, + ...examplePkgJson.dependencies, + '@shopify/cli-hydrogen': + templatePkgJson.dependencies?.['@shopify/cli-hydrogen'], + }), + ); + expect(resultPkgJson.devDependencies).toEqual( + expect.objectContaining({ + ...templatePkgJson.devDependencies, + ...examplePkgJson.devDependencies, + }), + ); + expect(resultPkgJson.peerDependencies).toEqual( + expect.objectContaining({ + ...templatePkgJson.peerDependencies, + ...examplePkgJson.peerDependencies, + }), + ); + + // --- Keeps original tsconfig.json + expect(await readFile(joinPath(templatePath, 'tsconfig.json'))).toEqual( + await readFile(joinPath(tmpDir, 'tsconfig.json')), + ); + }); + }); + + it('transpiles projects to JS', async () => { + await inTemporaryDirectory(async (tmpDir) => { + await setupTemplate({ + path: tmpDir, + git: false, + language: 'js', + template: 'hello-world', + }); + + const templateFiles = await glob('**/*', { + cwd: getSkeletonSourceDir().replace('skeleton', 'hello-world'), + ignore: ['**/node_modules/**', '**/dist/**'], + }); + const resultFiles = await glob('**/*', {cwd: tmpDir}); + + expect(resultFiles).toEqual( + expect.arrayContaining( + templateFiles + .filter((item) => !item.endsWith('.d.ts')) + .map((item) => + item + .replace(/\.ts(x)?$/, '.js$1') + .replace(/tsconfig\.json$/, 'jsconfig.json'), + ), + ), + ); + + // No types but JSDocs: + await expect(readFile(`${tmpDir}/server.js`)).resolves.toMatch( + /export default {\n\s+\/\*\*.*?\*\/\n\s+async fetch\(\s*request,\s*env,\s*executionContext,?\s*\)/s, + ); + + const output = outputMock.info(); + expect(output).toMatch('success'); + expect(output).not.toMatch('warning'); + expect(output).toMatch(/Language:\s*JavaScript/); + }); + }); +}); diff --git a/packages/cli/src/lib/onboarding/setup-template.mocks.ts b/packages/cli/src/lib/onboarding/setup-template.mocks.ts new file mode 100644 index 0000000000..548cf30f40 --- /dev/null +++ b/packages/cli/src/lib/onboarding/setup-template.mocks.ts @@ -0,0 +1,91 @@ +import {fileURLToPath} from 'node:url'; +import {vi} from 'vitest'; +import {createSymlink, remove as rmdir} from 'fs-extra/esm'; +import {writeFile} from '@shopify/cli-kit/node/fs'; +import {joinPath} from '@shopify/cli-kit/node/path'; +import {getRepoNodeModules} from '../build.js'; + +const {renderTasksHook} = vi.hoisted(() => ({renderTasksHook: vi.fn()})); + +vi.mock('../template-downloader.js', async () => ({ + downloadMonorepoTemplates: () => + Promise.resolve({ + version: '', + templatesDir: fileURLToPath( + new URL('../../../../../templates', import.meta.url), + ), + examplesDir: fileURLToPath( + new URL('../../../../../examples', import.meta.url), + ), + }), + downloadExternalRepo: () => + Promise.resolve({ + templateDir: fileURLToPath( + new URL('../../../../../templates/skeleton', import.meta.url), + ), + }), +})); + +vi.mock('@shopify/cli-kit/node/ui', async () => { + const original = await vi.importActual< + typeof import('@shopify/cli-kit/node/ui') + >('@shopify/cli-kit/node/ui'); + + return { + ...original, + renderConfirmationPrompt: vi.fn(), + renderSelectPrompt: vi.fn(), + renderTextPrompt: vi.fn(), + renderTasks: vi.fn(async (args) => { + await original.renderTasks(args); + renderTasksHook(); + }), + }; +}); + +vi.mock( + '@shopify/cli-kit/node/node-package-manager', + async (importOriginal) => { + const original = await importOriginal< + typeof import('@shopify/cli-kit/node/node-package-manager') + >(); + + return { + ...original, + getPackageManager: () => Promise.resolve('npm'), + packageManagerFromUserAgent: () => 'npm', + installNodeModules: vi.fn(async ({directory}: {directory: string}) => { + // Create lockfile at a later moment to simulate a slow install + renderTasksHook.mockImplementationOnce(async () => { + await writeFile(`${directory}/package-lock.json`, '{}'); + }); + + // "Install" dependencies by linking to monorepo's node_modules + await rmdir(joinPath(directory, 'node_modules')).catch(() => {}); + await createSymlink( + await getRepoNodeModules(), + joinPath(directory, 'node_modules'), + ); + }), + }; + }, +); + +vi.mock('./common.js', async (importOriginal) => { + type ModType = typeof import('./common.js'); + const original = await importOriginal(); + + return Object.keys(original).reduce((acc, item) => { + const key = item as keyof ModType; + const value = original[key]; + if (typeof value === 'function') { + // @ts-ignore + acc[key] = vi.fn(value); + } else { + // @ts-ignore + acc[key] = value; + } + + return acc; + }, {} as ModType); +});