Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
name: E2E Tests

on:
pull_request:
push:
branches:
- main

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
outputs:
should_run: ${{ github.event_name == 'push' || steps.filter.outputs.cli == 'true' }}
steps:
- uses: actions/checkout@v6
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4
id: filter
with:
filters: |
cli:
- 'packages/@sanity/cli/**'
- 'packages/@sanity/cli-core/**'
- 'packages/@sanity/cli-e2e/**'
- 'packages/@sanity/cli-test/**'
- 'packages/create-sanity/**'
- 'fixtures/**'
- 'pnpm-lock.yaml'

e2e:
needs: changes
if: needs.changes.outputs.should_run == 'true'
runs-on: ${{ matrix.os }}
timeout-minutes: 20
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
strategy:
matrix:
os: [ubuntu-latest]
node-version: [20, 22, 24]
fail-fast: false

steps:
- uses: actions/checkout@v6

- name: Setup Environment
uses: ./.github/actions/setup
with:
node-version: ${{ matrix.node-version }}

- name: Build CLI
run: pnpm build:cli

- name: Run E2E tests
env:
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

e2e-status:
if: always()
needs: [changes, e2e]
runs-on: ubuntu-latest
steps:
- name: Check status
run: |
# Fail if any needed job failed or was cancelled
results=("${{ needs.changes.result }}" "${{ needs.e2e.result }}")
for result in "${results[@]}"; do
if [ "$result" == "failure" ] || [ "$result" == "cancelled" ]; then
echo "Job failed or was cancelled: $result"
exit 1
fi
done
echo "All jobs passed or were skipped"
4 changes: 2 additions & 2 deletions fixtures/basic-studio/sanity.cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import {defineCliConfig} from 'sanity/cli'

