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
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ vi.mock('../../services/util', () => ({
configFilenames: ['checkly.config.ts'],
}),
getGitInformation: vi.fn(),
getGitRepoRoot: vi.fn(),
}))

vi.mock('prompts', () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ vi.mock('../../services/util', () => ({
configFilenames: ['checkly.config.ts'],
}),
getGitInformation: vi.fn(),
getGitRepoRoot: vi.fn(),
}))

import { loadChecklyConfig } from '../../services/checkly-config-loader.js'
Expand Down
165 changes: 165 additions & 0 deletions packages/cli/src/commands/__tests__/deploy-source-file.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import path from 'node:path'

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('../../helpers/cli-mode', () => ({
detectCliMode: vi.fn(() => 'agent'),
}))

vi.mock('../../rest/api', () => ({
runtimes: { getAll: vi.fn().mockResolvedValue([]) },
projects: { deploy: vi.fn().mockResolvedValue({ data: { diff: [] } }) },
validateAuthentication: vi.fn().mockResolvedValue({ name: 'Test Account' }),
}))

vi.mock('../../services/checkly-config-loader', async () => {
const { Diagnostics } = await import('../../constructs/diagnostics.js')
return {
loadChecklyConfig: vi.fn().mockResolvedValue({
config: {
logicalId: 'my-project',
projectName: 'My Project',
checks: {},
},
constructs: [],
diagnostics: new Diagnostics(),
}),
resolveDependencyCacheVersion: vi.fn(),
}
})

vi.mock('../../services/project-parser', () => ({
parseProject: vi.fn(),
}))

vi.mock('../../services/util', async importOriginal => ({
...await importOriginal<typeof import('../../services/util.js')>(),
splitConfigFilePath: vi.fn().mockReturnValue({
configDirectory: '.',
configFilenames: ['checkly.config.ts'],
}),
getGitInformation: vi.fn(),
getGitRepoRoot: vi.fn(),
}))

vi.mock('../../services/check-parser/bundler', () => ({
Bundler: {
createForWorkspace: vi.fn().mockResolvedValue({
isEmpty: true,
updateMarker: vi.fn(),
finalize: vi.fn().mockResolvedValue({ archiveFile: 'bundle.tgz', store: vi.fn() }),
}),
},
}))

import * as api from '../../rest/api.js'
import { parseProject } from '../../services/project-parser.js'
import { getGitInformation, getGitRepoRoot } from '../../services/util.js'
import { Ok } from '../../services/check-parser/package-files/result.js'
import { EmailAlertChannel } from '../../constructs/email-alert-channel.js'
import { Project } from '../../constructs/project.js'
import { Session } from '../../constructs/session.js'
import { AuthCommand } from '../authCommand.js'
import Deploy from '../deploy.js'

const repoRoot = path.resolve('/home/user/repo')

function createCommandContext () {
return {
parse: vi.fn().mockResolvedValue({
flags: {
'force': true,
'preview': true,
'output': false,
'verbose': false,
'config': undefined,
'schedule-on-deploy': true,
'verify-runtime-dependencies': true,
'debug-bundle': false,
'debug-bundle-output-file': './debug-bundle.json',
},
metadata: { flags: {} },
}),
log: vi.fn(),
exit: vi.fn((code: number) => {
throw new Error(`EXIT_${code}`)
}),
style: {
outputFormat: undefined,
diagnostics: vi.fn(),
actionStart: vi.fn(),
actionSuccess: vi.fn(),
actionFailure: vi.fn(),
actionStatus: vi.fn(),
longError: vi.fn(),
longWarning: vi.fn(),
longInfo: vi.fn(),
shortError: vi.fn(),
},
validateProject: (AuthCommand.prototype as any).validateProject,
formatPreview: (Deploy.prototype as any).formatPreview,
constructor: Deploy,
account: { name: 'Test Account', runtimeId: 'runtime-default' },
}
}

