Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
57e626c
test(cli-e2e): add init E2E tests with node-pty interactive support
binoy14 Apr 7, 2026
c24a7e0
test(cli-e2e): enable file parallelism for E2E tests
binoy14 Apr 20, 2026
2ecab1f
test(cli-e2e): address PR feedback for init E2E tests
binoy14 Apr 20, 2026
03b481e
ci(cli-e2e): shard E2E tests into 2 splits with vitest cache
binoy14 Apr 20, 2026
153d3c9
ci(cli-e2e): pass SANITY_E2E_ORGANIZATION_ID secret to E2E tests
binoy14 Apr 20, 2026
1077660
test(cli-e2e): remove invalid --no-typescript app test and fix intera…
binoy14 Apr 20, 2026
07b1aa5
chore: deny claude code from reading .env files
binoy14 Apr 21, 2026
ca3666f
ci: fix vitest cache path for vitest 4.x
binoy14 Apr 21, 2026
9e05782
test(cli-e2e): split studio init tests into interactive and non-inter…
binoy14 Apr 21, 2026
88655da
ci: fix vitest cache paths to match actual cache locations
binoy14 Apr 21, 2026
dafbdeb
test(cli-e2e): skip project creation test that leaves orphaned projects
binoy14 Apr 21, 2026
d22d607
test(cli-e2e): use useSystemTmp in app and bare init tests
binoy14 Apr 21, 2026
f85d2d3
test(cli-e2e): improve init e2e test structure and assertions
binoy14 Apr 22, 2026
3fbdb84
test(cli-e2e): tighten init e2e structure and assertions
binoy14 Apr 22, 2026
2ee6c3e
fix(cli-test): add useSystemTmp option to testFixture
binoy14 Apr 22, 2026
d7fff6e
test(cli-e2e): run init studio tests in both -y and unattended modes
binoy14 Apr 23, 2026
ec4a60c
test(cli-e2e): run nextjs init tests in both -y and unattended modes
binoy14 Apr 23, 2026
74e1d1a
test(cli-e2e): address @agent review comments in interactive and app …
binoy14 Apr 23, 2026
4065fe4
test(cli-e2e): add selectOption API, app interactive test, and skill …
binoy14 Apr 23, 2026
43e869e
ci: add vitest cache to e2e scheduled workflow
binoy14 Apr 23, 2026
e4402d8
test(cli-e2e): address PR review feedback on init interactive tests
binoy14 Apr 24, 2026
fe4e07c
test(cli-e2e): add app init test with --project and --dataset flags
binoy14 Apr 28, 2026
ebe3c86
test(cli-e2e): split studio init tests into separate files for sharding
binoy14 Apr 28, 2026
2af6920
test(cli-e2e): assert pnpm was chosen by lockfile auto-detection
binoy14 Apr 30, 2026
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
3 changes: 3 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
{
"permissions": {
"deny": ["Read(**/.env)"]
},
"hooks": {
"PostToolUse": [
{
Expand Down
451 changes: 451 additions & 0 deletions .claude/skills/writing-cli-e2e-tests/SKILL.md

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions .github/workflows/e2e-scheduled.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ jobs:
with:
node-version: ${{ matrix.node-version }}

- name: Restore Vitest cache
uses: actions/cache@v5
with:
path: packages/@sanity/cli-e2e/node_modules/.vite/vitest
key: vitest-e2e-scheduled-cache-${{ matrix.os }}-node${{ matrix.node-version }}-${{ github.run_id }}
restore-keys: |
vitest-e2e-scheduled-cache-${{ matrix.os }}-node${{ matrix.node-version }}-

- name: Install CLI from npm
run: |
CLI_VERSION="${{ inputs.cli_version || 'latest' }}"
Expand All @@ -54,6 +62,7 @@ jobs:
SANITY_E2E_TOKEN: ${{ secrets.SANITY_E2E_TOKEN }}
SANITY_E2E_PROJECT_ID: ${{ secrets.SANITY_E2E_PROJECT_ID }}
SANITY_E2E_DATASET: ${{ secrets.SANITY_E2E_DATASET }}
SANITY_E2E_ORGANIZATION_ID: ${{ secrets.SANITY_E2E_ORGANIZATION_ID }}
run: pnpm --filter @sanity/cli-e2e test

notify-failure:
Expand Down
13 changes: 12 additions & 1 deletion .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ jobs:
matrix:
os: [ubuntu-latest]
node-version: [20, 22, 24]
shardIndex: [1, 2]
shardTotal: [2]
fail-fast: false

steps:
Expand All @@ -55,6 +57,14 @@ jobs:
with:
node-version: ${{ matrix.node-version }}

- name: Restore Vitest cache
uses: actions/cache@v5
with:
path: packages/@sanity/cli-e2e/node_modules/.vite/vitest
key: vitest-e2e-cache-${{ matrix.os }}-node${{ matrix.node-version }}-shard${{ matrix.shardIndex }}-${{ github.run_id }}
restore-keys: |
vitest-e2e-cache-${{ matrix.os }}-node${{ matrix.node-version }}-shard${{ matrix.shardIndex }}-

- name: Build CLI
run: pnpm build:cli

Expand All @@ -63,7 +73,8 @@ jobs:
SANITY_E2E_TOKEN: ${{ secrets.SANITY_E2E_TOKEN }}
SANITY_E2E_PROJECT_ID: ${{ secrets.SANITY_E2E_PROJECT_ID }}
SANITY_E2E_DATASET: ${{ secrets.SANITY_E2E_DATASET }}
run: pnpm --filter @sanity/cli-e2e test
SANITY_E2E_ORGANIZATION_ID: ${{ secrets.SANITY_E2E_ORGANIZATION_ID }}
run: pnpm --filter @sanity/cli-e2e test --shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }}

e2e-status:
if: always()
Expand Down
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ All commands are run from the root of the repo.
- `pnpm check:deps` - unused dependency / export check
- `pnpm build:cli` - build the project
- `pnpm watch:cli` - build in watch mode
- `pnpm --filter @sanity/cli-e2e exec vitest run <file>` - run specific e2e test file
- `pnpm --filter @sanity/cli-e2e exec vitest run <file> -t "<pattern>"` - run specific e2e test by name

# Workflow

Expand Down
1 change: 1 addition & 0 deletions packages/@sanity/cli-e2e/.env.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
SANITY_E2E_TOKEN=
SANITY_E2E_PROJECT_ID=
SANITY_E2E_ORGANIZATION_ID=
130 changes: 130 additions & 0 deletions packages/@sanity/cli-e2e/__tests__/init/init.app.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import {existsSync, readFileSync} from 'node:fs'

import {createTmpDir} from '@sanity/cli-test'
import {afterEach, beforeEach, describe, expect, test} from 'vitest'

import {getE2EDataset, getE2EOrganizationId, getE2EProjectId, runCli} from '../../helpers/runCli.js'

const orgId = getE2EOrganizationId()
Comment thread
cursor[bot] marked this conversation as resolved.
const projectId = getE2EProjectId()
const dataset = getE2EDataset()

describe('sanity init - app', {timeout: 120_000}, () => {
let tmp: Awaited<ReturnType<typeof createTmpDir>>

beforeEach(async () => {
tmp = await createTmpDir({useSystemTmp: true})
})

afterEach(async () => {
await tmp.cleanup()
})

describe.each([
{label: 'with -y flag', yFlag: ['-y']},
{label: 'unattended (no -y)', yFlag: [] as string[]},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what does "unattended" mean in this context? Is it meant to mean "non-interactive terminal"? especially since there are some tests that do have interactions

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be hard to see on github but there are only 2 tests in the describe.each

The unattended part without yes comes from is without having interactive: true so it just has no TTY like an agent. Would it be more clear if it was interactive: boolean instead of optional?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'm probably just confused because of the "complete interactive flow" test, it's nbd

])('non-interactive ($label)', ({yFlag}) => {
Comment thread
binoy14 marked this conversation as resolved.
test('creates app with app-quickstart template', async () => {
const {error, exitCode, stdout} = await runCli({
args: [
'init',
...yFlag,
'--template',
'app-quickstart',
'--organization',
orgId,
'--output-path',
tmp.path,
'--typescript',
'--package-manager',
'pnpm',
'--no-git',
],
})

if (error) throw error
expect(exitCode).toBe(0)

expect(existsSync(`${tmp.path}/src/App.tsx`)).toBe(true)
expect(existsSync(`${tmp.path}/package.json`)).toBe(true)

const cliConfig = readFileSync(`${tmp.path}/sanity.cli.ts`, 'utf8')
expect(cliConfig).toContain('organizationId')
expect(cliConfig).toContain('entry')

expect(existsSync(`${tmp.path}/sanity.config.ts`)).toBe(false)

expect(stdout).toMatch(/app has been scaffolded|Success/i)
})

test('scaffolds app with --project and --dataset', async () => {
const {error, exitCode} = await runCli({
args: [
'init',
...yFlag,
'--template',
'app-quickstart',
'--project',
projectId,
'--dataset',
dataset,
'--output-path',
tmp.path,
'--package-manager',
'pnpm',
'--no-git',
],
})

if (error) throw error
expect(exitCode).toBe(0)

const appTsx = readFileSync(`${tmp.path}/src/App.tsx`, 'utf8')
expect(appTsx).toContain(projectId)
expect(appTsx).toContain(dataset)

const cliConfig = readFileSync(`${tmp.path}/sanity.cli.ts`, 'utf8')
expect(cliConfig).toContain(orgId)
})
})

test('complete interactive flow selects project and dataset', async () => {
const session = await runCli({
args: [
'init',
'--template',
'app-quickstart',
'--organization',
orgId,
'--output-path',
tmp.path,
'--no-git',
'--no-mcp',
],
interactive: true,
})

await session.waitForText(/Configure a project for this app/i)
await session.selectOption(new RegExp(`\\(${projectId}\\)`))

await session.waitForText(/Select dataset to use/i)
await session.selectOption(dataset)

await session.waitForText(/Package manager to use/i)
await session.selectOption('pnpm')

const exitCode = await session.waitForExit(90_000)
expect(exitCode).toBe(0)

expect(existsSync(`${tmp.path}/src/App.tsx`)).toBe(true)
expect(existsSync(`${tmp.path}/package.json`)).toBe(true)
expect(existsSync(`${tmp.path}/sanity.cli.ts`)).toBe(true)

const cliConfig = readFileSync(`${tmp.path}/sanity.cli.ts`, 'utf8')
expect(cliConfig).toContain('organizationId')

const output = session.getOutput()
expect(output).toContain('Your custom app has been scaffolded')
expect(output).toMatch(/Configured with project .+ and dataset/)
})
Comment thread
cursor[bot] marked this conversation as resolved.
})
190 changes: 190 additions & 0 deletions packages/@sanity/cli-e2e/__tests__/init/init.nextjs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import {existsSync, readFileSync} from 'node:fs'
import {rm} from 'node:fs/promises'
import {join} from 'node:path'

import {testFixture} from '@sanity/cli-test'
import {afterEach, beforeEach, describe, expect, test} from 'vitest'

import {getE2EProjectId, runCli} from '../../helpers/runCli.js'

const projectId = getE2EProjectId()

describe('sanity init - Next.js integration', {timeout: 120_000}, () => {
let nextjsDir: string

beforeEach(async () => {
nextjsDir = await testFixture('nextjs-app', {useSystemTmp: true})
await rm(join(nextjsDir, 'node_modules'), {force: true, recursive: true})
})

afterEach(async () => {
if (nextjsDir) await rm(nextjsDir, {force: true, recursive: true})
})

describe.each([
{label: 'with -y flag', yFlag: ['-y']},
{label: 'unattended (no -y)', yFlag: [] as string[]},
])('non-interactive ($label)', ({yFlag}) => {
test('creates sanity config, schema, and cli files with --nextjs-add-config-files', async () => {
const {error, exitCode} = await runCli({
args: [
'init',
...yFlag,
'--project',
projectId,
'--dataset',
'production',
'--nextjs-add-config-files',
'--package-manager',
'pnpm',
],
cwd: nextjsDir,
})

if (error) throw error
expect(exitCode).toBe(0)

expect(existsSync(`${nextjsDir}/sanity.config.ts`)).toBe(true)
expect(existsSync(`${nextjsDir}/sanity/schemaTypes/index.ts`)).toBe(true)

const cliConfig = readFileSync(`${nextjsDir}/sanity.cli.ts`, 'utf8')
expect(cliConfig).toContain('NEXT_PUBLIC_SANITY_PROJECT_ID')
})

test('creates embedded studio route with --nextjs-embed-studio', async () => {
const {error, exitCode} = await runCli({
args: [
'init',
...yFlag,
'--project',
projectId,
'--dataset',
'production',
'--nextjs-add-config-files',
'--nextjs-embed-studio',
'--package-manager',
'pnpm',
],
cwd: nextjsDir,
})

if (error) throw error
expect(exitCode).toBe(0)
expect(existsSync(`${nextjsDir}/app/studio/[[...tool]]/page.tsx`)).toBe(true)
})

test('writes env variables to .env.local with --nextjs-append-env', async () => {
const {error, exitCode} = await runCli({
args: [
'init',
...yFlag,
'--project',
projectId,
'--dataset',
'production',
'--nextjs-add-config-files',
'--nextjs-append-env',
'--package-manager',
'pnpm',
],
cwd: nextjsDir,
})

if (error) throw error
expect(exitCode).toBe(0)

const envContent = readFileSync(`${nextjsDir}/.env.local`, 'utf8')
expect(envContent).toContain('NEXT_PUBLIC_SANITY_PROJECT_ID')
expect(envContent).toContain('NEXT_PUBLIC_SANITY_DATASET')
})
})

describe('interactive', () => {
test('detects Next.js and completes with config files', async () => {
const session = await runCli({
args: [
'init',
'--project',
projectId,
'--dataset',
'production',
'--package-manager',
'pnpm',
'--no-mcp',
'--no-git',
],
cwd: nextjsDir,
interactive: true,
})

await session.waitForText(/Would you like to add configuration files/i)
session.sendKey('Enter')

await session.waitForText(/Do you want to use TypeScript/i)
session.sendKey('Enter')

await session.waitForText(/Would you like an embedded Sanity Studio/i)
session.sendKey('Enter')

await session.waitForText(/What route do you want to use for the Studio/i)
session.sendKey('Enter')

await session.waitForText(/Select project template to use/i)
session.sendKey('Enter')

await session.waitForText(/Would you like to add the project ID and dataset/i)
session.sendKey('Enter')

const exitCode = await session.waitForExit(90_000)
expect(exitCode).toBe(0)

expect(existsSync(`${nextjsDir}/sanity.config.ts`)).toBe(true)

const output = session.getOutput()
expect(output).toMatch(/\/studio/i)
expect(output).not.toMatch(/Project output path/i)
})

test('accepts custom studio route', async () => {
const session = await runCli({
args: [
'init',
'--project',
projectId,
'--dataset',
'production',
'--package-manager',
'pnpm',
'--no-mcp',
'--no-git',
],
cwd: nextjsDir,
interactive: true,
})

await session.waitForText(/Would you like to add configuration files/i)
session.sendKey('Enter')

await session.waitForText(/Do you want to use TypeScript/i)
session.sendKey('Enter')

await session.waitForText(/Would you like an embedded Sanity Studio/i)
session.sendKey('Enter')

await session.waitForText(/What route do you want to use for the Studio/i)
session.write('/admin\n')

await session.waitForText(/Select project template to use/i)
session.sendKey('Enter')

await session.waitForText(/Would you like to add the project ID and dataset/i)
session.sendKey('Enter')

const exitCode = await session.waitForExit(90_000)
expect(exitCode).toBe(0)

expect(existsSync(`${nextjsDir}/app/admin/[[...tool]]/page.tsx`)).toBe(true)
expect(existsSync(`${nextjsDir}/sanity.config.ts`)).toBe(true)
})
})
})
Loading
Loading