export default defineCliConfig({
api: {
dataset: 'test',
projectId: 'ppsg7ml5',
dataset: process.env.SANITY_E2E_DATASET || 'test',
projectId: process.env.SANITY_E2E_PROJECT_ID || 'ppsg7ml5',
},
deployment: {
autoUpdates: true,
Expand Down
4 changes: 4 additions & 0 deletions knip.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ const baseConfig = {
],
project,
},
'packages/@sanity/cli-e2e': {
entry: [],
project: ['helpers/**/*.{js,ts}', '__tests__/**/*.{js,ts}'],
},
'packages/@sanity/cli-test': {
entry: ['package.config.ts'],
project,
Expand Down
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"check:deps": "knip",
"check:format": "oxfmt --check",
"check:lint": "turbo run lint -- --fix",
"check:types": "turbo run check:types --filter=@sanity/cli --filter=@sanity/cli-core",
"check:types": "turbo run check:types --filter=@sanity/cli --filter=@sanity/cli-core --filter=@sanity/cli-e2e",
"clean": "rimraf packages/@sanity/*/lib packages/*/lib packages/@sanity/*/dist packages/*/dist",
"clean:deps": "rimraf packages/*/node_modules fixtures/*/node_modules node_modules",
"depcheck": "knip",
Expand All @@ -41,6 +41,8 @@
"release": "pnpm build:cli && pnpm publish-packages",
"pretest": "pnpm run build:cli",
"test": "vitest run",
"pretest:e2e": "pnpm run build:cli",
"test:e2e": "pnpm --filter @sanity/cli-e2e test",
"pretest:watch": "pnpm run build:cli",
"test:watch": "vitest",
"version-packages": "changeset version",
Expand Down Expand Up @@ -75,7 +77,8 @@
"pnpm": {
"onlyBuiltDependencies": [
"@swc/core",
"esbuild"
"esbuild",
"node-pty"
],
"overrides": {
"@sanity/cli": "workspace:*"
Expand Down
2 changes: 2 additions & 0 deletions packages/@sanity/cli-e2e/.env.example

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

add the dataset env to the example?

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.

It might not be needed, I might have to tweak this as I write more tests

Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
SANITY_E2E_TOKEN=
SANITY_E2E_PROJECT_ID=
5 changes: 5 additions & 0 deletions packages/@sanity/cli-e2e/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules
coverage
tmp
dist
.test-output
61 changes: 61 additions & 0 deletions packages/@sanity/cli-e2e/__tests__/datasetsList.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import {testFixture} from '@sanity/cli-test'
import {describe, expect, test} from 'vitest'

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

describe('sanity datasets list', () => {
test('without project context exits with project-not-found error', async () => {
const {exitCode, stderr} = await runCli({
args: ['datasets', 'list'],
// Ensure no token so we don't hit an interactive project prompt
env: {SANITY_AUTH_TOKEN: ''},
})

expect(exitCode).not.toBe(0)
expect(stderr).toContain('Unable to determine project ID')
})

test('with project context but no auth exits with login error', async () => {
const cwd = await testFixture('basic-studio')
const {exitCode, stderr} = await runCli({
args: ['datasets', 'list'],
cwd,
env: {SANITY_AUTH_TOKEN: ''},
})

expect(exitCode).not.toBe(0)
expect(stderr).toMatch(/login/i)
})

test('with --project-id flag but no auth exits with login error', async () => {
const {exitCode, stderr} = await runCli({
args: ['datasets', 'list', '--project-id', getE2EProjectId()],
env: {SANITY_AUTH_TOKEN: ''},
})

expect(exitCode).not.toBe(0)
expect(stderr).toMatch(/login/i)
})

describe('with authentication', () => {
test('lists datasets for the fixture project', async () => {
const cwd = await testFixture('basic-studio')
const {error, stdout} = await runCli({
args: ['datasets', 'list'],
cwd,
})

if (error) throw error
expect(stdout.trim().length).toBeGreaterThan(0)
})

test('lists datasets with --project-id flag', async () => {
const {error, stdout} = await runCli({
args: ['datasets', 'list', '--project-id', getE2EProjectId()],
})

if (error) throw error
expect(stdout.trim().length).toBeGreaterThan(0)
})
})
})
13 changes: 13 additions & 0 deletions packages/@sanity/cli-e2e/__tests__/help.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {describe, expect, test} from 'vitest'

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

describe('sanity --help', () => {
test('prints usage information and exits 0', async () => {
const {error, stdout} = await runCli({args: ['--help']})

if (error) throw error
expect(stdout).toContain('USAGE')
expect(stdout).toContain('COMMANDS')
})
})
17 changes: 17 additions & 0 deletions packages/@sanity/cli-e2e/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import {resolve} from 'node:path'

import {includeIgnoreFile} from '@eslint/compat'
import eslintConfig from '@sanity/eslint-config-cli'

export default [
includeIgnoreFile(resolve(import.meta.dirname, '.gitignore')),
...eslintConfig,
{
rules: {
'@typescript-eslint/no-explicit-any': 'warn',
// This is a private test-only package — all deps are devDependencies
'import-x/no-extraneous-dependencies': 'off',
'no-console': 'off',
},
},
]
44 changes: 44 additions & 0 deletions packages/@sanity/cli-e2e/globalSetup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {mkdtempSync, rmSync} from 'node:fs'
import {tmpdir} from 'node:os'
import {join} from 'node:path'

import {config as loadDotenv} from 'dotenv'

import {installFromTarball, packCli} from './helpers/packCli.js'

let cleanupDir: string | undefined
let tarballPath: string | undefined

export async function setup(): Promise<void> {
// Load .env file into process.env so tests can read SANITY_E2E_* vars.
// Existing env vars take precedence (CI sets them directly).
loadDotenv({quiet: true})
// If E2E_BINARY_PATH is already set (e.g., npm registry mode from CI),
// skip pack and use the provided binary.
if (process.env.E2E_BINARY_PATH) {
console.log(`Using pre-set E2E_BINARY_PATH: ${process.env.E2E_BINARY_PATH}`)
return
}

console.log('Packing @sanity/cli...')
const tarball = packCli()
tarballPath = tarball

const tmpDir = mkdtempSync(join(tmpdir(), 'cli-e2e-'))
cleanupDir = tmpDir

console.log(`Installing tarball into ${tmpDir}...`)
const binaryPath = installFromTarball(tarball, tmpDir)

process.env.E2E_BINARY_PATH = binaryPath
console.log(`E2E_BINARY_PATH set to ${binaryPath}`)
}

export async function teardown(): Promise<void> {
if (tarballPath) {
rmSync(tarballPath, {force: true})
}
if (cleanupDir) {
rmSync(cleanupDir, {force: true, recursive: true})
}
}
12 changes: 12 additions & 0 deletions packages/@sanity/cli-e2e/helpers/keys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export const KEYS = {
ArrowDown: '\u001B[B',
ArrowLeft: '\u001B[D',
ArrowRight: '\u001B[C',
ArrowUp: '\u001B[A',
Backspace: '\u007F',
Enter: '\r',
Escape: '\u001B',
Tab: '\t',
} as const

export type KeyName = keyof typeof KEYS
49 changes: 49 additions & 0 deletions packages/@sanity/cli-e2e/helpers/packCli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {execSync} from 'node:child_process'
import {existsSync} from 'node:fs'
import {createRequire} from 'node:module'
import {tmpdir} from 'node:os'
import {dirname, join} from 'node:path'

const require = createRequire(import.meta.url)

/**
* Runs `pnpm pack` on `@sanity/cli` and returns the absolute path to the tarball.
*/
export function packCli(): string {
const cliPkgJsonPath = require.resolve('@sanity/cli/package.json')
const cliDir = dirname(cliPkgJsonPath)

const packDest = tmpdir()
const tarballName = execSync(`pnpm pack --pack-destination ${packDest}`, {
cwd: cliDir,
encoding: 'utf8',
}).trim()

// pnpm pack may output lifecycle script logs before the tarball name.
// The tarball filename is always the last line of output.
const lines = tarballName.split('\n')
const tgzLine = lines.findLast((line) => line.endsWith('.tgz'))

if (!tgzLine) {
throw new Error(`No .tgz filename found in pnpm pack output:\n${tarballName}`)
}

return tgzLine
}

/**
* Installs a CLI tarball into a directory and returns
* the absolute path to the `sanity` binary.
*/
export function installFromTarball(tarballPath: string, installDir: string): string {
execSync(`npm install --prefix "${installDir}" "${tarballPath}"`, {
encoding: 'utf8',
stdio: 'pipe',
})

const binaryPath = join(installDir, 'node_modules', '.bin', 'sanity')
if (!existsSync(binaryPath)) {
throw new Error(`sanity binary not found at ${binaryPath} after installing tarball`)
}
return binaryPath
}
11 changes: 11 additions & 0 deletions packages/@sanity/cli-e2e/helpers/readEnv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export function readEnv<KnownEnvVar extends string>(name: KnownEnvVar): string {
const val = findEnv(name)
if (val === undefined) {
throw new Error(`Missing required environment variable "${name}"`)
}
return val
}

function findEnv<KnownEnvVar extends string>(name: KnownEnvVar): string | undefined {
return process.env[name]
}
21 changes: 21 additions & 0 deletions packages/@sanity/cli-e2e/helpers/resolveBinaryPath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import {existsSync} from 'node:fs'

/**
* Returns the absolute path to the `sanity` CLI binary.
*
* The path is read from the `E2E_BINARY_PATH` environment variable, which is
* set by `globalSetup` (via npm pack) or by the CI workflow (npm registry mode).
*/
export function resolveBinaryPath(): string {
const binaryPath = process.env.E2E_BINARY_PATH
if (!binaryPath) {
throw new Error(
'E2E_BINARY_PATH is not set. ' +
'This should be set by globalSetup (via npm pack) or by the CI workflow (npm registry mode).',
)
}
if (!existsSync(binaryPath)) {
throw new Error(`CLI binary not found at ${binaryPath}`)
}
return binaryPath
}
Loading
Loading