Skip to content
Open
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
3 changes: 2 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
"prepare:ai-context": "cross-env CHECKLY_SKIP_AUTH=1 CHECKLY_CLI_VERSION=99.0.0 ./bin/run import plan --root gen --debug-import-plan-input-file ./src/ai-context/context.fixtures.json && jiti ./scripts/prepare-ai-context.ts",
"prepare:dist": "tsc --build",
"prepare": "pnpm run clean && pnpm run prepare:dist && pnpm run prepare:ai-context",
"test": "pnpm pack && vitest --run",
"test": "pnpm pack && pnpm run test:types && vitest --run",
"test:types": "tsc -p tsconfig.type-tests.json --noEmit",
"test:e2e": "pnpm pack && cross-env NODE_CONFIG_DIR=./e2e/config vitest --run -c ./vitest.config.e2e.mts",
"test:e2e:local": "cross-env CHECKLY_BASE_URL=http://localhost:3000 CHECKLY_ENV=local pnpm run test:e2e",
"watch": "tsc --watch"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,16 @@ describe('AgenticCheckCodegen', () => {
expect(source).not.toContain('RetryStrategyBuilder')
})

it('should not emit intent because AgenticCheck does not support it', async () => {
const source = await renderResource(env, baseResource({
intent: {
goal: 'Backend data that must not be exposed on this construct.',
},
}))

expect(source).not.toContain('intent:')
})

it('should not emit `agentRuntime` when `agenticCheckData` is missing', async () => {
const source = await renderResource(env, baseResource())
expect(source).not.toContain('agentRuntime')
Expand Down
30 changes: 30 additions & 0 deletions packages/cli/src/constructs/__tests__/api-check.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,36 @@ describe('ApiCheck', () => {
}))
}, DEFAULT_TEST_TIMEOUT)

it('should synthesize normalized intent from a packed fixture project', async () => {
const output = await parseProject(
fixt,
'--config',
fixt.abspath('test-cases/test-intent/checkly.config.js'),
)

expect(output).toEqual(expect.objectContaining({
diagnostics: expect.objectContaining({
fatal: false,
}),
payload: expect.objectContaining({
resources: expect.arrayContaining([
expect.objectContaining({
logicalId: 'dashboard-intent',
type: 'check',
member: true,
payload: expect.objectContaining({
intent: {
goal: 'Verify that authenticated users can open the dashboard.',
requiredOutcomes: [],
mustPreserve: [],
},
}),
}),
]),
}),
}))
}, DEFAULT_TEST_TIMEOUT)

