Skip to content

Commit a8dd491

Browse files
binoy14claude
andauthored
test(cli-e2e): add comprehensive init E2E tests (#894)
* test(cli-e2e): add init E2E tests with node-pty interactive support Add comprehensive E2E tests for `sanity init`: - Error & flag validation (12 tests) — no auth needed - Non-interactive mode (26 tests) — templates, TypeScript, git, package managers - Bare mode (4 tests) — project/dataset output without file creation - Interactive mode (36 tests) — PTY-backed prompts via node-pty - Next.js integration (15 tests) — framework detection, config files, embedded studio Also adds nextjs-app fixture, CI workflow sharding, and CI env handling for interactive tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent dbec109 commit a8dd491

18 files changed

Lines changed: 1632 additions & 11 deletions

File tree

.claude/settings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
{
2+
"permissions": {
3+
"deny": ["Read(**/.env)"]
4+
},
25
"hooks": {
36
"PostToolUse": [
47
{

.claude/skills/writing-cli-e2e-tests/SKILL.md

Lines changed: 451 additions & 0 deletions
Large diffs are not rendered by default.

.github/workflows/e2e-scheduled.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@ jobs:
3535
with:
3636
node-version: ${{ matrix.node-version }}
3737

38+
- name: Restore Vitest cache
39+
uses: actions/cache@v5
40+
with:
41+
path: packages/@sanity/cli-e2e/node_modules/.vite/vitest
42+
key: vitest-e2e-scheduled-cache-${{ matrix.os }}-node${{ matrix.node-version }}-${{ github.run_id }}
43+
restore-keys: |
44+
vitest-e2e-scheduled-cache-${{ matrix.os }}-node${{ matrix.node-version }}-
45+
3846
- name: Install CLI from npm
3947
run: |
4048
CLI_VERSION="${{ inputs.cli_version || 'latest' }}"
@@ -54,6 +62,7 @@ jobs:
5462
SANITY_E2E_TOKEN: ${{ secrets.SANITY_E2E_TOKEN }}
5563
SANITY_E2E_PROJECT_ID: ${{ secrets.SANITY_E2E_PROJECT_ID }}
5664
SANITY_E2E_DATASET: ${{ secrets.SANITY_E2E_DATASET }}
65+
SANITY_E2E_ORGANIZATION_ID: ${{ secrets.SANITY_E2E_ORGANIZATION_ID }}
5766
run: pnpm --filter @sanity/cli-e2e test
5867

5968
notify-failure:

.github/workflows/e2e.yml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ jobs:
4545
matrix:
4646
os: [ubuntu-latest]
4747
node-version: [20, 22, 24]
48+
shardIndex: [1, 2]
49+
shardTotal: [2]
4850
fail-fast: false
4951

5052
steps:
@@ -55,6 +57,14 @@ jobs:
5557
with:
5658
node-version: ${{ matrix.node-version }}
5759

60+
- name: Restore Vitest cache
61+
uses: actions/cache@v5
62+
with:
63+
path: packages/@sanity/cli-e2e/node_modules/.vite/vitest
64+
key: vitest-e2e-cache-${{ matrix.os }}-node${{ matrix.node-version }}-shard${{ matrix.shardIndex }}-${{ github.run_id }}
65+
restore-keys: |
66+
vitest-e2e-cache-${{ matrix.os }}-node${{ matrix.node-version }}-shard${{ matrix.shardIndex }}-
67+
5868
- name: Build CLI
5969
run: pnpm build:cli
6070

@@ -63,7 +73,8 @@ jobs:
6373
SANITY_E2E_TOKEN: ${{ secrets.SANITY_E2E_TOKEN }}
6474
SANITY_E2E_PROJECT_ID: ${{ secrets.SANITY_E2E_PROJECT_ID }}
6575
SANITY_E2E_DATASET: ${{ secrets.SANITY_E2E_DATASET }}
66-
run: pnpm --filter @sanity/cli-e2e test
76+
SANITY_E2E_ORGANIZATION_ID: ${{ secrets.SANITY_E2E_ORGANIZATION_ID }}
77+
run: pnpm --filter @sanity/cli-e2e test --shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
6778

6879
e2e-status:
6980
if: always()

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ All commands are run from the root of the repo.
1212
- `pnpm check:deps` - unused dependency / export check
1313
- `pnpm build:cli` - build the project
1414
- `pnpm watch:cli` - build in watch mode
15+
- `pnpm --filter @sanity/cli-e2e exec vitest run <file>` - run specific e2e test file
16+
- `pnpm --filter @sanity/cli-e2e exec vitest run <file> -t "<pattern>"` - run specific e2e test by name
1517

1618
# Workflow
1719

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
SANITY_E2E_TOKEN=
22
SANITY_E2E_PROJECT_ID=
3+
SANITY_E2E_ORGANIZATION_ID=
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import {existsSync, readFileSync} from 'node:fs'
2+
3+
import {createTmpDir} from '@sanity/cli-test'
4+
import {afterEach, beforeEach, describe, expect, test} from 'vitest'
5+
6+
import {getE2EDataset, getE2EOrganizationId, getE2EProjectId, runCli} from '../../helpers/runCli.js'
7+
8+
const orgId = getE2EOrganizationId()
9+
const projectId = getE2EProjectId()
10+
const dataset = getE2EDataset()
11+
12+
describe('sanity init - app', {timeout: 120_000}, () => {
13+
let tmp: Awaited<ReturnType<typeof createTmpDir>>
14+
15+
beforeEach(async () => {
16+
tmp = await createTmpDir({useSystemTmp: true})
17+
})
18+
19+
afterEach(async () => {
20+
await tmp.cleanup()
21+
})
22+
23+
describe.each([
24+
{label: 'with -y flag', yFlag: ['-y']},
25+
{label: 'unattended (no -y)', yFlag: [] as string[]},
26+
])('non-interactive ($label)', ({yFlag}) => {
27+
test('creates app with app-quickstart template', async () => {
28+
const {error, exitCode, stdout} = await runCli({
29+
args: [
30+
'init',
31+
...yFlag,
32+
'--template',
33+
'app-quickstart',
34+
'--organization',
35+
orgId,
36+
'--output-path',
37+
tmp.path,
38+
'--typescript',
39+
'--package-manager',
40+
'pnpm',
41+
'--no-git',
42+
],
43+
})
44+
45+
if (error) throw error
46+
expect(exitCode).toBe(0)
47+
48+
expect(existsSync(`${tmp.path}/src/App.tsx`)).toBe(true)
49+
expect(existsSync(`${tmp.path}/package.json`)).toBe(true)
50+
51+
const cliConfig = readFileSync(`${tmp.path}/sanity.cli.ts`, 'utf8')
52+
expect(cliConfig).toContain('organizationId')
53+
expect(cliConfig).toContain('entry')
54+
55+
expect(existsSync(`${tmp.path}/sanity.config.ts`)).toBe(false)
56+
57+
expect(stdout).toMatch(/app has been scaffolded|Success/i)
58+
})
59+
60+
test('scaffolds app with --project and --dataset', async () => {
61+
const {error, exitCode} = await runCli({
62+
args: [
63+
'init',
64+
...yFlag,
65+
'--template',
66+
'app-quickstart',
67+
'--project',
68+
projectId,
69+
'--dataset',
70+
dataset,
71+
'--output-path',
72+
tmp.path,
73+
'--package-manager',
74+
'pnpm',
75+
'--no-git',
76+
],
77+
})
78+
79+
if (error) throw error
80+
expect(exitCode).toBe(0)
81+
82+
const appTsx = readFileSync(`${tmp.path}/src/App.tsx`, 'utf8')
83+
expect(appTsx).toContain(projectId)
84+
expect(appTsx).toContain(dataset)
85+
86+
const cliConfig = readFileSync(`${tmp.path}/sanity.cli.ts`, 'utf8')
87+
expect(cliConfig).toContain(orgId)
88+
})
89+
})
90+
91+
test('complete interactive flow selects project and dataset', async () => {
92+
const session = await runCli({
93+
args: [
94+
'init',
95+
'--template',
96+
'app-quickstart',
97+
'--organization',
98+
orgId,
99+
'--output-path',
100+
tmp.path,
101+
'--no-git',
102+
'--no-mcp',
103+
],
104+
interactive: true,
105+
})
106+
107+
await session.waitForText(/Configure a project for this app/i)
108+
await session.selectOption(new RegExp(`\\(${projectId}\\)`))
109+
110+
await session.waitForText(/Select dataset to use/i)
111+
await session.selectOption(dataset)
112+
113+
await session.waitForText(/Package manager to use/i)
114+
await session.selectOption('pnpm')
115+
116+
const exitCode = await session.waitForExit(90_000)
117+
expect(exitCode).toBe(0)
118+
119+
expect(existsSync(`${tmp.path}/src/App.tsx`)).toBe(true)
120+
expect(existsSync(`${tmp.path}/package.json`)).toBe(true)
121+
expect(existsSync(`${tmp.path}/sanity.cli.ts`)).toBe(true)
122+
123+
const cliConfig = readFileSync(`${tmp.path}/sanity.cli.ts`, 'utf8')
124+
expect(cliConfig).toContain('organizationId')
125+
126+
const output = session.getOutput()
127+
expect(output).toContain('Your custom app has been scaffolded')
128+
expect(output).toMatch(/Configured with project .+ and dataset/)
129+
})
130+
})
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import {existsSync, readFileSync} from 'node:fs'
2+
import {rm} from 'node:fs/promises'
3+
import {join} from 'node:path'
4+
5+
import {testFixture} from '@sanity/cli-test'
6+
import {afterEach, beforeEach, describe, expect, test} from 'vitest'
7+
8+
import {getE2EProjectId, runCli} from '../../helpers/runCli.js'
9+
10+
const projectId = getE2EProjectId()
11+
12+
describe('sanity init - Next.js integration', {timeout: 120_000}, () => {
13+
let nextjsDir: string
14+
15+
beforeEach(async () => {
16+
nextjsDir = await testFixture('nextjs-app', {useSystemTmp: true})
17+
await rm(join(nextjsDir, 'node_modules'), {force: true, recursive: true})
18+
})
19+
20+
afterEach(async () => {
21+
if (nextjsDir) await rm(nextjsDir, {force: true, recursive: true})
22+
})
23+
24+
describe.each([
25+
{label: 'with -y flag', yFlag: ['-y']},
26+
{label: 'unattended (no -y)', yFlag: [] as string[]},
27+
])('non-interactive ($label)', ({yFlag}) => {
28+
test('creates sanity config, schema, and cli files with --nextjs-add-config-files', async () => {
29+
const {error, exitCode} = await runCli({
30+
args: [
31+
'init',
32+
...yFlag,
33+
'--project',
34+
projectId,
35+
'--dataset',
36+
'production',
37+
'--nextjs-add-config-files',
38+
'--package-manager',
39+
'pnpm',
40+
],
41+
cwd: nextjsDir,
42+
})
43+
44+
if (error) throw error
45+
expect(exitCode).toBe(0)
46+
47+
expect(existsSync(`${nextjsDir}/sanity.config.ts`)).toBe(true)
48+
expect(existsSync(`${nextjsDir}/sanity/schemaTypes/index.ts`)).toBe(true)
49+
50+
const cliConfig = readFileSync(`${nextjsDir}/sanity.cli.ts`, 'utf8')
51+
expect(cliConfig).toContain('NEXT_PUBLIC_SANITY_PROJECT_ID')
52+
})
53+
54+
test('creates embedded studio route with --nextjs-embed-studio', async () => {
55+
const {error, exitCode} = await runCli({
56+
args: [
57+
'init',
58+
...yFlag,
59+
'--project',
60+
projectId,
61+
'--dataset',
62+
'production',
63+
'--nextjs-add-config-files',
64+
'--nextjs-embed-studio',
65+
'--package-manager',
66+
'pnpm',
67+
],
68+
cwd: nextjsDir,
69+
})
70+
71+
if (error) throw error
72+
expect(exitCode).toBe(0)
73+
expect(existsSync(`${nextjsDir}/app/studio/[[...tool]]/page.tsx`)).toBe(true)
74+
})
75+
76+
test('writes env variables to .env.local with --nextjs-append-env', async () => {
77+
const {error, exitCode} = await runCli({
78+
args: [
79+
'init',
80+
...yFlag,
81+
'--project',
82+
projectId,
83+
'--dataset',
84+
'production',
85+
'--nextjs-add-config-files',
86+
'--nextjs-append-env',
87+
'--package-manager',
88+
'pnpm',
89+
],
90+
cwd: nextjsDir,
91+
})
92+
93+
if (error) throw error
94+
expect(exitCode).toBe(0)
95+
96+
const envContent = readFileSync(`${nextjsDir}/.env.local`, 'utf8')
97+
expect(envContent).toContain('NEXT_PUBLIC_SANITY_PROJECT_ID')
98+
expect(envContent).toContain('NEXT_PUBLIC_SANITY_DATASET')
99+
})
100+
})
101+
102+
describe('interactive', () => {
103+
test('detects Next.js and completes with config files', async () => {
104+
const session = await runCli({
105+
args: [
106+
'init',
107+
'--project',
108+
projectId,
109+
'--dataset',
110+
'production',
111+
'--package-manager',
112+
'pnpm',
113+
'--no-mcp',
114+
'--no-git',
115+
],
116+
cwd: nextjsDir,
117+
interactive: true,
118+
})
119+
120+
await session.waitForText(/Would you like to add configuration files/i)
121+
session.sendKey('Enter')
122+
123+
await session.waitForText(/Do you want to use TypeScript/i)
124+
session.sendKey('Enter')
125+
126+
await session.waitForText(/Would you like an embedded Sanity Studio/i)
127+
session.sendKey('Enter')
128+
129+
await session.waitForText(/What route do you want to use for the Studio/i)
130+
session.sendKey('Enter')
131+
132+
await session.waitForText(/Select project template to use/i)
133+
session.sendKey('Enter')
134+
135+
await session.waitForText(/Would you like to add the project ID and dataset/i)
136+
session.sendKey('Enter')
137+
138+
const exitCode = await session.waitForExit(90_000)
139+
expect(exitCode).toBe(0)
140+
141+
expect(existsSync(`${nextjsDir}/sanity.config.ts`)).toBe(true)
142+
143+
const output = session.getOutput()
144+
expect(output).toMatch(/\/studio/i)
145+
expect(output).not.toMatch(/Project output path/i)
146+
})
147+
148+
test('accepts custom studio route', async () => {
149+
const session = await runCli({
150+
args: [
151+
'init',
152+
'--project',
153+
projectId,
154+
'--dataset',
155+
'production',
156+
'--package-manager',
157+
'pnpm',
158+
'--no-mcp',
159+
'--no-git',
160+
],
161+
cwd: nextjsDir,
162+
interactive: true,
163+
})
164+
165+
await session.waitForText(/Would you like to add configuration files/i)
166+
session.sendKey('Enter')
167+
168+
await session.waitForText(/Do you want to use TypeScript/i)
169+
session.sendKey('Enter')
170+
171+
await session.waitForText(/Would you like an embedded Sanity Studio/i)
172+
session.sendKey('Enter')
173+
174+
await session.waitForText(/What route do you want to use for the Studio/i)
175+
session.write('/admin\n')
176+
177+
await session.waitForText(/Select project template to use/i)
178+
session.sendKey('Enter')
179+
180+
await session.waitForText(/Would you like to add the project ID and dataset/i)
181+
session.sendKey('Enter')
182+
183+
const exitCode = await session.waitForExit(90_000)
184+
expect(exitCode).toBe(0)
185+
186+
expect(existsSync(`${nextjsDir}/app/admin/[[...tool]]/page.tsx`)).toBe(true)
187+
expect(existsSync(`${nextjsDir}/sanity.config.ts`)).toBe(true)
188+
})
189+
})
190+
})

0 commit comments

Comments
 (0)