diff --git a/.github/workflows/tests-pr.yml b/.github/workflows/tests-pr.yml index 19642321aee..a616e0c3e42 100644 --- a/.github/workflows/tests-pr.yml +++ b/.github/workflows/tests-pr.yml @@ -227,6 +227,7 @@ jobs: E2E_ACCOUNT_PASSWORD: ${{ secrets.E2E_ACCOUNT_PASSWORD }} E2E_STORE_FQDN: ${{ secrets.E2E_STORE_FQDN }} E2E_SECONDARY_CLIENT_ID: ${{ secrets.E2E_SECONDARY_CLIENT_ID }} + E2E_STORE_PASSWORD: ${{ secrets.E2E_STORE_PASSWORD }} run: npx playwright test - name: Upload Playwright report uses: actions/upload-artifact@v4 diff --git a/.github/workflows/theme-e2e.yml b/.github/workflows/theme-e2e.yml new file mode 100644 index 00000000000..c606bfdf25f --- /dev/null +++ b/.github/workflows/theme-e2e.yml @@ -0,0 +1,73 @@ +name: Theme E2E + +on: + pull_request: + paths: + - 'packages/theme/**' + - 'packages/cli-kit/src/public/node/themes/**' + - 'packages/e2e/tests/theme-*.spec.ts' + - 'packages/e2e/setup/theme*.ts' + workflow_dispatch: + +concurrency: + group: theme-e2e-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +env: + DEBUG: '1' + SHOPIFY_CLI_ENV: development + SHOPIFY_CONFIG: debug + PNPM_VERSION: '10.11.1' + BUNDLE_WITHOUT: 'test:development' + GH_TOKEN: ${{ secrets.SHOPIFY_GH_READ_CONTENT_TOKEN }} + GH_TOKEN_SHOP: ${{ secrets.SHOP_GH_READ_CONTENT_TOKEN }} + DEFAULT_NODE_VERSION: '24.1.0' + +jobs: + theme-e2e-tests: + name: 'Theme E2E tests' + if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 15 + continue-on-error: true + steps: + - uses: actions/checkout@v3 + with: + repository: ${{ github.event.pull_request.head.repo.full_name || github.event.repository.full_name }} + ref: ${{ github.event.pull_request.head.ref || github.event.merge_group.head_ref || github.ref }} + fetch-depth: 1 + - name: Setup deps + uses: ./.github/actions/setup-cli-deps + with: + node-version: ${{ env.DEFAULT_NODE_VERSION }} + - name: Build + run: pnpm nx run-many --all --skip-nx-cache --target=build --output-style=stream + - name: Install Playwright Chromium + run: npx playwright install chromium + working-directory: packages/e2e + - name: Rebuild node-pty + run: pnpm rebuild node-pty + - name: Run Theme E2E tests + working-directory: packages/e2e + env: + SHOPIFY_FLAG_CLIENT_ID: ${{ secrets.E2E_CLIENT_ID }} + E2E_ACCOUNT_EMAIL: ${{ secrets.E2E_ACCOUNT_EMAIL }} + E2E_ACCOUNT_PASSWORD: ${{ secrets.E2E_ACCOUNT_PASSWORD }} + E2E_STORE_FQDN: ${{ secrets.E2E_STORE_FQDN }} + E2E_SECONDARY_CLIENT_ID: ${{ secrets.E2E_SECONDARY_CLIENT_ID }} + E2E_STORE_PASSWORD: ${{ secrets.E2E_STORE_PASSWORD }} + run: npx playwright test theme- + - name: Upload Playwright report + uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: theme-playwright-report + path: packages/e2e/playwright-report/ + retention-days: 14 + - name: Upload test results + uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: theme-playwright-results + path: packages/e2e/test-results/ + retention-days: 14 diff --git a/packages/e2e/.env.example b/packages/e2e/.env.example index 5af4efc9c99..64119e2153e 100644 --- a/packages/e2e/.env.example +++ b/packages/e2e/.env.example @@ -17,3 +17,8 @@ E2E_STORE_FQDN= # Optional: Client ID of a secondary app for config link tests # CI secret: E2E_SECONDARY_CLIENT_ID E2E_SECONDARY_CLIENT_ID= + +# Optional: Storefront password for password-protected stores +# Required for theme console and theme dev tests if the store has password protection +# CI secret: E2E_STORE_PASSWORD +E2E_STORE_PASSWORD= diff --git a/packages/e2e/data/dawn-minimal/assets/base.css b/packages/e2e/data/dawn-minimal/assets/base.css new file mode 100644 index 00000000000..693ce9b8405 --- /dev/null +++ b/packages/e2e/data/dawn-minimal/assets/base.css @@ -0,0 +1 @@ +body { font-family: sans-serif; } diff --git a/packages/e2e/data/dawn-minimal/config/settings_schema.json b/packages/e2e/data/dawn-minimal/config/settings_schema.json new file mode 100644 index 00000000000..2afab6e6d33 --- /dev/null +++ b/packages/e2e/data/dawn-minimal/config/settings_schema.json @@ -0,0 +1 @@ +[{"name": "theme_info", "theme_name": "E2E Test Theme", "theme_version": "1.0.0"}] diff --git a/packages/e2e/data/dawn-minimal/layout/theme.liquid b/packages/e2e/data/dawn-minimal/layout/theme.liquid new file mode 100644 index 00000000000..5a850d35968 --- /dev/null +++ b/packages/e2e/data/dawn-minimal/layout/theme.liquid @@ -0,0 +1,5 @@ + + +{{ content_for_header }} +{{ content_for_layout }} + diff --git a/packages/e2e/data/dawn-minimal/locales/en.default.json b/packages/e2e/data/dawn-minimal/locales/en.default.json new file mode 100644 index 00000000000..6b1f7c11143 --- /dev/null +++ b/packages/e2e/data/dawn-minimal/locales/en.default.json @@ -0,0 +1 @@ +{"general": {"title": "E2E Test Store"}} diff --git a/packages/e2e/data/dawn-minimal/sections/header.liquid b/packages/e2e/data/dawn-minimal/sections/header.liquid new file mode 100644 index 00000000000..366dcb537e5 --- /dev/null +++ b/packages/e2e/data/dawn-minimal/sections/header.liquid @@ -0,0 +1,2 @@ +
E2E Test Header
+{% schema %}{"name": "Header"}{% endschema %} diff --git a/packages/e2e/data/dawn-minimal/snippets/icon.liquid b/packages/e2e/data/dawn-minimal/snippets/icon.liquid new file mode 100644 index 00000000000..b135714120c --- /dev/null +++ b/packages/e2e/data/dawn-minimal/snippets/icon.liquid @@ -0,0 +1 @@ +{{ icon }} diff --git a/packages/e2e/data/dawn-minimal/templates/index.json b/packages/e2e/data/dawn-minimal/templates/index.json new file mode 100644 index 00000000000..a45da1828e8 --- /dev/null +++ b/packages/e2e/data/dawn-minimal/templates/index.json @@ -0,0 +1 @@ +{"sections": {"main": {"type": "header"}}, "order": ["main"]} diff --git a/packages/e2e/setup/env.ts b/packages/e2e/setup/env.ts index ea22c69ceb6..1ea9724f422 100644 --- a/packages/e2e/setup/env.ts +++ b/packages/e2e/setup/env.ts @@ -16,6 +16,8 @@ export interface E2EEnv { storeFqdn: string /** Secondary app client ID for config link tests */ secondaryClientId: string + /** Storefront password for password-protected stores (empty string if not set) */ + storePassword: string /** Environment variables to pass to CLI processes */ processEnv: NodeJS.ProcessEnv /** Temporary directory root for this worker */ @@ -93,6 +95,7 @@ export const envFixture = base.extend<{}, {env: E2EEnv}>({ const clientId = process.env.SHOPIFY_FLAG_CLIENT_ID ?? '' const storeFqdn = process.env.E2E_STORE_FQDN ?? '' const secondaryClientId = process.env.E2E_SECONDARY_CLIENT_ID ?? '' + const storePassword = process.env.E2E_STORE_PASSWORD ?? '' const tmpBase = process.env.E2E_TEMP_DIR ?? path.join(directories.root, '.e2e-tmp') fs.mkdirSync(tmpBase, {recursive: true}) @@ -117,12 +120,16 @@ export const envFixture = base.extend<{}, {env: E2EEnv}>({ if (storeFqdn) { processEnv.SHOPIFY_FLAG_STORE = storeFqdn } + if (storePassword) { + processEnv.SHOPIFY_FLAG_STORE_PASSWORD = storePassword + } const env: E2EEnv = { partnersToken, clientId, storeFqdn, secondaryClientId, + storePassword, processEnv, tempDir, } diff --git a/packages/e2e/setup/theme.ts b/packages/e2e/setup/theme.ts new file mode 100644 index 00000000000..af5d1348177 --- /dev/null +++ b/packages/e2e/setup/theme.ts @@ -0,0 +1,169 @@ +/* eslint-disable no-restricted-imports */ +import {authFixture} from './auth.js' +import * as path from 'path' +import * as fs from 'fs' +import {fileURLToPath} from 'url' +import type {ExecResult} from './cli.js' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const FIXTURE_DIR = path.join(__dirname, '../data/dawn-minimal') + +export interface ThemeScaffold { + /** The directory containing the theme files */ + themeDir: string + /** Push theme to store, returns theme ID from output */ + push(opts?: {unpublished?: boolean; themeName?: string}): Promise<{result: ExecResult; themeId?: string}> + /** Pull theme from store by ID */ + pull(themeId: string): Promise + /** List all themes on the store */ + list(): Promise<{result: ExecResult; themes: {id: string; name: string; role: string}[]}> + /** Delete a theme by ID */ + delete(themeId: string): Promise + /** Rename a theme */ + rename(themeId: string, newName: string): Promise + /** Duplicate a theme (via push with development flag) */ + duplicate(themeId: string, newName: string): Promise +} + +/** + * Recursively copies a directory. + */ +function copyDirRecursive(src: string, dest: string): void { + fs.mkdirSync(dest, {recursive: true}) + for (const entry of fs.readdirSync(src, {withFileTypes: true})) { + const srcPath = path.join(src, entry.name) + const destPath = path.join(dest, entry.name) + if (entry.isDirectory()) { + copyDirRecursive(srcPath, destPath) + } else { + fs.copyFileSync(srcPath, destPath) + } + } +} + +/** + * Test-scoped fixture that copies the dawn-minimal fixture to a temp directory. + * Provides helper methods for theme CRUD operations. + * Depends on authLogin (worker-scoped) for OAuth session. + */ +export const themeScaffoldFixture = authFixture.extend<{themeScaffold: ThemeScaffold}>({ + themeScaffold: async ({cli, env, authLogin: _authLogin}, use) => { + const themeDir = fs.mkdtempSync(path.join(env.tempDir, 'theme-')) + const createdThemeIds: string[] = [] + const storeFqdn = env.storeFqdn + + // Copy fixture files recursively + copyDirRecursive(FIXTURE_DIR, themeDir) + + const scaffold: ThemeScaffold = { + themeDir, + + async push(opts = {}) { + const themeName = opts.themeName ?? `e2e-test-${Date.now()}` + const args = ['theme', 'push', '--store', storeFqdn, '--path', themeDir, '--theme', themeName] + if (opts.unpublished !== false) { + args.push('--unpublished') + } + // Add --json for parseable output + args.push('--json') + + const result = await cli.exec(args, {timeout: 2 * 60 * 1000}) + + // Try to extract theme ID from JSON output + let themeId: string | undefined + try { + const json = JSON.parse(result.stdout) + if (json.theme?.id) { + themeId = String(json.theme.id) + createdThemeIds.push(themeId) + } + } catch (error) { + // JSON parsing failed, try regex fallback + if (!(error instanceof SyntaxError)) throw error + const match = result.stdout.match(/theme[:\s]+(\d+)/i) ?? result.stderr.match(/theme[:\s]+(\d+)/i) + if (match?.[1]) { + themeId = match[1] + createdThemeIds.push(themeId) + } + } + + return {result, themeId} + }, + + async pull(themeId: string) { + return cli.exec(['theme', 'pull', '--store', storeFqdn, '--path', themeDir, '--theme', themeId], { + timeout: 2 * 60 * 1000, + }) + }, + + async list() { + const result = await cli.exec(['theme', 'list', '--store', storeFqdn, '--json'], {timeout: 60 * 1000}) + const themes: {id: string; name: string; role: string}[] = [] + + try { + const json = JSON.parse(result.stdout) + if (Array.isArray(json)) { + for (const theme of json) { + themes.push({ + id: String(theme.id), + name: theme.name ?? '', + role: theme.role ?? '', + }) + } + } + } catch (error) { + // JSON parsing failed - return empty array + if (!(error instanceof SyntaxError)) throw error + } + + return {result, themes} + }, + + async delete(themeId: string) { + const result = await cli.exec(['theme', 'delete', '--store', storeFqdn, '--theme', themeId, '--force'], { + timeout: 60 * 1000, + }) + // Remove from tracked IDs if successful + const idx = createdThemeIds.indexOf(themeId) + if (idx >= 0 && result.exitCode === 0) { + createdThemeIds.splice(idx, 1) + } + return result + }, + + async rename(themeId: string, newName: string) { + return cli.exec(['theme', 'rename', '--store', storeFqdn, '--theme', themeId, '--name', newName], { + timeout: 60 * 1000, + }) + }, + + async duplicate(themeId: string, newName: string) { + // Pull the theme first, then push with new name + const pullResult = await this.pull(themeId) + if (pullResult.exitCode !== 0) { + return pullResult + } + const {result} = await this.push({themeName: newName}) + return result + }, + } + + await use(scaffold) + + // Teardown: delete all themes created during the test (parallel for speed) + await Promise.all( + createdThemeIds.map((themeId) => + cli + .exec(['theme', 'delete', '--store', storeFqdn, '--theme', themeId, '--force'], {timeout: 60 * 1000}) + .catch(() => { + // Best effort cleanup - don't fail teardown + }), + ), + ) + + // Cleanup temp directory + fs.rmSync(themeDir, {recursive: true, force: true}) + }, +}) diff --git a/packages/e2e/tests/theme-console.spec.ts b/packages/e2e/tests/theme-console.spec.ts new file mode 100644 index 00000000000..c566427cedf --- /dev/null +++ b/packages/e2e/tests/theme-console.spec.ts @@ -0,0 +1,108 @@ +import {themeScaffoldFixture as test} from '../setup/theme.js' +import {requireEnv} from '../setup/env.js' +import {expect} from '@playwright/test' + +// Skip console tests - they hang in CI with no output, possibly due to auth/session handling +// See: https://github.com/Shopify/cli/pull/7034 +test.describe.skip('Theme console', () => { + test('console evaluates Liquid expressions', async ({themeScaffold, cli, env}) => { + requireEnv(env, 'storeFqdn') + + // Step 1: Push a theme first so we have something to work with + const themeName = `e2e-test-console-${Date.now()}` + const {result: pushResult, themeId} = await themeScaffold.push({themeName, unpublished: true}) + expect(pushResult.exitCode).toBe(0) + expect(themeId).toBeDefined() + + // Step 2: Start console via PTY + // Unset CI so the REPL is interactive + // Pass store password if available (for password-protected stores) + const consoleArgs = ['theme', 'console', '--store', env.storeFqdn] + if (env.storePassword) { + consoleArgs.push('--store-password', env.storePassword) + } + const console = await cli.spawn(consoleArgs, { + env: {CI: ''}, + }) + + // Step 3: Wait for the console to be ready + // Theme console outputs "Welcome to Shopify Liquid console" when ready + await console.waitForOutput('Welcome to Shopify Liquid console', 60_000) + + // Step 4: Send a Liquid expression (without {{ }} delimiters - console doesn't support them) + console.sendLine('1 | plus: 2') + + // Step 5: Wait for the result + await console.waitForOutput('3', 30_000) + + // Step 6: Verify the result is in the output + const output = console.getOutput() + expect(output).toContain('3') + + // Step 7: Exit the console + // Send Ctrl+C or type 'exit' + // Ctrl+C + console.sendKey('\x03') + + // Step 8: Wait for exit (may timeout if Ctrl+C doesn't work, that's OK) + try { + await console.waitForExit(10_000) + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (_error) { + // Timeout errors are expected - force kill if it doesn't exit gracefully + console.kill() + } + + // Cleanup + await themeScaffold.delete(themeId!) + }) + + test('console with --url evaluates in product context', async ({themeScaffold, cli, env}) => { + requireEnv(env, 'storeFqdn') + + // Step 1: Push a theme first so we have something to work with + const themeName = `e2e-test-console-url-${Date.now()}` + const {result: pushResult, themeId} = await themeScaffold.push({themeName, unpublished: true}) + expect(pushResult.exitCode).toBe(0) + expect(themeId).toBeDefined() + + // Step 2: Start console via PTY with --url pointing to products page + // Using /products as a generic URL that should work on any store + // Unset CI so the REPL is interactive + // Pass store password if available (for password-protected stores) + const consoleArgs = ['theme', 'console', '--store', env.storeFqdn, '--url', '/products'] + if (env.storePassword) { + consoleArgs.push('--store-password', env.storePassword) + } + const consoleProc = await cli.spawn(consoleArgs, { + env: {CI: ''}, + }) + + // Step 3: Wait for the console to be ready + // Theme console outputs "Welcome to Shopify Liquid console" when ready + await consoleProc.waitForOutput('Welcome to Shopify Liquid console', 60_000) + + // Step 4: Try to evaluate something that would exist on a products page + // Even if no products exist, the template context should be set + // Note: console doesn't support {{ }} delimiters + consoleProc.sendLine('request.path') + + // Step 5: Wait for output - should show /products or similar + await consoleProc.waitForOutput('products', 30_000) + + // Step 6: Exit the console (Ctrl+C) + consoleProc.sendKey('\x03') + + // Step 7: Wait for exit + try { + await consoleProc.waitForExit(10_000) + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (_error) { + // Timeout errors are expected - force kill if it doesn't exit gracefully + consoleProc.kill() + } + + // Cleanup + await themeScaffold.delete(themeId!) + }) +}) diff --git a/packages/e2e/tests/theme-crud.spec.ts b/packages/e2e/tests/theme-crud.spec.ts new file mode 100644 index 00000000000..c8c5c90f079 --- /dev/null +++ b/packages/e2e/tests/theme-crud.spec.ts @@ -0,0 +1,257 @@ +/* eslint-disable no-restricted-imports */ +import {themeScaffoldFixture as test} from '../setup/theme.js' +import {requireEnv} from '../setup/env.js' +import {expect} from '@playwright/test' +import * as fs from 'fs' +import * as path from 'path' + +test.describe('Theme CRUD operations', () => { + test('push creates a development theme, list shows it, delete removes it', async ({themeScaffold, env}) => { + requireEnv(env, 'storeFqdn') + + // Step 1: Push theme to create development theme + const themeName = `e2e-test-crud-${Date.now()}` + const {result: pushResult, themeId} = await themeScaffold.push({themeName, unpublished: true}) + + expect(pushResult.exitCode).toBe(0) + expect(themeId).toBeDefined() + + // Step 2: List themes and verify our theme appears + const {result: listResult, themes} = await themeScaffold.list() + expect(listResult.exitCode).toBe(0) + + const ourTheme = themes.find((t) => t.id === themeId) + expect(ourTheme).toBeDefined() + expect(ourTheme?.name).toBe(themeName) + + // Step 3: Delete the theme + const deleteResult = await themeScaffold.delete(themeId!) + expect(deleteResult.exitCode).toBe(0) + + // Step 4: Verify theme is gone + const {themes: themesAfterDelete} = await themeScaffold.list() + const deletedTheme = themesAfterDelete.find((t) => t.id === themeId) + expect(deletedTheme).toBeUndefined() + }) + + test('pull downloads theme files', async ({themeScaffold, cli, env}) => { + requireEnv(env, 'storeFqdn') + + // Step 1: Push a theme first + const themeName = `e2e-test-pull-${Date.now()}` + const {result: pushResult, themeId} = await themeScaffold.push({themeName, unpublished: true}) + expect(pushResult.exitCode).toBe(0) + expect(themeId).toBeDefined() + + // Step 2: Create a clean directory and pull into it + const pullDir = fs.mkdtempSync(path.join(env.tempDir, 'theme-pull-')) + + const pullResult = await cli.exec( + ['theme', 'pull', '--store', env.storeFqdn, '--path', pullDir, '--theme', themeId!], + { + timeout: 2 * 60 * 1000, + }, + ) + expect(pullResult.exitCode).toBe(0) + + // Step 3: Verify files were downloaded + expect(fs.existsSync(path.join(pullDir, 'layout', 'theme.liquid'))).toBe(true) + expect(fs.existsSync(path.join(pullDir, 'config', 'settings_schema.json'))).toBe(true) + + // Cleanup + fs.rmSync(pullDir, {recursive: true, force: true}) + await themeScaffold.delete(themeId!) + }) + + test('rename changes theme name', async ({themeScaffold, env}) => { + requireEnv(env, 'storeFqdn') + + // Step 1: Push a theme + const originalName = `e2e-test-rename-${Date.now()}` + const {result: pushResult, themeId} = await themeScaffold.push({themeName: originalName, unpublished: true}) + expect(pushResult.exitCode).toBe(0) + expect(themeId).toBeDefined() + + // Step 2: Rename the theme + const newName = `${originalName}-renamed` + const renameResult = await themeScaffold.rename(themeId!, newName) + expect(renameResult.exitCode).toBe(0) + + // Step 3: Verify the name changed + const {themes} = await themeScaffold.list() + const renamedTheme = themes.find((t) => t.id === themeId) + expect(renamedTheme?.name).toBe(newName) + + // Cleanup + await themeScaffold.delete(themeId!) + }) + + test('duplicate creates a copy of a theme', async ({themeScaffold, env}) => { + requireEnv(env, 'storeFqdn') + + // Step 1: Push original theme + const originalName = `e2e-test-dup-orig-${Date.now()}` + const {result: pushResult, themeId: originalId} = await themeScaffold.push({ + themeName: originalName, + unpublished: true, + }) + expect(pushResult.exitCode).toBe(0) + expect(originalId).toBeDefined() + + // Step 2: Duplicate the theme + const duplicateName = `e2e-test-dup-copy-${Date.now()}` + const duplicateResult = await themeScaffold.duplicate(originalId!, duplicateName) + expect(duplicateResult.exitCode).toBe(0) + + // Step 3: Verify both themes exist + const {themes} = await themeScaffold.list() + const originalTheme = themes.find((t) => t.id === originalId) + const duplicateTheme = themes.find((t) => t.name === duplicateName) + + expect(originalTheme).toBeDefined() + expect(duplicateTheme).toBeDefined() + expect(duplicateTheme?.id).not.toBe(originalId) + + // Cleanup (fixture will cleanup original, we need to cleanup duplicate) + if (duplicateTheme?.id) { + await themeScaffold.delete(duplicateTheme.id) + } + await themeScaffold.delete(originalId!) + }) + + test('push with --ignore excludes matching files', async ({themeScaffold, cli, env}) => { + requireEnv(env, 'storeFqdn') + + // Step 1: Push a theme with --ignore to exclude snippets + const themeName = `e2e-test-push-ignore-${Date.now()}` + const pushResult = await cli.exec( + [ + 'theme', + 'push', + '--store', + env.storeFqdn, + '--path', + themeScaffold.themeDir, + '--theme', + themeName, + '--unpublished', + '--ignore', + 'snippets/*', + '--json', + ], + {timeout: 2 * 60 * 1000}, + ) + expect(pushResult.exitCode).toBe(0) + + // Extract theme ID from JSON output + let themeId: string | undefined + try { + const json = JSON.parse(pushResult.stdout) + if (json.theme?.id) { + themeId = String(json.theme.id) + } + } catch (error) { + if (!(error instanceof SyntaxError)) throw error + } + expect(themeId).toBeDefined() + + // Step 2: Pull theme to new directory + const pullDir = fs.mkdtempSync(path.join(env.tempDir, 'theme-pull-ignore-')) + const pullResult = await cli.exec( + ['theme', 'pull', '--store', env.storeFqdn, '--path', pullDir, '--theme', themeId!], + { + timeout: 2 * 60 * 1000, + }, + ) + expect(pullResult.exitCode).toBe(0) + + // Step 3: Verify snippets were NOT uploaded (directory should be empty or missing) + const snippetsDir = path.join(pullDir, 'snippets') + if (fs.existsSync(snippetsDir)) { + const snippetFiles = fs.readdirSync(snippetsDir) + expect(snippetFiles.length).toBe(0) + } + // If snippets dir doesn't exist, that's also a pass + + // Cleanup + fs.rmSync(pullDir, {recursive: true, force: true}) + await themeScaffold.delete(themeId!) + }) + + test('pull with --ignore excludes matching files', async ({themeScaffold, cli, env}) => { + requireEnv(env, 'storeFqdn') + + // Step 1: Push a full theme first (including snippets) + const themeName = `e2e-test-pull-ignore-${Date.now()}` + const {result: pushResult, themeId} = await themeScaffold.push({themeName, unpublished: true}) + expect(pushResult.exitCode).toBe(0) + expect(themeId).toBeDefined() + + // Step 2: Pull to new directory with --ignore to exclude snippets + const pullDir = fs.mkdtempSync(path.join(env.tempDir, 'theme-pull-ignore-')) + const pullResult = await cli.exec( + ['theme', 'pull', '--store', env.storeFqdn, '--path', pullDir, '--theme', themeId!, '--ignore', 'snippets/*'], + {timeout: 2 * 60 * 1000}, + ) + expect(pullResult.exitCode).toBe(0) + + // Step 3: Verify other files were downloaded but snippets were NOT + expect(fs.existsSync(path.join(pullDir, 'layout', 'theme.liquid'))).toBe(true) + expect(fs.existsSync(path.join(pullDir, 'config', 'settings_schema.json'))).toBe(true) + + // Snippets should be empty or missing + const snippetsDir = path.join(pullDir, 'snippets') + if (fs.existsSync(snippetsDir)) { + const snippetFiles = fs.readdirSync(snippetsDir) + expect(snippetFiles.length).toBe(0) + } + + // Cleanup + fs.rmSync(pullDir, {recursive: true, force: true}) + await themeScaffold.delete(themeId!) + }) + + test('list with --name filters themes by glob pattern', async ({themeScaffold, cli, env}) => { + requireEnv(env, 'storeFqdn') + + // Step 1: Create a theme with a known prefix + const timestamp = Date.now() + const themeName = `e2e-glob-test-${timestamp}` + const {result: pushResult, themeId} = await themeScaffold.push({themeName, unpublished: true}) + expect(pushResult.exitCode).toBe(0) + expect(themeId).toBeDefined() + + // Step 2: List themes with --name glob that should match our theme + const listResult = await cli.exec( + ['theme', 'list', '--store', env.storeFqdn, '--name', 'e2e-glob-test-*', '--json'], + { + timeout: 60 * 1000, + }, + ) + expect(listResult.exitCode).toBe(0) + + // Step 3: Parse results and verify only matching themes are returned + let themes: {id: string; name: string}[] = [] + try { + const json = JSON.parse(listResult.stdout) + if (Array.isArray(json)) { + themes = json.map((t: {id: number; name: string}) => ({id: String(t.id), name: t.name})) + } + } catch (error) { + if (!(error instanceof SyntaxError)) throw error + } + + // Our theme should be in the results + const ourTheme = themes.find((t) => t.id === themeId) + expect(ourTheme).toBeDefined() + expect(ourTheme?.name).toBe(themeName) + + // All returned themes should match the glob pattern + for (const theme of themes) { + expect(theme.name).toMatch(/^e2e-glob-test-/) + } + + // Cleanup + await themeScaffold.delete(themeId!) + }) +}) diff --git a/packages/e2e/tests/theme-dev.spec.ts b/packages/e2e/tests/theme-dev.spec.ts new file mode 100644 index 00000000000..c78b40cb506 --- /dev/null +++ b/packages/e2e/tests/theme-dev.spec.ts @@ -0,0 +1,97 @@ +/* eslint-disable no-restricted-imports */ +import {themeScaffoldFixture as test} from '../setup/theme.js' +import {requireEnv} from '../setup/env.js' +import {expect} from '@playwright/test' +import * as fs from 'fs' +import * as path from 'path' + +// Skip dev tests - store password not configured in CI yet +// See: https://github.com/Shopify/cli/pull/7034 +test.describe.skip('Theme dev server', () => { + test('dev starts, shows ready message, and quits with q', async ({themeScaffold, cli, env}) => { + requireEnv(env, 'storeFqdn') + + // Step 1: Push a theme first so we have something to develop against + const themeName = `e2e-test-dev-${Date.now()}` + const {result: pushResult, themeId} = await themeScaffold.push({themeName, unpublished: true}) + expect(pushResult.exitCode).toBe(0) + expect(themeId).toBeDefined() + + // Step 2: Start dev server via PTY + // Unset CI so keyboard shortcuts are enabled + // Pass store password if available (for password-protected stores) + const devArgs = ['theme', 'dev', '--store', env.storeFqdn, '--path', themeScaffold.themeDir, '--theme', themeId!] + if (env.storePassword) { + devArgs.push('--store-password', env.storePassword) + } + const dev = await cli.spawn(devArgs, { + env: {CI: ''}, + }) + + // Step 3: Wait for the ready message + // Theme dev prints a URL when ready + await dev.waitForOutput('http://127.0.0.1', 2 * 60 * 1000) + + // Step 4: Verify keyboard shortcuts are shown (indicates TTY mode is working) + const output = dev.getOutput() + expect(output).toMatch(/q|quit|press/i) + + // Step 5: Press q to quit + dev.sendKey('q') + + // Step 6: Wait for clean exit + const exitCode = await dev.waitForExit(30_000) + expect(exitCode).toBe(0) + + // Cleanup + await themeScaffold.delete(themeId!) + }) + + test('dev syncs file changes and shows sync message', async ({themeScaffold, cli, env}) => { + requireEnv(env, 'storeFqdn') + + // Step 1: Push a theme first so we have something to develop against + const themeName = `e2e-test-dev-sync-${Date.now()}` + const {result: pushResult, themeId} = await themeScaffold.push({themeName, unpublished: true}) + expect(pushResult.exitCode).toBe(0) + expect(themeId).toBeDefined() + + // Step 2: Start dev server via PTY + // Unset CI so keyboard shortcuts are enabled + // Pass store password if available (for password-protected stores) + const devArgs = ['theme', 'dev', '--store', env.storeFqdn, '--path', themeScaffold.themeDir, '--theme', themeId!] + if (env.storePassword) { + devArgs.push('--store-password', env.storePassword) + } + const dev = await cli.spawn(devArgs, { + env: {CI: ''}, + }) + + // Step 3: Wait for the ready message + await dev.waitForOutput('http://127.0.0.1', 2 * 60 * 1000) + + // Step 4: Modify a file to trigger sync + const headerPath = path.join(themeScaffold.themeDir, 'sections', 'header.liquid') + const originalContent = fs.readFileSync(headerPath, 'utf-8') + const modifiedContent = originalContent.replace('E2E Test Header', `E2E Test Header Modified ${Date.now()}`) + fs.writeFileSync(headerPath, modifiedContent) + + // Step 5: Wait for sync message + // Theme dev outputs: "• TIMESTAMP Synced » update sections/header.liquid" + await dev.waitForOutput('Synced', 60_000) + + // Step 6: Verify the sync message mentions our file + const output = dev.getOutput() + expect(output).toContain('header.liquid') + + // Step 7: Press q to quit + dev.sendKey('q') + + // Step 8: Wait for clean exit + const exitCode = await dev.waitForExit(30_000) + expect(exitCode).toBe(0) + + // Cleanup + await themeScaffold.delete(themeId!) + }) +}) diff --git a/packages/e2e/tests/theme-local.spec.ts b/packages/e2e/tests/theme-local.spec.ts new file mode 100644 index 00000000000..1b48599cb2d --- /dev/null +++ b/packages/e2e/tests/theme-local.spec.ts @@ -0,0 +1,79 @@ +/* eslint-disable no-restricted-imports */ +import {cliFixture as test} from '../setup/cli.js' +import {expect} from '@playwright/test' +import * as fs from 'fs' +import * as path from 'path' +import {fileURLToPath} from 'url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +const DAWN_MINIMAL_DIR = path.join(__dirname, '../data/dawn-minimal') + +test.describe('Theme local commands', () => { + test('theme init creates a new theme from Dawn', async ({cli, env}) => { + const initDir = fs.mkdtempSync(path.join(env.tempDir, 'theme-init-')) + const themeName = 'e2e-test-init' + + const result = await cli.exec(['theme', 'init', themeName, '--path', initDir], {timeout: 2 * 60 * 1000}) + + expect(result.exitCode).toBe(0) + // Theme is created in a subdirectory with the theme name + const themeDir = path.join(initDir, themeName) + expect(fs.existsSync(path.join(themeDir, 'layout', 'theme.liquid'))).toBe(true) + expect(fs.existsSync(path.join(themeDir, 'config', 'settings_schema.json'))).toBe(true) + + // Cleanup + fs.rmSync(initDir, {recursive: true, force: true}) + }) + + test('theme check validates theme files', async ({cli, env}) => { + // Copy dawn-minimal to temp dir for checking + const checkDir = fs.mkdtempSync(path.join(env.tempDir, 'theme-check-')) + copyDirRecursive(DAWN_MINIMAL_DIR, checkDir) + + const result = await cli.exec(['theme', 'check', '--path', checkDir], {timeout: 60 * 1000}) + + // theme check exits 0 if no errors (warnings are OK) + // Exit code 1 means there are errors + expect(result.exitCode).toBeLessThanOrEqual(1) + // Verify it actually ran - output should mention checking + expect(result.stdout + result.stderr).toMatch(/check|valid|error|warning/i) + + // Cleanup + fs.rmSync(checkDir, {recursive: true, force: true}) + }) + + test('theme package creates a zip file', async ({cli, env}) => { + // Copy dawn-minimal to temp dir for packaging + const packageDir = fs.mkdtempSync(path.join(env.tempDir, 'theme-package-')) + copyDirRecursive(DAWN_MINIMAL_DIR, packageDir) + + const result = await cli.exec(['theme', 'package', '--path', packageDir], {timeout: 60 * 1000}) + + expect(result.exitCode).toBe(0) + + // Verify zip file was created - look for .zip file in output dir + const files = fs.readdirSync(packageDir) + const zipFile = files.find((file) => file.endsWith('.zip')) + expect(zipFile).toBeDefined() + + // Cleanup + fs.rmSync(packageDir, {recursive: true, force: true}) + }) +}) + +/** + * Recursively copies a directory. + */ +function copyDirRecursive(src: string, dest: string): void { + fs.mkdirSync(dest, {recursive: true}) + for (const entry of fs.readdirSync(src, {withFileTypes: true})) { + const srcPath = path.join(src, entry.name) + const destPath = path.join(dest, entry.name) + if (entry.isDirectory()) { + copyDirRecursive(srcPath, destPath) + } else { + fs.copyFileSync(srcPath, destPath) + } + } +} diff --git a/qa-flow.md b/qa-flow.md new file mode 100644 index 00000000000..8a84b452d93 --- /dev/null +++ b/qa-flow.md @@ -0,0 +1,137 @@ +Release checklist template (to copy) +Create a theme +Run shopify theme init +Check if creates a theme in the directory +Check all AI files variations +Install the Theme Access app if you haven’t already +Then “Create password” and enter your information +Check your email to get the password +You can then use in commands like shopify theme list --password + +For all of these that want you to test with regular auth and Theme Access app, it’s probably easiest to test with regular auth first (if you’re already logged in). Afterwards, run `shopify auth logout` and test with a password from the Theme Access app +Upload a theme (with regular auth and Theme Access app) +Go to the directory (from the previous step) +Run shopify theme push +Select [Create a new theme] +Wait for the upload +Open the code editor (the URL of the code editor is https://.myshopify.com/admin/themes/) +Check if all theme files have been uploaded (ie: all theme folders should be uploaded. But all other files/folders at the root like README.md should NOT be uploaded) +Run shopify theme push --ignore "snippets/*" -u -t +Check if all files have been uploaded, except snippets + +Download a theme (with regular auth and Theme Access app) +Go to an empty directory +Run shopify theme pull +Select a theme +Wait for the download +Open the local directory +Check if all files have been downloaded +Go to another empty directory +Run shopify theme pull --ignore "snippets/*" -t +Check if all files have been downloaded, except snippets + +Develop a theme (with regular auth and Theme Access app) +Go to the directory (from the previous step) +Run shopify theme dev +Open the http://127.0.0.1:9292 preview on Google Chrome +Insert some text inside the first
in sections/hello-world.liquid +Check if the text appears in the browser +Check in the Chrome Console if the hot reload logs appear, to be sure the page wasn't fully refreshed +Update assets/critical.css file with a visible change, something like * { background: #050 !important } +Check in the Chrome Console if the hot reload logs appear, to be sure the page wasn't fully refreshed +Update the layout/theme.liquid file +Notice the entire page gets refreshed and that your change appears in the browser +Stop the development server with CTRL+C + +Develop a theme with the theme editor in companion mode +Go to the directory (from the previous step) +Run shopify theme dev --theme-editor-sync +Open the http://127.0.0.1:9292 preview on Google Chrome +Open the theme editor (the URL of the code editor is https://.myshopify.com/admin/themes//editor) +Update some element in the index page using the theme editor and save it +Check if the element appears in the http://127.0.0.1:9292 page +Check if the templates/index.json file is updated in the CLI log +Stop the development server with CTRL+C + +Show information about your theme env +Run shopify theme info +Check if the proper information appears +Check all environments working +Run shopify theme info with an environments.default in a shopify.theme.toml file +Check if the proper information appears +Run `shopify theme info -e using a shopify.theme.toml file +Check if the proper information appears +Run `shopify theme info -e -e using a shopify.theme.toml file +Check if the proper information appears + +Publish a theme +Run shopify theme publish +Select a theme +Check if the selected theme has been published in your store + +List your themes +Run shopify theme list +Check if all themes are listed +Run shopify theme list --name "*" +Check if the themes with the given prefix are listed as expected +Run shopify theme list --name "*" +Check if the themes with the given suffix are listed as expected + +Delete a theme +Run shopify theme delete +Select a theme +Check if the selected theme has been removed from your store +Run shopify theme delete -d -f +Check if the development theme is deleted without the confirmation prompt + +Rename a theme +Run shopify theme rename +Type the new name +Select a theme to rename +Check if the select theme has been renamed as expected + +Duplicate a theme +Run shopify theme duplicate +Check if the select theme has been duplicated as expected + +Package a theme +Go to the directory (from the previous step) +Run shopify theme package +Check if the zip file with the theme has been created + +Open a theme +Run shopify theme open +Select a theme +Check if the preview has been opened in the browser +Run shopify theme open -t --editor +Check if the theme editor, for the given theme, has been opened in the browser + +Share a theme +Go to the directory (from the previous step) +Run shopify theme share +Wait for the upload +Open the preview URL +Check if the theme being previewed/shared matches with your local theme + +Use theme console +Run shopify theme console +Try this Liquid snippet 1 | plus :2 | json | append: " << result" | upcase +Check if it evaluates to "3 << RESULT" +Run shopify theme console --url /products/classic-leather-jacket (you may use the URL of some product in your store) +Try this Liquid snippet product.title +Check if it evaluates to "Classic Leather Jacket" + +Lint a theme +Go to the directory (from the previous step) +Run shopify theme check +Check if you get the result of the linting +Create a linting error – for example, replace a
with +Run shopify theme check +Notice the linting error appears +Run shopify theme check --init +Ignore the linting error +Add an auto-correctable offense in a Liquid file – for example: {{ '#EA5AB9' | hex_to_rgba }} +Run shopify theme check -a +Check if auto-corrects that offense +Run shopify theme check +Notice your linting error no longer appears