Skip to content
Draft

wip #7034

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/tests-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
73 changes: 73 additions & 0 deletions .github/workflows/theme-e2e.yml
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions packages/e2e/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
1 change: 1 addition & 0 deletions packages/e2e/data/dawn-minimal/assets/base.css
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
body { font-family: sans-serif; }
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[{"name": "theme_info", "theme_name": "E2E Test Theme", "theme_version": "1.0.0"}]
5 changes: 5 additions & 0 deletions packages/e2e/data/dawn-minimal/layout/theme.liquid
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<!DOCTYPE html>
<html>
<head>{{ content_for_header }}</head>
<body>{{ content_for_layout }}</body>
</html>
1 change: 1 addition & 0 deletions packages/e2e/data/dawn-minimal/locales/en.default.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"general": {"title": "E2E Test Store"}}
2 changes: 2 additions & 0 deletions packages/e2e/data/dawn-minimal/sections/header.liquid
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<header>E2E Test Header</header>
{% schema %}{"name": "Header"}{% endschema %}
1 change: 1 addition & 0 deletions packages/e2e/data/dawn-minimal/snippets/icon.liquid
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<span class="icon">{{ icon }}</span>
1 change: 1 addition & 0 deletions packages/e2e/data/dawn-minimal/templates/index.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"sections": {"main": {"type": "header"}}, "order": ["main"]}
7 changes: 7 additions & 0 deletions packages/e2e/setup/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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})
Expand All @@ -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,
}
Expand Down
169 changes: 169 additions & 0 deletions packages/e2e/setup/theme.ts
Original file line number Diff line number Diff line change
@@ -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<ExecResult>
/** 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<ExecResult>
/** Rename a theme */
rename(themeId: string, newName: string): Promise<ExecResult>
/** Duplicate a theme (via push with development flag) */
duplicate(themeId: string, newName: string): Promise<ExecResult>
}

/**
* 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})
},
})
Loading
Loading