Skip to content

Commit db1dca6

Browse files
binoy14claude
andcommitted
feat(cli-e2e): add E2E testing package with pnpm pack integration
Create @sanity/cli-e2e package for E2E testing the Sanity CLI against its packed publishable artifact via pnpm pack, ensuring the files field, lifecycle scripts, and bin entries are correct. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7b98e0b commit db1dca6

20 files changed

Lines changed: 578 additions & 12 deletions

File tree

.github/workflows/e2e.yml

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
name: E2E Tests
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches:
7+
- main
8+
9+
concurrency:
10+
group: ${{ github.workflow }}-${{ github.ref }}
11+
cancel-in-progress: true
12+
13+
jobs:
14+
changes:
15+
runs-on: ubuntu-latest
16+
permissions:
17+
contents: read
18+
pull-requests: read
19+
outputs:
20+
should_run: ${{ github.event_name == 'push' || steps.filter.outputs.cli == 'true' }}
21+
steps:
22+
- uses: actions/checkout@v6
23+
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4
24+
id: filter
25+
with:
26+
filters: |
27+
cli:
28+
- 'packages/@sanity/cli/**'
29+
- 'packages/@sanity/cli-core/**'
30+
- 'packages/@sanity/cli-e2e/**'
31+
- 'packages/@sanity/cli-test/**'
32+
- 'packages/create-sanity/**'
33+
- 'fixtures/**'
34+
- 'pnpm-lock.yaml'
35+
36+
e2e:
37+
needs: changes
38+
if: needs.changes.outputs.should_run == 'true'
39+
runs-on: ${{ matrix.os }}
40+
timeout-minutes: 20
41+
env:
42+
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
43+
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
44+
strategy:
45+
matrix:
46+
os: [ubuntu-latest]
47+
node-version: [20, 22, 24]
48+
fail-fast: false
49+
50+
steps:
51+
- uses: actions/checkout@v6
52+
53+
- name: Setup Environment
54+
uses: ./.github/actions/setup
55+
with:
56+
node-version: ${{ matrix.node-version }}
57+
58+
- name: Build CLI
59+
run: pnpm build:cli
60+
61+
- name: Run E2E tests
62+
env:
63+
SANITY_E2E_TOKEN: ${{ secrets.SANITY_E2E_TOKEN }}
64+
SANITY_E2E_PROJECT_ID: ${{ secrets.SANITY_E2E_PROJECT_ID }}
65+
SANITY_E2E_DATASET: ${{ secrets.SANITY_E2E_DATASET }}
66+
run: pnpm --filter @sanity/cli-e2e test
67+
68+
e2e-status:
69+
if: always()
70+
needs: [changes, e2e]
71+
runs-on: ubuntu-latest
72+
steps:
73+
- name: Check status
74+
run: |
75+
# Fail if any needed job failed or was cancelled
76+
results=("${{ needs.changes.result }}" "${{ needs.e2e.result }}")
77+
for result in "${results[@]}"; do
78+
if [ "$result" == "failure" ] || [ "$result" == "cancelled" ]; then
79+
echo "Job failed or was cancelled: $result"
80+
exit 1
81+
fi
82+
done
83+
echo "All jobs passed or were skipped"

fixtures/basic-studio/sanity.cli.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import {defineCliConfig} from 'sanity/cli'
22

