Skip to content

Commit 633cc3c

Browse files
committed
E2E - QA: init, deploy, versions list, config link, 2nd deploy
1 parent 21fd11c commit 633cc3c

2 files changed

Lines changed: 241 additions & 42 deletions

File tree

packages/e2e/setup/app.ts

Lines changed: 123 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -27,20 +27,16 @@ export async function createApp(ctx: {
2727
const template = ctx.template ?? 'reactRouter'
2828
const packageManager =
2929
ctx.packageManager ?? (process.env.E2E_PACKAGE_MANAGER as 'npm' | 'yarn' | 'pnpm' | 'bun') ?? 'pnpm'
30-
31-
const args = [
32-
'--name',
33-
name,
34-
'--path',
35-
parentDir,
36-
'--package-manager',
37-
packageManager,
38-
'--local',
39-
'--template',
40-
template,
41-
]
30+
// reactRouter/remix both require a --flavor or they'll hang on the language
31+
// prompt in non-interactive runs. Default to javascript when template needs
32+
// it. For `--template none` (extension-only) flavor is ignored.
33+
const flavor = ctx.flavor ?? (template === 'none' ? undefined : 'javascript')
34+
35+
const args = ['--template', template]
36+
if (flavor) args.push('--flavor', flavor)
37+
args.push('--name', name, '--package-manager', packageManager, '--local')
4238
if (ctx.orgId) args.push('--organization-id', ctx.orgId)
43-
if (ctx.flavor) args.push('--flavor', ctx.flavor)
39+
args.push('--path', parentDir)
4440

4541
const result = await cli.execCreateApp(args, {
4642
env: {FORCE_COLOR: '0'},
@@ -116,8 +112,9 @@ export async function generateExtension(
116112
flavor?: string
117113
},
118114
): Promise<ExecResult> {
119-
const args = ['app', 'generate', 'extension', '--name', ctx.name, '--path', ctx.appDir, '--template', ctx.template]
115+
const args = ['app', 'generate', 'extension', '--template', ctx.template]
120116
if (ctx.flavor) args.push('--flavor', ctx.flavor)
117+
args.push('--name', ctx.name, '--path', ctx.appDir)
121118
return ctx.cli.exec(args, {timeout: CLI_TIMEOUT.long})
122119
}
123120

@@ -134,12 +131,13 @@ export async function deployApp(
134131
noBuild?: boolean
135132
},
136133
): Promise<ExecResult> {
137-
const args = ['app', 'deploy', '--path', ctx.appDir]
138-
if (ctx.force ?? true) args.push('--force')
139-
if (ctx.noBuild) args.push('--no-build')
134+
const args = ['app', 'deploy']
140135
if (ctx.version) args.push('--version', ctx.version)
141136
if (ctx.message) args.push('--message', ctx.message)
142137
if (ctx.config) args.push('--config', ctx.config)
138+
if (ctx.force ?? true) args.push('--force')
139+
if (ctx.noBuild) args.push('--no-build')
140+
args.push('--path', ctx.appDir)
143141
return ctx.cli.exec(args, {timeout: CLI_TIMEOUT.long})
144142
}
145143

@@ -152,7 +150,7 @@ export async function appInfo(ctx: CLIContext): Promise<{
152150
entrySourceFilePath: string
153151
}[]
154152
}> {
155-
const result = await ctx.cli.exec(['app', 'info', '--path', ctx.appDir, '--json'])
153+
const result = await ctx.cli.exec(['app', 'info', '--json', '--path', ctx.appDir])
156154
if (result.exitCode !== 0) {
157155
throw new Error(`app info failed (exit ${result.exitCode}):\nstdout: ${result.stdout}\nstderr: ${result.stderr}`)
158156
}
@@ -168,25 +166,124 @@ export async function functionRun(
168166
inputPath: string
169167
},
170168
): Promise<ExecResult> {
171-
return ctx.cli.exec(['app', 'function', 'run', '--path', ctx.appDir, '--input', ctx.inputPath], {
169+
return ctx.cli.exec(['app', 'function', 'run', '--input', ctx.inputPath, '--path', ctx.appDir], {
172170
timeout: CLI_TIMEOUT.short,
173171
})
174172
}
175173

176-
export async function versionsList(ctx: CLIContext): Promise<ExecResult> {
177-
return ctx.cli.exec(['app', 'versions', 'list', '--path', ctx.appDir, '--json'], {
178-
timeout: CLI_TIMEOUT.short,
179-
})
174+
export async function versionsList(
175+
ctx: CLIContext & {
176+
config?: string
177+
},
178+
): Promise<ExecResult> {
179+
const args = ['app', 'versions', 'list', '--json']
180+
if (ctx.config) args.push('--config', ctx.config)
181+
args.push('--path', ctx.appDir)
182+
return ctx.cli.exec(args, {timeout: CLI_TIMEOUT.short})
180183
}
181184

185+
/**
186+
* Run `app config link` to create a brand-new app on Shopify interactively.
187+
* Answers the prompts:
188+
* "Which organization is this work for?" → filter by orgId → Enter
189+
* "Create this project as a new app on Shopify?" → Yes (default)
190+
* "App name" → appName
191+
* "Configuration file name" → skipped via `--config` flag
192+
*
193+
* Env overrides (via PTY spawn):
194+
* CI=undefined — drop the key so prompts render.
195+
* Fixture default is CI=1; Ink's `is-in-ci`
196+
* treats `'CI' in env` as CI even when ''.
197+
* In CI mode Ink suppresses prompt frames
198+
* (only emitted on unmount), so waitForOutput
199+
* hangs until the process is killed.
200+
* SHOPIFY_CLI_NEVER_USE_PARTNERS_API=1 — skip Partners client in fetchOrganizations.
201+
* Without this, fetchOrganizations iterates
202+
* AppManagement AND Partners sequentially.
203+
* Partners requires SHOPIFY_CLI_PARTNERS_TOKEN
204+
* (not set in OAuth-auth'd tests) and hangs
205+
* for minutes trying to authenticate. The e2e
206+
* test org (161686155) lives in AppManagement.
207+
*/
182208
export async function configLink(
183209
ctx: CLIContext & {
184-
clientId: string
210+
appName: string
211+
orgId: string
212+
configName?: string
185213
},
186214
): Promise<ExecResult> {
187-
return ctx.cli.exec(['app', 'config', 'link', '--path', ctx.appDir, '--client-id', ctx.clientId], {
188-
timeout: CLI_TIMEOUT.medium,
215+
const args = ['app', 'config', 'link']
216+
// Pass configName as --config flag. link.ts → loadConfigurationFileName skips
217+
// the "Configuration file name" prompt when options.configName is set, which
218+
// also side-steps a painful interactive quirk: that prompt uses
219+
// `initialAnswer = remoteApp.title`, so any text we write would be appended
220+
// to the app name rather than replacing it.
221+
if (ctx.configName) args.push('--config', ctx.configName)
222+
args.push('--path', ctx.appDir)
223+
224+
const proc = await ctx.cli.spawn(args, {
225+
env: {
226+
CI: undefined,
227+
SHOPIFY_CLI_NEVER_USE_PARTNERS_API: '1',
228+
},
189229
})
230+
231+
// Short sleep so Ink's useInput hooks attach before we start writing.
232+
// Without this, an Enter press arrives mid-mount and a subsequent render can
233+
// flip the prompt state unexpectedly (e.g. turning a select into search mode).
234+
const settle = (ms = 150) => new Promise<void>((resolve) => setTimeout(resolve, ms))
235+
236+
try {
237+
// The first prompt is either the multi-org selector or — when the account
238+
// has only one org, or none of the orgs have existing apps — we jump
239+
// straight to `createAsNewAppPrompt`. Race both.
240+
const firstPrompt = await Promise.race([
241+
proc.waitForOutput('Which organization', CLI_TIMEOUT.medium).then(() => 'org' as const),
242+
proc.waitForOutput('Create this project as a new app', CLI_TIMEOUT.medium).then(() => 'create' as const),
243+
proc.waitForOutput('App name', CLI_TIMEOUT.medium).then(() => 'appName' as const),
244+
])
245+
246+
if (firstPrompt === 'org') {
247+
// Type the orgId to filter the autocomplete prompt to exactly one match.
248+
// selectOrganizationPrompt's label includes `(${org.id})` when duplicate
249+
// org names exist (which is true for the e2e test account), so substring
250+
// matching on the numeric ID is unique. Avoids relying on MRU ordering.
251+
await settle()
252+
proc.ptyProcess.write(ctx.orgId)
253+
await settle()
254+
proc.sendKey('\r')
255+
// After org selection the CLI fetches apps for the chosen org. If
256+
// the org has existing apps → "Create this project" prompt. If it has
257+
// zero apps → selectOrCreateApp skips straight to appNamePrompt.
258+
const next = await Promise.race([
259+
proc.waitForOutput('Create this project as a new app', CLI_TIMEOUT.medium).then(() => 'create' as const),
260+
proc.waitForOutput('App name', CLI_TIMEOUT.medium).then(() => 'appName' as const),
261+
])
262+
if (next === 'create') {
263+
await settle()
264+
proc.sendKey('\r')
265+
}
266+
} else if (firstPrompt === 'create') {
267+
await settle()
268+
proc.sendKey('\r')
269+
}
270+
271+
// Wait for "App name" text prompt and submit the desired name.
272+
// Important: Ink parses each PTY data event as ONE keypress. If we write
273+
// "name\r" in one call, parseKeypress sees the whole string and treats
274+
// it as text (not Enter), so the prompt never submits. We must write the
275+
// text, wait for it to be consumed, then write \r separately.
276+
await proc.waitForOutput('App name', CLI_TIMEOUT.medium)
277+
await settle()
278+
proc.ptyProcess.write(ctx.appName)
279+
await settle()
280+
proc.sendKey('\r')
281+
282+
const exitCode = await proc.waitForExit(CLI_TIMEOUT.long)
283+
return {exitCode, stdout: proc.getOutput(), stderr: ''}
284+
} finally {
285+
proc.kill()
286+
}
190287
}
191288

192289
// ---------------------------------------------------------------------------

packages/e2e/tests/app-deploy.spec.ts

Lines changed: 118 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,157 @@
1-
import {appTestFixture as test, createApp, deployApp, versionsList} from '../setup/app.js'
1+
/* eslint-disable no-restricted-imports */
2+
import {appTestFixture as test, createApp, deployApp, versionsList, configLink} from '../setup/app.js'
23
import {teardownAll} from '../setup/teardown.js'
34
import {TEST_TIMEOUT} from '../setup/constants.js'
45
import {requireEnv} from '../setup/env.js'
56
import {expect} from '@playwright/test'
67
import * as fs from 'fs'
7-
import * as path from 'path' // eslint-disable-line no-restricted-imports
8+
import * as path from 'path'
9+
10+
/**
11+
* Test A — full deploy lifecycle (QA checklist: Apps section, deploy flow).
12+
*
13+
* 1. `app init` Create primary app (React Router + JavaScript)
14+
* 2. `app deploy --version v1` Deploy with a version tag
15+
* 3. `app versions list` Verify the primary tag is active and no other
16+
* version is stuck active
17+
* 4. `app config link` from primary dir → creates a brand-new secondary app
18+
* interactively (answers org → "Create new?" → app name; config name
19+
* prompt is skipped via `--config secondary`)
20+
* 5. `app deploy --config secondary` Deploy from primary dir to secondary app
21+
* 6. `app versions list --config secondary` Verify the secondary deploy hit
22+
* the secondary app (not a silent fallback to primary) and the primary
23+
* tag does not leak into secondary's list
24+
*
25+
* Test body is pure CLI; teardown uses the dev dashboard to delete both apps.
26+
*/
27+
28+
interface VersionLine {
29+
versionTag?: string | null
30+
status: string
31+
}
32+
33+
/**
34+
* Asserts a `versions list --json` result shows:
35+
* - `expectedTag` is present and `active`
36+
* - no other version is stuck `active`
37+
* - (if `forbiddenTag` provided) `forbiddenTag` does not appear at all
38+
*
39+
* The last check guards cross-app leakage: a version we expect to live on one
40+
* app should never appear in another app's version list.
41+
*/
42+
function assertActiveVersion(opts: {
43+
result: {stdout: string; stderr: string; exitCode: number}
44+
expectedTag: string
45+
step: string
46+
forbiddenTag?: string
47+
}) {
48+
const {result, expectedTag, step, forbiddenTag} = opts
49+
const output = result.stdout + result.stderr
50+
expect(result.exitCode, `${step} - versions list failed:\n${output}`).toBe(0)
51+
const versions = JSON.parse(result.stdout) as VersionLine[]
52+
const entry = versions.find((version) => version.versionTag === expectedTag)
53+
expect(entry, `${step} - version tag "${expectedTag}" not found in:\n${result.stdout}`).toBeDefined()
54+
expect(entry?.status, `${step} - expected "${expectedTag}" to be active, got "${entry?.status}"`).toBe('active')
55+
const otherActive = versions.filter((version) => version.versionTag !== expectedTag && version.status === 'active')
56+
expect(otherActive, `${step} - unexpected other active versions: ${JSON.stringify(otherActive)}`).toHaveLength(0)
57+
if (forbiddenTag) {
58+
const tags = versions.map((version) => version.versionTag)
59+
expect(tags, `${step} - tag "${forbiddenTag}" unexpectedly found in list`).not.toContain(forbiddenTag)
60+
}
61+
}
862

963
test.describe('App deploy', () => {
10-
test('deploy and verify version exists', async ({cli, env, browserPage}) => {
64+
test('init, deploy, versions list, config link, deploy to secondary', async ({cli, env, browserPage}) => {
1165
test.setTimeout(TEST_TIMEOUT.long)
1266
requireEnv(env, 'orgId')
1367

1468
const parentDir = fs.mkdtempSync(path.join(env.tempDir, 'app-'))
15-
const appName = `E2E-deploy-${Date.now()}`
69+
const appName = `E2E-deploy1-${Date.now()}`
70+
const secondaryAppName = `E2E-deploy2-${Date.now()}`
1671

1772
try {
18-
// Step 1: Create an extension-only app (no scopes needed for deploy)
73+
// Step 1: Create primary app (React Router template)
1974
const initResult = await createApp({
2075
cli,
2176
parentDir,
2277
name: appName,
23-
template: 'none',
78+
template: 'reactRouter',
79+
flavor: 'javascript',
2480
packageManager: 'pnpm',
2581
orgId: env.orgId,
2682
})
27-
expect(initResult.exitCode, `createApp failed:\nstdout: ${initResult.stdout}\nstderr: ${initResult.stderr}`).toBe(
28-
0,
29-
)
83+
expect(initResult.exitCode, `Step 1 - primary app init failed:\n${initResult.stderr}`).toBe(0)
3084
const appDir = initResult.appDir
3185

3286
// Step 2: Deploy with a tagged version
33-
const versionTag = `e2e-v-${Date.now()}`
34-
const deployResult = await deployApp({cli, appDir, version: versionTag, message: 'E2E test deployment'})
87+
const versionTag = `E2E-v1-${Date.now()}`
88+
const deployResult = await deployApp({cli, appDir, version: versionTag, message: 'E2E A primary deployment'})
3589
const deployOutput = deployResult.stdout + deployResult.stderr
36-
expect(deployResult.exitCode, `deploy failed:\n${deployOutput}`).toBe(0)
90+
expect(deployResult.exitCode, `Step 2 - deploy failed:\n${deployOutput}`).toBe(0)
3791

38-
// Step 3: Verify the version exists via versions list
92+
// Step 3: Verify the primary tag is active and no other version is stuck active.
3993
const listResult = await versionsList({cli, appDir})
40-
const listOutput = listResult.stdout + listResult.stderr
41-
expect(listResult.exitCode, `versions list failed:\n${listOutput}`).toBe(0)
42-
expect(listOutput).toContain(versionTag)
94+
assertActiveVersion({result: listResult, expectedTag: versionTag, step: 'Step 3'})
95+
96+
// Step 4: Config link from primary dir → creates a brand-new secondary app
97+
// interactively (org → "Create new?" → "App name"). The "Configuration file
98+
// name" prompt is skipped via `--config secondary`.
99+
const secondaryConfig = 'secondary'
100+
const linkResult = await configLink({
101+
cli,
102+
appDir,
103+
appName: secondaryAppName,
104+
orgId: env.orgId,
105+
configName: secondaryConfig,
106+
})
107+
const linkOutput = linkResult.stdout + linkResult.stderr
108+
expect(linkResult.exitCode, `Step 4 - config link failed:\n${linkOutput}`).toBe(0)
109+
expect(linkOutput, 'Step 4 - missing "is now linked to"').toContain('is now linked to')
110+
const secondaryTomlPath = path.join(appDir, `shopify.app.${secondaryConfig}.toml`)
111+
expect(
112+
fs.existsSync(secondaryTomlPath),
113+
`Step 4 - expected ${secondaryTomlPath} to exist after config link`,
114+
).toBe(true)
115+
116+
// Step 5: Deploy from primary dir to secondary app via --config secondary
117+
const secondaryVersionTag = `E2E-v2-${Date.now()}`
118+
const secondaryDeployResult = await deployApp({
119+
cli,
120+
appDir,
121+
config: secondaryConfig,
122+
version: secondaryVersionTag,
123+
message: 'E2E A secondary deployment',
124+
})
125+
const secondaryDeployOutput = secondaryDeployResult.stdout + secondaryDeployResult.stderr
126+
expect(secondaryDeployResult.exitCode, `Step 5 - secondary deploy failed:\n${secondaryDeployOutput}`).toBe(0)
127+
128+
// Step 6: Verify the secondary deploy hit the secondary app (not a silent
129+
// fallback to primary). Checks the secondary tag is active, no other
130+
// version is stuck active, and the primary tag doesn't leak into secondary.
131+
const secondaryListResult = await versionsList({cli, appDir, config: secondaryConfig})
132+
assertActiveVersion({
133+
result: secondaryListResult,
134+
expectedTag: secondaryVersionTag,
135+
step: 'Step 6',
136+
forbiddenTag: versionTag,
137+
})
43138
} finally {
44139
// E2E_SKIP_TEARDOWN=1 skips teardown for debugging. Run cleanup scripts afterward.
45140
if (!process.env.E2E_SKIP_TEARDOWN) {
46141
fs.rmSync(parentDir, {recursive: true, force: true})
142+
// Neither app was installed on a store — delete the apps only (no uninstall)
47143
await teardownAll({
48144
browserPage,
49145
appName,
50146
orgId: env.orgId,
51147
workerIndex: env.workerIndex,
52148
})
149+
await teardownAll({
150+
browserPage,
151+
appName: secondaryAppName,
152+
orgId: env.orgId,
153+
workerIndex: env.workerIndex,
154+
})
53155
}
54156
}
55157
})

0 commit comments

Comments
 (0)