Skip to content

Commit

Permalink
feat(cli): update CLI to use new deploy endpoint (#7244)
Browse files Browse the repository at this point in the history
* feat(cli): update CLI to use new deploy endpoint

* feat: restore streaming support

* refactor: use client to stream instead of fetch

* fix: use `urlType` for new API spec

* fix(cli): fixes issues with deploying to new endpoint

* fix(cli): fixes dependency issue

* fix(core): fixes issue with depedency in tests

* feat(cli): handle error statusCode from API

* fix(cli): don't create default apps on deploy

* feat(cli): add list of deployments on deploy

* test: fix and add tests for new changes

* fix: error handling and update copy

* chore(cli): update copy

Co-authored-by: Carolina Gonzalez <[email protected]>

* feat(cli): add message about adding studioHost to cliConfig on deploys (#7349)

Co-authored-by: Rune Botten <[email protected]>

---------

Co-authored-by: Binoy Patel <[email protected]>
Co-authored-by: Carolina Gonzalez <[email protected]>
Co-authored-by: Carolina Gonzalez <[email protected]>
Co-authored-by: Rune Botten <[email protected]>
  • Loading branch information
5 people authored Aug 14, 2024
1 parent a361063 commit 14ae5cb
Show file tree
Hide file tree
Showing 9 changed files with 1,273 additions and 117 deletions.
2 changes: 2 additions & 0 deletions packages/@sanity/cli/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,8 @@ export interface CliConfig {
vite?: UserViteConfig

autoUpdates?: boolean

studioHost?: string
}

export type UserViteConfig =
Expand Down
1 change: 1 addition & 0 deletions packages/sanity/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@
"esbuild-register": "^3.5.0",
"execa": "^2.0.0",
"exif-component": "^1.0.1",
"form-data": "^4.0.0",
"framer-motion": "11.0.8",
"get-it": "^8.6.4",
"get-random-values-esm": "1.0.2",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,295 @@
import zlib from 'node:zlib'

import {beforeEach, describe, expect, it, jest} from '@jest/globals'
import {type CliCommandArguments, type CliCommandContext} from '@sanity/cli'
import tar from 'tar-fs'

import buildSanityStudio from '../../build/buildAction'
import deployStudioAction, {type DeployStudioActionFlags} from '../deployAction'
import * as _helpers from '../helpers'
import {type UserApplication} from '../helpers'

// Mock dependencies
jest.mock('tar-fs')
jest.mock('node:zlib')
jest.mock('../helpers')
jest.mock('../../build/buildAction')

type Helpers = typeof _helpers
const helpers = _helpers as unknown as {[K in keyof Helpers]: jest.Mock<Helpers[K]>}
const buildSanityStudioMock = buildSanityStudio as jest.Mock<typeof buildSanityStudio>
const tarPackMock = tar.pack as jest.Mock
const zlibCreateGzipMock = zlib.createGzip as jest.Mock
type SpinnerInstance = {
start: jest.Mock<() => SpinnerInstance>
succeed: jest.Mock<() => SpinnerInstance>
fail: jest.Mock<() => SpinnerInstance>
}

describe('deployStudioAction', () => {
let mockContext: CliCommandContext
let spinnerInstance: SpinnerInstance

const mockApplication: UserApplication = {
id: 'app-id',
appHost: 'app-host',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
urlType: 'internal',
projectId: 'example',
title: null,
type: 'studio',
}

beforeEach(() => {
jest.clearAllMocks()

spinnerInstance = {
start: jest.fn(() => spinnerInstance),
succeed: jest.fn(() => spinnerInstance),
fail: jest.fn(() => spinnerInstance),
}

mockContext = {
apiClient: jest.fn().mockReturnValue({
withConfig: jest.fn().mockReturnThis(),
}),
workDir: '/fake/work/dir',
chalk: {cyan: jest.fn((str) => str), red: jest.fn((str) => str)},
output: {
error: jest.fn((str) => str),
print: jest.fn(),
spinner: jest.fn().mockReturnValue(spinnerInstance),
},
prompt: {single: jest.fn()},
cliConfig: {},
} as unknown as CliCommandContext
})

it('builds and deploys the studio if the directory is empty', async () => {
const mockSpinner = mockContext.output.spinner('')

// Mock utility functions
helpers.dirIsEmptyOrNonExistent.mockResolvedValueOnce(true)
helpers.getInstalledSanityVersion.mockResolvedValueOnce('vX')
helpers.getOrCreateUserApplication.mockResolvedValueOnce(mockApplication)
helpers.createDeployment.mockResolvedValueOnce({location: 'https://app-host.sanity.studio'})
buildSanityStudioMock.mockResolvedValueOnce({didCompile: true})
tarPackMock.mockReturnValueOnce({pipe: jest.fn().mockReturnValue('tarball')})
zlibCreateGzipMock.mockReturnValue('gzipped')

await deployStudioAction(
{
argsWithoutOptions: ['customSourceDir'],
extOptions: {},
} as CliCommandArguments<DeployStudioActionFlags>,
mockContext,
)

// Check that buildSanityStudio was called
expect(buildSanityStudioMock).toHaveBeenCalledWith(
expect.objectContaining({
extOptions: {build: true},
argsWithoutOptions: ['customSourceDir'],
}),
mockContext,
{basePath: '/'},
)
expect(helpers.dirIsEmptyOrNonExistent).toHaveBeenCalledWith(
expect.stringContaining('customSourceDir'),
)
expect(helpers.getOrCreateUserApplication).toHaveBeenCalledWith(
expect.objectContaining({
client: expect.anything(),
context: expect.anything(),
}),
)
expect(helpers.createDeployment).toHaveBeenCalledWith({
client: expect.anything(),
applicationId: 'app-id',
version: 'vX',
isAutoUpdating: false,
tarball: 'tarball',
})

expect(mockContext.output.print).toHaveBeenCalledWith(
'\nSuccess! Studio deployed to https://app-host.sanity.studio',
)
expect(mockSpinner.succeed).toHaveBeenCalled()
})

it('builds and deploys the studio if the directory is empty and hostname in config', async () => {
const mockSpinner = mockContext.output.spinner('')
mockContext.cliConfig = {studioHost: 'app-host'}

// Mock utility functions
helpers.dirIsEmptyOrNonExistent.mockResolvedValueOnce(true)
helpers.getInstalledSanityVersion.mockResolvedValueOnce('vX')
helpers.getOrCreateUserApplicationFromConfig.mockResolvedValueOnce(mockApplication)
helpers.createDeployment.mockResolvedValueOnce({location: 'https://app-host.sanity.studio'})
buildSanityStudioMock.mockResolvedValueOnce({didCompile: true})
tarPackMock.mockReturnValueOnce({pipe: jest.fn().mockReturnValue('tarball')})
zlibCreateGzipMock.mockReturnValue('gzipped')

await deployStudioAction(
{
argsWithoutOptions: ['customSourceDir'],
extOptions: {},
} as CliCommandArguments<DeployStudioActionFlags>,
mockContext,
)

// Check that buildSanityStudio was called
expect(buildSanityStudioMock).toHaveBeenCalledWith(
expect.objectContaining({
extOptions: {build: true},
argsWithoutOptions: ['customSourceDir'],
}),
mockContext,
{basePath: '/'},
)
expect(helpers.dirIsEmptyOrNonExistent).toHaveBeenCalledWith(
expect.stringContaining('customSourceDir'),
)
expect(helpers.getOrCreateUserApplicationFromConfig).toHaveBeenCalledWith(
expect.objectContaining({
client: expect.anything(),
context: expect.anything(),
appHost: 'app-host',
}),
)
expect(helpers.createDeployment).toHaveBeenCalledWith({
client: expect.anything(),
applicationId: 'app-id',
version: 'vX',
isAutoUpdating: false,
tarball: 'tarball',
})

expect(mockContext.output.print).toHaveBeenCalledWith(
'\nSuccess! Studio deployed to https://app-host.sanity.studio',
)
expect(mockSpinner.succeed).toHaveBeenCalled()
})

it('prompts the user if the directory is not empty', async () => {
const mockSpinner = mockContext.output.spinner('')

helpers.dirIsEmptyOrNonExistent.mockResolvedValueOnce(false)
;(
mockContext.prompt.single as jest.Mock<typeof mockContext.prompt.single>
).mockResolvedValueOnce(true) // User confirms to proceed
helpers.getInstalledSanityVersion.mockResolvedValueOnce('vX')
helpers.getOrCreateUserApplication.mockResolvedValueOnce(mockApplication)
helpers.createDeployment.mockResolvedValueOnce({location: 'https://app-host.sanity.studio'})
buildSanityStudioMock.mockResolvedValueOnce({didCompile: true})
tarPackMock.mockReturnValueOnce({pipe: jest.fn().mockReturnValue('tarball')})
zlibCreateGzipMock.mockReturnValue('gzipped')

await deployStudioAction(
{
argsWithoutOptions: ['customSourceDir'],
extOptions: {},
} as CliCommandArguments<DeployStudioActionFlags>,
mockContext,
)

expect(helpers.dirIsEmptyOrNonExistent).toHaveBeenCalledWith(
expect.stringContaining('customSourceDir'),
)
expect(mockContext.prompt.single).toHaveBeenCalledWith({
type: 'confirm',
message: expect.stringContaining('is not empty, do you want to proceed?'),
default: false,
})
expect(buildSanityStudioMock).toHaveBeenCalled()
expect(mockSpinner.start).toHaveBeenCalled()
expect(mockSpinner.succeed).toHaveBeenCalled()
})

it('does not proceed if build fails', async () => {
const mockSpinner = mockContext.output.spinner('')

helpers.dirIsEmptyOrNonExistent.mockResolvedValueOnce(true)
buildSanityStudioMock.mockResolvedValueOnce({didCompile: false})

await deployStudioAction(
{
argsWithoutOptions: ['customSourceDir'],
extOptions: {},
} as CliCommandArguments<DeployStudioActionFlags>,
mockContext,
)

expect(buildSanityStudioMock).toHaveBeenCalled()
expect(helpers.createDeployment).not.toHaveBeenCalled()
expect(mockSpinner.fail).not.toHaveBeenCalled()
})

it('fails if the directory does not exist', async () => {
const mockSpinner = mockContext.output.spinner('')

helpers.checkDir.mockRejectedValueOnce(new Error('Example error'))
helpers.dirIsEmptyOrNonExistent.mockResolvedValue(true)
buildSanityStudioMock.mockResolvedValueOnce({didCompile: true})

await expect(
deployStudioAction(
{
argsWithoutOptions: ['nonexistentDir'],
extOptions: {},
} as CliCommandArguments<DeployStudioActionFlags>,
mockContext,
),
).rejects.toThrow('Example error')

expect(mockSpinner.fail).toHaveBeenCalled()
})

it('throws an error if "graphql" is passed as a source directory', async () => {
await expect(
deployStudioAction(
{
argsWithoutOptions: ['graphql'],
extOptions: {},
} as CliCommandArguments<DeployStudioActionFlags>,
mockContext,
),
).rejects.toThrow('Did you mean `sanity graphql deploy`?')
})

it('returns an error if API responds with 402', async () => {
// Mock utility functions
helpers.dirIsEmptyOrNonExistent.mockResolvedValueOnce(true)
helpers.getInstalledSanityVersion.mockResolvedValueOnce('vX')
helpers.getOrCreateUserApplication.mockRejectedValueOnce({
statusCode: 402,
message: 'Application limit reached',
error: 'Payment Required',
})
buildSanityStudioMock.mockResolvedValueOnce({didCompile: true})
tarPackMock.mockReturnValueOnce({pipe: jest.fn().mockReturnValue('tarball')})
zlibCreateGzipMock.mockReturnValue('gzipped')

await deployStudioAction(
{
argsWithoutOptions: ['customSourceDir'],
extOptions: {},
} as CliCommandArguments<DeployStudioActionFlags>,
mockContext,
)

expect(helpers.dirIsEmptyOrNonExistent).toHaveBeenCalledWith(
expect.stringContaining('customSourceDir'),
)
expect(helpers.getOrCreateUserApplication).toHaveBeenCalledWith(
expect.objectContaining({
client: expect.anything(),
context: expect.anything(),
spinner: expect.anything(),
}),
)

expect(mockContext.output.error).toHaveBeenCalledWith('Application limit reached')
})
})
Loading

0 comments on commit 14ae5cb

Please sign in to comment.