33
export default defineCliConfig({
44
api: {
5-
dataset: 'test',
6-
projectId: 'ppsg7ml5',
5+
dataset: process.env.SANITY_E2E_DATASET || 'test',
6+
projectId: process.env.SANITY_E2E_PROJECT_ID || 'ppsg7ml5',
77
},
88
deployment: {
99
autoUpdates: true,

knip.config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ const baseConfig = {
6363
],
6464
project,
6565
},
66+
'packages/@sanity/cli-e2e': {
67+
entry: [],
68+
project: ['helpers/**/*.{js,ts}', '__tests__/**/*.{js,ts}'],
69+
},
6670
'packages/@sanity/cli-test': {
6771
entry: ['package.config.ts'],
6872
project,

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@
4141
"release": "pnpm build:cli && pnpm publish-packages",
4242
"pretest": "pnpm run build:cli",
4343
"test": "vitest run",
44+
"pretest:e2e": "pnpm run build:cli",
45+
"test:e2e": "pnpm --filter @sanity/cli-e2e test",
4446
"pretest:watch": "pnpm run build:cli",
4547
"test:watch": "vitest",
4648
"version-packages": "changeset version",
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
node_modules
2+
coverage
3+
tmp
4+
dist
5+
.test-output
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import {testFixture} from '@sanity/cli-test'
2+
import {describe, expect, test} from 'vitest'
3+
4+
import {E2E_PROJECT_ID, runCli} from '../helpers/runCli.js'
5+
6+
describe('sanity datasets list', () => {
7+
test('without project context exits with project-not-found error', async () => {
8+
const {exitCode, stderr} = await runCli({
9+
args: ['datasets', 'list'],
10+
// Ensure no token so we don't hit an interactive project prompt
11+
env: {SANITY_AUTH_TOKEN: ''},
12+
})
13+
14+
expect(exitCode).not.toBe(0)
15+
expect(stderr).toContain('Unable to determine project ID')
16+
})
17+
18+
test('with project context but no auth exits with login error', async () => {
19+
const cwd = await testFixture('basic-studio')
20+
const {exitCode, stderr} = await runCli({
21+
args: ['datasets', 'list'],
22+
cwd,
23+
env: {SANITY_AUTH_TOKEN: ''},
24+
})
25+
26+
expect(exitCode).not.toBe(0)
27+
expect(stderr).toMatch(/login/i)
28+
})
29+
30+
test('with --project-id flag but no auth exits with login error', async () => {
31+
const {exitCode, stderr} = await runCli({
32+
args: ['datasets', 'list', '--project-id', E2E_PROJECT_ID],
33+
env: {SANITY_AUTH_TOKEN: ''},
34+
})
35+
36+
expect(exitCode).not.toBe(0)
37+
expect(stderr).toMatch(/login/i)
38+
})
39+
40+
describe('with authentication', () => {
41+
test('lists datasets for the fixture project', async () => {
42+
const cwd = await testFixture('basic-studio')
43+
const {error, stdout} = await runCli({
44+
args: ['datasets', 'list'],
45+
cwd,
46+
})
47+
48+
if (error) throw error
49+
expect(stdout.trim().length).toBeGreaterThan(0)
50+
})
51+
52+
test('lists datasets with --project-id flag', async () => {
53+
const {error, stdout} = await runCli({
54+
args: ['datasets', 'list', '--project-id', E2E_PROJECT_ID],
55+
})
56+
57+
if (error) throw error
58+
expect(stdout.trim().length).toBeGreaterThan(0)
59+
})
60+
})
61+
})
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import {describe, expect, test} from 'vitest'
2+
3+
import {runCli} from '../helpers/runCli.js'
4+
5+
describe('sanity --help', () => {
6+
test('prints usage information and exits 0', async () => {
7+
const {error, stdout} = await runCli({args: ['--help']})
8+
9+
if (error) throw error
10+
expect(stdout).toContain('USAGE')
11+
expect(stdout).toContain('COMMANDS')
12+
})
13+
})
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import {resolve} from 'node:path'
2+
3+
import {includeIgnoreFile} from '@eslint/compat'
4+
import eslintConfig from '@sanity/eslint-config-cli'
5+
6+
export default [
7+
includeIgnoreFile(resolve(import.meta.dirname, '.gitignore')),
8+
...eslintConfig,
9+
{
10+
rules: {
11+
'@typescript-eslint/no-explicit-any': 'warn',
12+
// This is a private test-only package — all deps are devDependencies
13+
'import-x/no-extraneous-dependencies': 'off',
14+
'no-console': 'off',
15+
},
16+
},
17+
]
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import {exec as execCb} from 'node:child_process'
2+
import {existsSync, mkdirSync, readdirSync, rmSync, symlinkSync} from 'node:fs'
3+
import {createRequire} from 'node:module'
4+
import {dirname, join, resolve} from 'node:path'
5+
import {promisify} from 'node:util'
6+
7+
const exec = promisify(execCb)
8+
9+
/**
10+
* Subdirectory within `tmp/` where the packed CLI is extracted.
11+
* Used by both this setup and `resolveBinaryPath()` to locate the binary.
12+
*/
13+
export const CLI_PACK_DIR = 'cli-packed'
14+
15+
/**
16+
* Returns the absolute path to the directory containing the extracted packed CLI.
17+
*
18+
* The packed CLI is extracted into `tmp/cli-packed/package/` (the default
19+
* directory name that `tar` creates when extracting an npm tarball).
20+
*/
21+
export function getPackedCliPath(): string {
22+
return resolve(process.cwd(), 'tmp', CLI_PACK_DIR, 'package')
23+
}
24+
25+
/**
26+
* Vitest global setup that packs `@sanity/cli` into a tarball, extracts it,
27+
* and symlinks `node_modules` from the workspace so the binary can run.
28+
*
29+
* This ensures E2E tests exercise the actual publishable artifact rather than
30+
* the raw workspace source, catching issues with the `files` field, lifecycle
31+
* scripts, and missing exports.
32+
*
33+
* Requires `@sanity/cli` (and its workspace deps) to be built beforehand.
34+
*/
35+
export async function teardown(): Promise<void> {
36+
const packDestination = resolve(process.cwd(), 'tmp', CLI_PACK_DIR)
37+
if (existsSync(packDestination)) {
38+
rmSync(packDestination, {force: true, recursive: true})
39+
}
40+
}
41+
42+
export async function setup(): Promise<void> {
43+
// 1. Locate the @sanity/cli package in the workspace
44+
// (import.meta.resolve is not available in vitest's globalSetup runner)
45+
const require = createRequire(import.meta.url)
46+
const cliPackageJsonPath = require.resolve('@sanity/cli/package.json')
47+
const cliPackageDir = dirname(cliPackageJsonPath)
48+
49+
// 2. Prepare the destination directory
50+
const packDestination = resolve(process.cwd(), 'tmp', CLI_PACK_DIR)
51+
52+
// Clean any previous pack artifacts
53+
if (existsSync(packDestination)) {
54+
rmSync(packDestination, {force: true, recursive: true})
55+
}
56+
mkdirSync(packDestination, {recursive: true})
57+
58+
// 3. Pack @sanity/cli → tarball
59+
// pnpm pack runs prepack (manifest:generate) and postpack (cleanup) hooks.
60+
// stdout includes lifecycle script output, so we find the .tgz by listing the directory.
61+
await exec(`pnpm pack --pack-destination "${packDestination}"`, {
62+
cwd: cliPackageDir,
63+
})
64+
65+
const tarballFile = readdirSync(packDestination).find((f) => f.endsWith('.tgz'))
66+
if (!tarballFile) {
67+
throw new Error(`No .tgz file found in ${packDestination} after pnpm pack`)
68+
}
69+
const tarballPath = join(packDestination, tarballFile)
70+
71+
// 4. Extract the tarball → creates packDestination/package/
72+
await exec(`tar xzf "${tarballPath}" -C "${packDestination}"`)
73+
74+
// Clean up the tarball — no longer needed
75+
rmSync(tarballPath)
76+
77+
// 5. Symlink node_modules from the workspace package so the CLI can
78+
// resolve its runtime dependencies
79+
const extractedDir = join(packDestination, 'package')
80+
const nodeModulesTarget = join(extractedDir, 'node_modules')
81+
82+
if (!existsSync(nodeModulesTarget)) {
83+
symlinkSync(join(cliPackageDir, 'node_modules'), nodeModulesTarget)
84+
}
85+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export function readEnv<KnownEnvVar extends string>(name: KnownEnvVar): string {
2+
const val = findEnv(name)
3+
if (val === undefined) {
4+
throw new Error(`Missing required environment variable "${name}"`)
5+
}
6+
return val
7+
}
8+
9+
function findEnv<KnownEnvVar extends string>(name: KnownEnvVar): string | undefined {
10+
return process.env[name]
11+
}

0 commit comments

Comments
 (0)