diff --git a/.claude/settings.json b/.claude/settings.json index a2e7f4c4e0..88c0236dc6 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,4 +1,7 @@ { + "permissions": { + "deny": ["Read(**/.env)"] + }, "hooks": { "PostToolUse": [ { diff --git a/.claude/skills/writing-cli-e2e-tests/SKILL.md b/.claude/skills/writing-cli-e2e-tests/SKILL.md new file mode 100644 index 0000000000..2269a55b22 --- /dev/null +++ b/.claude/skills/writing-cli-e2e-tests/SKILL.md @@ -0,0 +1,451 @@ +--- +name: writing-cli-e2e-tests +description: Use when writing, adding, or modifying e2e tests for CLI commands in packages/@sanity/cli-e2e/. Triggers on e2e test creation, new command test coverage, or changes to CLI test infrastructure. +--- + +# Writing CLI E2E Tests + +## Overview + +E2e tests run real CLI commands against real infrastructure with real side effects. They validate that commands work end-to-end for both humans (interactive) and agents/CI (non-interactive). This skill defines the philosophy and patterns for writing effective e2e tests in `packages/@sanity/cli-e2e/`. + +## When to Use + +- Adding e2e test coverage for a new CLI command +- Adding tests for new flags or flows on an existing command +- Modifying CLI behavior that needs e2e validation + +## Discovery & Planning + +Before writing any tests, read the command source code and present a plan for user approval. + +**Step 1: Understand the command.** Run the command interactively and with `--help` to observe its behavior. Identify: +- All flags and arguments the command accepts +- All distinct flows and code paths (e.g., `init` has studio, app, and Next.js paths) +- Which prompts appear and in what order +- What side effects the command produces (files, API calls, data) + +Discover behavior by running the CLI, not by reading source code. The test should verify what the user experiences, and the actual prompt flow may differ from what the source suggests. + +**Step 2: Plan the test structure.** Present to the user: +- Proposed file structure (`__tests__//...`) +- For each file, the list of test names with the CLI command each will run +- Which tests are non-interactive vs interactive +- Any inherently incomplete flows (abort, unauthenticated) + +**Step 3: Get user approval.** Wait for the user to review and revise the plan before writing any test code. + +Example plan output: +``` +__tests__/init/ + init.test.ts + - "rejects invalid input with helpful error" (smoke test, no auth) + - "outputs project info without creating files" (--bare, non-interactive) + init.studio.test.ts + describe.each([{-y}, {no -y}]) — non-interactive, both unattended modes + - "creates studio with clean template" → init --template clean ... + - "generates JavaScript files with --no-typescript" → init --no-typescript ... + init.studio-interactive.test.ts + - "Ctrl+C aborts cleanly" → init (interactive, send Ctrl+C, expect 130) + - "walks through all prompts" → init (interactive, no --template/--typescript/--package-manager) + init.app.test.ts + describe.each([{-y}, {no -y}]) — non-interactive + - "creates app with app-quickstart template" → init --template app-quickstart ... + - "shows project config prompt" → init --template app-quickstart (interactive) +``` + +## Core Principles + +### 1. Test Complete Flows With Thorough Assertions + +Run commands to their final outcome — files created, data written, success message printed. A half-finished flow that kills the session partway gives false confidence. Since each full run is expensive, assert everything meaningful from that single invocation: exit code, stdout messages, generated files, config content. + +### 2. Cover Both Execution Modes + +Every command should have both interactive and non-interactive tests. Non-interactive tests verify agents and CI automation can use the command fully. Interactive tests verify humans get proper prompts, navigation, and feedback. Both modes must work completely — they serve different audiences. + +### Inherently Incomplete Flows + +Some tests cannot run to completion by design. These are the only acceptable exceptions to the "complete flows" principle: + +- **Abort handling (Ctrl+C):** One test per command that sends `sendControl('c')` at the earliest prompt and asserts `expect(exitCode).toBe(130)` (SIGINT). One abort test is sufficient — the abort mechanism is the same regardless of which prompt stage it fires at, so testing Ctrl+C at multiple stages adds cost without value. +- **Unauthenticated prompts:** Tests that verify login prompts appear when no token is provided. These can't complete because there's no real login flow in tests. + +Keep these minimal — one of each per command, at the top level (no `describe` wrapper). Every other test should run to completion. + +## Choosing Execution Mode + +**Is the behavior driven by user prompts/navigation?** +- **YES** → Does the command support flags that bypass the prompts? + - **YES** → Write BOTH: non-interactive test with flags AND interactive test with prompts + - **NO** → Interactive test only (PTY) +- **NO** → Non-interactive test only (spawn) + +**Non-interactive (spawn):** Command fully driven by flags/args. Fast, reliable, easy stdout/stderr assertions. + +**Interactive (PTY):** Tests the prompt experience — selection navigation, text input, abort handling. + +**Both:** Most commands should have both. Non-interactive proves the command works. Interactive proves the UX works. Apply this per-flow, not per-command — if a command has studio, app, and Next.js flows, each flow needs both modes. + +## Test File Organization + +All commands get a folder. Split non-interactive and interactive tests into separate files (e.g., `init.studio.test.ts` and `init.studio-interactive.test.ts`) — this improves vitest sharding and keeps files focused. + +**For commands with multiple distinct flows**, split files by user flow or functionality: +``` +__tests__/ + init/ + init.studio.test.ts + init.nextjs.test.ts + init.bare.test.ts + init.errors.test.ts + deploy/ + deploy.app.test.ts + deploy.studio.test.ts +``` + +**For simpler commands**, a single test file is enough: +``` +__tests__/ + datasets/ + datasets.test.ts + help/ + help.test.ts +``` + +**Don't create single-test files.** If a flow only has 1-2 tests (e.g., a smoke test or a simple mode like `--bare`), merge it into the command's base `.test.ts` file rather than giving it a dedicated file. + +**Test naming:** Descriptive names that accurately describe the behavior being verified. No numeric IDs. The name must match what the test actually asserts — a test for a deprecated flag should say "deprecated", not "invalid input". + +```typescript +// GOOD +test('creates TypeScript studio with correct config files', ...) +test('rejects deprecated --reconfigure flag', ...) + +// BAD +test('2.4 default init creates correct TypeScript project', ...) +test('rejects invalid input with helpful error', ...) // if it's testing a deprecated flag, not invalid input +``` + +## Test Structure Style + +**Flat over nested.** Don't wrap single tests in `describe` blocks. Only use `describe` when tests share setup/teardown or parameterization (`describe.each`). Don't use `describe` just to label categories like "abort handling" or "complete flows" — a flat list under one top-level `describe` is clearer. + +**`beforeEach`/`afterEach` for cleanup.** Use lifecycle hooks for temp directory creation and cleanup instead of `try/finally` in every test: + +```typescript +describe('sanity init', {timeout: 120_000}, () => { + let tmp: Awaited> + + beforeEach(async () => { + tmp = await createTmpDir({useSystemTmp: true}) + }) + + afterEach(async () => { + await tmp.cleanup() + }) + + test('creates studio', async () => { + // use tmp.path directly — no try/finally needed + }) +}) +``` + +**`test.each` for variants.** When testing the same flow with different inputs (e.g., templates, package managers), use `test.each` instead of duplicating tests: + +```typescript +test.each(['clean', 'blog'])('creates studio with %s template', async (template) => { + // ... +}) +``` + +**Consolidate interactive prompt tests.** When a command has multiple sequential prompts (template, TypeScript, package manager), write one test that walks through all of them rather than separate tests that each omit one flag. Each CLI invocation is expensive (~12s); one test that exercises three prompts is better than three tests that each exercise one. + +```typescript +// BAD: three separate tests, three CLI invocations +test('shows template selection', ...) // omits --template +test('shows TypeScript prompt', ...) // omits --typescript +test('shows package manager prompt', ...) // omits --package-manager + +// GOOD: one test, one invocation, all prompts exercised +test('walks through template, TypeScript, and package manager prompts', async () => { + const session = await runCli({ + args: ['init', '--project', projectId, '--dataset', 'production', + '--output-path', tmp.path, '--no-mcp', '--no-git'], + interactive: true, + }) + + await session.waitForText(/Select project template/i) + session.sendKey('Enter') + + await session.waitForText(/Do you want to use TypeScript/i) + session.sendKey('Enter') + + await session.waitForText(/package manager/i) + session.sendKey('Enter') + + const exitCode = await session.waitForExit(90_000) + expect(exitCode).toBe(0) + expect(existsSync(`${tmp.path}/sanity.config.ts`)).toBe(true) +}) +``` + +**Minimal flags.** For non-interactive tests, only include flags the test is specifically testing. For interactive tests, leave prompts unpinned so `selectOption` exercises them — only pin infrastructure values like `--organization` or `--output-path` that aren't UX under test. Use `--no-git` and `--no-mcp` to skip side effects irrelevant to the flow being tested. + +**Precise assertions.** Assert on actual content, not proxies: + +```typescript +// BAD: vague, tells you nothing on failure +expect(stderr.length).toBeGreaterThan(0) +expect(stdout).toMatch(/import/i) +expect(exitCode).not.toBe(0) + +// GOOD: specific, failure message is immediately useful +expect(stderr).toContain('--reconfigure is deprecated') +expect(stdout).toMatch(/Done! Imported \d+ documents/) +expect(exitCode).toBe(1) +``` + +## Available Tools + +- **`runCli()`** — Execute CLI commands. `interactive: true` for PTY, default for spawn. See `helpers/runCli.ts`. +- **`testFixture()`** — Get an isolated copy of a pre-built project fixture (e.g., `'basic-studio'`, `'nextjs-app'`). From `@sanity/cli-test`. +- **`createTmpDir()`** — Create an isolated temp directory with a cleanup function. From `@sanity/cli-test`. +- **Interactive session API** — `waitForText(regex)`, `selectOption(pattern)`, `sendKey('ArrowDown')`, `write('text')`, `sendControl('c')`, `getOutput()`, `waitForExit()`, `kill()`. See `helpers/spawnPty.ts`. +- **`getE2EDataset()`** — Returns the e2e dataset name (currently `'production'`). From `helpers/runCli.ts`. + +Check `@sanity/cli-test` for shared utilities before creating local helpers. Inline CLI args directly in each test for clarity — avoid abstracting args into helper functions as it obscures what each test actually runs. + +## Test Structure Patterns + +### Non-Interactive Complete Flow + +```typescript +describe('sanity init - studio', {timeout: 120_000}, () => { + let tmp: Awaited> + + beforeEach(async () => { + tmp = await createTmpDir({useSystemTmp: true}) + }) + + afterEach(async () => { + await tmp.cleanup() + }) + + test('creates studio with TypeScript and correct config', async () => { + const {error, stdout} = await runCli({ + args: ['init', '-y', '--project', projectId, '--dataset', 'production', + '--output-path', tmp.path, '--typescript'], + }) + + if (error) throw error + + expect(existsSync(`${tmp.path}/sanity.config.ts`)).toBe(true) + expect(existsSync(`${tmp.path}/package.json`)).toBe(true) + const config = readFileSync(`${tmp.path}/sanity.cli.ts`, 'utf8') + expect(config).toContain(projectId) + expect(stdout).toMatch(/sanity docs|sanity help/i) + }) +}) +``` + +### Interactive Complete Flow + +Use `selectOption(pattern)` to navigate select prompts by text instead of counting ArrowDown presses. It scrolls through the list, handles off-screen options, and throws if zero or multiple options match. Pass a string for exact match or a regex for partial match (e.g., matching a project ID inside `"Project Name (id)"`). + +```typescript +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('production') + + 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) +}) +``` + +### Testing Unauthenticated Flows + +Auth token is always available. To test unauthenticated behavior, override the env: + +```typescript +test('no token triggers login prompt', async () => { + const session = await runCli({ + args: ['init'], + env: {SANITY_AUTH_TOKEN: ''}, + interactive: true, + }) + + await session.waitForText(/log in|create.*account/i) + // ... complete the flow +}) +``` + +## Timeouts + +Set timeouts at the `describe` block level. The vitest config provides defaults (`testTimeout: 30_000`). If a group of tests needs more time, set it once: + +```typescript +describe('studio creation flows', {timeout: 120_000}, () => { + test('creates studio with TypeScript', async () => { + // inherits describe timeout + }) + + test('creates studio with JavaScript', async () => { + // inherits describe timeout + }) +}) +``` + +## Interactive Test Resilience + +Interactive tests must not depend on list order or item positions. Use `selectOption` to find items by text — it handles scrolling and position automatically. + +```typescript +// BAD: counts ArrowDown presses, breaks if list order changes +session.sendKey('ArrowDown') +session.sendKey('ArrowDown') +session.sendKey('Enter') + +// GOOD: finds the option by text regardless of position +await session.selectOption('production') +await session.selectOption(new RegExp(`\\(${projectId}\\)`)) +``` + +For assertions on output, match structural prompt text, not dynamic API content: + +```typescript +// BAD: asserts on specific dynamic content +expect(output).toContain('My Specific Project Name') + +// GOOD: asserts on prompt structure or known output strings +await session.waitForText(/Select project|Configure a project/i) +expect(output).toContain('Your custom app has been scaffolded') +``` + +**Rules:** +- Use `selectOption(pattern)` for all select prompts — never count ArrowDown presses +- Use `waitForText(regex)` to detect prompt appearance before interacting +- Use regex patterns for structural prompts, exact strings for known output messages +- For `selectOption`, use a string for exact matches (`'production'`) and regex when the display text wraps the value (`new RegExp(`\\(${projectId}\\)`)`) + +## Spawn Mode Behavior + +Non-interactive tests use `spawnProcess` which sets `stdio: ['ignore', 'pipe', 'pipe']` — no TTY. This means `isInteractive()` returns false, and the CLI treats the run as unattended regardless of whether `-y` is passed. + +**Prompts are skipped in spawn mode.** Any behavior gated behind an interactive prompt will use its fallback default, which may differ from the prompt's default selection. For example, if a prompt defaults to "Yes" when shown to a user, but the code falls back to `undefined` (falsy) when the prompt is skipped, the spawn-mode behavior differs from the interactive default. + +Before omitting a flag from a non-interactive test, run the command without it to verify the unattended default matches what your test expects. If the fallback differs from what you'd expect, add the flag explicitly. + +**Test both `-y` and non-`-y` unattended modes.** Both `-y` and a non-interactive terminal trigger `isUnattended() === true`, but they enter through different code paths. Use `describe.each` to parameterize all non-interactive tests across both modes. This also gives vitest distinct test entries for better sharding. + +```typescript +describe.each([ + {label: 'with -y flag', yFlag: ['-y']}, + {label: 'unattended (no -y)', yFlag: [] as string[]}, +])('sanity init - studio ($label)', {timeout: 120_000}, ({yFlag}) => { + test('creates studio with default settings', async () => { + const {error} = await runCli({ + args: ['init', ...yFlag, '--project', projectId, '--dataset', 'production', + '--output-path', tmp.path, '--typescript'], + }) + if (error) throw error + // assertions... + }) +}) +``` + +## Running E2E Tests + +After writing tests, run them — lint and type checks verify syntax, not behavior. Always validate with an actual e2e run before reporting tests as complete. + +```bash +# Run a specific e2e test file +pnpm --filter @sanity/cli-e2e exec vitest run __tests__/init/init.studio.test.ts --reporter=verbose + +# Run a specific test by name pattern +pnpm --filter @sanity/cli-e2e exec vitest run __tests__/init/init.studio.test.ts -t "creates studio with default" +``` + +## When Tests Reveal Product Bugs + +If a test failure reveals a product bug rather than a test bug, file an issue in Linear, skip the affected test with a link to the issue, and move on. Don't paper over product bugs with workarounds in test assertions. + +```typescript +// Skipped: --bare flag doesn't create package.json. See https://linear.app/sanity/issue/SDK-XXXX +test.skip('bare init creates minimal project', () => { + // ... +}) +``` + +## What Belongs in E2E vs Command Tests + +E2e tests validate real commands against real infrastructure with real side effects. They are expensive to run and should focus on proving the full flow works. + +**Command tests** (`packages/@sanity/cli/src/commands/__tests__/`) test command handlers with mocked HTTP or client methods. They are fast, isolated, and appropriate for testing the full matrix of input validation, flag parsing, error messages, and edge cases. + +| Concern | Where to test | +|---------|---------------| +| Flag validation, argument parsing | Command tests | +| Error messages and exit codes for bad input | Command tests | +| Config file parsing, input sanitization | Command tests | +| Complete command flows with real side effects | E2e tests | +| Files generated, APIs called, prompts displayed | E2e tests | + +For error handling in e2e, keep a single smoke test per command that confirms the binary rejects bad input. Test the full matrix of validation rules as command tests. + +```typescript +// ONE e2e smoke test for error rejection +test('rejects deprecated --reconfigure flag', async () => { + const {exitCode, stderr} = await runCli({ + args: ['init', '--reconfigure'], + env: {SANITY_AUTH_TOKEN: ''}, + }) + expect(exitCode).toBe(2) + expect(stderr).toContain('--reconfigure is deprecated') +}) + +// Individual validation rules → command tests in +// packages/@sanity/cli/src/commands/__tests__/ +``` + +## Common Mistakes + +| Mistake | Fix | +|---------|-----| +| Fragment tests that kill session halfway | Run to completion, assert on outcome | +| One assertion per CLI invocation | Batch related assertions in one test | +| Hardcoded list positions via arrow keys | Use `selectOption(pattern)` to find by text | +| Per-test timeouts | Set timeout on describe block | +| `try/finally` cleanup in every test | Use `beforeEach`/`afterEach` for temp dir lifecycle | +| Wrapping a single test in a `describe` | Only use `describe` for 2+ related tests | +| Vague assertions (`stderr.length > 0`) | Assert on actual content (`stderr.toContain(...)`) | +| `expect(exitCode).not.toBe(0)` | Use `toBe(1)` when you know the expected code | +| Test name doesn't match behavior | Name must describe what the test actually asserts | +| Duplicating tests with different inputs | Use `test.each` for variants | +| Pinning prompts with flags in interactive tests | Leave prompts unpinned and use `selectOption` to exercise them | +| Papering over product bugs in assertions | File issue, skip test with link, move on | +| Separate tests for each interactive prompt | One test that walks through all prompts sequentially | +| Only testing non-interactive with `-y` | Use `describe.each` to test both `-y` and non-`-y` unattended | +| Multiple abort tests at different prompt stages | One abort test at the earliest prompt is sufficient | +| `describe` blocks that just label categories | Only use `describe` for shared setup/teardown or parameterization | +| `skipIf(!hasToken)` for unauthed tests | Override with `env: {SANITY_AUTH_TOKEN: ''}` | +| Asserting on dynamic API content | Use structural regex patterns | +| Mutating `process.env` directly | Use `vi.stubEnv()` for env overrides | +| Mocking functions, APIs, or services | Never mock in e2e tests — test real infrastructure | +| Testing flag validation/deprecation in e2e | One smoke test per command; validation matrix belongs in command tests | +| Reading source to predict prompt order | Run the test and observe the actual CLI output to understand the flow | +| Only running lint/types to validate | Always run the actual e2e tests before reporting done | diff --git a/.github/workflows/e2e-scheduled.yml b/.github/workflows/e2e-scheduled.yml index 9ffc84dbc7..7282e31e43 100644 --- a/.github/workflows/e2e-scheduled.yml +++ b/.github/workflows/e2e-scheduled.yml @@ -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' }}" @@ -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: diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 48ebf30efe..d6d7f404a6 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -45,6 +45,8 @@ jobs: matrix: os: [ubuntu-latest] node-version: [20, 22, 24] + shardIndex: [1, 2] + shardTotal: [2] fail-fast: false steps: @@ -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 @@ -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() diff --git a/AGENTS.md b/AGENTS.md index 55a017207d..309dc8c4bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ` - run specific e2e test file +- `pnpm --filter @sanity/cli-e2e exec vitest run -t ""` - run specific e2e test by name # Workflow diff --git a/packages/@sanity/cli-e2e/.env.example b/packages/@sanity/cli-e2e/.env.example index 342172fc43..366a92515f 100644 --- a/packages/@sanity/cli-e2e/.env.example +++ b/packages/@sanity/cli-e2e/.env.example @@ -1,2 +1,3 @@ SANITY_E2E_TOKEN= SANITY_E2E_PROJECT_ID= +SANITY_E2E_ORGANIZATION_ID= diff --git a/packages/@sanity/cli-e2e/__tests__/init/init.app.test.ts b/packages/@sanity/cli-e2e/__tests__/init/init.app.test.ts new file mode 100644 index 0000000000..15c24928f8 --- /dev/null +++ b/packages/@sanity/cli-e2e/__tests__/init/init.app.test.ts @@ -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() +const projectId = getE2EProjectId() +const dataset = getE2EDataset() + +describe('sanity init - app', {timeout: 120_000}, () => { + let tmp: Awaited> + + 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[]}, + ])('non-interactive ($label)', ({yFlag}) => { + 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/) + }) +}) diff --git a/packages/@sanity/cli-e2e/__tests__/init/init.nextjs.test.ts b/packages/@sanity/cli-e2e/__tests__/init/init.nextjs.test.ts new file mode 100644 index 0000000000..e518581041 --- /dev/null +++ b/packages/@sanity/cli-e2e/__tests__/init/init.nextjs.test.ts @@ -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) + }) + }) +}) diff --git a/packages/@sanity/cli-e2e/__tests__/init/init.studio-interactive.test.ts b/packages/@sanity/cli-e2e/__tests__/init/init.studio-interactive.test.ts new file mode 100644 index 0000000000..57e297d0d6 --- /dev/null +++ b/packages/@sanity/cli-e2e/__tests__/init/init.studio-interactive.test.ts @@ -0,0 +1,193 @@ +import {existsSync, writeFileSync} from 'node:fs' + +import {createTmpDir} 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 - studio (interactive)', {timeout: 120_000}, () => { + let tmp: Awaited> + + beforeEach(async () => { + tmp = await createTmpDir({useSystemTmp: true}) + }) + + afterEach(async () => { + await tmp.cleanup() + }) + + test('triggers login prompt without auth token', async () => { + const session = await runCli({ + args: ['init'], + env: {SANITY_AUTH_TOKEN: ''}, + interactive: true, + }) + + await session.waitForText(/log in|create.*account|provider/i) + + session.kill() + }) + + test('Ctrl+C aborts cleanly', async () => { + const session = await runCli({ + args: ['init'], + interactive: true, + }) + + await session.waitForText(/Select project|Create.*project/i) + session.sendControl('c') + + const exitCode = await session.waitForExit() + expect(exitCode).toBe(130) + }) + + test('produces working studio when flags bypass prompts', async () => { + const session = await runCli({ + args: [ + 'init', + '--project', + projectId, + '--dataset', + 'production', + '--output-path', + tmp.path, + '--template', + 'clean', + '--typescript', + '--package-manager', + 'pnpm', + '--no-mcp', + '--no-git', + ], + interactive: true, + }) + + const exitCode = await session.waitForExit(90_000) + expect(exitCode).toBe(0) + + expect(existsSync(`${tmp.path}/sanity.config.ts`)).toBe(true) + expect(existsSync(`${tmp.path}/sanity.cli.ts`)).toBe(true) + expect(existsSync(`${tmp.path}/package.json`)).toBe(true) + + const output = session.getOutput() + expect(output).toMatch(/sanity docs|sanity manage|sanity help/i) + expect(output).not.toMatch(/Select project template/i) + expect(output).not.toMatch(/Do you want to use TypeScript/i) + expect(output).not.toMatch(/Select.*package manager/i) + }) + + test('walks through template, TypeScript, and package manager prompts', async () => { + const session = await runCli({ + args: [ + 'init', + '--project', + projectId, + '--dataset', + 'production', + '--output-path', + tmp.path, + '--no-mcp', + '--no-git', + ], + interactive: true, + }) + + await session.waitForText(/Select project template/i) + session.sendKey('Enter') + + await session.waitForText(/Do you want to use TypeScript/i) + session.sendKey('Enter') + + await session.waitForText(/package manager|npm|yarn|pnpm/i) + session.sendKey('Enter') + + const exitCode = await session.waitForExit(90_000) + expect(exitCode).toBe(0) + + expect(existsSync(`${tmp.path}/sanity.config.ts`)).toBe(true) + expect(existsSync(`${tmp.path}/sanity.cli.ts`)).toBe(true) + expect(existsSync(`${tmp.path}/package.json`)).toBe(true) + expect(existsSync(`${tmp.path}/node_modules`)).toBe(true) + }) + + test('auto-detects package manager from existing lockfile', async () => { + writeFileSync(`${tmp.path}/pnpm-lock.yaml`, 'lockfileVersion: 5.4\n') + + const session = await runCli({ + args: [ + 'init', + '--project', + projectId, + '--dataset', + 'production', + '--output-path', + tmp.path, + '--template', + 'clean', + '--typescript', + '--no-mcp', + '--no-git', + ], + interactive: true, + }) + + const exitCode = await session.waitForExit(90_000) + expect(exitCode).toBe(0) + + const output = session.getOutput() + expect(output).not.toMatch(/Select.*package manager/i) + expect(existsSync(`${tmp.path}/sanity.config.ts`)).toBe(true) + expect(existsSync(`${tmp.path}/package-lock.json`)).toBe(false) + expect(existsSync(`${tmp.path}/yarn.lock`)).toBe(false) + }) + + test.each([ + { + answer: 'y\n', + name: 'imports sample data when accepted', + postAnswerWait: /Imported \d+ documents/i, + }, + { + answer: 'n\n', + name: 'skips import when declined', + postAnswerWait: /installing|Success/i, + }, + ])('$name', async ({answer, postAnswerWait}) => { + const session = await runCli({ + args: [ + 'init', + '--project', + projectId, + '--dataset', + 'production', + '--output-path', + tmp.path, + '--template', + 'moviedb', + '--no-mcp', + '--package-manager', + 'pnpm', + '--no-git', + ], + interactive: true, + }) + + await session.waitForText(/TypeScript/i) + session.sendKey('Enter') + + await session.waitForText(/sampling.*movies|dataset on the hosted backend/i) + session.write(answer) + + await session.waitForText(postAnswerWait, {timeout: 90_000}) + + const exitCode = await session.waitForExit(90_000) + expect(exitCode).toBe(0) + + if (answer === 'n\n') { + const output = session.getOutput() + expect(output).not.toMatch(/Imported \d+ documents/i) + } + }) +}) diff --git a/packages/@sanity/cli-e2e/__tests__/init/init.studio.shared.ts b/packages/@sanity/cli-e2e/__tests__/init/init.studio.shared.ts new file mode 100644 index 0000000000..4cb84f016e --- /dev/null +++ b/packages/@sanity/cli-e2e/__tests__/init/init.studio.shared.ts @@ -0,0 +1,445 @@ +import {existsSync, readFileSync} from 'node:fs' + +import {createTmpDir} from '@sanity/cli-test' +import {afterEach, beforeEach, expect, test} from 'vitest' + +import {getE2EOrganizationId, getE2EProjectId, runCli} from '../../helpers/runCli.js' + +const projectId = getE2EProjectId() + +export function registerStudioInitTests(yFlag: string[]): void { + let tmp: Awaited> + + beforeEach(async () => { + tmp = await createTmpDir({useSystemTmp: true}) + }) + + afterEach(async () => { + await tmp.cleanup() + }) + + test('creates studio with default settings', async () => { + const {error} = await runCli({ + args: [ + 'init', + ...yFlag, + '--project', + projectId, + '--dataset', + 'production', + '--output-path', + tmp.path, + '--typescript', + ], + }) + + if (error) throw error + + expect(existsSync(`${tmp.path}/sanity.config.ts`)).toBe(true) + + const pkg = JSON.parse(readFileSync(`${tmp.path}/package.json`, 'utf8')) + expect(pkg.dependencies?.sanity ?? pkg.devDependencies?.sanity).toBeDefined() + expect(existsSync(`${tmp.path}/node_modules`)).toBe(true) + + const cliConfig = readFileSync(`${tmp.path}/sanity.cli.ts`, 'utf8') + expect(cliConfig).toContain('autoUpdates') + }) + + test('defaults to TypeScript without explicit --typescript flag', async () => { + const {error} = await runCli({ + args: [ + 'init', + ...yFlag, + '--project', + projectId, + '--dataset', + 'production', + '--output-path', + tmp.path, + ], + }) + + if (error) throw error + + expect(existsSync(`${tmp.path}/sanity.config.ts`)).toBe(true) + expect(existsSync(`${tmp.path}/sanity.cli.ts`)).toBe(true) + expect(existsSync(`${tmp.path}/tsconfig.json`)).toBe(true) + expect(existsSync(`${tmp.path}/sanity.config.js`)).toBe(false) + }) + + test.each(['clean', 'blog'])('creates studio with %s template', async (template) => { + const {error} = await runCli({ + args: [ + 'init', + ...yFlag, + '--project', + projectId, + '--dataset', + 'production', + '--output-path', + tmp.path, + '--template', + template, + '--typescript', + ], + }) + + if (error) throw error + expect(existsSync(`${tmp.path}/sanity.config.ts`)).toBe(true) + expect(existsSync(`${tmp.path}/package.json`)).toBe(true) + if (template === 'blog') { + expect(existsSync(`${tmp.path}/schemaTypes`)).toBe(true) + } + }) + + test('creates TypeScript project with correct config files', async () => { + const {error} = await runCli({ + args: [ + 'init', + ...yFlag, + '--project', + projectId, + '--dataset', + 'production', + '--output-path', + tmp.path, + '--typescript', + ], + }) + + if (error) throw error + expect(existsSync(`${tmp.path}/tsconfig.json`)).toBe(true) + expect(existsSync(`${tmp.path}/sanity.config.ts`)).toBe(true) + expect(existsSync(`${tmp.path}/sanity.cli.ts`)).toBe(true) + + const cliConfig = readFileSync(`${tmp.path}/sanity.cli.ts`, 'utf8') + expect(cliConfig).toContain(projectId) + expect(cliConfig).toContain('production') + + const config = readFileSync(`${tmp.path}/sanity.config.ts`, 'utf8') + expect(config).toContain(projectId) + expect(config).toContain('production') + }) + + test('generates JavaScript files with --no-typescript', async () => { + const {error} = await runCli({ + args: [ + 'init', + ...yFlag, + '--project', + projectId, + '--dataset', + 'production', + '--output-path', + tmp.path, + '--no-typescript', + ], + }) + + if (error) throw error + expect(existsSync(`${tmp.path}/sanity.config.js`)).toBe(true) + expect(existsSync(`${tmp.path}/sanity.cli.js`)).toBe(true) + expect(existsSync(`${tmp.path}/tsconfig.json`)).toBe(false) + expect(existsSync(`${tmp.path}/sanity.config.ts`)).toBe(false) + }) + + test('installs with npm and creates package-lock.json', async () => { + const {error} = await runCli({ + args: [ + 'init', + ...yFlag, + '--project', + projectId, + '--dataset', + 'production', + '--output-path', + tmp.path, + '--package-manager', + 'npm', + ], + }) + + if (error) throw error + expect(existsSync(`${tmp.path}/node_modules`)).toBe(true) + expect(existsSync(`${tmp.path}/package-lock.json`)).toBe(true) + expect(existsSync(`${tmp.path}/pnpm-lock.yaml`)).toBe(false) + }) + + test('skips git with --no-git', async () => { + const {error} = await runCli({ + args: [ + 'init', + ...yFlag, + '--project', + projectId, + '--dataset', + 'production', + '--output-path', + tmp.path, + '--no-git', + ], + }) + + if (error) throw error + expect(existsSync(`${tmp.path}/.git`)).toBe(false) + }) + + test('creates git repo with custom commit message', async () => { + const {error} = await runCli({ + args: [ + 'init', + ...yFlag, + '--project', + projectId, + '--dataset', + 'production', + '--output-path', + tmp.path, + '--git', + 'initial commit', + ], + env: { + GIT_AUTHOR_EMAIL: 'test@example.com', + GIT_AUTHOR_NAME: 'E2E Test', + GIT_COMMITTER_EMAIL: 'test@example.com', + GIT_COMMITTER_NAME: 'E2E Test', + }, + }) + + if (error) throw error + expect(existsSync(`${tmp.path}/.git`)).toBe(true) + }) + + test('uses production dataset by default', async () => { + const {error} = await runCli({ + args: [ + 'init', + ...yFlag, + '--project', + projectId, + '--dataset-default', + '--output-path', + tmp.path, + '--typescript', + ], + }) + + if (error) throw error + const config = readFileSync(`${tmp.path}/sanity.config.ts`, 'utf8') + expect(config).toContain('production') + }) + + test('uses specified dataset name', async () => { + const {error} = await runCli({ + args: [ + 'init', + ...yFlag, + '--project', + projectId, + '--dataset', + 'staging', + '--output-path', + tmp.path, + '--typescript', + ], + }) + + if (error) throw error + const config = readFileSync(`${tmp.path}/sanity.config.ts`, 'utf8') + expect(config).toContain('staging') + const cliConfig = readFileSync(`${tmp.path}/sanity.cli.ts`, 'utf8') + expect(cliConfig).toContain('staging') + }) + + test('creates dataset with public visibility', async () => { + const uniqueDataset = `pub${Date.now().toString(36)}` + const {error, exitCode} = await runCli({ + args: [ + 'init', + ...yFlag, + '--project', + projectId, + '--dataset', + uniqueDataset, + '--output-path', + tmp.path, + '--visibility', + 'public', + '--typescript', + ], + }) + + if (error) throw error + expect(exitCode).toBe(0) + const cliConfig = readFileSync(`${tmp.path}/sanity.cli.ts`, 'utf8') + expect(cliConfig).toContain(uniqueDataset) + }) + + // Skipped: creates a new project on the Sanity backend that cannot be cleaned up automatically + test.skip('creates new project with --project-name', async () => { + const orgId = getE2EOrganizationId() + const randomSuffix = Math.random().toString(36).slice(2, 8) + const {error, stdout} = await runCli({ + args: [ + 'init', + ...yFlag, + '--project-name', + `E2E Test ${randomSuffix}`, + '--organization', + orgId, + '--dataset', + 'production', + '--output-path', + tmp.path, + ], + }) + + if (error) throw error + expect(stdout).toContain('Project ID:') + }) + + test('falls back gracefully with invalid coupon', async () => { + const {error, exitCode} = await runCli({ + args: [ + 'init', + ...yFlag, + '--project', + projectId, + '--dataset', + 'production', + '--output-path', + tmp.path, + '--coupon', + 'invalid-coupon-xyz', + '--typescript', + ], + }) + + if (error) throw error + expect(exitCode).toBe(0) + expect(existsSync(`${tmp.path}/sanity.config.ts`)).toBe(true) + expect(existsSync(`${tmp.path}/package.json`)).toBe(true) + }) + + test('skips MCP setup with --no-mcp', async () => { + const {error, stdout} = await runCli({ + args: [ + 'init', + ...yFlag, + '--project', + projectId, + '--dataset', + 'production', + '--output-path', + tmp.path, + '--no-mcp', + ], + }) + + if (error) throw error + expect(stdout).not.toMatch(/configured for Sanity MCP/i) + }) + + test('disables auto-updates with --no-auto-updates', async () => { + const {error} = await runCli({ + args: [ + 'init', + ...yFlag, + '--project', + projectId, + '--dataset', + 'production', + '--output-path', + tmp.path, + '--no-auto-updates', + '--typescript', + ], + }) + + if (error) throw error + const cliConfig = readFileSync(`${tmp.path}/sanity.cli.ts`, 'utf8') + expect(cliConfig).toContain('autoUpdates: false') + }) + + test('writes env file and exits early with --env', async () => { + const {error} = await runCli({ + args: [ + 'init', + ...yFlag, + '--project', + projectId, + '--dataset', + 'production', + '--output-path', + tmp.path, + '--env', + '.env.custom', + ], + }) + + if (error) throw error + const envContent = readFileSync(`${tmp.path}/.env.custom`, 'utf8') + expect(envContent).toMatch(/PROJECT_ID/) + expect(envContent).toMatch(/DATASET/) + expect(existsSync(`${tmp.path}/sanity.config.ts`)).toBe(false) + expect(existsSync(`${tmp.path}/sanity.config.js`)).toBe(false) + }) + + test('imports sample data with --import-dataset', async () => { + const {error, exitCode, stdout} = await runCli({ + args: [ + 'init', + ...yFlag, + '--project', + projectId, + '--dataset', + 'production', + '--output-path', + tmp.path, + '--template', + 'moviedb', + '--import-dataset', + '--typescript', + ], + }) + + if (error) throw error + expect(exitCode).toBe(0) + expect(stdout).toMatch(/Done! Imported \d+ documents/) + expect(stdout).toContain('sanity dataset delete') + expect(existsSync(`${tmp.path}/sanity.config.ts`)).toBe(true) + }) + + test('overwrites existing files with --overwrite-files', async () => { + const firstResult = await runCli({ + args: [ + 'init', + ...yFlag, + '--project', + projectId, + '--dataset', + 'production', + '--output-path', + tmp.path, + ], + }) + if (firstResult.error) throw firstResult.error + + const {error, exitCode} = await runCli({ + args: [ + 'init', + ...yFlag, + '--project', + projectId, + '--dataset', + 'production', + '--output-path', + tmp.path, + '--overwrite-files', + ], + }) + + if (error) throw error + expect(exitCode).toBe(0) + }) +} diff --git a/packages/@sanity/cli-e2e/__tests__/init/init.studio.unattended.test.ts b/packages/@sanity/cli-e2e/__tests__/init/init.studio.unattended.test.ts new file mode 100644 index 0000000000..9f1679ec18 --- /dev/null +++ b/packages/@sanity/cli-e2e/__tests__/init/init.studio.unattended.test.ts @@ -0,0 +1,7 @@ +import {describe} from 'vitest' + +import {registerStudioInitTests} from './init.studio.shared.js' + +describe('sanity init - studio (unattended, no -y)', {timeout: 120_000}, () => { + registerStudioInitTests([]) +}) diff --git a/packages/@sanity/cli-e2e/__tests__/init/init.studio.yes.test.ts b/packages/@sanity/cli-e2e/__tests__/init/init.studio.yes.test.ts new file mode 100644 index 0000000000..d21f11845f --- /dev/null +++ b/packages/@sanity/cli-e2e/__tests__/init/init.studio.yes.test.ts @@ -0,0 +1,7 @@ +import {describe} from 'vitest' + +import {registerStudioInitTests} from './init.studio.shared.js' + +describe('sanity init - studio (with -y flag)', {timeout: 120_000}, () => { + registerStudioInitTests(['-y']) +}) diff --git a/packages/@sanity/cli-e2e/__tests__/init/init.test.ts b/packages/@sanity/cli-e2e/__tests__/init/init.test.ts new file mode 100644 index 0000000000..94610574f5 --- /dev/null +++ b/packages/@sanity/cli-e2e/__tests__/init/init.test.ts @@ -0,0 +1,57 @@ +import {readdir} from 'node:fs/promises' + +import {createTmpDir} 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 --bare', () => { + let tmp: Awaited> + + beforeEach(async () => { + tmp = await createTmpDir({useSystemTmp: true}) + }) + + afterEach(async () => { + await tmp.cleanup() + }) + + test('outputs project info without creating any files', async () => { + const {error, exitCode, stdout} = await runCli({ + args: ['init', '-y', '--bare', '--project', projectId, '--dataset', 'production'], + cwd: tmp.path, + }) + + if (error) throw error + expect(exitCode).toBe(0) + + expect(stdout).toContain('Project ID:') + expect(stdout).toContain(projectId) + expect(stdout).toContain('Dataset:') + expect(stdout).toContain('production') + expect(stdout).toContain(`sanity.io/manage/project/${projectId}`) + + const entries = await readdir(tmp.path) + expect(entries).toEqual([]) + }) + + test.skip('fails with non-existent project', async () => { + const {exitCode, stderr} = await runCli({ + args: [ + 'init', + '-y', + '--bare', + '--project', + 'nonexistent-project-id', + '--dataset', + 'production', + ], + cwd: tmp.path, + }) + + expect(exitCode).toBe(1) + expect(stderr).toMatch(/not found|does not exist|unauthorized/i) + }) +}) diff --git a/packages/@sanity/cli-e2e/helpers/runCli.ts b/packages/@sanity/cli-e2e/helpers/runCli.ts index 8ba0b89110..9b48b7aaf2 100644 --- a/packages/@sanity/cli-e2e/helpers/runCli.ts +++ b/packages/@sanity/cli-e2e/helpers/runCli.ts @@ -10,6 +10,14 @@ export function getE2EProjectId(): string { return readEnv('SANITY_E2E_PROJECT_ID') } +export function getE2EDataset(): string { + return 'production' +} + +export function getE2EOrganizationId(): string { + return readEnv('SANITY_E2E_ORGANIZATION_ID') +} + interface RunCliBaseOptions { args?: string[] /** Override the binary path. Defaults to E2E_BINARY_PATH (the packed `@sanity/cli`). */ @@ -48,6 +56,12 @@ export async function runCli( } if (interactive) { + // Remove CI from env so that isInteractive() returns true for PTY-based interactive tests. + // GitHub Actions sets CI=true, which causes the CLI to throw NonInteractiveError + // instead of showing prompts. The key must be deleted (not set to '') because + // isInteractive() checks 'CI' in process.env (key presence, not value). + delete sharedEnv.CI + return spawnPty({ args: [resolvedBinaryPath, ...args], command: 'node', diff --git a/packages/@sanity/cli-e2e/helpers/spawnPty.ts b/packages/@sanity/cli-e2e/helpers/spawnPty.ts index f54f06ebae..e2c745b6ad 100644 --- a/packages/@sanity/cli-e2e/helpers/spawnPty.ts +++ b/packages/@sanity/cli-e2e/helpers/spawnPty.ts @@ -14,6 +14,14 @@ export interface InteractiveSession { /** Kill the process */ kill(signal?: string): void + /** + * Navigate to the option matching `pattern` in an active select prompt and press Enter. + * Waits for options to render, verifies exactly one match exists, then iterates + * ArrowDown until the ❯ cursor is on the matching line and confirms with Enter. + * Throws if zero or multiple options match. + */ + selectOption(pattern: RegExp | string, opts?: {timeout?: number}): Promise + /** Send Ctrl+ (e.g., sendControl('c') for SIGINT) */ sendControl(char: string): void @@ -81,6 +89,59 @@ export function spawnPty({args = [], command, cwd, env}: SpawnPtyOptions): Inter } }, + async selectOption(pattern: RegExp | string, opts?: {timeout?: number}): Promise { + const timeout = opts?.timeout ?? DEFAULT_TIMEOUT + const deadline = Date.now() + timeout + const regex = + pattern instanceof RegExp + ? pattern + : new RegExp(pattern.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`)) + + // Navigate ArrowDown until ❯ is on a line matching the pattern. + // The option may be off-screen and require scrolling, so we can't + // wait for it to appear before navigating. + const seenOptions = new Set() + while (Date.now() < deadline) { + const current = stripAnsi(output) + const selectedLine = current.split('\n').findLast((line) => line.includes('❯')) + + if (selectedLine && regex.test(selectedLine)) { + // Before confirming, check that only one distinct option matches + const allOptionLines = current + .split('\n') + .filter((line) => /^\s*[❯ ]\s+\S/.test(line)) + .map((line) => line.replace(/^\s*❯?\s*/, '').trim()) + const matchingOptions = [...new Set(allOptionLines.filter((line) => regex.test(line)))] + if (matchingOptions.length > 1) { + throw new Error( + `Multiple options match ${regex} — must match exactly one\n\nMatches:\n${matchingOptions.join('\n')}`, + ) + } + + ptyProcess.write(KEYS.Enter) + return + } + + // Track seen options to detect when we've wrapped around the full list + if (selectedLine) { + const optionText = selectedLine.replace(/^\s*❯?\s*/, '').trim() + if (seenOptions.has(optionText) && seenOptions.size > 1) { + throw new Error( + `Option matching ${regex} not found after scrolling through all options\n\nSeen options:\n${[...seenOptions].join('\n')}`, + ) + } + seenOptions.add(optionText) + } + + ptyProcess.write(KEYS.ArrowDown) + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL)) + } + + throw new Error( + `Timed out navigating to option matching ${regex}\n\nSeen options:\n${[...seenOptions].join('\n')}`, + ) + }, + sendControl(char: string) { const code = char.toLowerCase().codePointAt(0)! - 96 ptyProcess.write(String.fromCodePoint(code)) diff --git a/packages/@sanity/cli-e2e/vitest.config.ts b/packages/@sanity/cli-e2e/vitest.config.ts index 6e17d7ebd4..dbea8070aa 100644 --- a/packages/@sanity/cli-e2e/vitest.config.ts +++ b/packages/@sanity/cli-e2e/vitest.config.ts @@ -10,8 +10,6 @@ export default defineConfig({ disableConsoleIntercept: true, environment: 'node', exclude: ['**/node_modules/**', '**/dist/**'], - // Run tests sequentially — PTY-backed tests are resource-intensive - fileParallelism: false, globals: false, // 1. Pack @sanity/cli into a tarball and extract to tmp/ for realistic E2E testing // 2. Initialize test fixtures (copies fixtures, installs deps) diff --git a/packages/@sanity/cli-test/src/test/testFixture.ts b/packages/@sanity/cli-test/src/test/testFixture.ts index bd54f821f9..3778180434 100644 --- a/packages/@sanity/cli-test/src/test/testFixture.ts +++ b/packages/@sanity/cli-test/src/test/testFixture.ts @@ -1,5 +1,6 @@ import {randomBytes} from 'node:crypto' import {copyFile, mkdir, readdir, readFile, stat, symlink, writeFile} from 'node:fs/promises' +import {tmpdir} from 'node:os' import {join} from 'node:path' import {getFixturesPath, getTempPath} from '../utils/paths.js' @@ -49,9 +50,15 @@ export async function testCopyDirectory( */ export interface TestFixtureOptions { /** - * Custom temp directory. Defaults to process.cwd()/tmp + * Custom temp directory. Defaults to process.cwd()/tmp (or the OS temp directory when + * `useSystemTmp` is true). */ tempDir?: string + /** + * Use the OS temp directory instead of cwd/tmp. Avoids monorepo workspace detection by + * package managers and git when tests run inside a monorepo. Ignored when `tempDir` is set. + */ + useSystemTmp?: boolean } /** @@ -88,14 +95,24 @@ export async function testFixture( fixtureName: FixtureName | (string & {}), options: TestFixtureOptions = {}, ): Promise { - const {tempDir} = options + const {tempDir, useSystemTmp = false} = options const {includeDist = false} = fixtureName in DEFAULT_FIXTURES ? DEFAULT_FIXTURES[fixtureName as FixtureName] : {} - const tempDirectory = getTempPath(tempDir) - - // Fixtures are cloned in the tmp directory by the setup function - let tempFixturePath = join(tempDirectory, `fixture-${fixtureName}`) + // Source is always looked up in the default temp path (cwd/tmp) where global setup + // pre-clones fixtures and installs their node_modules. The destination can be redirected + // to system tmp to keep the working copy out of the monorepo (avoids pnpm treating it as + // a workspace importer and mutating pnpm-lock.yaml). + const sourceTempDir = getTempPath(tempDir) + const destTempDir = tempDir + ? sourceTempDir + : useSystemTmp + ? join(tmpdir(), 'sanity-cli-e2e') + : sourceTempDir + await mkdir(destTempDir, {recursive: true}) + + // Fixtures are cloned in the source tmp directory by the setup function + let tempFixturePath = join(sourceTempDir, `fixture-${fixtureName}`) try { const stats = await stat(tempFixturePath) @@ -112,7 +129,7 @@ export async function testFixture( } const tempId = randomBytes(8).toString('hex') - const tempPath = join(tempDirectory, `fixture-${fixtureName}-${tempId}`) + const tempPath = join(destTempDir, `fixture-${fixtureName}-${tempId}`) // Always skip node_modules (will be symlinked), tmp and (unless specifically included) dist const skipDirs = ['node_modules', 'tmp', ...(includeDist ? [] : ['dist'])] diff --git a/packages/@sanity/cli-test/src/utils/paths.ts b/packages/@sanity/cli-test/src/utils/paths.ts index ade8a9610d..37bb06aa55 100644 --- a/packages/@sanity/cli-test/src/utils/paths.ts +++ b/packages/@sanity/cli-test/src/utils/paths.ts @@ -1,4 +1,6 @@ -import {resolve} from 'node:path' +import {mkdir, mkdtemp, rm} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {join, resolve} from 'node:path' // Capture the initial working directory before any tests change it const INITIAL_CWD = process.cwd() @@ -32,6 +34,29 @@ export function getTempPath(customTempDir?: string): string { return customTempDir || resolve(INITIAL_CWD, 'tmp') } +/** + * Creates a unique temporary directory for test output. + * Uses {@link getTempPath} as the base, creating it if needed. + * + * @param options - Configuration options: `prefix` (default: 'cli-e2e-') and + * `useSystemTmp` (default: false) which uses the OS temp directory instead of + * cwd/tmp to avoid monorepo workspace detection by package managers and git. + * @returns The path and a cleanup function + * @public + */ +export async function createTmpDir( + options: {prefix?: string; useSystemTmp?: boolean} = {}, +): Promise<{cleanup: () => Promise; path: string}> { + const {prefix = 'cli-e2e-', useSystemTmp = false} = options + const basePath = useSystemTmp ? join(tmpdir(), 'sanity-cli-e2e') : getTempPath() + await mkdir(basePath, {recursive: true}) + const path = await mkdtemp(join(basePath, prefix)) + return { + cleanup: () => rm(path, {force: true, recursive: true}), + path, + } +} + /** * Gets the current Windows drive letter from process.cwd(). * Falls back to 'C:\\' if detection fails.