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
2 changes: 1 addition & 1 deletion packages/cli/src/ai-context/references/configure.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ Run `npx checkly skills manage plan` for the full reference.

- Deploy checks using the `npx checkly deploy` command. Use `--output` to see the created, updated, and deleted resources. Use `--verbose` to also include each resource's name and physical ID (UUID), which is useful for programmatically referencing deployed resources (e.g. `npx checkly checks get <id>`).
- Use `--preview` to see which resources a deploy would create, update, delete or keep, without applying it: an overview of every resource the deploy touches and a diff of each updated resource's construct as deployed against as in code. A plain interactive `checkly deploy` prints that same preview before asking the user to apply the changes or cancel. The machine-readable forms (`--dry-run`, and the `confirmation_required` envelope) additionally carry the individual properties that would change.
- When the preview shows a resource that was edited outside the project (in the web app or through the API), an interactive `checkly deploy` offers a third choice next to apply and cancel: update the code with the values from Checkly and deploy nothing. It rewrites literal values (strings, numbers, booleans, and arrays or objects of those, including `runtimeId` on runtime checks and groups and the `request` of every check and monitor type) and the helper-spelled properties `frequency` (`Frequency.EVERY_5M`), `retryStrategy` (`RetryStrategyBuilder`), `alertEscalationPolicy` (`AlertEscalationBuilder`) and `assertions` (the class's assertion builder) inside the `new ApiCheck('id', { … })` call of checks and check groups, adds a helper's import when the file lacks it, leaves the rest of the file untouched, and lists everything it could not update with the reason (references to other resources, secrets, scripts, a helper call holding a variable, `doubleCheck` set beside a retry strategy, a check moved to the global alert policy, which needs `alertEscalationPolicy` removed by hand). It exists only in a terminal; there is no flag for it, and the `confirmation_required` envelope is unchanged.
- When the preview shows a resource that was edited outside the project (in the web app or through the API), an interactive `checkly deploy` offers a third choice next to apply and cancel: update the code with the values from Checkly and deploy nothing. It rewrites literal values (strings, numbers, booleans, and arrays or objects of those, including `runtimeId` on runtime checks and groups, the `request` of every check and monitor type, `sslCheckDomain` and `aiAutoRepairEnabled` on browser checks, `aiAutoRepairEnabled` on multistep checks and `prompt` on agentic checks; a multi-line string over a template literal is written as a template literal) and the helper-spelled properties `frequency` (`Frequency.EVERY_5M`), `retryStrategy` (`RetryStrategyBuilder`), `alertEscalationPolicy` (`AlertEscalationBuilder`) and `assertions` (the class's assertion builder) inside the `new ApiCheck('id', { … })` call of checks and check groups, adds a helper's import when the file lacks it, leaves the rest of the file untouched, and lists everything it could not update with the reason (references to other resources, secrets, scripts, an incident trigger, a helper call holding a variable, `doubleCheck` set beside a retry strategy, a check moved to the global alert policy, which needs `alertEscalationPolicy` removed by hand). It exists only in a terminal; there is no flag for it, and the `confirmation_required` envelope is unchanged.
- Use `--skip-plan` to deploy without asking Checkly for a plan: nothing is previewed and no plan token is used, so the deploy applies whatever the account looks like when it runs. Resources to delete are still listed before the confirmation, but the code bundle is uploaded before it rather than after. Incompatible with `--preview`, `--dry-run`, `--plan-token` and `--prune-relations`. Prefer a planned deploy unless the plan itself is the problem.
- Use `--prune-relations` to also delete the alert channel subscriptions and private location assignments on this project's checks and groups that the project does not manage. Without it they are only reported.

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'

import { applyEdits } from '../apply-edits.js'
import { detectStyle, evaluateLiteral, isPlainLiteral, resolvePath } from '../literal-edit.js'
import { detectStyle, evaluateLiteral, isPlainLiteral, resolvePath, templateLiteral } from '../literal-edit.js'
import { findConstructOptions, parseSource, WriteBackSkipped } from '../source-file.js'

/**
Expand Down Expand Up @@ -462,3 +462,12 @@ new ApiCheck('api', { name: 'x', tags: [
expect(readsBack('a.ts', nested.text, ['request'], { url: 'u' })).toBe(true)
})
})

describe('templateLiteral', () => {
it('escapes what a template would read differently, and refuses what it cannot carry', () => {
expect(templateLiteral('a\nb')).toBe('`a\nb`')
expect(templateLiteral('`${x}` \\n\tend')).toBe('`\\`\\${x}\\` \\\\n\tend`')
expect(templateLiteral('a\r\nb')).toBeUndefined()
expect(templateLiteral('a\u0000b')).toBeUndefined()
})
})
76 changes: 75 additions & 1 deletion packages/cli/src/services/write-back/__tests__/plan.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import { ApiCheck } from '../../../constructs/api-check.js'
import { BrowserCheck } from '../../../constructs/browser-check.js'
import { MultiStepCheck } from '../../../constructs/multi-step-check.js'
import { DnsMonitor } from '../../../constructs/dns-monitor.js'
import { IcmpMonitor } from '../../../constructs/icmp-monitor.js'
import { SslMonitor } from '../../../constructs/ssl-monitor.js'
Expand All @@ -24,7 +25,7 @@ import { CheckGroupV1 } from '../../../constructs/check-group-v1.js'
import { GrpcMonitor } from '../../../constructs/grpc-monitor.js'
import { Monitor } from '../../../constructs/monitor.js'
import * as literalEdit from '../literal-edit.js'
import { applyWriteBack, type ConstructClass, planWriteBack, type Rule, RULES_BY_CLASS } from '../plan.js'
import { applyWriteBack, type ConstructClass, planWriteBack, type Rule, RULES_BY_CLASS, WRITTEN_BY_CLASS } from '../plan.js'
import { AGENTIC_CHECK_OMITTED_PROPS } from '../../../constructs/internal/agentic-check-defaults.js'
import { PLAYWRIGHT_CHECK_OMITTED_PROPS } from '../../../constructs/playwright-check-codegen.js'
import { CheckGroupV2 } from '../../../constructs/check-group-v2.js'
Expand Down Expand Up @@ -877,6 +878,79 @@ new CheckGroupV1('own', { name: 'Own', alertEscalationPolicy: AlertEscalationBui
}
})

it('writes exactly the top-level keys each class declares', () => {
// The build-time assertion in plan.ts holds every props type to its
// class's written list; this holds the rule table to the same list.
for (const [cls, rules] of RULES_BY_CLASS) {
const targets = new Set(rules.filter(rule => rule.companion === undefined).map(rule => rule.target[0]))
expect([...targets].sort(), cls.name).toEqual([...new Set(WRITTEN_BY_CLASS.get(cls))].sort())
}
expect(WRITTEN_BY_CLASS.size).toBe(RULES_BY_CLASS.size)
})

it('writes the literal props of browser, multistep and agentic checks', async () => {
await declare('literal.check.ts', `import { AgenticCheck, BrowserCheck, MultiStepCheck } from 'checkly/constructs'
new BrowserCheck('browser', { name: 'Browser', sslCheckDomain: 'example.com', code: { content: '' } })
new MultiStepCheck('multi', { name: 'Multi', aiAutoRepairEnabled: false, code: { content: '' } })
new AgenticCheck('short', { name: 'Short', prompt: 'Check the login page' })
new AgenticCheck('long', {
name: 'Long',
prompt: \`Open the page.
Log in.\`,
})
new AgenticCheck('crlf', { name: 'CRLF', prompt: \`one
two\` })
`, () => {
new BrowserCheck('browser', { name: 'Browser', sslCheckDomain: 'example.com', code: { content: '' } })
new MultiStepCheck('multi', { name: 'Multi', aiAutoRepairEnabled: false, code: { content: '' } })
new AgenticCheck('short', { name: 'Short', prompt: 'Check the login page' } as any)
new AgenticCheck('long', { name: 'Long', prompt: 'Open the page.\nLog in.' } as any)
new AgenticCheck('crlf', { name: 'CRLF', prompt: 'one\ntwo' } as any)
})
const check = (logicalId: string, checkType: string, changes: DiffEntry['changes'], before: object): DiffEntry =>
({ type: 'check', logicalId, action: 'UPDATE', changes, before: { checkType, name: logicalId, ...before }, redactions: [] })
const plan = await planWriteBack({
diff: [
check('browser', 'BROWSER', [
{ path: '/sslCheckDomain', origin: 'remote', before: 'example.com', after: 'www.example.com' },
{ path: '/aiAutoRepairEnabled', origin: 'remote', before: null, after: true },
{ path: '/playwrightConfig/use/baseURL', origin: 'remote', before: null, after: 'https://example.com' },
{ path: '/triggerIncident', origin: 'remote', before: false, after: true },
], { sslCheckDomain: 'www.example.com', aiAutoRepairEnabled: true, playwrightConfig: { use: { baseURL: 'https://example.com' } }, triggerIncident: true }),
check('multi', 'MULTI_STEP', [{ path: '/aiAutoRepairEnabled', origin: 'remote', before: false, after: true }],
{ aiAutoRepairEnabled: true }),
check('short', 'AGENTIC', [{ path: '/prompt', origin: 'remote', before: 'Check the login page', after: 'Check the signup page' }],
{ prompt: 'Check the signup page' }),
check('long', 'AGENTIC', [{ path: '/prompt', origin: 'remote', before: 'Open the page.\nLog in.', after: 'Open `the` ${page}.\nLog in \\ out.' }],
{ prompt: 'Open `the` ${page}.\nLog in \\ out.' }),
check('crlf', 'AGENTIC', [{ path: '/prompt', origin: 'remote', before: 'one\ntwo', after: 'one\r\ntwo' }],
{ prompt: 'one\r\ntwo' }),
],
project,
cwd: dir,
})
expect(plan.skipped).toEqual([
'check browser /playwrightConfig/use/baseURL: this tool does not update the Playwright config yet; '
+ 'set it in checkly.config.ts or on the check by hand',
'check browser /triggerIncident: Checkly does not report the incident trigger\'s settings; edit it by hand',
])
expect(plan.applied.map(line => [line.logicalId, line.property, line.rendered])).toEqual([
['browser', 'sslCheckDomain', '\'www.example.com\''],
['browser', 'aiAutoRepairEnabled', 'true'],
['multi', 'aiAutoRepairEnabled', 'true'],
['short', 'prompt', '\'Check the signup page\''],
// A multi-line prompt over a template literal stays one, escaped; a
// carriage return cannot survive a template, so that one is quoted.
['long', 'prompt', '`Open \\`the\\` \\${page}.\nLog in \\\\ out.`'],
['crlf', 'prompt', '\'one\\r\\ntwo\''],
])
expect(plan.files[0].text).toContain(`new AgenticCheck('long', {
name: 'Long',
prompt: \`Open \\\`the\\\` \\\${page}.
Log in \\\\ out.\`,
})`)
})

it('maps every key of the SSL request onto the construct spelling', () => {
// The one request whose wire shape differs from the construct's; the
// other monitors' lists are checked exhaustively at compile time.
Expand Down
9 changes: 8 additions & 1 deletion packages/cli/src/services/write-back/apply-edits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
memberColumn,
memberName,
renderValue,
templateLiteral,
resolvePath,
type SkippedEdit,
type SourceStyle,
Expand Down Expand Up @@ -264,7 +265,13 @@ function render (
): Rendered {
const at = edit.path.join('.')
if (helper === undefined) {
return { edit, text: renderValue(edit.value, style, { ...layout, at }), form: 'literal', expected: edit.value, needs: [] }
// A multi-line string over a template literal stays one; anything else
// is quoted the way the file quotes.
const template = node?.type === 'TemplateLiteral' && typeof edit.value === 'string' && edit.value.includes('\n')
? templateLiteral(edit.value)
: undefined
const text = template ?? renderValue(edit.value, style, { ...layout, at })
return { edit, text, form: 'literal', expected: edit.value, needs: [] }
}
if (node !== undefined && helper.literalAlternative !== undefined && isPlainLiteral(node)) {
const expected = helper.literalAlternative
Expand Down
14 changes: 14 additions & 0 deletions packages/cli/src/services/write-back/literal-edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,20 @@ export function quoteString (value: string, quote: SourceStyle['quote']): string
return `'${json.slice(1, -1).replace(/\\"/g, '"').replace(/'/g, '\\\'')}'`
}

/**
* `value` as a template literal, for a multi-line string replacing one so
* the author's form is kept; undefined when it cannot read back as the same
* string, which a parser guarantees only for LF and TAB among the control
* characters (CR and CRLF are normalised to LF inside a template).
*/
export function templateLiteral (value: string): string | undefined {
// eslint-disable-next-line no-control-regex
if (/[\u0000-\u0008\u000b-\u001f\u007f]/.test(value)) {
return undefined
}
return '`' + value.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${') + '`'
}

export function renderKey (key: string, quote: SourceStyle['quote']): string {
// A bare `__proto__` in an object literal sets the prototype rather than
// a property; quoted, it is a property like any other.
Expand Down
Loading
Loading