it('should not synthesize default runtime', async () => {
const output = await parseProject(
fixt,
Expand Down
254 changes: 254 additions & 0 deletions packages/cli/src/constructs/__tests__/check-intent.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'

import { ApiCheck } from '../api-check.js'
import { CheckIntent } from '../check.js'
import { Diagnostics } from '../diagnostics.js'
import { DnsMonitor } from '../dns-monitor.js'
import { PlaywrightCheck } from '../playwright-check.js'
import { Project } from '../project.js'
import { Session } from '../session.js'
import { UrlMonitor } from '../url-monitor.js'

const completeIntent: CheckIntent = {
goal: 'Verify that authenticated users can open the dashboard.',
requiredOutcomes: [
'Authentication succeeds for a valid user.',
'The dashboard displays the account overview.',
],
mustPreserve: [
'Do not remove or weaken the authentication assertion.',
'Do not replace the dashboard assertion with a generic page-load assertion.',
],
}

let nextLogicalId = 0

function apiCheck (intent?: CheckIntent | null): ApiCheck {
return new ApiCheck(`api-intent-${nextLogicalId++}`, {
name: 'Dashboard API',
intent,
request: {
method: 'GET',
url: 'https://example.com/api/dashboard',
},
})
}

async function validateIntent (intent: unknown): Promise<Diagnostics> {
const diagnostics = new Diagnostics()
await apiCheck(intent as CheckIntent).validate(diagnostics)
return diagnostics
}

function messages (diagnostics: Diagnostics): string[] {
return diagnostics.observations.map(observation => observation.message)
}

describe('check intent', () => {
beforeEach(() => {
nextLogicalId = 0
Session.project = new Project('intent-project', {
name: 'Intent Project',
repoUrl: 'https://github.com/checkly/checkly-cli',
})
})

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

describe('synthesis', () => {
it('normalizes omitted intent sections to empty arrays', () => {
const synthesized = apiCheck({
goal: ' Verify that authenticated users can open the dashboard. ',
}).synthesize()

expect(synthesized.intent).toEqual({
goal: 'Verify that authenticated users can open the dashboard.',
requiredOutcomes: [],
mustPreserve: [],
})
})

it('synthesizes complete structured intent', () => {
const check = apiCheck(completeIntent)

expect(check.intent).toBe(completeIntent)
expect(check.synthesize()).toMatchObject({
intent: completeIntent,
})
})

it('synthesizes intent on runtime checks, monitors, and Playwright checks', () => {
const monitor = new UrlMonitor('url-intent', {
name: 'Dashboard URL',
intent: completeIntent,
request: {
url: 'https://example.com/dashboard',
},
})
const playwright = new PlaywrightCheck('playwright-intent', {
name: 'Dashboard browser flow',
intent: completeIntent,
playwrightConfigPath: '/tmp/playwright.config.ts',
})

expect(apiCheck(completeIntent).synthesize()).toHaveProperty('intent', completeIntent)
expect(monitor.intent).toBe(completeIntent)
expect(monitor.synthesize()).toHaveProperty('intent', completeIntent)
expect(playwright.intent).toBe(completeIntent)
expect(playwright.synthesize()).toHaveProperty('intent', completeIntent)
})

it('omits undefined intent so deployments do not take ownership of existing intent', () => {
expect(apiCheck().synthesize()).not.toHaveProperty('intent')
})

it('synthesizes null to explicitly clear intent', () => {
expect(apiCheck(null).synthesize()).toHaveProperty('intent', null)
})

it('uses reassigned runtime-check intent for validation and synthesis', async () => {
const check = apiCheck(completeIntent)
check.intent = { goal: ' ' }

const diagnostics = new Diagnostics()
await check.validate(diagnostics)

expect(messages(diagnostics)).toEqual(expect.arrayContaining([
expect.stringContaining('The intent goal must not be blank.'),
]))

check.intent = { goal: ' Verify the replacement dashboard flow. ' }
expect(check.synthesize()).toHaveProperty('intent', {
goal: 'Verify the replacement dashboard flow.',
requiredOutcomes: [],
mustPreserve: [],
})

check.intent = null
expect(check.synthesize()).toHaveProperty('intent', null)
})

it('uses reassigned monitor intent when synthesizing an explicit clear', () => {
const monitor = new DnsMonitor('dns-intent-reassignment', {
name: 'Dashboard DNS',
intent: completeIntent,
request: {
recordType: 'A',
query: 'example.com',
},
})

monitor.intent = null

expect(monitor.synthesize()).toHaveProperty('intent', null)
})
})

describe('validation', () => {
it('accepts a one-character goal and rejects a blank goal', async () => {
expect((await validateIntent({ goal: 'x' })).isFatal()).toBe(false)

const diagnostics = await validateIntent({ goal: ' \n\t ' })
expect(diagnostics.isFatal()).toBe(true)
expect(messages(diagnostics)).toEqual(expect.arrayContaining([
expect.stringContaining('The intent goal must not be blank.'),
]))
})

it('accepts a 2,000-character goal and rejects a 2,001-character goal', async () => {
expect((await validateIntent({ goal: 'g'.repeat(2_000) })).isFatal()).toBe(false)

const diagnostics = await validateIntent({ goal: 'g'.repeat(2_001) })
expect(diagnostics.isFatal()).toBe(true)
expect(messages(diagnostics)).toEqual(expect.arrayContaining([
expect.stringContaining('The intent goal must be at most 2000 characters after trimming, got 2001.'),
]))
})

it('accepts 20 required outcomes and rejects 21', async () => {
expect((await validateIntent({
goal: 'Verify the dashboard.',
requiredOutcomes: Array.from({ length: 20 }, (_, index) => `Outcome ${index}`),
})).isFatal()).toBe(false)

const diagnostics = await validateIntent({
goal: 'Verify the dashboard.',
requiredOutcomes: Array.from({ length: 21 }, (_, index) => `Outcome ${index}`),
})
expect(messages(diagnostics)).toEqual(expect.arrayContaining([
expect.stringContaining('may contain at most 20 required outcomes, got 21.'),
]))
})

it('accepts 20 must-preserve guardrails and rejects 21', async () => {
expect((await validateIntent({
goal: 'Verify the dashboard.',
mustPreserve: Array.from({ length: 20 }, (_, index) => `Guardrail ${index}`),
})).isFatal()).toBe(false)

const diagnostics = await validateIntent({
goal: 'Verify the dashboard.',
mustPreserve: Array.from({ length: 21 }, (_, index) => `Guardrail ${index}`),
})
expect(messages(diagnostics)).toEqual(expect.arrayContaining([
expect.stringContaining('may contain at most 20 must-preserve guardrails, got 21.'),
]))
})

it.each([
['required outcome', 'requiredOutcomes'],
['must-preserve guardrail', 'mustPreserve'],
] as const)('rejects a blank %s statement', async (label, property) => {
const diagnostics = await validateIntent({
goal: 'Verify the dashboard.',
[property]: [' '],
})
expect(messages(diagnostics)).toEqual(expect.arrayContaining([
expect.stringContaining(`The intent ${label} must not be blank.`),
]))
})

it.each([
['required outcome', 'requiredOutcomes'],
['must-preserve guardrail', 'mustPreserve'],
] as const)('accepts a 1,000-character %s and rejects 1,001 characters', async (label, property) => {
expect((await validateIntent({
goal: 'Verify the dashboard.',
[property]: ['s'.repeat(1_000)],
})).isFatal()).toBe(false)

const diagnostics = await validateIntent({
goal: 'Verify the dashboard.',
[property]: ['s'.repeat(1_001)],
})
expect(messages(diagnostics)).toEqual(expect.arrayContaining([
expect.stringContaining(`The intent ${label} must be at most 1000 characters after trimming, got 1001.`),
]))
})

it('rejects unknown fields instead of silently discarding them', async () => {
const diagnostics = await validateIntent({
goal: 'Verify the dashboard.',
assertion: 'The dashboard is visible.',
})
expect(messages(diagnostics)).toEqual(expect.arrayContaining([
expect.stringContaining('"intent" contains unknown field "assertion".'),
]))
})

it('rejects non-string statements and non-array statement sections', async () => {
const diagnostics = await validateIntent({
goal: 'Verify the dashboard.',
requiredOutcomes: 'The dashboard loads.',
mustPreserve: [42],
})

expect(messages(diagnostics)).toEqual(expect.arrayContaining([
expect.stringContaining('"intent.requiredOutcomes" must be an array of strings.'),
expect.stringContaining('The intent must-preserve guardrail must be a string.'),
]))
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { defineConfig } from 'checkly'

export default defineConfig({
projectName: 'Check Intent Fixture',
logicalId: 'check-intent-fixture',
checks: {
checkMatch: '**/*.check.js',
},
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { ApiCheck } from 'checkly/constructs'

new ApiCheck('dashboard-intent', {
name: 'Dashboard intent',
intent: {
goal: 'Verify that authenticated users can open the dashboard.',
},
request: {
method: 'GET',
url: 'https://example.com/api/dashboard',
},
})
Loading
Loading