function declareProject () {
Session.reset()
Session.workspace = Ok({} as any)
const project = new Project('my-project', { name: 'My Project' })
Session.project = project

Session.checkFileAbsolutePath = path.join(repoRoot, 'src', 'alerts.ts')
new EmailAlertChannel('in-repo', { address: 'alerts@example.com' })
Session.checkFileAbsolutePath = path.resolve('/home/user/elsewhere/alerts.ts')
new EmailAlertChannel('outside-repo', { address: 'alerts@example.com' })
Session.checkFileAbsolutePath = undefined

vi.mocked(parseProject).mockResolvedValue(project)
return project
}

function deployedResources () {
expect(api.projects.deploy).toHaveBeenCalledOnce()
const [payload] = vi.mocked(api.projects.deploy).mock.calls[0]
return payload.resources
}

describe('deploy source files', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(getGitInformation).mockReturnValue(null)
declareProject()
})

afterEach(() => {
Session.reset()
})

it('sends each resource\'s source file relative to the git repository root', async () => {
vi.mocked(getGitRepoRoot).mockReturnValue(repoRoot)

await Deploy.prototype.run.call(createCommandContext() as any)

const resources = deployedResources()
expect(resources).toEqual(expect.arrayContaining([
expect.objectContaining({ logicalId: 'in-repo', type: 'alert-channel', sourceFile: 'src/alerts.ts' }),
]))
const outside = resources.find(resource => resource.logicalId === 'outside-repo')
expect(outside).toBeDefined()
expect(outside).not.toHaveProperty('sourceFile')
expect(outside!.payload).not.toHaveProperty('sourceFile')
})

it('omits source files outside a git repository', async () => {
vi.mocked(getGitRepoRoot).mockReturnValue(undefined)

await Deploy.prototype.run.call(createCommandContext() as any)

const resources = deployedResources()
expect(resources).toHaveLength(2)
for (const resource of resources) {
expect(resource).not.toHaveProperty('sourceFile')
}
})
})
5 changes: 3 additions & 2 deletions packages/cli/src/commands/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
StatusPageV3Component, StatusPageV3AutomationRule,
} from '../constructs/index.js'
import chalk from 'chalk'
import { splitConfigFilePath, getGitInformation } from '../services/util.js'
import { splitConfigFilePath, getGitInformation, getGitRepoRoot } from '../services/util.js'
import commonMessages from '../messages/common-messages.js'
import { forceFlag } from '../helpers/flags.js'
import { ProjectDeployResponse, ProjectDeployCancelledError } from '../rest/projects.js'
Expand Down Expand Up @@ -186,6 +186,7 @@ export default class Deploy extends AuthCommand {
playwrightChecks: checklyConfig.checks?.playwrightChecks,
})
const repoInfo = getGitInformation(project.repoUrl)
const repoRoot = getGitRepoRoot()

this.style.actionSuccess()

Expand Down Expand Up @@ -254,7 +255,7 @@ export default class Deploy extends AuthCommand {
}
}

