From 67437c65d3a6da1d392a1d132d125b9a241fb028 Mon Sep 17 00:00:00 2001 From: miliberlin Date: Wed, 4 Feb 2026 09:01:41 +0100 Subject: [PATCH 1/8] feat: normalize logicalId [red-71] --- packages/cli/src/constructs/construct.ts | 2 +- packages/cli/src/constructs/project.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/constructs/construct.ts b/packages/cli/src/constructs/construct.ts index 5e466ba33..95d9431ed 100644 --- a/packages/cli/src/constructs/construct.ts +++ b/packages/cli/src/constructs/construct.ts @@ -64,7 +64,7 @@ export abstract class Construct implements Validate, Bundle { * @param member Whether this construct is a member of the project */ constructor (type: string, logicalId: string, physicalId?: string | number, member?: boolean) { - this.logicalId = logicalId + this.logicalId = Session.sanitizeLogicalId(logicalId) this.type = type this.physicalId = physicalId this.member = member ?? true diff --git a/packages/cli/src/constructs/project.ts b/packages/cli/src/constructs/project.ts index 935b2528f..e0d593ba2 100644 --- a/packages/cli/src/constructs/project.ts +++ b/packages/cli/src/constructs/project.ts @@ -293,15 +293,15 @@ export class Session { } } + static sanitizeLogicalId (logicalId: string): string { + return logicalId.replace(/[^A-Za-z0-9_\-/#.]/g, '') + } + static validateCreateConstruct (construct: Construct) { if (typeof construct.logicalId !== 'string') { throw new ValidationError(`The "logicalId" of a ${construct.type} construct must be a string (logicalId=${construct.logicalId} [${typeof construct.logicalId}])`) } - if (!/^[A-Za-z0-9_\-/#.]+$/.test(construct.logicalId)) { - throw new ValidationError(`The "logicalId" can only include the following characters: [A-Za-z0-9_-/#.]. (logicalId='${construct.logicalId}')`) - } - if (construct.type === Project.__checklyType) { // Creating the construct is allowed - We're creating the project. } else if (Session.project) { From 2d1e9929cff53666d9a4ec39ac0f983bac8d355c Mon Sep 17 00:00:00 2001 From: miliberlin Date: Wed, 4 Feb 2026 09:04:26 +0100 Subject: [PATCH 2/8] feat: push test [red-71] --- .../src/constructs/__tests__/project.spec.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 packages/cli/src/constructs/__tests__/project.spec.ts diff --git a/packages/cli/src/constructs/__tests__/project.spec.ts b/packages/cli/src/constructs/__tests__/project.spec.ts new file mode 100644 index 000000000..0220e9bb6 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/project.spec.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest' + +import { Session } from '../project' + +describe('Session.sanitizeLogicalId', () => { + it('should return valid logicalIds unchanged', () => { + expect(Session.sanitizeLogicalId('my-check')).toBe('my-check') + expect(Session.sanitizeLogicalId('my_check')).toBe('my_check') + expect(Session.sanitizeLogicalId('myCheck123')).toBe('myCheck123') + expect(Session.sanitizeLogicalId('my/check')).toBe('my/check') + expect(Session.sanitizeLogicalId('my#check')).toBe('my#check') + expect(Session.sanitizeLogicalId('my.check')).toBe('my.check') + }) + + it('should strip spaces from logicalIds', () => { + expect(Session.sanitizeLogicalId('my check')).toBe('mycheck') + expect(Session.sanitizeLogicalId('my check name')).toBe('mycheckname') + }) + + it('should strip special characters from logicalIds', () => { + expect(Session.sanitizeLogicalId('my(check)')).toBe('mycheck') + expect(Session.sanitizeLogicalId('my@check!')).toBe('mycheck') + expect(Session.sanitizeLogicalId('my$check%name')).toBe('mycheckname') + }) + + it('should handle mixed valid and invalid characters', () => { + expect(Session.sanitizeLogicalId('my-check (test)')).toBe('my-checktest') + expect(Session.sanitizeLogicalId('API Check #1')).toBe('APICheck#1') + }) + + it('should return empty string for all invalid characters', () => { + expect(Session.sanitizeLogicalId(' ')).toBe('') + expect(Session.sanitizeLogicalId('!@$%^&*()')).toBe('') + }) +}) From 69a89f2a76501f6a0f3979f437617a99f72962cf Mon Sep 17 00:00:00 2001 From: miliberlin Date: Thu, 5 Feb 2026 11:03:41 +0100 Subject: [PATCH 3/8] feat: prompt user confirmation for sanitized logicalIds [red-71] Instead of silently sanitizing logicalIds with invalid characters, prompt the user for confirmation before proceeding. This makes it clear what changes will be applied when syncing with Checkly. - Track sanitized logicalIds in Session - Add confirmation prompt in deploy, test, and pw-test commands - Explain that source files won't be modified - Show valid character set to help users fix their config - Fix Project constructor to use sanitized logicalId from Construct Co-Authored-By: Claude Opus 4.5 --- packages/cli/src/commands/deploy.ts | 24 ++++++++++- packages/cli/src/commands/pw-test.ts | 24 +++++++++++ packages/cli/src/commands/test.ts | 24 +++++++++++ .../src/constructs/__tests__/project.spec.ts | 41 ++++++++++++++++++- packages/cli/src/constructs/construct.ts | 2 +- packages/cli/src/constructs/project.ts | 24 +++++++++-- packages/cli/src/services/project-parser.ts | 4 ++ 7 files changed, 136 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/commands/deploy.ts b/packages/cli/src/commands/deploy.ts index 1bef80f7f..a5541ea95 100644 --- a/packages/cli/src/commands/deploy.ts +++ b/packages/cli/src/commands/deploy.ts @@ -10,7 +10,7 @@ import type { Runtime } from '../rest/runtimes' import { Check, AlertChannelSubscription, AlertChannel, CheckGroup, Dashboard, MaintenanceWindow, PrivateLocation, PrivateLocationCheckAssignment, PrivateLocationGroupAssignment, - Project, ProjectData, Diagnostics, + Project, ProjectData, Diagnostics, Session, } from '../constructs' import chalk from 'chalk' import { splitConfigFilePath, getGitInformation } from '../services/util' @@ -120,6 +120,28 @@ export default class Deploy extends AuthCommand { this.style.actionSuccess() + // Check for sanitized logicalIds and prompt user for confirmation + const sanitizedIds = Session.getSanitizedLogicalIds() + if (sanitizedIds.length > 0 && !force) { + this.log(chalk.yellow('\nThe following logicalIds contain invalid characters and will be sanitized:\n')) + for (const { constructType, original, sanitized } of sanitizedIds) { + this.log(` ${constructType}: "${original}" → "${sanitized}"`) + } + this.log('') + this.log(chalk.dim('Your source files will not be modified. The sanitized logicalIds will only')) + this.log(chalk.dim('be used when syncing with Checkly. To avoid this warning, update your')) + this.log(chalk.dim('configuration to use valid characters (A-Z, a-z, 0-9, _ - / # .).\n')) + const { confirm } = await prompts({ + name: 'confirm', + type: 'confirm', + message: 'Do you want to continue with the sanitized logicalIds?', + }) + if (!confirm) { + this.log('Aborted. Please update your configuration to use valid logicalId characters.') + return + } + } + this.style.actionStart('Validating project resources') const diagnostics = new Diagnostics() diff --git a/packages/cli/src/commands/pw-test.ts b/packages/cli/src/commands/pw-test.ts index 1d3aec4ad..a0e76c255 100644 --- a/packages/cli/src/commands/pw-test.ts +++ b/packages/cli/src/commands/pw-test.ts @@ -1,3 +1,5 @@ +import chalk from 'chalk' +import prompts from 'prompts' import { AuthCommand } from './authCommand' import { findPlaywrightConfigPath, @@ -208,6 +210,28 @@ export default class PwTestCommand extends AuthCommand { this.style.actionSuccess() + // Check for sanitized logicalIds and prompt user for confirmation + const sanitizedIds = Session.getSanitizedLogicalIds() + if (sanitizedIds.length > 0) { + this.log(chalk.yellow('\nThe following logicalIds contain invalid characters and will be sanitized:\n')) + for (const { constructType, original, sanitized } of sanitizedIds) { + this.log(` ${constructType}: "${original}" → "${sanitized}"`) + } + this.log('') + this.log(chalk.dim('Your source files will not be modified. The sanitized logicalIds will only')) + this.log(chalk.dim('be used when syncing with Checkly. To avoid this warning, update your')) + this.log(chalk.dim('configuration to use valid characters (A-Z, a-z, 0-9, _ - / # .).\n')) + const { confirm } = await prompts({ + name: 'confirm', + type: 'confirm', + message: 'Do you want to continue with the sanitized logicalIds?', + }) + if (!confirm) { + this.log('Aborted. Please update your configuration to use valid logicalId characters.') + return + } + } + this.style.actionStart('Validating project resources') const diagnostics = new Diagnostics() diff --git a/packages/cli/src/commands/test.ts b/packages/cli/src/commands/test.ts index ed898b4c0..fd562463a 100644 --- a/packages/cli/src/commands/test.ts +++ b/packages/cli/src/commands/test.ts @@ -1,5 +1,7 @@ import { Flags, Args, ux } from '@oclif/core' import indentString from 'indent-string' +import chalk from 'chalk' +import prompts from 'prompts' import * as api from '../rest/api' import config from '../services/config' import { parseProject } from '../services/project-parser' @@ -245,6 +247,28 @@ export default class Test extends AuthCommand { this.style.actionSuccess() + // Check for sanitized logicalIds and prompt user for confirmation + const sanitizedIds = Session.getSanitizedLogicalIds() + if (sanitizedIds.length > 0) { + this.log(chalk.yellow('\nThe following logicalIds contain invalid characters and will be sanitized:\n')) + for (const { constructType, original, sanitized } of sanitizedIds) { + this.log(` ${constructType}: "${original}" → "${sanitized}"`) + } + this.log('') + this.log(chalk.dim('Your source files will not be modified. The sanitized logicalIds will only')) + this.log(chalk.dim('be used when syncing with Checkly. To avoid this warning, update your')) + this.log(chalk.dim('configuration to use valid characters (A-Z, a-z, 0-9, _ - / # .).\n')) + const { confirm } = await prompts({ + name: 'confirm', + type: 'confirm', + message: 'Do you want to continue with the sanitized logicalIds?', + }) + if (!confirm) { + this.log('Aborted. Please update your configuration to use valid logicalId characters.') + return + } + } + this.style.actionStart('Validating project resources') const diagnostics = new Diagnostics() diff --git a/packages/cli/src/constructs/__tests__/project.spec.ts b/packages/cli/src/constructs/__tests__/project.spec.ts index 0220e9bb6..1a76e94c9 100644 --- a/packages/cli/src/constructs/__tests__/project.spec.ts +++ b/packages/cli/src/constructs/__tests__/project.spec.ts @@ -1,8 +1,12 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, beforeEach } from 'vitest' import { Session } from '../project' describe('Session.sanitizeLogicalId', () => { + beforeEach(() => { + Session.clearSanitizedLogicalIds() + }) + it('should return valid logicalIds unchanged', () => { expect(Session.sanitizeLogicalId('my-check')).toBe('my-check') expect(Session.sanitizeLogicalId('my_check')).toBe('my_check') @@ -32,4 +36,39 @@ describe('Session.sanitizeLogicalId', () => { expect(Session.sanitizeLogicalId(' ')).toBe('') expect(Session.sanitizeLogicalId('!@$%^&*()')).toBe('') }) + + it('should track sanitized logicalIds when constructType is provided', () => { + Session.sanitizeLogicalId('my check', 'check') + Session.sanitizeLogicalId('valid-id', 'check') + Session.sanitizeLogicalId('another (invalid)', 'check-group') + + const sanitizedIds = Session.getSanitizedLogicalIds() + expect(sanitizedIds).toHaveLength(2) + expect(sanitizedIds[0]).toEqual({ + constructType: 'check', + original: 'my check', + sanitized: 'mycheck', + }) + expect(sanitizedIds[1]).toEqual({ + constructType: 'check-group', + original: 'another (invalid)', + sanitized: 'anotherinvalid', + }) + }) + + it('should not track sanitized logicalIds when constructType is not provided', () => { + Session.sanitizeLogicalId('my check') + Session.sanitizeLogicalId('another (invalid)') + + const sanitizedIds = Session.getSanitizedLogicalIds() + expect(sanitizedIds).toHaveLength(0) + }) + + it('should clear sanitized logicalIds', () => { + Session.sanitizeLogicalId('my check', 'check') + expect(Session.getSanitizedLogicalIds()).toHaveLength(1) + + Session.clearSanitizedLogicalIds() + expect(Session.getSanitizedLogicalIds()).toHaveLength(0) + }) }) diff --git a/packages/cli/src/constructs/construct.ts b/packages/cli/src/constructs/construct.ts index 95d9431ed..08811d517 100644 --- a/packages/cli/src/constructs/construct.ts +++ b/packages/cli/src/constructs/construct.ts @@ -64,7 +64,7 @@ export abstract class Construct implements Validate, Bundle { * @param member Whether this construct is a member of the project */ constructor (type: string, logicalId: string, physicalId?: string | number, member?: boolean) { - this.logicalId = Session.sanitizeLogicalId(logicalId) + this.logicalId = Session.sanitizeLogicalId(logicalId, type) this.type = type this.physicalId = physicalId this.member = member ?? true diff --git a/packages/cli/src/constructs/project.ts b/packages/cli/src/constructs/project.ts index e0d593ba2..d41fe315a 100644 --- a/packages/cli/src/constructs/project.ts +++ b/packages/cli/src/constructs/project.ts @@ -57,7 +57,6 @@ export type ProjectData = { export class Project extends Construct { name: string repoUrl?: string - logicalId: string testOnlyAllowed = false data: ProjectData = { 'check': {}, @@ -85,7 +84,7 @@ export class Project extends Construct { super(Project.__checklyType, logicalId) this.name = props.name this.repoUrl = props.repoUrl - this.logicalId = logicalId + // logicalId is already set by Construct constructor (with sanitization) } describe (): string { @@ -246,6 +245,7 @@ export class Session { static ignoreDirectoriesMatch: string[] = [] static currentCommand?: 'pw-test' | 'test' | 'deploy' static includeFlagProvided?: boolean + static sanitizedLogicalIds: Array<{ constructType: string, original: string, sanitized: string }> = [] static async loadFile (filePath: string): Promise { const loader = this.loader @@ -293,8 +293,24 @@ export class Session { } } - static sanitizeLogicalId (logicalId: string): string { - return logicalId.replace(/[^A-Za-z0-9_\-/#.]/g, '') + static sanitizeLogicalId (logicalId: string, constructType?: string): string { + const sanitized = logicalId.replace(/[^A-Za-z0-9_\-/#.]/g, '') + if (sanitized !== logicalId && constructType) { + Session.sanitizedLogicalIds.push({ + constructType, + original: logicalId, + sanitized, + }) + } + return sanitized + } + + static clearSanitizedLogicalIds (): void { + Session.sanitizedLogicalIds = [] + } + + static getSanitizedLogicalIds (): Array<{ constructType: string, original: string, sanitized: string }> { + return Session.sanitizedLogicalIds } static validateCreateConstruct (construct: Construct) { diff --git a/packages/cli/src/services/project-parser.ts b/packages/cli/src/services/project-parser.ts index ffe3973cd..8fdc816fa 100644 --- a/packages/cli/src/services/project-parser.ts +++ b/packages/cli/src/services/project-parser.ts @@ -65,6 +65,10 @@ export async function parseProject (opts: ProjectParseOpts): Promise { playwrightChecks, currentCommand, } = opts + + // Clear sanitized logicalIds at the start of parsing + Session.clearSanitizedLogicalIds() + const project = new Project(projectLogicalId, { name: projectName, repoUrl, From 37f35cd56a3342a5587981222d1002dfe3960d66 Mon Sep 17 00:00:00 2001 From: miliberlin Date: Thu, 5 Feb 2026 11:09:41 +0100 Subject: [PATCH 4/8] refactor: extract sanitized logicalId confirmation to helper [red-71] Extract the duplicated confirmation prompt code into a reusable helper function to reduce code duplication across deploy, test, and pw-test commands. Co-Authored-By: Claude Opus 4.5 --- packages/cli/src/commands/deploy.ts | 25 ++-------- packages/cli/src/commands/pw-test.ts | 25 ++-------- packages/cli/src/commands/test.ts | 25 ++-------- .../cli/src/helpers/sanitized-logical-id.ts | 49 +++++++++++++++++++ 4 files changed, 62 insertions(+), 62 deletions(-) create mode 100644 packages/cli/src/helpers/sanitized-logical-id.ts diff --git a/packages/cli/src/commands/deploy.ts b/packages/cli/src/commands/deploy.ts index a5541ea95..d0bf2b48b 100644 --- a/packages/cli/src/commands/deploy.ts +++ b/packages/cli/src/commands/deploy.ts @@ -10,10 +10,11 @@ import type { Runtime } from '../rest/runtimes' import { Check, AlertChannelSubscription, AlertChannel, CheckGroup, Dashboard, MaintenanceWindow, PrivateLocation, PrivateLocationCheckAssignment, PrivateLocationGroupAssignment, - Project, ProjectData, Diagnostics, Session, + Project, ProjectData, Diagnostics, } from '../constructs' import chalk from 'chalk' import { splitConfigFilePath, getGitInformation } from '../services/util' +import { confirmSanitizedLogicalIds } from '../helpers/sanitized-logical-id' import commonMessages from '../messages/common-messages' import { ProjectDeployResponse } from '../rest/projects' import { uploadSnapshots } from '../services/snapshot-service' @@ -121,25 +122,9 @@ export default class Deploy extends AuthCommand { this.style.actionSuccess() // Check for sanitized logicalIds and prompt user for confirmation - const sanitizedIds = Session.getSanitizedLogicalIds() - if (sanitizedIds.length > 0 && !force) { - this.log(chalk.yellow('\nThe following logicalIds contain invalid characters and will be sanitized:\n')) - for (const { constructType, original, sanitized } of sanitizedIds) { - this.log(` ${constructType}: "${original}" → "${sanitized}"`) - } - this.log('') - this.log(chalk.dim('Your source files will not be modified. The sanitized logicalIds will only')) - this.log(chalk.dim('be used when syncing with Checkly. To avoid this warning, update your')) - this.log(chalk.dim('configuration to use valid characters (A-Z, a-z, 0-9, _ - / # .).\n')) - const { confirm } = await prompts({ - name: 'confirm', - type: 'confirm', - message: 'Do you want to continue with the sanitized logicalIds?', - }) - if (!confirm) { - this.log('Aborted. Please update your configuration to use valid logicalId characters.') - return - } + const shouldContinue = await confirmSanitizedLogicalIds(this.log.bind(this), { force }) + if (!shouldContinue) { + return } this.style.actionStart('Validating project resources') diff --git a/packages/cli/src/commands/pw-test.ts b/packages/cli/src/commands/pw-test.ts index a0e76c255..e6fd5a7fa 100644 --- a/packages/cli/src/commands/pw-test.ts +++ b/packages/cli/src/commands/pw-test.ts @@ -1,6 +1,5 @@ -import chalk from 'chalk' -import prompts from 'prompts' import { AuthCommand } from './authCommand' +import { confirmSanitizedLogicalIds } from '../helpers/sanitized-logical-id' import { findPlaywrightConfigPath, getCiInformation, @@ -211,25 +210,9 @@ export default class PwTestCommand extends AuthCommand { this.style.actionSuccess() // Check for sanitized logicalIds and prompt user for confirmation - const sanitizedIds = Session.getSanitizedLogicalIds() - if (sanitizedIds.length > 0) { - this.log(chalk.yellow('\nThe following logicalIds contain invalid characters and will be sanitized:\n')) - for (const { constructType, original, sanitized } of sanitizedIds) { - this.log(` ${constructType}: "${original}" → "${sanitized}"`) - } - this.log('') - this.log(chalk.dim('Your source files will not be modified. The sanitized logicalIds will only')) - this.log(chalk.dim('be used when syncing with Checkly. To avoid this warning, update your')) - this.log(chalk.dim('configuration to use valid characters (A-Z, a-z, 0-9, _ - / # .).\n')) - const { confirm } = await prompts({ - name: 'confirm', - type: 'confirm', - message: 'Do you want to continue with the sanitized logicalIds?', - }) - if (!confirm) { - this.log('Aborted. Please update your configuration to use valid logicalId characters.') - return - } + const shouldContinue = await confirmSanitizedLogicalIds(this.log.bind(this)) + if (!shouldContinue) { + return } this.style.actionStart('Validating project resources') diff --git a/packages/cli/src/commands/test.ts b/packages/cli/src/commands/test.ts index fd562463a..b99dda505 100644 --- a/packages/cli/src/commands/test.ts +++ b/packages/cli/src/commands/test.ts @@ -1,8 +1,7 @@ import { Flags, Args, ux } from '@oclif/core' import indentString from 'indent-string' -import chalk from 'chalk' -import prompts from 'prompts' import * as api from '../rest/api' +import { confirmSanitizedLogicalIds } from '../helpers/sanitized-logical-id' import config from '../services/config' import { parseProject } from '../services/project-parser' import { @@ -248,25 +247,9 @@ export default class Test extends AuthCommand { this.style.actionSuccess() // Check for sanitized logicalIds and prompt user for confirmation - const sanitizedIds = Session.getSanitizedLogicalIds() - if (sanitizedIds.length > 0) { - this.log(chalk.yellow('\nThe following logicalIds contain invalid characters and will be sanitized:\n')) - for (const { constructType, original, sanitized } of sanitizedIds) { - this.log(` ${constructType}: "${original}" → "${sanitized}"`) - } - this.log('') - this.log(chalk.dim('Your source files will not be modified. The sanitized logicalIds will only')) - this.log(chalk.dim('be used when syncing with Checkly. To avoid this warning, update your')) - this.log(chalk.dim('configuration to use valid characters (A-Z, a-z, 0-9, _ - / # .).\n')) - const { confirm } = await prompts({ - name: 'confirm', - type: 'confirm', - message: 'Do you want to continue with the sanitized logicalIds?', - }) - if (!confirm) { - this.log('Aborted. Please update your configuration to use valid logicalId characters.') - return - } + const shouldContinue = await confirmSanitizedLogicalIds(this.log.bind(this)) + if (!shouldContinue) { + return } this.style.actionStart('Validating project resources') diff --git a/packages/cli/src/helpers/sanitized-logical-id.ts b/packages/cli/src/helpers/sanitized-logical-id.ts new file mode 100644 index 000000000..b48c9f068 --- /dev/null +++ b/packages/cli/src/helpers/sanitized-logical-id.ts @@ -0,0 +1,49 @@ +import chalk from 'chalk' +import prompts from 'prompts' +import { Session } from '../constructs' + +interface LogFunction { + (message: string): void +} + +/** + * Checks if any logicalIds were sanitized during parsing and prompts the user + * for confirmation before proceeding. + * + * @param log - Function to log messages (e.g., this.log from a command) + * @param options - Optional configuration + * @param options.force - If true, skip the confirmation prompt + * @returns true if the operation should continue, false if aborted + */ +export async function confirmSanitizedLogicalIds ( + log: LogFunction, + options: { force?: boolean } = {}, +): Promise { + const sanitizedIds = Session.getSanitizedLogicalIds() + + if (sanitizedIds.length === 0 || options.force) { + return true + } + + log(chalk.yellow('\nThe following logicalIds contain invalid characters and will be sanitized:\n')) + for (const { constructType, original, sanitized } of sanitizedIds) { + log(` ${constructType}: "${original}" → "${sanitized}"`) + } + log('') + log(chalk.dim('Your source files will not be modified. The sanitized logicalIds will only')) + log(chalk.dim('be used when syncing with Checkly. To avoid this warning, update your')) + log(chalk.dim('configuration to use valid characters (A-Z, a-z, 0-9, _ - / # .).\n')) + + const { confirm } = await prompts({ + name: 'confirm', + type: 'confirm', + message: 'Do you want to continue with the sanitized logicalIds?', + }) + + if (!confirm) { + log('Aborted. Please update your configuration to use valid logicalId characters.') + return false + } + + return true +} From 87da1b8dc79526eb3d15c034d5184d5d83120e45 Mon Sep 17 00:00:00 2001 From: miliberlin Date: Tue, 17 Feb 2026 11:32:23 +0100 Subject: [PATCH 5/8] fix: validate logicalId via diagnostics instead of throwing at construction time Move logicalId character validation from Session.validateCreateConstruct (which threw a hard error at parse time) to Construct.validate() (which reports diagnostics). This lets users see all invalid logicalIds at once instead of fixing them one at a time. Also extracts LOGICAL_ID_PATTERN to constants.ts and converts CheckTypes to a named export. Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/commands/deploy.ts | 7 -- packages/cli/src/commands/import/plan.ts | 3 +- packages/cli/src/commands/pw-test.ts | 7 -- packages/cli/src/commands/test.ts | 7 -- packages/cli/src/constants.ts | 4 +- .../src/constructs/__tests__/project.spec.ts | 101 ++++++++---------- packages/cli/src/constructs/check-group-v1.ts | 2 +- packages/cli/src/constructs/construct.ts | 13 ++- .../cli/src/constructs/heartbeat-monitor.ts | 2 +- .../cli/src/constructs/multi-step-check.ts | 2 +- packages/cli/src/constructs/project.ts | 23 ---- .../cli/src/helpers/sanitized-logical-id.ts | 49 --------- packages/cli/src/services/project-parser.ts | 3 - 13 files changed, 63 insertions(+), 160 deletions(-) delete mode 100644 packages/cli/src/helpers/sanitized-logical-id.ts diff --git a/packages/cli/src/commands/deploy.ts b/packages/cli/src/commands/deploy.ts index 7d773c0a5..f7c344667 100644 --- a/packages/cli/src/commands/deploy.ts +++ b/packages/cli/src/commands/deploy.ts @@ -13,7 +13,6 @@ import { } from '../constructs' import chalk from 'chalk' import { splitConfigFilePath, getGitInformation } from '../services/util' -import { confirmSanitizedLogicalIds } from '../helpers/sanitized-logical-id' import commonMessages from '../messages/common-messages' import { ProjectDeployResponse } from '../rest/projects' import { uploadSnapshots } from '../services/snapshot-service' @@ -122,12 +121,6 @@ export default class Deploy extends AuthCommand { this.style.actionSuccess() - // Check for sanitized logicalIds and prompt user for confirmation - const shouldContinue = await confirmSanitizedLogicalIds(this.log.bind(this), { force }) - if (!shouldContinue) { - return - } - this.style.actionStart('Validating project resources') const diagnostics = new Diagnostics() diff --git a/packages/cli/src/commands/import/plan.ts b/packages/cli/src/commands/import/plan.ts index 0e912fd19..12629626e 100644 --- a/packages/cli/src/commands/import/plan.ts +++ b/packages/cli/src/commands/import/plan.ts @@ -8,6 +8,7 @@ import chalk from 'chalk' import logSymbols from 'log-symbols' import { validate as validateUuid } from 'uuid' +import { LOGICAL_ID_PATTERN } from '../../constants' import * as api from '../../rest/api' import { AuthCommand } from '../authCommand' import commonMessages from '../../messages/common-messages' @@ -526,7 +527,7 @@ ${chalk.cyan('For safety, resources are not deletable until the plan has been co message: 'How would you like your project to be identified?', initial: suggested, validate: input => { - if (!/^[A-Za-z0-9_\-/#.]+$/.test(input)) { + if (!LOGICAL_ID_PATTERN.test(input)) { return `Please only use ASCII letters, numbers, and the ` + `symbols _, -, /, #, and .` } diff --git a/packages/cli/src/commands/pw-test.ts b/packages/cli/src/commands/pw-test.ts index bb9673c56..8970b1489 100644 --- a/packages/cli/src/commands/pw-test.ts +++ b/packages/cli/src/commands/pw-test.ts @@ -1,5 +1,4 @@ import { AuthCommand } from './authCommand' -import { confirmSanitizedLogicalIds } from '../helpers/sanitized-logical-id' import { findPlaywrightConfigPath, getCiInformation, @@ -218,12 +217,6 @@ export default class PwTestCommand extends AuthCommand { this.style.actionSuccess() - // Check for sanitized logicalIds and prompt user for confirmation - const shouldContinue = await confirmSanitizedLogicalIds(this.log.bind(this)) - if (!shouldContinue) { - return - } - this.style.actionStart('Validating project resources') const diagnostics = new Diagnostics() diff --git a/packages/cli/src/commands/test.ts b/packages/cli/src/commands/test.ts index e31007a35..e7b65a0a1 100644 --- a/packages/cli/src/commands/test.ts +++ b/packages/cli/src/commands/test.ts @@ -1,7 +1,6 @@ import { Flags, Args, ux } from '@oclif/core' import indentString from 'indent-string' import * as api from '../rest/api' -import { confirmSanitizedLogicalIds } from '../helpers/sanitized-logical-id' import config from '../services/config' import { parseProject } from '../services/project-parser' import { @@ -247,12 +246,6 @@ export default class Test extends AuthCommand { this.style.actionSuccess() - // Check for sanitized logicalIds and prompt user for confirmation - const shouldContinue = await confirmSanitizedLogicalIds(this.log.bind(this)) - if (!shouldContinue) { - return - } - this.style.actionStart('Validating project resources') const diagnostics = new Diagnostics() diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index 327bf8433..7fcdef2c4 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -1,8 +1,8 @@ -const CheckTypes = { +export const CheckTypes = { API: 'API', BROWSER: 'BROWSER', HEARTBEAT: 'HEARTBEAT', MULTI_STEP: 'MULTI_STEP', } -export default CheckTypes +export const LOGICAL_ID_PATTERN = /^[A-Za-z0-9_\-/#.]+$/ diff --git a/packages/cli/src/constructs/__tests__/project.spec.ts b/packages/cli/src/constructs/__tests__/project.spec.ts index 1a76e94c9..60873d9ee 100644 --- a/packages/cli/src/constructs/__tests__/project.spec.ts +++ b/packages/cli/src/constructs/__tests__/project.spec.ts @@ -1,74 +1,65 @@ import { describe, it, expect, beforeEach } from 'vitest' import { Session } from '../project' +import { Construct } from '../construct' +import { Diagnostics } from '../diagnostics' +import { InvalidPropertyValueDiagnostic } from '../construct-diagnostics' -describe('Session.sanitizeLogicalId', () => { - beforeEach(() => { - Session.clearSanitizedLogicalIds() - }) +class TestConstruct extends Construct { + constructor (logicalId: string) { + super('test', logicalId) + } - it('should return valid logicalIds unchanged', () => { - expect(Session.sanitizeLogicalId('my-check')).toBe('my-check') - expect(Session.sanitizeLogicalId('my_check')).toBe('my_check') - expect(Session.sanitizeLogicalId('myCheck123')).toBe('myCheck123') - expect(Session.sanitizeLogicalId('my/check')).toBe('my/check') - expect(Session.sanitizeLogicalId('my#check')).toBe('my#check') - expect(Session.sanitizeLogicalId('my.check')).toBe('my.check') - }) + describe (): string { + return `Test:${this.logicalId}` + } - it('should strip spaces from logicalIds', () => { - expect(Session.sanitizeLogicalId('my check')).toBe('mycheck') - expect(Session.sanitizeLogicalId('my check name')).toBe('mycheckname') - }) + synthesize () { + return null + } +} - it('should strip special characters from logicalIds', () => { - expect(Session.sanitizeLogicalId('my(check)')).toBe('mycheck') - expect(Session.sanitizeLogicalId('my@check!')).toBe('mycheck') - expect(Session.sanitizeLogicalId('my$check%name')).toBe('mycheckname') +describe('Construct logicalId validation', () => { + beforeEach(() => { + Session.reset() + Session.project = { addResource: () => {} } as any }) - it('should handle mixed valid and invalid characters', () => { - expect(Session.sanitizeLogicalId('my-check (test)')).toBe('my-checktest') - expect(Session.sanitizeLogicalId('API Check #1')).toBe('APICheck#1') + it('should pass validation for valid logicalIds', async () => { + const validIds = ['my-check', 'my_check', 'myCheck123', 'my/check', 'my#check', 'my.check'] + for (const id of validIds) { + const construct = new TestConstruct(id) + const diagnostics = new Diagnostics() + await construct.validate(diagnostics) + expect(diagnostics.isFatal(), `Expected "${id}" to be valid`).toBe(false) + } }) - it('should return empty string for all invalid characters', () => { - expect(Session.sanitizeLogicalId(' ')).toBe('') - expect(Session.sanitizeLogicalId('!@$%^&*()')).toBe('') + it('should store the original logicalId without modification', () => { + const construct = new TestConstruct('my check') + expect(construct.logicalId).toBe('my check') }) - it('should track sanitized logicalIds when constructType is provided', () => { - Session.sanitizeLogicalId('my check', 'check') - Session.sanitizeLogicalId('valid-id', 'check') - Session.sanitizeLogicalId('another (invalid)', 'check-group') - - const sanitizedIds = Session.getSanitizedLogicalIds() - expect(sanitizedIds).toHaveLength(2) - expect(sanitizedIds[0]).toEqual({ - constructType: 'check', - original: 'my check', - sanitized: 'mycheck', - }) - expect(sanitizedIds[1]).toEqual({ - constructType: 'check-group', - original: 'another (invalid)', - sanitized: 'anotherinvalid', - }) + it('should produce an error diagnostic for logicalIds with spaces', async () => { + const construct = new TestConstruct('my check') + const diagnostics = new Diagnostics() + await construct.validate(diagnostics) + expect(diagnostics.isFatal()).toBe(true) + expect(diagnostics.observations).toHaveLength(1) + expect(diagnostics.observations[0]).toBeInstanceOf(InvalidPropertyValueDiagnostic) }) - it('should not track sanitized logicalIds when constructType is not provided', () => { - Session.sanitizeLogicalId('my check') - Session.sanitizeLogicalId('another (invalid)') - - const sanitizedIds = Session.getSanitizedLogicalIds() - expect(sanitizedIds).toHaveLength(0) + it('should produce an error diagnostic for logicalIds with special characters', async () => { + const construct = new TestConstruct('my@check!') + const diagnostics = new Diagnostics() + await construct.validate(diagnostics) + expect(diagnostics.isFatal()).toBe(true) }) - it('should clear sanitized logicalIds', () => { - Session.sanitizeLogicalId('my check', 'check') - expect(Session.getSanitizedLogicalIds()).toHaveLength(1) - - Session.clearSanitizedLogicalIds() - expect(Session.getSanitizedLogicalIds()).toHaveLength(0) + it('should produce an error diagnostic for empty logicalIds', async () => { + const construct = new TestConstruct('') + const diagnostics = new Diagnostics() + await construct.validate(diagnostics) + expect(diagnostics.isFatal()).toBe(true) }) }) diff --git a/packages/cli/src/constructs/check-group-v1.ts b/packages/cli/src/constructs/check-group-v1.ts index 0e4862abf..63e06d3d1 100644 --- a/packages/cli/src/constructs/check-group-v1.ts +++ b/packages/cli/src/constructs/check-group-v1.ts @@ -15,7 +15,7 @@ import { import { AlertEscalation } from './alert-escalation-policy' import { Diagnostics } from './diagnostics' import { DeprecatedConstructDiagnostic, DeprecatedPropertyDiagnostic, InvalidPropertyValueDiagnostic } from './construct-diagnostics' -import CheckTypes from '../constants' +import { CheckTypes } from '../constants' import { CheckConfigDefaults } from '../services/checkly-config-loader' import { AlertChannelSubscription } from './alert-channel-subscription' import { BrowserCheck } from './browser-check' diff --git a/packages/cli/src/constructs/construct.ts b/packages/cli/src/constructs/construct.ts index 08811d517..9a582fbbd 100644 --- a/packages/cli/src/constructs/construct.ts +++ b/packages/cli/src/constructs/construct.ts @@ -1,5 +1,7 @@ import path from 'node:path' +import { LOGICAL_ID_PATTERN } from '../constants' +import { InvalidPropertyValueDiagnostic } from './construct-diagnostics' import { Diagnostics } from './diagnostics' import { Session } from './project' import { Ref } from './ref' @@ -64,7 +66,7 @@ export abstract class Construct implements Validate, Bundle { * @param member Whether this construct is a member of the project */ constructor (type: string, logicalId: string, physicalId?: string | number, member?: boolean) { - this.logicalId = Session.sanitizeLogicalId(logicalId, type) + this.logicalId = logicalId this.type = type this.physicalId = physicalId this.member = member ?? true @@ -123,9 +125,14 @@ export abstract class Construct implements Validate, Bundle { * @param diagnostics The Diagnostics instance that any issues should be added to. * @returns A Promise that resolves when validation is complete. */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars, require-await + // eslint-disable-next-line require-await async validate (diagnostics: Diagnostics): Promise { - return + if (!LOGICAL_ID_PATTERN.test(this.logicalId)) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'logicalId', + new Error(`The logicalId "${this.logicalId}" contains invalid characters. Only A-Z, a-z, 0-9, _, -, /, #, and . are allowed.`), + )) + } } /** diff --git a/packages/cli/src/constructs/heartbeat-monitor.ts b/packages/cli/src/constructs/heartbeat-monitor.ts index 951a6b569..08b8c0f9a 100644 --- a/packages/cli/src/constructs/heartbeat-monitor.ts +++ b/packages/cli/src/constructs/heartbeat-monitor.ts @@ -1,7 +1,7 @@ import { Monitor, MonitorProps } from './monitor' import { Session } from './project' import { DateTime } from 'luxon' -import CheckTypes from '../constants' +import { CheckTypes } from '../constants' type TimeUnits = 'seconds' | 'minutes' | 'hours' | 'days' diff --git a/packages/cli/src/constructs/multi-step-check.ts b/packages/cli/src/constructs/multi-step-check.ts index cb2066de8..7791c8260 100644 --- a/packages/cli/src/constructs/multi-step-check.ts +++ b/packages/cli/src/constructs/multi-step-check.ts @@ -3,7 +3,7 @@ import fs from 'node:fs/promises' import { CheckProps, RuntimeCheck, RuntimeCheckProps } from './check' import { Session, SharedFileRef } from './project' import { Content, Entrypoint, isContent, isEntrypoint } from './construct' -import CheckTypes from '../constants' +import { CheckTypes } from '../constants' import { PlaywrightConfig } from './playwright-config' import { Diagnostics } from './diagnostics' import { InvalidPropertyValueDiagnostic, UnsupportedRuntimeFeatureDiagnostic } from './construct-diagnostics' diff --git a/packages/cli/src/constructs/project.ts b/packages/cli/src/constructs/project.ts index 8524be15e..3314eb9ef 100644 --- a/packages/cli/src/constructs/project.ts +++ b/packages/cli/src/constructs/project.ts @@ -87,7 +87,6 @@ export class Project extends Construct { super(Project.__checklyType, logicalId) this.name = props.name this.repoUrl = props.repoUrl - // logicalId is already set by Construct constructor (with sanitization) } describe (): string { @@ -249,7 +248,6 @@ export class Session { static ignoreDirectoriesMatch: string[] = [] static currentCommand?: 'pw-test' | 'test' | 'deploy' static includeFlagProvided?: boolean - static sanitizedLogicalIds: Array<{ constructType: string, original: string, sanitized: string }> = [] static packageManager: PackageManager = npmPackageManager static workspace: Result = Err(new Error(`Workspace support not initialized`)) @@ -272,7 +270,6 @@ export class Session { this.parsers = new Map() this.constructExports = [] this.ignoreDirectoriesMatch = [] - this.sanitizedLogicalIds = [] this.packageManager = npmPackageManager this.workspace = Err(new Error(`Workspace support not initialized`)) this.resetSharedFiles() @@ -324,26 +321,6 @@ export class Session { } } - static sanitizeLogicalId (logicalId: string, constructType?: string): string { - const sanitized = logicalId.replace(/[^A-Za-z0-9_\-/#.]/g, '') - if (sanitized !== logicalId && constructType) { - Session.sanitizedLogicalIds.push({ - constructType, - original: logicalId, - sanitized, - }) - } - return sanitized - } - - static clearSanitizedLogicalIds (): void { - Session.sanitizedLogicalIds = [] - } - - static getSanitizedLogicalIds (): Array<{ constructType: string, original: string, sanitized: string }> { - return Session.sanitizedLogicalIds - } - static validateCreateConstruct (construct: Construct) { if (typeof construct.logicalId !== 'string') { throw new ValidationError(`The "logicalId" of a ${construct.type} construct must be a string (logicalId=${construct.logicalId} [${typeof construct.logicalId}])`) diff --git a/packages/cli/src/helpers/sanitized-logical-id.ts b/packages/cli/src/helpers/sanitized-logical-id.ts deleted file mode 100644 index b48c9f068..000000000 --- a/packages/cli/src/helpers/sanitized-logical-id.ts +++ /dev/null @@ -1,49 +0,0 @@ -import chalk from 'chalk' -import prompts from 'prompts' -import { Session } from '../constructs' - -interface LogFunction { - (message: string): void -} - -/** - * Checks if any logicalIds were sanitized during parsing and prompts the user - * for confirmation before proceeding. - * - * @param log - Function to log messages (e.g., this.log from a command) - * @param options - Optional configuration - * @param options.force - If true, skip the confirmation prompt - * @returns true if the operation should continue, false if aborted - */ -export async function confirmSanitizedLogicalIds ( - log: LogFunction, - options: { force?: boolean } = {}, -): Promise { - const sanitizedIds = Session.getSanitizedLogicalIds() - - if (sanitizedIds.length === 0 || options.force) { - return true - } - - log(chalk.yellow('\nThe following logicalIds contain invalid characters and will be sanitized:\n')) - for (const { constructType, original, sanitized } of sanitizedIds) { - log(` ${constructType}: "${original}" → "${sanitized}"`) - } - log('') - log(chalk.dim('Your source files will not be modified. The sanitized logicalIds will only')) - log(chalk.dim('be used when syncing with Checkly. To avoid this warning, update your')) - log(chalk.dim('configuration to use valid characters (A-Z, a-z, 0-9, _ - / # .).\n')) - - const { confirm } = await prompts({ - name: 'confirm', - type: 'confirm', - message: 'Do you want to continue with the sanitized logicalIds?', - }) - - if (!confirm) { - log('Aborted. Please update your configuration to use valid logicalId characters.') - return false - } - - return true -} diff --git a/packages/cli/src/services/project-parser.ts b/packages/cli/src/services/project-parser.ts index 3e188d021..14ce3c474 100644 --- a/packages/cli/src/services/project-parser.ts +++ b/packages/cli/src/services/project-parser.ts @@ -150,9 +150,6 @@ export async function parseProject (opts: ProjectParseOpts): Promise { enableWorkspaces = true, } = opts - // Clear sanitized logicalIds at the start of parsing - Session.clearSanitizedLogicalIds() - const project = new Project(projectLogicalId, { name: projectName, repoUrl, From f4ae237c5fba388abb1d6e46bb31c67d29819894 Mon Sep 17 00:00:00 2001 From: miliberlin Date: Tue, 17 Feb 2026 11:42:58 +0100 Subject: [PATCH 6/8] fix: restore logicalId property declaration on Project class Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/constructs/project.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/cli/src/constructs/project.ts b/packages/cli/src/constructs/project.ts index 3314eb9ef..8cf1f8e34 100644 --- a/packages/cli/src/constructs/project.ts +++ b/packages/cli/src/constructs/project.ts @@ -60,6 +60,7 @@ export type ProjectData = { export class Project extends Construct { name: string repoUrl?: string + logicalId: string testOnlyAllowed = false data: ProjectData = { 'check': {}, @@ -87,6 +88,7 @@ export class Project extends Construct { super(Project.__checklyType, logicalId) this.name = props.name this.repoUrl = props.repoUrl + this.logicalId = logicalId } describe (): string { From 2dffaa6e85de3e9e29591f9e3cb3a51914b81cb6 Mon Sep 17 00:00:00 2001 From: miliberlin Date: Tue, 17 Feb 2026 14:08:23 +0100 Subject: [PATCH 7/8] fix: update deploy e2e test to use named CheckTypes import Co-Authored-By: Claude Opus 4.6 --- packages/cli/e2e/__tests__/deploy.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/e2e/__tests__/deploy.spec.ts b/packages/cli/e2e/__tests__/deploy.spec.ts index 70a65f61d..7ff0ad6fb 100644 --- a/packages/cli/e2e/__tests__/deploy.spec.ts +++ b/packages/cli/e2e/__tests__/deploy.spec.ts @@ -8,7 +8,7 @@ import { DateTime, Duration } from 'luxon' import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from 'vitest' import Projects from '../../src/rest/projects' -import CheckTypes from '../../src/constants' +import { CheckTypes } from '../../src/constants' import { FixtureSandbox, RunOptions } from '../../src/testing/fixture-sandbox' import { ExecaError } from 'execa' From fe72a467b471fc15a905fbb7b030bd654b4c1706 Mon Sep 17 00:00:00 2001 From: miliberlin Date: Tue, 17 Feb 2026 18:03:19 +0100 Subject: [PATCH 8/8] fix: address PR review feedback on logicalId validation tests Use expect.arrayContaining/objectContaining/stringContaining pattern in tests instead of checking exact diagnostic types. Remove property name prefix from error message per review nit. Co-Authored-By: Claude Opus 4.6 --- .../src/constructs/__tests__/project.spec.ts | 18 +++++++++++++++--- packages/cli/src/constructs/construct.ts | 2 +- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/constructs/__tests__/project.spec.ts b/packages/cli/src/constructs/__tests__/project.spec.ts index 60873d9ee..e36f3bf68 100644 --- a/packages/cli/src/constructs/__tests__/project.spec.ts +++ b/packages/cli/src/constructs/__tests__/project.spec.ts @@ -3,7 +3,6 @@ import { describe, it, expect, beforeEach } from 'vitest' import { Session } from '../project' import { Construct } from '../construct' import { Diagnostics } from '../diagnostics' -import { InvalidPropertyValueDiagnostic } from '../construct-diagnostics' class TestConstruct extends Construct { constructor (logicalId: string) { @@ -45,8 +44,11 @@ describe('Construct logicalId validation', () => { const diagnostics = new Diagnostics() await construct.validate(diagnostics) expect(diagnostics.isFatal()).toBe(true) - expect(diagnostics.observations).toHaveLength(1) - expect(diagnostics.observations[0]).toBeInstanceOf(InvalidPropertyValueDiagnostic) + expect(diagnostics.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('contains invalid characters'), + }), + ])) }) it('should produce an error diagnostic for logicalIds with special characters', async () => { @@ -54,6 +56,11 @@ describe('Construct logicalId validation', () => { const diagnostics = new Diagnostics() await construct.validate(diagnostics) expect(diagnostics.isFatal()).toBe(true) + expect(diagnostics.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('contains invalid characters'), + }), + ])) }) it('should produce an error diagnostic for empty logicalIds', async () => { @@ -61,5 +68,10 @@ describe('Construct logicalId validation', () => { const diagnostics = new Diagnostics() await construct.validate(diagnostics) expect(diagnostics.isFatal()).toBe(true) + expect(diagnostics.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('contains invalid characters'), + }), + ])) }) }) diff --git a/packages/cli/src/constructs/construct.ts b/packages/cli/src/constructs/construct.ts index 9a582fbbd..48b1be679 100644 --- a/packages/cli/src/constructs/construct.ts +++ b/packages/cli/src/constructs/construct.ts @@ -130,7 +130,7 @@ export abstract class Construct implements Validate, Bundle { if (!LOGICAL_ID_PATTERN.test(this.logicalId)) { diagnostics.add(new InvalidPropertyValueDiagnostic( 'logicalId', - new Error(`The logicalId "${this.logicalId}" contains invalid characters. Only A-Z, a-z, 0-9, _, -, /, #, and . are allowed.`), + new Error(`"${this.logicalId}" contains invalid characters. Only A-Z, a-z, 0-9, _, -, /, #, and . are allowed.`), )) } }