const projectPayload = projectBundle.synthesize()
const projectPayload = projectBundle.synthesize({ repoRoot })
if (!projectPayload.resources.length) {
if (preview) {
this.log('\nNo checks were detected. More information on how to set up a Checkly CLI project is available at https://checklyhq.com/docs/cli/.\n')
Expand Down
118 changes: 118 additions & 0 deletions packages/cli/src/constructs/__tests__/project-bundle.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import path from 'node:path'

import { describe, it, expect, beforeEach } from 'vitest'

import { EmailAlertChannel } from '../email-alert-channel.js'
import { Project } from '../project.js'
import { resolveSourceFile } from '../project-bundle.js'
import { Session } from '../session.js'
import { Bundler } from '../../services/check-parser/bundler.js'

const repoRoot = path.resolve('/home/user/repo')

async function bundleProject () {
const bundler = await Bundler.create({ cacheHash: 'foo' })
return Session.project!.bundle(bundler)
}

describe('resolveSourceFile()', () => {
it('returns the file relative to the repository root', () => {
expect(resolveSourceFile(repoRoot, path.join(repoRoot, 'src', '__checks__', 'api.check.ts')))
.toBe('src/__checks__/api.check.ts')
})

it('is undefined without a repository root', () => {
expect(resolveSourceFile(undefined, path.join(repoRoot, 'api.check.ts'))).toBeUndefined()
})

it('is undefined without a declaring file', () => {
expect(resolveSourceFile(repoRoot, undefined)).toBeUndefined()
})

it('is undefined for a file outside the repository', () => {
expect(resolveSourceFile(repoRoot, path.resolve('/home/user/elsewhere/api.check.ts'))).toBeUndefined()
expect(resolveSourceFile(repoRoot, repoRoot)).toBeUndefined()
})

it('keeps a repository entry whose name merely starts with two dots', () => {
expect(resolveSourceFile(repoRoot, path.join(repoRoot, '..dotted', 'api.check.ts')))
.toBe('..dotted/api.check.ts')
})

it('uses posix separators for Windows paths', () => {
expect(resolveSourceFile('C:\\repo', 'C:\\repo\\src\\api.check.ts', path.win32))
.toBe('src/api.check.ts')
})

it('is undefined for a file on another Windows drive', () => {
expect(resolveSourceFile('C:\\repo', 'D:\\other\\api.check.ts', path.win32)).toBeUndefined()
})
})

describe('ProjectBundle.synthesize()', () => {
beforeEach(() => {
Session.reset()
Session.project = new Project('project-id', {
name: 'Test Project',
repoUrl: 'https://github.com/checkly/checkly-cli',
})
})

function declareAlertChannel (logicalId: string, checkFileAbsolutePath: string | undefined) {
Session.checkFileAbsolutePath = checkFileAbsolutePath
try {
return new EmailAlertChannel(logicalId, { address: 'alerts@example.com' })
} finally {
Session.checkFileAbsolutePath = undefined
}
}

it('reports each resource\'s source file relative to the repository root', async () => {
declareAlertChannel('email', path.join(repoRoot, 'src', 'alerts', 'email.ts'))

const { resources } = (await bundleProject()).synthesize({ repoRoot })

expect(resources).toEqual([
expect.objectContaining({ logicalId: 'email', sourceFile: 'src/alerts/email.ts' }),
])
})

it('reports the config file for constructs declared in checkly.config.ts', async () => {
declareAlertChannel('email', path.join(repoRoot, 'checkly.config.ts'))

const { resources } = (await bundleProject()).synthesize({ repoRoot })

expect(resources).toEqual([
expect.objectContaining({ logicalId: 'email', sourceFile: 'checkly.config.ts' }),
])
})

it('omits sourceFile without a repository root', async () => {
declareAlertChannel('email', path.join(repoRoot, 'src', 'email.ts'))

const { resources } = (await bundleProject()).synthesize()

expect(resources).toHaveLength(1)
expect(resources[0]).not.toHaveProperty('sourceFile')
})

it('omits sourceFile for constructs declared outside the repository', async () => {
declareAlertChannel('outside', path.resolve('/home/user/elsewhere/email.ts'))
declareAlertChannel('unknown', undefined)

const { resources } = (await bundleProject()).synthesize({ repoRoot })

expect(resources).toHaveLength(2)
for (const resource of resources) {
expect(resource).not.toHaveProperty('sourceFile')
}
})

it('keeps sourceFile on the envelope rather than in the payload', async () => {
declareAlertChannel('email', path.join(repoRoot, 'src', 'email.ts'))

const { resources } = (await bundleProject()).synthesize({ repoRoot })

expect(resources[0].payload).not.toHaveProperty('sourceFile')
})
})
Loading