From a098a1497bcd19d0f98d82929f72ca32c78090af Mon Sep 17 00:00:00 2001 From: Jon Edvald Date: Tue, 26 Sep 2023 15:17:24 +0200 Subject: [PATCH] feat(config): better error messages around schema validation (#4889) * feat(config): better error messages around schema validation This attaches the YAML parse results to configs when loading, and leverages that when rendering validation error messages. This should be a useful step for an IDE highlighting integration as well. * fix: loosen YAML validation to match prior compatibility (TBS) * test: add test for error in doc in multi-doc YAML (TBS) * chore: switch from peg.js to peggy * chore: change resolveTemplateStrings function signature (TBS) * improvement(config): add yaml source mapping to template string resolution * chore: fix missing dev dependency * chore: fix linting warnings * chore: add try/catch around YAML context annotation * chore: fix errors after merge (TBS) * chore: update `package-lock.json` --------- Co-authored-by: Vladimir Vagaytsev --- core/gulpfile.js | 25 - core/package.json | 9 +- core/src/actions/types.ts | 3 +- core/src/cli/cli.ts | 6 +- core/src/commands/custom.ts | 23 +- core/src/commands/login.ts | 4 +- core/src/commands/logout.ts | 4 +- core/src/commands/workflow.ts | 16 +- core/src/config/base.ts | 71 +- core/src/config/common.ts | 6 + core/src/config/config-template.ts | 9 +- core/src/config/project.ts | 52 +- core/src/config/render-template.ts | 36 +- core/src/config/template-contexts/base.ts | 2 +- core/src/config/validation.ts | 177 +++- core/src/config/workflow.ts | 23 +- core/src/docs/generate.ts | 3 + core/src/exceptions.ts | 7 + core/src/garden.ts | 28 +- core/src/graph/actions.ts | 53 +- core/src/outputs.ts | 15 +- core/src/plugin-context.ts | 2 +- core/src/plugins/kubernetes/system.ts | 3 + core/src/resolve-module.ts | 63 +- core/src/server/commands.ts | 4 +- core/src/tasks/publish.ts | 2 +- core/src/tasks/resolve-action.ts | 50 +- core/src/tasks/resolve-provider.ts | 21 +- core/src/template-string/template-string.ts | 240 +++-- core/src/util/testing.ts | 9 +- .../invalid-syntax-module/garden.yml | 1 + core/test/helpers.ts | 5 +- core/test/unit/src/commands/self-update.ts | 4 + core/test/unit/src/config/base.ts | 300 +++---- core/test/unit/src/config/common.ts | 40 +- core/test/unit/src/config/project.ts | 5 +- core/test/unit/src/config/render-template.ts | 21 +- .../unit/src/config/template-contexts/base.ts | 4 +- .../src/config/template-contexts/project.ts | 13 +- core/test/unit/src/config/validation.ts | 374 ++++++++ core/test/unit/src/config/workflow.ts | 5 +- core/test/unit/src/garden.ts | 88 +- core/test/unit/src/router/base.ts | 6 +- core/test/unit/src/router/deploy.ts | 4 +- core/test/unit/src/router/run.ts | 4 +- core/test/unit/src/template-string.ts | 848 +++++++++++------- package-lock.json | 523 ++++------- .../test/conftest-container.ts | 3 + plugins/conftest/test/conftest.ts | 3 + plugins/pulumi/src/helpers.ts | 2 +- 50 files changed, 2050 insertions(+), 1169 deletions(-) delete mode 100644 core/gulpfile.js create mode 100644 core/test/unit/src/config/validation.ts diff --git a/core/gulpfile.js b/core/gulpfile.js deleted file mode 100644 index bccb4fdf4e..0000000000 --- a/core/gulpfile.js +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright (C) 2018-2023 Garden Technologies, Inc. - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -const { resolve, join } = require("path") - -const gulp = require("gulp") -const pegjs = require("gulp-pegjs") - -const pegjsSources = resolve(__dirname, "src", "template-string", "*.pegjs") -const destDir = resolve(__dirname, "build") - -gulp.task("pegjs", () => - gulp.src(pegjsSources) - .pipe(pegjs({ format: "commonjs" })) - .pipe(gulp.dest(join(destDir, "src", "template-string"))), -) - -gulp.task("pegjs-watch", () => - gulp.watch(pegjsSources, gulp.series(["pegjs"])), -) diff --git a/core/package.json b/core/package.json index 01cd87353f..33ea259745 100644 --- a/core/package.json +++ b/core/package.json @@ -244,16 +244,15 @@ "eslint-plugin-no-null": "^1.0.2", "eslint-plugin-react": "^7.32.2", "finalhandler": "^1.2.0", - "gulp": "^4.0.2", - "gulp-pegjs": "^0.2.0", "is-subset": "^0.1.1", "md5": "^2.3.0", "mocha": "^10.2.0", "nock": "^12.0.3", + "nodemon": "^2.0.15", "node-fetch": "^2.7.0", "nyc": "^15.1.0", "p-event": "^4.2.0", - "pegjs": "^0.10.0", + "peggy": "^3.0.2", "prettier": "3.0.0", "ps-tree": "^1.2.0", "replace-in-file": "^6.3.5", @@ -268,11 +267,11 @@ "utility-types": "^3.10.0" }, "scripts": { - "build": "gulp pegjs", + "build": "mkdir -p build/src/template-string && peggy src/template-string/parser.pegjs --output build/src/template-string/parser.js", "check-package-lock": "git diff-index --quiet HEAD -- package-lock.json || (echo 'package-lock.json is dirty!' && exit 1)", "check-types": "tsc -p . --noEmit", "clean": "shx rm -rf build", - "dev": "gulp pegjs-watch", + "dev": "nodemon --watch src/template-string/*.pegjs --exec yarn build", "fix-format": "prettier --write \"{src,test}/**/*.ts\"", "lint": "eslint -c ../.eslintrc --ignore-pattern 'src/lib/**' --ext .ts src/ test/", "migration:generate": "typeorm migration:generate --config ormconfig.js -n", diff --git a/core/src/actions/types.ts b/core/src/actions/types.ts index 234fc5673f..abb4d64cf9 100644 --- a/core/src/actions/types.ts +++ b/core/src/actions/types.ts @@ -20,6 +20,7 @@ import type { BaseAction } from "./base" import type { ValidResultType } from "../tasks/base" import type { BaseGardenResource, GardenResourceInternalFields } from "../config/base" import type { LinkedSource } from "../config-store/local" +import { GardenApiVersion } from "../constants" // TODO: split this file @@ -48,7 +49,7 @@ export interface BaseActionConfig No templating is allowed on these. - apiVersion?: string + apiVersion?: GardenApiVersion kind: K type: T name: string diff --git a/core/src/cli/cli.ts b/core/src/cli/cli.ts index 81fd082419..49b3185bed 100644 --- a/core/src/cli/cli.ts +++ b/core/src/cli/cli.ts @@ -32,7 +32,7 @@ import { cliStyles, } from "./helpers" import { ParameterObject, globalOptions, OUTPUT_RENDERERS, GlobalOptions, ParameterValues } from "./params" -import { ProjectResource } from "../config/project" +import { ProjectConfig } from "../config/project" import { ERROR_LOG_FILENAME, DEFAULT_GARDEN_DIR_NAME, LOGS_DIR_NAME, gardenEnv } from "../constants" import { generateBasicDebugInfoReport } from "../commands/get/get-debug-info" import { AnalyticsHandler } from "../analytics/analytics" @@ -428,7 +428,7 @@ ${renderCommands(commands)} return done(1, chalk.red(`Could not find specified root path (${argv.root})`)) } - let projectConfig: ProjectResource | undefined + let projectConfig: ProjectConfig | undefined // First look for native Garden commands let { command, rest, matchedPath } = pickCommand(Object.values(this.commands), argv._) @@ -595,7 +595,7 @@ ${renderCommands(commands)} } @pMemoizeDecorator() - async getProjectConfig(log: Log, workingDir: string): Promise { + async getProjectConfig(log: Log, workingDir: string): Promise { return findProjectConfig({ log, path: workingDir }) } diff --git a/core/src/commands/custom.ts b/core/src/commands/custom.ts index ef1f80fd02..672b054c1f 100644 --- a/core/src/commands/custom.ts +++ b/core/src/commands/custom.ts @@ -121,9 +121,15 @@ export class CustomCommandWrapper extends Command { // Strip the command name and any specified arguments off the $rest variable const rest = removeSlice(parsed._unknown, this.getPath()).slice(Object.keys(this.arguments || {}).length) + const yamlDoc = this.spec.internal.yamlDoc + // Render the command variables const variablesContext = new CustomCommandContext({ ...garden, args, opts, rest }) - const commandVariables = resolveTemplateStrings(this.spec.variables, variablesContext) + const commandVariables = resolveTemplateStrings({ + value: this.spec.variables, + context: variablesContext, + source: { yamlDoc, basePath: ["variables"] }, + }) const variables: any = jsonMerge(cloneDeep(garden.variables), commandVariables) // Make a new template context with the resolved variables @@ -137,11 +143,16 @@ export class CustomCommandWrapper extends Command { const startedAt = new Date() const exec = validateWithPath({ - config: resolveTemplateStrings(this.spec.exec, commandContext), + config: resolveTemplateStrings({ + value: this.spec.exec, + context: commandContext, + source: { yamlDoc, basePath: ["exec"] }, + }), schema: customCommandExecSchema(), path: this.spec.internal.basePath, projectRoot: garden.projectRoot, configType: `exec field in custom Command '${this.name}'`, + source: undefined, }) const command = exec.command @@ -186,11 +197,16 @@ export class CustomCommandWrapper extends Command { const startedAt = new Date() let gardenCommand = validateWithPath({ - config: resolveTemplateStrings(this.spec.gardenCommand, commandContext), + config: resolveTemplateStrings({ + value: this.spec.gardenCommand, + context: commandContext, + source: { yamlDoc, basePath: ["gardenCommand"] }, + }), schema: customCommandGardenCommandSchema(), path: this.spec.internal.basePath, projectRoot: garden.projectRoot, configType: `gardenCommand field in custom Command '${this.name}'`, + source: undefined, }) log.debug(`Running Garden command: ${gardenCommand.join(" ")}`) @@ -287,6 +303,7 @@ export async function getCustomCommands(log: Log, projectRoot: string) { path: (config).internal.basePath, projectRoot, configType: `custom Command '${config.name}'`, + source: { yamlDoc: (config).internal.yamlDoc }, }) ) diff --git a/core/src/commands/login.ts b/core/src/commands/login.ts index 25346f2c82..68c2270664 100644 --- a/core/src/commands/login.ts +++ b/core/src/commands/login.ts @@ -15,7 +15,7 @@ import { ConfigurationError, TimeoutError, InternalError, CloudApiError } from " import { AuthRedirectServer } from "../cloud/auth" import { EventBus } from "../events/events" import { getCloudDistributionName } from "../util/util" -import { ProjectResource } from "../config/project" +import { ProjectConfig } from "../config/project" import { findProjectConfig } from "../config/base" import { BooleanParameter } from "../cli/params" import { deline } from "../util/string" @@ -56,7 +56,7 @@ export class LoginCommand extends Command<{}, Opts> { // so we initialize it here. noProject also make sure that the project config is not // initialized in the garden class, so we need to read it in here to get the cloud // domain. - let projectConfig: ProjectResource | undefined = undefined + let projectConfig: ProjectConfig | undefined = undefined const forceProjectCheck = !opts["disable-project-check"] if (forceProjectCheck) { diff --git a/core/src/commands/logout.ts b/core/src/commands/logout.ts index b65c589a9a..001d49d182 100644 --- a/core/src/commands/logout.ts +++ b/core/src/commands/logout.ts @@ -12,7 +12,7 @@ import { CloudApi, getGardenCloudDomain } from "../cloud/api" import { dedent, deline } from "../util/string" import { getCloudDistributionName } from "../util/util" import { ConfigurationError } from "../exceptions" -import { ProjectResource } from "../config/project" +import { ProjectConfig } from "../config/project" import { findProjectConfig } from "../config/base" import { BooleanParameter } from "../cli/params" @@ -43,7 +43,7 @@ export class LogOutCommand extends Command<{}, Opts> { // The Cloud API is missing from the Garden class for commands with noProject // so we initialize it with a cloud domain derived from `getGardenCloudDomain`. - let projectConfig: ProjectResource | undefined = undefined + let projectConfig: ProjectConfig | undefined = undefined const forceProjectCheck = !opts["disable-project-check"] if (forceProjectCheck) { diff --git a/core/src/commands/workflow.ts b/core/src/commands/workflow.ts index 466e3c18b3..f2dfc63b0f 100644 --- a/core/src/commands/workflow.ts +++ b/core/src/commands/workflow.ts @@ -92,7 +92,12 @@ export class WorkflowCommand extends Command { await registerAndSetUid(garden, log, workflow) garden.events.emit("workflowRunning", {}) const templateContext = new WorkflowConfigContext(garden, garden.variables) - const files = resolveTemplateStrings(workflow.files || [], templateContext) + const yamlDoc = workflow.internal.yamlDoc + const files = resolveTemplateStrings({ + value: workflow.files || [], + context: templateContext, + source: { yamlDoc, basePath: ["files"] }, + }) // Write all the configured files for the workflow await Promise.all(files.map((file) => writeWorkflowFile(garden, file))) @@ -162,10 +167,15 @@ export class WorkflowCommand extends Command { stepBodyLog.root.storeEntries = true try { if (step.command) { - step.command = resolveTemplateStrings(step.command, stepTemplateContext).filter((arg) => !!arg) + step.command = resolveTemplateStrings({ + value: step.command, + context: stepTemplateContext, + source: { yamlDoc, basePath: ["steps", index, "command"] }, + }).filter((arg) => !!arg) + stepResult = await runStepCommand(stepParams) } else if (step.script) { - step.script = resolveTemplateString(step.script, stepTemplateContext) + step.script = resolveTemplateString({ string: step.script, context: stepTemplateContext }) stepResult = await runStepScript(stepParams) } else { garden.events.emit("workflowStepError", getStepEndEvent(index, stepStartedAt)) diff --git a/core/src/config/base.ts b/core/src/config/base.ts index 440b2d3622..394da15503 100644 --- a/core/src/config/base.ts +++ b/core/src/config/base.ts @@ -8,24 +8,25 @@ import dotenv = require("dotenv") import { sep, resolve, relative, basename, dirname, join } from "path" -import { load, loadAll } from "js-yaml" +import { load } from "js-yaml" import { lint } from "yaml-lint" import { pathExists, readFile } from "fs-extra" import { omit, isPlainObject, isArray } from "lodash" import { coreModuleSpecSchema, baseModuleSchemaKeys, BuildDependencyConfig, ModuleConfig } from "./module" import { ConfigurationError, FilesystemError, ParameterError } from "../exceptions" import { DEFAULT_BUILD_TIMEOUT_SEC, DOCS_BASE_URL, GardenApiVersion } from "../constants" -import { ProjectConfig, ProjectResource } from "../config/project" +import type { ProjectConfig } from "../config/project" import { validateWithPath } from "./validation" import { defaultDotIgnoreFile, listDirectory } from "../util/fs" import { isConfigFilename } from "../util/fs" import { ConfigTemplateKind } from "./config-template" -import { isTruthy } from "../util/util" +import { isNotNull, isTruthy } from "../util/util" import { createSchema, DeepPrimitiveMap, joi, PrimitiveMap } from "./common" import { emitNonRepeatableWarning } from "../warnings" import { ActionKind, actionKinds } from "../actions/types" import { mayContainTemplateString } from "../template-string/template-string" import { Log } from "../logger/log-entry" +import { Document, parseAllDocuments } from "yaml" import { dedent, deline } from "../util/string" export const configTemplateKind = "ConfigTemplate" @@ -42,6 +43,10 @@ The format of the files is determined by the configured file's extension: _NOTE: The default varfile format will change to YAML in Garden v0.13, since YAML allows for definition of nested objects and arrays._ `.trim() +export interface YamlDocumentWithSource extends Document { + source: string +} + export interface GardenResourceInternalFields { basePath: string configFilePath?: string @@ -49,10 +54,12 @@ export interface GardenResourceInternalFields { inputs?: DeepPrimitiveMap parentName?: string templateName?: string + // Used to map fields to specific doc and location + yamlDoc?: YamlDocumentWithSource } export interface BaseGardenResource { - apiVersion?: string + apiVersion?: GardenApiVersion kind: string name: string internal: GardenResourceInternalFields @@ -66,6 +73,7 @@ export const baseInternalFieldsSchema = createSchema({ inputs: joi.object().optional().meta({ internal: true }), parentName: joi.string().optional().meta({ internal: true }), templateName: joi.string().optional().meta({ internal: true }), + yamlDoc: joi.any().optional().meta({ internal: true }), }), allowUnknown: true, meta: { internal: true }, @@ -87,9 +95,15 @@ export const allConfigKinds = ["Module", "Workflow", "Project", configTemplateKi * @param content - The contents of the file as a string. * @param path - The path to the file. */ -export async function loadAndValidateYaml(content: string, path: string): Promise { +export async function loadAndValidateYaml(content: string, path: string): Promise { try { - return loadAll(content) || [] + return Array.from(parseAllDocuments(content, { merge: true, strict: false }) || []).map((doc: any) => { + if (doc.errors.length > 0) { + throw doc.errors[0] + } + doc.source = content + return doc + }) } catch (loadErr) { // We try to find the error using a YAML linter try { @@ -147,13 +161,13 @@ export async function validateRawConfig({ // Ignore empty resources rawSpecs = rawSpecs.filter(Boolean) - const resources = rawSpecs + const resources = rawSpecs .map((s) => { const relPath = relative(projectRoot, configPath) const description = `config at ${relPath}` - return prepareResource({ log, spec: s, configFilePath: configPath, projectRoot, description, allowInvalid }) + return prepareResource({ log, doc: s, configFilePath: configPath, projectRoot, description, allowInvalid }) }) - .filter(Boolean) + .filter(isNotNull) return resources } @@ -169,14 +183,14 @@ export async function readConfigFile(configPath: string, projectRoot: string) { export function prepareResource({ log, - spec, + doc, configFilePath, projectRoot, description, allowInvalid = false, }: { log: Log - spec: any + doc: YamlDocumentWithSource configFilePath: string projectRoot: string description: string @@ -184,6 +198,12 @@ export function prepareResource({ }): GardenResource | ModuleConfig | null { const relPath = relative(projectRoot, configFilePath) + const spec = doc.toJS() + + if (spec === null) { + return null + } + if (!isPlainObject(spec)) { throw new ConfigurationError({ message: `Invalid configuration found in ${description}. Expected mapping object but got ${typeof spec}.`, @@ -202,7 +222,7 @@ export function prepareResource({ }) } } - if (spec.internal) { + if (spec.internal !== undefined) { throw new ConfigurationError({ message: `Found invalid key "internal" in config at ${relPath}`, }) @@ -217,7 +237,10 @@ export function prepareResource({ if (kind === "Project") { spec.path = basePath spec.configPath = configFilePath - delete spec.internal + spec.internal = { + basePath, + yamlDoc: doc, + } return prepareProjectResource(log, spec) } else if ( actionKinds.includes(kind) || @@ -229,6 +252,7 @@ export function prepareResource({ spec.internal = { basePath, configFilePath, + yamlDoc: doc, } return spec } else if (kind === "Module") { @@ -250,9 +274,9 @@ export function prepareResource({ } // TODO-0.14: remove these deprecation handlers in 0.14 -type DeprecatedConfigHandler = (log: Log, spec: ProjectResource) => ProjectResource +type DeprecatedConfigHandler = (log: Log, spec: ProjectConfig) => ProjectConfig -function handleDotIgnoreFiles(log: Log, projectSpec: ProjectResource) { +function handleDotIgnoreFiles(log: Log, projectSpec: ProjectConfig) { // If the project config has an explicitly defined `dotIgnoreFile` field, // it means the config has already been updated to 0.13 format. if (!!projectSpec.dotIgnoreFile) { @@ -285,8 +309,8 @@ function handleDotIgnoreFiles(log: Log, projectSpec: ProjectResource) { }) } -function handleProjectModules(log: Log, projectSpec: ProjectResource): ProjectResource { - // Field 'modules' was intentionally removed from the internal interface `ProjectResource`, +function handleProjectModules(log: Log, projectSpec: ProjectConfig): ProjectConfig { + // Field 'modules' was intentionally removed from the internal interface `ProjectConfig`, // but it still can be presented in the runtime if the old config format is used. if (projectSpec["modules"]) { emitNonRepeatableWarning( @@ -298,7 +322,7 @@ function handleProjectModules(log: Log, projectSpec: ProjectResource): ProjectRe return projectSpec } -function handleMissingApiVersion(log: Log, projectSpec: ProjectResource): ProjectResource { +function handleMissingApiVersion(log: Log, projectSpec: ProjectConfig): ProjectConfig { // We conservatively set the apiVersion to be compatible with 0.12. if (projectSpec["apiVersion"] === undefined) { emitNonRepeatableWarning( @@ -329,8 +353,8 @@ const bonsaiDeprecatedConfigHandlers: DeprecatedConfigHandler[] = [ handleProjectModules, ] -export function prepareProjectResource(log: Log, spec: any): ProjectResource { - let projectSpec = spec +export function prepareProjectResource(log: Log, spec: any): ProjectConfig { + let projectSpec = spec for (const handler of bonsaiDeprecatedConfigHandlers) { projectSpec = handler(log, projectSpec) } @@ -396,6 +420,7 @@ export function prepareModuleResource(spec: any, configPath: string, projectRoot projectRoot, configType: "module", ErrorClass: ConfigurationError, + source: undefined, }) return config @@ -429,7 +454,7 @@ export async function findProjectConfig({ path: string allowInvalid?: boolean scan?: boolean -}): Promise { +}): Promise { let sepCount = path.split(sep).length - 1 let allProjectSpecs: GardenResource[] = [] @@ -452,12 +477,12 @@ export async function findProjectConfig({ } if (allProjectSpecs.length > 1 && !allowInvalid) { - const configPaths = allProjectSpecs.map((i) => `- ${(i as ProjectConfig).configPath}`) + const configPaths = allProjectSpecs.map((c) => `- ${(c as ProjectConfig).configPath}`) throw new ConfigurationError({ message: `Multiple project declarations found at paths:\n${configPaths.join("\n")}`, }) } else if (allProjectSpecs.length === 1) { - return allProjectSpecs[0] + return allProjectSpecs[0] } if (!scan) { diff --git a/core/src/config/common.ts b/core/src/config/common.ts index f033d2401b..151af60a9c 100644 --- a/core/src/config/common.ts +++ b/core/src/config/common.ts @@ -465,6 +465,12 @@ joi = joi.extend({ const outputError = helpers.error("validation") outputError.message = error.message outputError.zodError = error + + if (error instanceof z.ZodError && error.issues.length > 0) { + // Not perfect, but at least we can get the path of the first error + outputError.path = error.issues[0].path + } + return outputError } }, diff --git a/core/src/config/config-template.ts b/core/src/config/config-template.ts index e2355a9535..38fc429daa 100644 --- a/core/src/config/config-template.ts +++ b/core/src/config/config-template.ts @@ -11,7 +11,7 @@ import { baseModuleSpecSchema, BaseModuleSpec } from "./module" import { dedent, naturalList } from "../util/string" import { configTemplateKind, renderTemplateKind, BaseGardenResource, baseInternalFieldsSchema } from "./base" import { resolveTemplateStrings } from "../template-string/template-string" -import { validateWithPath } from "./validation" +import { validateConfig } from "./validation" import { Garden } from "../garden" import { ConfigurationError } from "../exceptions" import { resolve, posix, dirname } from "path" @@ -62,16 +62,15 @@ export async function resolveConfigTemplate( const loggedIn = garden.isLoggedIn() const enterpriseDomain = garden.cloudApi?.domain const context = new ProjectConfigContext({ ...garden, loggedIn, enterpriseDomain }) - const resolved = resolveTemplateStrings(partial, context) + const resolved = resolveTemplateStrings({ value: partial, context, source: { yamlDoc: resource.internal.yamlDoc } }) const configPath = resource.internal.configFilePath // Validate the partial config - const validated = validateWithPath({ + const validated = validateConfig({ config: resolved, - path: configPath || resource.internal.basePath, schema: configTemplateSchema(), projectRoot: garden.projectRoot, - configType: configTemplateKind, + yamlDocBasePath: [], }) // Read and validate the JSON schema, if specified diff --git a/core/src/config/project.ts b/core/src/config/project.ts index aecbffbda9..0b890b88d0 100644 --- a/core/src/config/project.ts +++ b/core/src/config/project.ts @@ -23,7 +23,7 @@ import { Primitive, PrimitiveMap, } from "./common" -import { validateWithPath } from "./validation" +import { validateConfig, validateWithPath } from "./validation" import { resolveTemplateStrings } from "../template-string/template-string" import { EnvironmentConfigContext, ProjectConfigContext } from "./template-contexts/project" import { findByName, getNames } from "../util/util" @@ -36,7 +36,7 @@ import { defaultDotIgnoreFile } from "../util/fs" import type { CommandInfo } from "../plugin-context" import type { VcsInfo } from "../vcs/vcs" import { profileAsync } from "../util/profiling" -import { loadVarfile, varfileDescription } from "./base" +import { BaseGardenResource, baseInternalFieldsSchema, loadVarfile, varfileDescription } from "./base" import chalk = require("chalk") import { Log } from "../logger/log-entry" import { renderDivider } from "../logger/util" @@ -194,7 +194,7 @@ interface GitConfig { mode: GitScanMode } -export interface ProjectConfig { +export interface ProjectConfig extends BaseGardenResource { apiVersion: GardenApiVersion kind: "Project" name: string @@ -219,10 +219,6 @@ export interface ProjectConfig { variables: DeepPrimitiveMap } -export interface ProjectResource extends ProjectConfig { - kind: "Project" -} - export const projectNameSchema = memoize(() => joiIdentifier().required().description("The name of the project.").example("my-sweet-project") ) @@ -314,6 +310,7 @@ export const projectSchema = createSchema({ kind: joi.string().default("Project").valid("Project").description("Indicate what kind of config this is."), path: projectRootSchema().meta({ internal: true }), configPath: joi.string().meta({ internal: true }).description("The path to the project config file."), + internal: baseInternalFieldsSchema(), name: projectNameSchema(), // TODO: Refer to enterprise documentation for more details. id: joi.string().meta({ internal: true }).description("The project's ID in Garden Cloud."), @@ -483,15 +480,15 @@ export function resolveProjectConfig({ let globalConfig: any try { - globalConfig = resolveTemplateStrings( - { + globalConfig = resolveTemplateStrings({ + value: { apiVersion: config.apiVersion, varfile: config.varfile, variables: config.variables, environments: [], sources: [], }, - new ProjectConfigContext({ + context: new ProjectConfigContext({ projectName: name, projectRoot: config.path, artifactsPath, @@ -501,8 +498,9 @@ export function resolveProjectConfig({ enterpriseDomain, secrets, commandInfo, - }) - ) + }), + source: { yamlDoc: config.internal.yamlDoc, basePath: [] }, + }) } catch (err) { log.error("Failed to resolve project configuration.") log.error(chalk.red.bold(renderDivider())) @@ -510,7 +508,7 @@ export function resolveProjectConfig({ } // Validate after resolving global fields - config = validateWithPath({ + config = validateConfig({ config: { ...config, ...globalConfig, @@ -521,9 +519,8 @@ export function resolveProjectConfig({ sources: [], }, schema: projectSchema(), - configType: "project", - path: config.path, projectRoot: config.path, + yamlDocBasePath: [], }) const providers = config.providers @@ -594,7 +591,16 @@ export const pickEnvironment = profileAsync(async function _pickEnvironment({ let { environment, namespace } = parseEnvironment(envString) - let environmentConfig = findByName(environments, environment) + let environmentConfig: EnvironmentConfig | undefined + let index = -1 + + for (const env of environments) { + index++ + if (env.name === environment) { + environmentConfig = env + break + } + } if (!environmentConfig) { const definedEnvironments = getNames(environments) @@ -613,10 +619,12 @@ export const pickEnvironment = profileAsync(async function _pickEnvironment({ }) const projectVariables: DeepPrimitiveMap = merge(projectConfig.variables, projectVarfileVars) + const source = { yamlDoc: projectConfig.internal.yamlDoc, basePath: ["environments", index] } + // Resolve template strings in the environment config, except providers - environmentConfig = resolveTemplateStrings( - { ...environmentConfig }, - new EnvironmentConfigContext({ + environmentConfig = resolveTemplateStrings({ + value: { ...environmentConfig }, + context: new EnvironmentConfigContext({ projectName, projectRoot, artifactsPath, @@ -627,8 +635,9 @@ export const pickEnvironment = profileAsync(async function _pickEnvironment({ enterpriseDomain, secrets, commandInfo, - }) - ) + }), + source, + }) environmentConfig = validateWithPath({ config: environmentConfig, @@ -636,6 +645,7 @@ export const pickEnvironment = profileAsync(async function _pickEnvironment({ configType: `environment ${environment}`, path: projectConfig.path, projectRoot: projectConfig.path, + source, }) namespace = getNamespace(environmentConfig, namespace) diff --git a/core/src/config/render-template.ts b/core/src/config/render-template.ts index 13cfd9c6fe..b02260b712 100644 --- a/core/src/config/render-template.ts +++ b/core/src/config/render-template.ts @@ -6,6 +6,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import { Document } from "yaml" import { ModuleConfig } from "./module" import { dedent, deline, naturalList } from "../util/string" import { @@ -16,6 +17,7 @@ import { prepareResource, RenderTemplateKind, renderTemplateKind, + YamlDocumentWithSource, } from "./base" import { maybeTemplateString, resolveTemplateString, resolveTemplateStrings } from "../template-string/template-string" import { validateWithPath } from "./validation" @@ -119,9 +121,21 @@ export async function renderConfigTemplate({ const loggedIn = garden.isLoggedIn() const enterpriseDomain = garden.cloudApi?.domain const templateContext = new EnvironmentConfigContext({ ...garden, loggedIn, enterpriseDomain }) - const resolvedWithoutInputs = resolveTemplateStrings({ ...omit(config, "inputs") }, templateContext) - const partiallyResolvedInputs = resolveTemplateStrings(config.inputs || {}, templateContext, { - allowPartial: true, + + const yamlDoc = config.internal.yamlDoc + + const resolvedWithoutInputs = resolveTemplateStrings({ + value: { ...omit(config, "inputs") }, + context: templateContext, + source: { yamlDoc }, + }) + const partiallyResolvedInputs = resolveTemplateStrings({ + value: config.inputs || {}, + context: templateContext, + contextOpts: { + allowPartial: true, + }, + source: { yamlDoc, basePath: ["inputs"] }, }) let resolved: RenderTemplateConfig = { ...resolvedWithoutInputs, @@ -142,6 +156,7 @@ export async function renderConfigTemplate({ path: resolved.internal.configFilePath || resolved.internal.basePath, schema: renderTemplateConfigSchema(), projectRoot: garden.projectRoot, + source: undefined, }) const template = templates[resolved.template] @@ -185,10 +200,17 @@ async function renderModules({ context: RenderTemplateConfigContext renderConfig: RenderTemplateConfig }): Promise { + const yamlDoc = template.internal.yamlDoc + return Promise.all( - (template.modules || []).map(async (m) => { + (template.modules || []).map(async (m, i) => { // Run a partial template resolution with the parent+template info - const spec = resolveTemplateStrings(m, context, { allowPartial: true }) + const spec = resolveTemplateStrings({ + value: m, + context, + contextOpts: { allowPartial: true }, + source: { yamlDoc, basePath: ["modules", i] }, + }) const renderConfigPath = renderConfig.internal.configFilePath || renderConfig.internal.basePath let moduleConfig: ModuleConfig @@ -254,7 +276,7 @@ async function renderConfigs({ let resolvedName = m.name try { - resolvedName = resolveTemplateString(m.name, context, { allowPartial: false }) + resolvedName = resolveTemplateString({ string: m.name, context, contextOpts: { allowPartial: false } }) } catch (error) { throw new ConfigurationError({ message: `Could not resolve the \`name\` field (${m.name}) for a config in ${templateDescription}: ${error}\n\nNote that template strings in config names in must be fully resolvable at the time of scanning. This means that e.g. references to other actions, modules or runtime outputs cannot be used.`, @@ -286,7 +308,7 @@ async function renderConfigs({ try { resource = prepareResource({ log, - spec, + doc: new Document(spec) as YamlDocumentWithSource, configFilePath: renderConfigPath, projectRoot: garden.projectRoot, description: `resource in Render ${renderConfig.name}`, diff --git a/core/src/config/template-contexts/base.ts b/core/src/config/template-contexts/base.ts index 8ada923195..346eaf6548 100644 --- a/core/src/config/template-contexts/base.ts +++ b/core/src/config/template-contexts/base.ts @@ -158,7 +158,7 @@ export abstract class ConfigContext { // handle templated strings in context variables if (isString(value)) { opts.stack.push(stackEntry) - value = resolveTemplateString(value, this._rootContext, opts) + value = resolveTemplateString({ string: value, context: this._rootContext, contextOpts: opts }) } if (value === undefined) { diff --git a/core/src/config/validation.ts b/core/src/config/validation.ts index a29f5c128f..b4b7aef2a6 100644 --- a/core/src/config/validation.ts +++ b/core/src/config/validation.ts @@ -12,6 +12,9 @@ import chalk from "chalk" import { relative } from "path" import { uuidv4 } from "../util/random" import { profile } from "../util/profiling" +import { BaseGardenResource, YamlDocumentWithSource } from "./base" +import { ParsedNode, Range } from "yaml" +import { padEnd } from "lodash" export const joiPathPlaceholder = uuidv4() const joiPathPlaceholderRegex = new RegExp(joiPathPlaceholder, "g") @@ -36,9 +39,15 @@ const joiOptions: Joi.ValidationOptions = { errors: errorPrefs, } +export interface ConfigSource { + yamlDoc?: YamlDocumentWithSource + basePath?: (string | number)[] +} + export interface ValidateOptions { context?: string // Descriptive text to include in validation error messages, e.g. "module at some/local/path" ErrorClass?: typeof ConfigurationError + source?: ConfigSource } export interface ValidateWithPathParams { @@ -48,6 +57,7 @@ export interface ValidateWithPathParams { projectRoot: string name?: string // Name of the top-level entity that the config belongs to, e.g. "some-module" or "some-project" configType: string // The type of top-level entity that the config belongs to, e.g. "module" or "project" + source: ConfigSource | undefined ErrorClass?: typeof ConfigurationError } @@ -65,6 +75,7 @@ export const validateWithPath = profile(function $validateWithPath({ name, configType, ErrorClass, + source, }: ValidateWithPathParams) { const context = `${configType} ${name ? `'${name}' ` : ""}` + @@ -72,6 +83,7 @@ export const validateWithPath = profile(function $validateWithPath({ const validateOpts = { context: context.trim(), + source, } if (ErrorClass) { @@ -81,73 +93,148 @@ export const validateWithPath = profile(function $validateWithPath({ return validateSchema(config, schema, validateOpts) }) +export interface ValidateConfigParams { + config: T + schema: Joi.Schema + projectRoot: string + yamlDocBasePath: (string | number)[] + ErrorClass?: typeof ConfigurationError +} + +export function validateConfig(params: ValidateConfigParams): T { + const { config, schema, projectRoot, ErrorClass, yamlDocBasePath } = params + + const { name, kind } = config + const path = config.internal.configFilePath || config.internal.basePath + + const context = + `${kind} ${name ? `'${name}' ` : ""}` + + `${path && projectRoot !== path ? "(" + relative(projectRoot, path) + ")" : ""}` + + return validateSchema(config, schema, { + context: context.trim(), + source: config.internal.yamlDoc ? { yamlDoc: config.internal.yamlDoc, basePath: yamlDocBasePath } : undefined, + ErrorClass, + }) +} + export const validateSchema = profile(function $validateSchema( value: T, schema: Joi.Schema, - { context = "", ErrorClass = ConfigurationError }: ValidateOptions = {} + { source, context = "", ErrorClass = ConfigurationError }: ValidateOptions = {} ): T { const result = schema.validate(value, joiOptions) const error = result.error - if (error) { - const description = schema.describe() - - const errorDetails = error.details.map((e) => { - // render the key path in a much nicer way - let renderedPath = "" - - if (e.path.length) { - let d = description - - for (const part of e.path) { - if (d.keys && d.keys[part]) { - renderedPath += "." + part - d = d.keys[part] - } else if (d.patterns) { - for (const p of d.patterns) { - if (part.toString().match(new RegExp(p.regex.slice(1, -1)))) { - renderedPath += `[${part}]` - d = p.rule - break - } + if (!error) { + return result.value + } + + const description = schema.describe() + + const yamlDoc = source?.yamlDoc + const rawYaml = yamlDoc?.source + const yamlBasePath = source?.basePath || [] + + const errorDetails = error.details.map((e) => { + // render the key path in a much nicer way + let renderedPath = yamlBasePath.join(".") + + if (e.path.length) { + let d = description + + for (const part of e.path) { + if (d.keys && d.keys[part]) { + renderedPath = renderedPath ? renderedPath + "." + part : part.toString() + d = d.keys[part] + } else if (d.patterns) { + for (const p of d.patterns) { + if (part.toString().match(new RegExp(p.regex.slice(1, -1)))) { + renderedPath += `[${part}]` + d = p.rule + break } - } else { - renderedPath += `[${part}]` } + } else { + renderedPath += `[${part}]` } } + } - // a little hack to always use full key paths instead of just the label - e.message = e.message.replace( - joiLabelPlaceholderRegex, - renderedPath ? "key " + chalk.underline(renderedPath) : "value" - ) - e.message = e.message.replace(joiPathPlaceholderRegex, chalk.underline(renderedPath || ".")) - // FIXME: remove once we've customized the error output from AJV in customObject.jsonSchema() - e.message = e.message.replace(/should NOT have/g, "should not have") + // a little hack to always use full key paths instead of just the label + e.message = e.message.replace(joiLabelPlaceholderRegex, renderedPath ? chalk.bold.underline(renderedPath) : "value") + e.message = e.message.replace(joiPathPlaceholderRegex, chalk.bold.underline(renderedPath || ".")) + // FIXME: remove once we've customized the error output from AJV in customObject.jsonSchema() + e.message = e.message.replace(/should NOT have/g, "should not have") - return e - }) + const node = yamlDoc?.getIn([...yamlBasePath, ...e.path], true) as ParsedNode | undefined + const range = node?.range - const msgPrefix = context ? `Error validating ${context}` : "Validation error" - let errorDescription = errorDetails.map((e) => e.message).join(", ") + if (rawYaml && yamlDoc?.contents && range) { + try { + e.message = addYamlContext({ rawYaml, range, message: e.message }) + } catch { + // ignore + } + } - const schemaDescription = schema.describe() + return e + }) - if (schemaDescription.keys) { - // Not the case e.g. for array schemas - errorDescription += `. Available keys: ${Object.keys(schema.describe().keys).join(", ")})` - } + const msgPrefix = context ? `Error validating ${context}` : "Validation error" + let errorDescription = errorDetails.map((e) => e.message).join("\n") + + const schemaDescription = schema.describe() - throw new ErrorClass({ - message: `${msgPrefix}: ${errorDescription}`, - }) + if (schemaDescription.keys && errorDescription.includes("is not allowed at path")) { + // Not the case e.g. for array schemas + errorDescription += `. Available keys: ${Object.keys(schema.describe().keys).join(", ")})` } - return result.value + throw new ErrorClass({ + message: `${msgPrefix}:\n${errorDescription}`, + }) }) export interface ArtifactSpec { source: string target: string } + +function addYamlContext({ rawYaml, range, message }: { rawYaml: string; range: Range; message: string }): string { + // Get one line before the error location start, and the line including the error location end + const toStart = rawYaml.slice(0, range[0]) + let lineNumber = toStart.split("\n").length + 1 + let snippetLines = 1 + + const errorLineStart = toStart.lastIndexOf("\n") + 1 + + let snippetStart = errorLineStart + if (snippetStart > 0) { + snippetStart = rawYaml.slice(0, snippetStart - 1).lastIndexOf("\n") + 1 + } + if (snippetStart === 0) { + snippetStart = errorLineStart + } else { + snippetLines++ + } + + const snippetEnd = rawYaml.indexOf("\n", range[1] - 1) || rawYaml.length + + const linePrefixLength = lineNumber.toString().length + 2 + let snippet = rawYaml + .slice(snippetStart, snippetEnd) + .trimEnd() + .split("\n") + .map((l, i) => chalk.gray(padEnd("" + (lineNumber - snippetLines + i), linePrefixLength) + "| ") + chalk.cyan(l)) + .join("\n") + + if (snippetStart > 0) { + snippet = chalk.gray("...\n") + snippet + } + + const errorLineOffset = range[0] - errorLineStart + linePrefixLength + 2 + const marker = chalk.red("-".repeat(errorLineOffset)) + chalk.red.bold("^") + + return `\n${snippet}\n${marker}\n${chalk.red.bold(message)}` +} diff --git a/core/src/config/workflow.ts b/core/src/config/workflow.ts index d6ceda908c..bc25d52e7f 100644 --- a/core/src/config/workflow.ts +++ b/core/src/config/workflow.ts @@ -23,12 +23,12 @@ import { ServiceLimitSpec } from "../plugins/container/moduleConfig" import { Garden } from "../garden" import { WorkflowConfigContext } from "./template-contexts/workflow" import { resolveTemplateStrings } from "../template-string/template-string" -import { validateWithPath } from "./validation" +import { validateConfig } from "./validation" import { ConfigurationError, GardenError } from "../exceptions" import { EnvironmentConfig, getNamespace } from "./project" import { omitUndefined } from "../util/objects" import { BaseGardenResource, GardenResource } from "./base" -import { DOCS_BASE_URL } from "../constants" +import { DOCS_BASE_URL, GardenApiVersion } from "../constants" export const minimumWorkflowRequests = { cpu: 50, // 50 millicpu @@ -53,7 +53,7 @@ export const defaultWorkflowResources = { } export interface WorkflowConfig extends BaseGardenResource { - apiVersion: string + apiVersion: GardenApiVersion description?: string envVars: PrimitiveMap kind: "Workflow" @@ -358,7 +358,7 @@ export function resolveWorkflowConfig(garden: Garden, config: WorkflowConfig) { } let resolvedPartialConfig: WorkflowConfig = { - ...resolveTemplateStrings(partialConfig, context), + ...resolveTemplateStrings({ value: partialConfig, context, source: { yamlDoc: config.internal.yamlDoc } }), name: config.name, } @@ -367,19 +367,24 @@ export function resolveWorkflowConfig(garden: Garden, config: WorkflowConfig) { } if (config.internal.inputs) { - resolvedPartialConfig.internal.inputs = resolveTemplateStrings(config.internal.inputs, context, { - allowPartial: true, + resolvedPartialConfig.internal.inputs = resolveTemplateStrings({ + value: config.internal.inputs, + context, + contextOpts: { + allowPartial: true, + }, + // TODO: Map inputs to their original YAML sources + source: undefined, }) } log.silly(`Validating config for workflow ${config.name}`) - resolvedPartialConfig = validateWithPath({ + resolvedPartialConfig = validateConfig({ config: resolvedPartialConfig, - configType: "workflow", schema: workflowConfigSchema(), - path: config.internal.basePath, projectRoot: garden.projectRoot, + yamlDocBasePath: [], }) // Re-add the deferred step commands and scripts diff --git a/core/src/docs/generate.ts b/core/src/docs/generate.ts index 44322317ea..b59ec1c3b6 100644 --- a/core/src/docs/generate.ts +++ b/core/src/docs/generate.ts @@ -73,6 +73,9 @@ export async function writeConfigReferenceDocs( apiVersion: GardenApiVersion.v1, kind: "Project", name: "generate-docs", + internal: { + basePath: __dirname, + }, defaultEnvironment, dotIgnoreFile: defaultDotIgnoreFile, variables: {}, diff --git a/core/src/exceptions.ts b/core/src/exceptions.ts index 91f8fd943b..1ebf17e09f 100644 --- a/core/src/exceptions.ts +++ b/core/src/exceptions.ts @@ -266,6 +266,13 @@ export class CloudApiError extends GardenError { export class TemplateStringError extends GardenError { type = "template-string" + + path?: (string | number)[] + + constructor(params: GardenErrorParams & { path?: (string | number)[] }) { + super(params) + this.path = params.path + } } interface GenericGardenErrorParams extends GardenErrorParams { diff --git a/core/src/garden.ts b/core/src/garden.ts index 007ba903b0..7f9cc53918 100644 --- a/core/src/garden.ts +++ b/core/src/garden.ts @@ -829,7 +829,7 @@ export class Garden { provider.moduleConfigs.map(async (moduleConfig) => { // Make sure module and all nested entities are scoped to the plugin moduleConfig.plugin = provider.name - await this.addModuleConfig(moduleConfig) + return this.addModuleConfig(moduleConfig) }) ) ) @@ -1503,9 +1503,15 @@ export class Garden { */ public getProjectSources() { const context = new RemoteSourceConfigContext(this, this.variables) - const resolved = validateSchema(resolveTemplateStrings(this.projectSources, context), projectSourcesSchema(), { - context: "remote source", - }) + const source = { yamlDoc: this.projectConfig.internal.yamlDoc, basePath: ["sources"] } + const resolved = validateSchema( + resolveTemplateStrings({ value: this.projectSources, context, source }), + projectSourcesSchema(), + { + context: "remote source", + source, + } + ) return resolved } @@ -1723,19 +1729,20 @@ export async function resolveGardenParamsPartial(currentDirectory: string, opts: configType: "project environments", path: config.path, projectRoot: config.path, + source: { yamlDoc: config.internal.yamlDoc, basePath: ["environments"] }, }) - const configDefaultEnvironment = resolveTemplateString( - config.defaultEnvironment || "", - new DefaultEnvironmentContext({ + const configDefaultEnvironment = resolveTemplateString({ + string: config.defaultEnvironment || "", + context: new DefaultEnvironmentContext({ projectName, projectRoot, artifactsPath, vcsInfo, username: _username, commandInfo, - }) - ) as string + }), + }) as string const localConfigStore = new LocalConfigStore(gardenDirPath) @@ -2025,6 +2032,9 @@ export async function makeDummyGarden(root: string, gardenOpts: GardenOpts) { apiVersion: GardenApiVersion.v1, kind: "Project", name: "no-project", + internal: { + basePath: root, + }, defaultEnvironment: "", dotIgnoreFile: defaultDotIgnoreFile, environments: [{ name: environmentName, defaultNamespace: _defaultNamespace, variables: {} }], diff --git a/core/src/graph/actions.ts b/core/src/graph/actions.ts index 1e161146ba..238bf8aae9 100644 --- a/core/src/graph/actions.ts +++ b/core/src/graph/actions.ts @@ -522,9 +522,9 @@ export const preprocessActionConfig = profileAsync(async function preprocessActi variables: config.variables, varfiles: resolvedVarFiles, }) - const resolvedVariables = resolveTemplateStrings( - variables, - new ActionConfigContext({ + const resolvedVariables = resolveTemplateStrings({ + value: variables, + context: new ActionConfigContext({ garden, config: { ...config, internal: { ...config.internal, inputs: {} } }, thisContextParams: { @@ -533,14 +533,16 @@ export const preprocessActionConfig = profileAsync(async function preprocessActi }, variables, }), - { allowPartial: true } - ) + contextOpts: { allowPartial: true }, + // TODO: See about mapping this to the original variable sources + source: undefined, + }) if (templateName) { // Partially resolve inputs - const partiallyResolvedInputs = resolveTemplateStrings( - config.internal.inputs || {}, - new ActionConfigContext({ + const partiallyResolvedInputs = resolveTemplateStrings({ + value: config.internal.inputs || {}, + context: new ActionConfigContext({ garden, config: { ...config, internal: { ...config.internal, inputs: {} } }, thisContextParams: { @@ -549,8 +551,10 @@ export const preprocessActionConfig = profileAsync(async function preprocessActi }, variables: resolvedVariables, }), - { allowPartial: true } - ) + contextOpts: { allowPartial: true }, + // TODO: See about mapping this to the original inputs source + source: undefined, + }) const template = garden.configTemplates[templateName] @@ -570,6 +574,7 @@ export const preprocessActionConfig = profileAsync(async function preprocessActi path: config.internal.basePath, schema: template.inputsSchema, projectRoot: garden.projectRoot, + source: undefined, }) } @@ -584,11 +589,18 @@ export const preprocessActionConfig = profileAsync(async function preprocessActi variables: resolvedVariables, }) + const yamlDoc = config.internal.yamlDoc + function resolveTemplates() { // Fully resolve built-in fields that only support `ActionConfigContext`. // TODO-0.13.1: better error messages when something goes wrong here (missing inputs for example) - const resolvedBuiltin = resolveTemplateStrings(pick(config, builtinConfigKeys), builtinFieldContext, { - allowPartial: false, + const resolvedBuiltin = resolveTemplateStrings({ + value: pick(config, builtinConfigKeys), + context: builtinFieldContext, + contextOpts: { + allowPartial: false, + }, + source: { yamlDoc, basePath: [] }, }) config = { ...config, ...resolvedBuiltin } const { spec = {} } = config @@ -606,15 +618,20 @@ export const preprocessActionConfig = profileAsync(async function preprocessActi name: config.name, path: config.internal.basePath, projectRoot: garden.projectRoot, + source: { yamlDoc }, }) config = { ...config, variables: resolvedVariables, spec } // Partially resolve other fields // TODO-0.13.1: better error messages when something goes wrong here (missing inputs for example) - - const resolvedOther = resolveTemplateStrings(omit(config, builtinConfigKeys), builtinFieldContext, { - allowPartial: true, + const resolvedOther = resolveTemplateStrings({ + value: omit(config, builtinConfigKeys), + context: builtinFieldContext, + contextOpts: { + allowPartial: true, + }, + source: { yamlDoc }, }) config = { ...config, ...resolvedOther } } @@ -736,7 +753,11 @@ function dependenciesFromActionConfig( if (maybeTemplateString(ref.name)) { try { - ref.name = resolveTemplateString(ref.name, templateContext, { allowPartial: false }) + ref.name = resolveTemplateString({ + string: ref.name, + context: templateContext, + contextOpts: { allowPartial: false }, + }) } catch (err) { log.warn( `Unable to infer dependency from action reference in ${description}, because template string '${ref.name}' could not be resolved. Either fix the dependency or specify it explicitly.` diff --git a/core/src/outputs.ts b/core/src/outputs.ts index c098977180..3370467862 100644 --- a/core/src/outputs.ts +++ b/core/src/outputs.ts @@ -58,18 +58,21 @@ export async function resolveProjectOutputs(garden: Garden, log: Log): Promise(o: T, opts?: ResolveTemplateStringsOpts) => { - return resolveTemplateStrings(o, templateContext, opts || {}) + return resolveTemplateStrings({ value: o, context: templateContext, contextOpts: opts || {}, source: undefined }) }, sessionId: garden.sessionId, tools: await garden.getTools(), diff --git a/core/src/plugins/kubernetes/system.ts b/core/src/plugins/kubernetes/system.ts index aa2d3b44c7..e7d8708199 100644 --- a/core/src/plugins/kubernetes/system.ts +++ b/core/src/plugins/kubernetes/system.ts @@ -66,6 +66,9 @@ export async function getSystemGarden( path: systemProjectPath, apiVersion: GardenApiVersion.v1, kind: "Project", + internal: { + basePath: systemProjectPath, + }, name: systemNamespace, defaultEnvironment: "default", dotIgnoreFile: defaultDotIgnoreFile, diff --git a/core/src/resolve-module.ts b/core/src/resolve-module.ts index 38c9d7d5bb..7205c59ddd 100644 --- a/core/src/resolve-module.ts +++ b/core/src/resolve-module.ts @@ -288,7 +288,13 @@ export class ModuleResolver { // Try resolving template strings if possible let buildDeps: string[] = [] - const resolvedDeps = resolveTemplateStrings(rawConfig.build.dependencies, configContext, { allowPartial: true }) + const resolvedDeps = resolveTemplateStrings({ + value: rawConfig.build.dependencies, + context: configContext, + contextOpts: { allowPartial: true }, + // Note: We're not implementing the YAML source mapping for modules + source: undefined, + }) // The build.dependencies field may not resolve at all, in which case we can't extract any deps from there if (isArray(resolvedDeps)) { @@ -349,12 +355,14 @@ export class ModuleResolver { if (templateName) { const template = this.garden.configTemplates[templateName] - inputs = resolveTemplateStrings( - inputs, - new ModuleConfigContext(templateContextParams), + inputs = resolveTemplateStrings({ + value: inputs, + context: new ModuleConfigContext(templateContextParams), // Not all inputs may need to be resolvable - { allowPartial: true } - ) + contextOpts: { allowPartial: true }, + // Note: We're not implementing the YAML source mapping for modules + source: undefined, + }) inputs = validateWithPath({ config: inputs, @@ -362,6 +370,7 @@ export class ModuleResolver { path: config.configPath || config.path, schema: template.inputsSchema, projectRoot: garden.projectRoot, + source: undefined, }) config.inputs = inputs @@ -371,8 +380,14 @@ export class ModuleResolver { const resolvedModuleVariables = await this.resolveVariables(config, templateContextParams) // Now resolve just references to inputs on the config - config = resolveTemplateStrings(cloneDeep(config), new GenericContext({ inputs }), { - allowPartial: true, + config = resolveTemplateStrings({ + value: cloneDeep(config), + context: new GenericContext({ inputs }), + contextOpts: { + allowPartial: true, + }, + // Note: We're not implementing the YAML source mapping for modules + source: undefined, }) // And finally fully resolve the config. @@ -384,8 +399,14 @@ export class ModuleResolver { inputs: { ...inputs }, }) - config = resolveTemplateStrings({ ...config, inputs: {}, variables: {} }, configContext, { - allowPartial: false, + config = resolveTemplateStrings({ + value: { ...config, inputs: {}, variables: {} }, + context: configContext, + contextOpts: { + allowPartial: false, + }, + // Note: We're not implementing the YAML source mapping for modules + source: undefined, }) config.variables = resolvedModuleVariables @@ -433,6 +454,7 @@ export class ModuleResolver { name: config.name, path: config.path, projectRoot: garden.projectRoot, + source: undefined, }) } @@ -444,6 +466,7 @@ export class ModuleResolver { name: config.name, path: config.path, projectRoot: garden.projectRoot, + source: undefined, }) if (config.repositoryUrl) { @@ -479,6 +502,7 @@ export class ModuleResolver { projectRoot: garden.projectRoot, configType: `configuration for module '${config.name}' (base schema from '${base.name}' plugin)`, ErrorClass: ConfigurationError, + source: undefined, }) } } @@ -548,7 +572,7 @@ export class ModuleResolver { } const resolvedContents = fileSpec.resolveTemplates - ? resolveTemplateString(contents, configContext, { unescape: true }) + ? resolveTemplateString({ string: contents, context: configContext, contextOpts: { unescape: true } }) : contents const targetDir = resolve(resolvedConfig.path, ...posix.dirname(fileSpec.targetPath).split(posix.sep)) @@ -608,6 +632,7 @@ export class ModuleResolver { path: module.configPath || module.path, projectRoot: this.garden.projectRoot, ErrorClass: PluginError, + source: undefined, }) } @@ -625,6 +650,7 @@ export class ModuleResolver { projectRoot: this.garden.projectRoot, configType: `outputs for module '${module.name}' (base schema from '${base.name}' plugin)`, ErrorClass: PluginError, + source: undefined, }) } } @@ -642,9 +668,14 @@ export class ModuleResolver { ): Promise { const moduleConfigContext = new ModuleConfigContext(templateContextParams) const resolveOpts = { allowPartial: false } + let varfileVars: DeepPrimitiveMap = {} if (config.varfile) { - const varfilePath = resolveTemplateString(config.varfile, moduleConfigContext, resolveOpts) + const varfilePath = resolveTemplateString({ + string: config.varfile, + context: moduleConfigContext, + contextOpts: resolveOpts, + }) varfileVars = await loadVarfile({ configRoot: config.path, path: varfilePath, @@ -653,7 +684,13 @@ export class ModuleResolver { } const rawVariables = config.variables - const moduleVariables = resolveTemplateStrings(cloneDeep(rawVariables || {}), moduleConfigContext, resolveOpts) + const moduleVariables = resolveTemplateStrings({ + value: cloneDeep(rawVariables || {}), + context: moduleConfigContext, + contextOpts: resolveOpts, + // Note: We're not implementing the YAML source mapping for modules + source: undefined, + }) const mergedVariables: DeepPrimitiveMap = ( merge(moduleVariables, merge(varfileVars, this.garden.variableOverrides)) ) diff --git a/core/src/server/commands.ts b/core/src/server/commands.ts index dd3cbe88a0..63ae60a975 100644 --- a/core/src/server/commands.ts +++ b/core/src/server/commands.ts @@ -26,7 +26,7 @@ import { isMatch } from "micromatch" import type { GardenInstanceManager } from "./instance-manager" import { isDirectory } from "../util/fs" import { pathExists } from "fs-extra" -import type { ProjectResource } from "../config/project" +import type { ProjectConfig } from "../config/project" import { findProjectConfig } from "../config/base" import type { GlobalConfigStore } from "../config-store/global" import type { ParsedArgs } from "minimist" @@ -404,7 +404,7 @@ export async function resolveRequest({ return { error: { code, message, detail } } } - let projectConfig: ProjectResource | undefined + let projectConfig: ProjectConfig | undefined // TODO: support --root option flag diff --git a/core/src/tasks/publish.ts b/core/src/tasks/publish.ts index c8f3b0035d..d2935220a3 100644 --- a/core/src/tasks/publish.ts +++ b/core/src/tasks/publish.ts @@ -105,7 +105,7 @@ export class PublishTask extends BaseActionTask extends BaseActionTask>): Promise> { const action = this.action - const config = action.getConfig() + const config = action.getConfig() as BaseActionConfig // Collect dependencies const resolvedDependencies: ResolvedAction[] = [] @@ -120,7 +120,15 @@ export class ResolveActionTask extends BaseActionTask extends BaseActionTask extends BaseActionTask extends BaseActionTask extends BaseActionTask extends BaseActionTask extends BaseActionTask extends BaseActionTask { const context = new ProviderConfigContext(this.garden, resolvedProviders, this.garden.variables) this.log.silly(`Resolving template strings for provider ${this.config.name}`) - let resolvedConfig = resolveTemplateStrings(this.config, context) + + const projectConfig = this.garden.getProjectConfig() + const yamlDoc = projectConfig.internal.yamlDoc + let yamlDocBasePath: (string | number)[] = [] + + if (yamlDoc) { + projectConfig.providers.forEach((p, i) => { + if (p.name === this.config.name) { + yamlDocBasePath = ["providers", i] + return false + } + return true + }) + } + + const source = { yamlDoc, basePath: yamlDocBasePath } + + let resolvedConfig = resolveTemplateStrings({ value: this.config, context, source }) const providerName = resolvedConfig.name @@ -187,6 +204,7 @@ export class ResolveProviderTask extends BaseTask { projectRoot: this.garden.projectRoot, configType: "provider configuration", ErrorClass: ConfigurationError, + source, }) } @@ -254,6 +272,7 @@ export class ResolveProviderTask extends BaseTask { projectRoot: this.garden.projectRoot, configType: `provider configuration (base schema from '${base.name}' plugin)`, ErrorClass: ConfigurationError, + source: { yamlDoc, basePath: yamlDocBasePath }, }) } diff --git a/core/src/template-string/template-string.ts b/core/src/template-string/template-string.ts index 0abc1636ba..fe888f9e03 100644 --- a/core/src/template-string/template-string.ts +++ b/core/src/template-string/template-string.ts @@ -7,7 +7,7 @@ */ import chalk from "chalk" -import { ConfigurationError, GardenError, TemplateStringError } from "../exceptions" +import { ConfigurationError, GardenError, GardenErrorParams, TemplateStringError } from "../exceptions" import { ConfigContext, ContextKeySegment, @@ -33,7 +33,6 @@ import { Primitive, StringMap, } from "../config/common" -import { profile } from "../util/profiling" import { dedent, deline, naturalList, titleize, truncate } from "../util/string" import type { ObjectWithName } from "../util/util" import { Log } from "../logger/log-entry" @@ -41,6 +40,7 @@ import type { ModuleConfigContext } from "../config/template-contexts/module" import { callHelperFunction } from "./functions" import { ActionKind, actionKindsLower } from "../actions/types" import { deepMap } from "../util/objects" +import { ConfigSource } from "../config/validation" const missingKeyExceptionType = "template-string-missing-key" const passthroughExceptionType = "template-string-passthrough" @@ -80,6 +80,23 @@ function getValue(v: Primitive | undefined | ResolvedClause) { return isPlainObject(v) ? (v).resolved : v } +type ObjectPath = (string | number)[] + +export class TemplateError extends GardenError { + type = "template" + + path: ObjectPath | undefined + value: any + resolved: any + + constructor(params: GardenErrorParams & { path: ObjectPath | undefined; value: any; resolved: any }) { + super(params) + this.path = params.path + this.value = params.value + this.resolved = params.resolved + } +} + /** * Parse and resolve a templated string, with the given context. The template format is similar to native JS templated * strings but only supports simple lookups from the given context, e.g. "prefix-${nested.key}-suffix", and not @@ -88,7 +105,17 @@ function getValue(v: Primitive | undefined | ResolvedClause) { * The context should be a ConfigContext instance. The optional `stack` parameter is used to detect circular * dependencies when resolving context variables. */ -export function resolveTemplateString(string: string, context: ConfigContext, opts: ContextResolveOpts = {}): any { +export function resolveTemplateString({ + string, + context, + contextOpts = {}, + path, +}: { + string: string + context: ConfigContext + contextOpts?: ContextResolveOpts + path?: ObjectPath +}): any { // Just return immediately if this is definitely not a template string if (!maybeTemplateString(string)) { return string @@ -98,10 +125,10 @@ export function resolveTemplateString(string: string, context: ConfigContext, op try { const parsed = parser.parse(string, { getKey: (key: string[], resolveOpts?: ContextResolveOpts) => { - return context.resolve({ key, nodePath: [], opts: { ...opts, ...(resolveOpts || {}) } }) + return context.resolve({ key, nodePath: [], opts: { ...contextOpts, ...(resolveOpts || {}) } }) }, getValue, - resolveNested: (nested: string) => resolveTemplateString(nested, context, opts), + resolveNested: (nested: string) => resolveTemplateString({ string: nested, context, contextOpts }), buildBinaryExpression, buildLogicalExpression, isArray: Array.isArray, @@ -109,8 +136,8 @@ export function resolveTemplateString(string: string, context: ConfigContext, op TemplateStringError, missingKeyExceptionType, passthroughExceptionType, - allowPartial: !!opts.allowPartial, - unescape: !!opts.unescape, + allowPartial: !!contextOpts.allowPartial, + unescape: !!contextOpts.unescape, escapePrefix, optionalSuffix: "}?", isPlainObject, @@ -211,7 +238,7 @@ export function resolveTemplateString(string: string, context: ConfigContext, op const prefix = `Invalid template string (${chalk.white(truncate(string, 35).replace(/\n/g, "\\n"))}): ` const message = err.message.startsWith(prefix) ? err.message : prefix + err.message - throw new TemplateStringError({ message }) + throw new TemplateStringError({ message, path }) } } @@ -220,23 +247,37 @@ export function resolveTemplateString(string: string, context: ConfigContext, op */ // `extends any` here isn't pretty but this function is hard to type correctly -export const resolveTemplateStrings = profile(function $resolveTemplateStrings( - value: T, - context: ConfigContext, - opts: ContextResolveOpts = {} -): T { +//export const resolveTemplateStrings = profile(function $resolveTemplateStrings( +export function resolveTemplateStrings({ + value, + context, + contextOpts = {}, + path, + source, +}: { + value: T + context: ConfigContext + contextOpts?: ContextResolveOpts + path?: ObjectPath + source: ConfigSource | undefined +}): T { if (value === null) { return null as T } if (value === undefined) { return undefined as T } + + if (!path) { + path = [] + } + if (typeof value === "string") { - return resolveTemplateString(value, context, opts) + return resolveTemplateString({ string: value, context, path, contextOpts }) } else if (Array.isArray(value)) { const output: unknown[] = [] - for (const v of value) { + value.forEach((v, i) => { if (isPlainObject(v) && v[arrayConcatKey] !== undefined) { if (Object.keys(v).length > 1) { const extraKeys = naturalList( @@ -244,51 +285,68 @@ export const resolveTemplateStrings = profile(function $resolveTemplateStrings k !== arrayConcatKey) .map((k) => JSON.stringify(k)) ) - throw new ConfigurationError({ + throw new TemplateError({ message: `A list item with a ${arrayConcatKey} key cannot have any other keys (found ${extraKeys})`, + path, + value, + resolved: undefined, }) } // Handle array concatenation via $concat - const resolved = resolveTemplateStrings(v[arrayConcatKey], context, opts) + const resolved = resolveTemplateStrings({ + value: v[arrayConcatKey], + context, + contextOpts: { + ...contextOpts, + }, + path: path && [...path, arrayConcatKey], + source, + }) if (Array.isArray(resolved)) { output.push(...resolved) - } else if (opts.allowPartial) { + } else if (contextOpts.allowPartial) { output.push({ $concat: resolved }) } else { - throw new ConfigurationError({ + throw new TemplateError({ message: `Value of ${arrayConcatKey} key must be (or resolve to) an array (got ${typeof resolved})`, + path, + value, + resolved, }) } } else { - output.push(resolveTemplateStrings(v, context, opts)) + output.push(resolveTemplateStrings({ value: v, context, contextOpts, source, path: path && [...path, i] })) } - } + }) return (output) } else if (isPlainObject(value)) { if (value[arrayForEachKey] !== undefined) { // Handle $forEach loop - return handleForEachObject(value, context, opts) + return handleForEachObject({ value, context, contextOpts, path, source }) } else if (value[conditionalKey] !== undefined) { // Handle $if conditional - return handleConditional(value, context, opts) + return handleConditional({ value, context, contextOpts, path, source }) } else { // Resolve $merge keys, depth-first, leaves-first let output = {} for (const [k, v] of Object.entries(value)) { - const resolved = resolveTemplateStrings(v, context, opts) + const resolved = resolveTemplateStrings({ value: v, context, contextOpts, source, path: path && [...path, k] }) if (k === objectSpreadKey) { if (isPlainObject(resolved)) { output = { ...output, ...resolved } - } else if (opts.allowPartial) { + } else if (contextOpts.allowPartial) { output[k] = resolved } else { - throw new ConfigurationError({ + throw new TemplateError({ message: `Value of ${objectSpreadKey} key must be (or resolve to) a mapping object (got ${typeof resolved})`, + path: [...path, k], + value, + resolved, }) } } else { @@ -301,17 +359,32 @@ export const resolveTemplateStrings = profile(function $resolveTemplateStringsvalue } -}) +} const expectedForEachKeys = [arrayForEachKey, arrayForEachReturnKey, arrayForEachFilterKey] -function handleForEachObject(value: any, context: ConfigContext, opts: ContextResolveOpts) { +function handleForEachObject({ + value, + context, + contextOpts, + path, + source, +}: { + value: any + context: ConfigContext + contextOpts: ContextResolveOpts + path: ObjectPath | undefined + source: ConfigSource | undefined +}) { // Validate input object if (value[arrayForEachReturnKey] === undefined) { - throw new ConfigurationError({ + throw new TemplateError({ message: `Missing ${arrayForEachReturnKey} field next to ${arrayForEachKey} field. Got ${naturalList( Object.keys(value) )}`, + path: path && [...path, arrayForEachKey], + value, + resolved: undefined, }) } @@ -320,24 +393,35 @@ function handleForEachObject(value: any, context: ConfigContext, opts: ContextRe if (unexpectedKeys.length > 0) { const extraKeys = naturalList(unexpectedKeys.map((k) => JSON.stringify(k))) - throw new ConfigurationError({ + throw new TemplateError({ message: `Found one or more unexpected keys on ${arrayForEachKey} object: ${extraKeys}. Expected keys: ${naturalList( expectedForEachKeys )}`, + path, + value, + resolved: undefined, }) } // Try resolving the value of the $forEach key - let resolvedInput = resolveTemplateStrings(value[arrayForEachKey], context, opts) - + let resolvedInput = resolveTemplateStrings({ + value: value[arrayForEachKey], + context, + contextOpts, + source, + path: path && [...path, arrayForEachKey], + }) const isObject = isPlainObject(resolvedInput) if (!Array.isArray(resolvedInput) && !isObject) { - if (opts.allowPartial) { + if (contextOpts.allowPartial) { return value } else { - throw new ConfigurationError({ + throw new TemplateError({ message: `Value of ${arrayForEachKey} key must be (or resolve to) an array or mapping object (got ${typeof resolvedInput})`, + path: path && [...path, arrayForEachKey], + value, + resolved: resolvedInput, }) } } @@ -356,11 +440,11 @@ function handleForEachObject(value: any, context: ConfigContext, opts: ContextRe // If partial application is disabled // then we need to make sure that the resulting expression is evaluated again // since the magic keys only get resolved via `resolveTemplateStrings` - if (opts.allowPartial) { + if (contextOpts.allowPartial) { return value } - resolvedInput = resolveTemplateStrings(resolvedInput, context, opts) + resolvedInput = resolveTemplateStrings({ value: resolvedInput, context, contextOpts, source: undefined }) } } @@ -386,36 +470,68 @@ function handleForEachObject(value: any, context: ConfigContext, opts: ContextRe // Check $filter clause output, if applicable if (filterExpression !== undefined) { - const filterResult = resolveTemplateStrings(value[arrayForEachFilterKey], loopContext, opts) + const filterResult = resolveTemplateStrings({ + value: value[arrayForEachFilterKey], + context: loopContext, + contextOpts, + source, + path: path && [...path, arrayForEachFilterKey], + }) if (filterResult === false) { continue } else if (filterResult !== true) { - throw new ConfigurationError({ + throw new TemplateError({ message: `${arrayForEachFilterKey} clause in ${arrayForEachKey} loop must resolve to a boolean value (got ${typeof resolvedInput})`, + path: path && [...path, arrayForEachFilterKey], + value, + resolved: undefined, }) } } - output.push(resolveTemplateStrings(value[arrayForEachReturnKey], loopContext, opts)) + output.push( + resolveTemplateStrings({ + value: value[arrayForEachReturnKey], + context: loopContext, + contextOpts, + source, + path: path && [...path, arrayForEachKey, i], + }) + ) } // Need to resolve once more to handle e.g. $concat expressions - return resolveTemplateStrings(output, context, opts) + return resolveTemplateStrings({ value: output, context, contextOpts, source, path }) } const expectedConditionalKeys = [conditionalKey, conditionalThenKey, conditionalElseKey] -function handleConditional(value: any, context: ConfigContext, opts: ContextResolveOpts) { +function handleConditional({ + value, + context, + contextOpts, + path, + source, +}: { + value: any + context: ConfigContext + contextOpts: ContextResolveOpts + path: ObjectPath | undefined + source: ConfigSource | undefined +}) { // Validate input object const thenExpression = value[conditionalThenKey] const elseExpression = value[conditionalElseKey] if (thenExpression === undefined) { - throw new ConfigurationError({ + throw new TemplateError({ message: `Missing ${conditionalThenKey} field next to ${conditionalKey} field. Got: ${naturalList( Object.keys(value) )}`, + path, + value, + resolved: undefined, }) } @@ -424,30 +540,54 @@ function handleConditional(value: any, context: ConfigContext, opts: ContextReso if (unexpectedKeys.length > 0) { const extraKeys = naturalList(unexpectedKeys.map((k) => JSON.stringify(k))) - throw new ConfigurationError({ + throw new TemplateError({ message: `Found one or more unexpected keys on ${conditionalKey} object: ${extraKeys}. Expected: ${naturalList( expectedConditionalKeys )}`, + path, + value, + resolved: undefined, }) } // Try resolving the value of the $if key - const resolvedConditional = resolveTemplateStrings(value[conditionalKey], context, opts) + const resolvedConditional = resolveTemplateStrings({ + value: value[conditionalKey], + context, + contextOpts, + source, + path: path && [...path, conditionalKey], + }) if (typeof resolvedConditional !== "boolean") { - if (opts.allowPartial) { + if (contextOpts.allowPartial) { return value } else { - throw new ConfigurationError({ + throw new TemplateError({ message: `Value of ${conditionalKey} key must be (or resolve to) a boolean (got ${typeof resolvedConditional})`, + path: path && [...path, conditionalKey], + value, + resolved: resolvedConditional, }) } } // Note: We implicitly default the $else value to undefined - const resolvedThen = resolveTemplateStrings(thenExpression, context, opts) - const resolvedElse = resolveTemplateStrings(elseExpression, context, opts) + const resolvedThen = resolveTemplateStrings({ + value: thenExpression, + context, + path: path && [...path, conditionalThenKey], + contextOpts, + source, + }) + const resolvedElse = resolveTemplateStrings({ + value: elseExpression, + context, + path: path && [...path, conditionalElseKey], + contextOpts, + source, + }) if (!!resolvedConditional) { return resolvedThen @@ -487,7 +627,7 @@ export function mayContainTemplateString(obj: any): boolean { */ export function collectTemplateReferences(obj: T): ContextKeySegment[][] { const context = new ScanContext() - resolveTemplateStrings(obj, context, { allowPartial: true }) + resolveTemplateStrings({ value: obj, context, contextOpts: { allowPartial: true }, source: undefined }) return uniq(context.foundKeys.entries()).sort() } @@ -602,7 +742,7 @@ export function getModuleTemplateReferences(obj: T, context: M const moduleNames = refs.filter((ref) => ref[0] === "modules" && ref.length > 1) // Resolve template strings in name refs. This would ideally be done ahead of this function, but is currently // necessary to resolve templated module name references in ModuleTemplates. - return resolveTemplateStrings(moduleNames, context) + return resolveTemplateStrings({ value: moduleNames, context, source: undefined }) } /** diff --git a/core/src/util/testing.ts b/core/src/util/testing.ts index 56cf0fc117..5fe66897e2 100644 --- a/core/src/util/testing.ts +++ b/core/src/util/testing.ts @@ -43,6 +43,7 @@ import { validateSchema } from "../config/validation" import { mkdirp, remove } from "fs-extra" import { GlobalConfigStore } from "../config-store/global" import { isPromise } from "./objects" +import chalk from "chalk" export class TestError extends GardenError { type = "_test" @@ -418,7 +419,13 @@ export function expectFuzzyMatch(str: string, sample: string | string[]) { const errorMessageNonAnsi = stripAnsi(str) const samples = typeof sample === "string" ? [sample] : sample const samplesNonAnsi = samples.map(stripAnsi) - samplesNonAnsi.forEach((s) => expect(errorMessageNonAnsi.toLowerCase()).to.contain(s.toLowerCase())) + try { + samplesNonAnsi.forEach((s) => expect(errorMessageNonAnsi.toLowerCase()).to.contain(s.toLowerCase())) + } catch (err) { + // eslint-disable-next-line no-console + console.log("Error message:\n", chalk.red(errorMessageNonAnsi), "\n") + throw err + } } export function expectLogsContain(logs: string[], sample: string) { diff --git a/core/test/data/test-project-invalid-config/invalid-syntax-module/garden.yml b/core/test/data/test-project-invalid-config/invalid-syntax-module/garden.yml index fca5b7a8d3..776ee53829 100644 --- a/core/test/data/test-project-invalid-config/invalid-syntax-module/garden.yml +++ b/core/test/data/test-project-invalid-config/invalid-syntax-module/garden.yml @@ -1,4 +1,5 @@ kind: Module +type: test name: invalid-syntax name: invalid-syntax-again foo \ No newline at end of file diff --git a/core/test/helpers.ts b/core/test/helpers.ts index 5a6f0496df..cf7236b2cb 100644 --- a/core/test/helpers.ts +++ b/core/test/helpers.ts @@ -101,6 +101,9 @@ export const getDefaultProjectConfig = (): ProjectConfig => kind: "Project", name: "test", path: "tmp", + internal: { + basePath: "/foo", + }, defaultEnvironment, dotIgnoreFile: defaultDotIgnoreFile, environments: [{ name: "default", defaultNamespace, variables: {} }], @@ -241,7 +244,7 @@ export const makeTestGardenBuildDependants = profileAsync(async function _makeTe */ export async function makeTempGarden(opts?: TestGardenOpts) { const tmpDir = await makeTempDir({ git: true }) - await dumpYaml(join(tmpDir.path, "project.garden.yml"), opts?.config || getDefaultProjectConfig()) + await dumpYaml(join(tmpDir.path, "project.garden.yml"), omit(opts?.config || getDefaultProjectConfig(), "internal")) const garden = await makeTestGarden(tmpDir.path, opts) return { tmpDir, garden } } diff --git a/core/test/unit/src/commands/self-update.ts b/core/test/unit/src/commands/self-update.ts index ed90679374..086655ffef 100644 --- a/core/test/unit/src/commands/self-update.ts +++ b/core/test/unit/src/commands/self-update.ts @@ -88,6 +88,10 @@ describe("SelfUpdateCommand", () => { server = createServer((req, res) => { serve(req, res, finalhandler(req, res)) }) + server.on("error", (err) => { + // eslint-disable-next-line no-console + console.error(err) + }) server.listen(staticServerPort) command._baseReleasesUrl = `http://127.0.0.1:${staticServerPort}/` diff --git a/core/test/unit/src/config/base.ts b/core/test/unit/src/config/base.ts index ba29c9402a..9544205857 100644 --- a/core/test/unit/src/config/base.ts +++ b/core/test/unit/src/config/base.ts @@ -24,6 +24,7 @@ import { safeDumpYaml } from "../../../../src/util/serialization" import { getRootLogger } from "../../../../src/logger/logger" import { ConfigurationError } from "../../../../src/exceptions" import { resetNonRepeatableWarningHistory } from "../../../../src/warnings" +import { omit } from "lodash" const projectPathA = getDataDir("test-project-a") const modulePathA = resolve(projectPathA, "module-a") @@ -165,7 +166,7 @@ describe("loadConfigResources", () => { await expectError( async () => await loadConfigResources(log, projectPath, resolve(projectPath, "invalid-syntax-module", "garden.yml")), - { contains: ["Could not parse", "duplicated mapping key"] } + { contains: ["could not parse", "duplicated mapping key"] } ) }) @@ -191,7 +192,7 @@ describe("loadConfigResources", () => { await expectError( async () => await loadConfigResources(log, projectPath, resolve(projectPath, "missing-type", "garden.yml")), { - contains: "Error validating module (missing-type/garden.yml): key .type is required", + contains: ["Error validating module (missing-type/garden.yml)", "type is required"], } ) }) @@ -201,7 +202,7 @@ describe("loadConfigResources", () => { await expectError( async () => await loadConfigResources(log, projectPath, resolve(projectPath, "missing-name", "garden.yml")), { - contains: "Error validating module (missing-name/garden.yml): key .name is required", + contains: ["Error validating module (missing-name/garden.yml)", "name is required"], } ) }) @@ -224,90 +225,90 @@ describe("loadConfigResources", () => { const configPath = resolve(projectPathA, "garden.yml") const parsed = await loadConfigResources(log, projectPathA, configPath) - expect(parsed).to.eql([ - { - apiVersion: GardenApiVersion.v1, - kind: "Project", - path: projectPathA, - configPath, - name: "test-project-a", - environments: [ + expect(parsed.length).to.equal(1) + + expect(omit(parsed[0], "internal")).to.eql({ + apiVersion: GardenApiVersion.v1, + kind: "Project", + path: projectPathA, + configPath, + name: "test-project-a", + environments: [ + { + name: "local", + }, + { + name: "other", + }, + ], + providers: [{ name: "test-plugin" }, { name: "test-plugin-b", environments: ["local"] }], + outputs: [ + { + name: "taskName", + value: "task-a", + }, + ], + variables: { some: "variable" }, + }) + }) + + it("should load and parse a module config", async () => { + const configPath = resolve(modulePathA, "garden.yml") + const parsed = await loadConfigResources(log, projectPathA, configPath) + + expect(parsed.length).to.equal(1) + + expect(omit(parsed[0], "internal")).to.eql({ + apiVersion: GardenApiVersion.v0, + kind: "Module", + name: "module-a", + type: "test", + configPath, + description: undefined, + disabled: undefined, + generateFiles: undefined, + include: undefined, + exclude: undefined, + repositoryUrl: undefined, + allowPublish: undefined, + build: { dependencies: [], timeout: DEFAULT_BUILD_TIMEOUT_SEC }, + path: modulePathA, + variables: { msg: "OK" }, + varfile: undefined, + + spec: { + build: { + command: ["echo", "A"], + dependencies: [], + }, + services: [{ name: "service-a" }], + tasks: [ { - name: "local", + name: "task-a", + command: ["echo", "${var.msg}"], }, { - name: "other", + name: "task-a2", + command: ["echo", "${environment.name}-${var.msg}"], }, ], - providers: [{ name: "test-plugin" }, { name: "test-plugin-b", environments: ["local"] }], - outputs: [ + tests: [ { - name: "taskName", - value: "task-a", + name: "unit", + command: ["echo", "${var.msg}"], + }, + { + name: "integration", + command: ["echo", "${var.msg}"], + dependencies: ["service-a"], }, ], - variables: { some: "variable" }, }, - ]) - }) - - it("should load and parse a module config", async () => { - const configPath = resolve(modulePathA, "garden.yml") - const parsed = await loadConfigResources(log, projectPathA, configPath) - - expect(parsed).to.eql([ - { - apiVersion: GardenApiVersion.v0, - kind: "Module", - name: "module-a", - type: "test", - configPath, - description: undefined, - disabled: undefined, - generateFiles: undefined, - include: undefined, - exclude: undefined, - repositoryUrl: undefined, - allowPublish: undefined, - build: { dependencies: [], timeout: DEFAULT_BUILD_TIMEOUT_SEC }, - path: modulePathA, - variables: { msg: "OK" }, - varfile: undefined, - - spec: { - build: { - command: ["echo", "A"], - dependencies: [], - }, - services: [{ name: "service-a" }], - tasks: [ - { - name: "task-a", - command: ["echo", "${var.msg}"], - }, - { - name: "task-a2", - command: ["echo", "${environment.name}-${var.msg}"], - }, - ], - tests: [ - { - name: "unit", - command: ["echo", "${var.msg}"], - }, - { - name: "integration", - command: ["echo", "${var.msg}"], - dependencies: ["service-a"], - }, - ], - }, - serviceConfigs: [], - taskConfigs: [], - testConfigs: [], - }, - ]) + serviceConfigs: [], + taskConfigs: [], + testConfigs: [], + }) }) it("should load and parse a module template", async () => { @@ -315,71 +316,68 @@ describe("loadConfigResources", () => { const configFilePath = resolve(projectPath, "templates.garden.yml") const parsed: any = await loadConfigResources(log, projectPath, configFilePath) - expect(parsed).to.eql([ - { - kind: configTemplateKind, - name: "combo", + expect(parsed.length).to.equal(1) - internal: { - basePath: projectPath, - configFilePath, - }, + expect(omit(parsed[0], "internal")).to.eql({ + kind: configTemplateKind, + name: "combo", - inputsSchemaPath: "module-templates.json", - modules: [ - { - type: "test", - name: "${parent.name}-${inputs.name}-a", - include: [], - build: { - command: ["${inputs.value}"], - }, - generateFiles: [ - { - targetPath: "module-a.log", - value: "hellow", - }, - ], + inputsSchemaPath: "module-templates.json", + modules: [ + { + type: "test", + name: "${parent.name}-${inputs.name}-a", + include: [], + build: { + command: ["${inputs.value}"], }, - { - type: "test", - name: "${parent.name}-${inputs.name}-b", - include: [], - build: { - dependencies: ["${parent.name}-${inputs.name}-a"], + generateFiles: [ + { + targetPath: "module-a.log", + value: "hellow", }, - generateFiles: [ - { - targetPath: "module-b.log", - sourcePath: "source.txt", - }, - ], + ], + }, + { + type: "test", + name: "${parent.name}-${inputs.name}-b", + include: [], + build: { + dependencies: ["${parent.name}-${inputs.name}-a"], }, - { - type: "test", - name: "${parent.name}-${inputs.name}-c", - include: [], - build: { - dependencies: ["${parent.name}-${inputs.name}-a"], + generateFiles: [ + { + targetPath: "module-b.log", + sourcePath: "source.txt", }, - generateFiles: [ - { - targetPath: ".garden/subdir/module-c.log", - value: - 'Hello I am string!\ninput: ${inputs.value}\nmodule reference: ${modules["${parent.name}-${inputs.name}-a"].path}\n', - }, - ], + ], + }, + { + type: "test", + name: "${parent.name}-${inputs.name}-c", + include: [], + build: { + dependencies: ["${parent.name}-${inputs.name}-a"], }, - ], - }, - ]) + generateFiles: [ + { + targetPath: ".garden/subdir/module-c.log", + value: + 'Hello I am string!\ninput: ${inputs.value}\nmodule reference: ${modules["${parent.name}-${inputs.name}-a"].path}\n', + }, + ], + }, + ], + }) }) it("should load and parse a config file defining a project and a module", async () => { const configPath = resolve(projectPathMultipleModules, "garden.yml") const parsed = await loadConfigResources(log, projectPathMultipleModules, configPath) - expect(parsed).to.eql([ + expect(parsed.length).to.equal(2) + + expect(parsed.map((p) => omit(p, "internal"))).to.eql([ { apiVersion: GardenApiVersion.v1, kind: "Project", @@ -434,7 +432,9 @@ describe("loadConfigResources", () => { const configPath = resolve(modulePathAMultiple, "garden.yml") const parsed = await loadConfigResources(log, projectPathMultipleModules, configPath) - expect(parsed).to.eql([ + expect(parsed.length).to.equal(2) + + expect(parsed.map((p) => omit(p, "internal"))).to.eql([ { apiVersion: GardenApiVersion.v0, kind: "Module", @@ -506,17 +506,17 @@ describe("loadConfigResources", () => { const configPath = resolve(projectPath, "garden.yml") const parsed = await loadConfigResources(log, projectPath, configPath) - expect(parsed).to.eql([ - { - apiVersion: GardenApiVersion.v1, - kind: "Project", - path: projectPath, - configPath, - name: "test-project-a", - environments: [{ name: "local" }, { name: "other" }], - providers: [{ name: "test-plugin", environments: ["local"] }, { name: "test-plugin-b" }], - }, - ]) + expect(parsed.length).to.equal(1) + + expect(omit(parsed[0], "internal")).to.eql({ + apiVersion: GardenApiVersion.v1, + kind: "Project", + path: projectPath, + configPath, + name: "test-project-a", + environments: [{ name: "local" }, { name: "other" }], + providers: [{ name: "test-plugin", environments: ["local"] }, { name: "test-plugin-b" }], + }) }) it("should throw if config file is not found", async () => { @@ -530,16 +530,14 @@ describe("loadConfigResources", () => { const configPath = resolve(path, "garden.yml") const parsed = await loadConfigResources(log, path, configPath) - expect(parsed).to.eql([ - { - apiVersion: GardenApiVersion.v1, - kind: "Project", - name: "foo", - environments: [{ name: "local" }], - path, - configPath, - }, - ]) + expect(omit(parsed[0], "internal")).to.eql({ + apiVersion: GardenApiVersion.v1, + kind: "Project", + name: "foo", + environments: [{ name: "local" }], + path, + configPath, + }) }) }) diff --git a/core/test/unit/src/config/common.ts b/core/test/unit/src/config/common.ts index f24e9cc348..704adbd8cd 100644 --- a/core/test/unit/src/config/common.ts +++ b/core/test/unit/src/config/common.ts @@ -157,7 +157,7 @@ describe("validate", () => { }) await expectError(() => validateSchema(obj, schema), { - contains: "key .A is required, key .B.b is required", + contains: ["A is required", "B.b is required"], }) }) @@ -181,7 +181,7 @@ describe("validate", () => { }) await expectError(() => validateSchema(obj, schema), { - contains: "key .A.B[c].C is required", + contains: "A.B[c].C is required", }) }) @@ -202,7 +202,7 @@ describe("validate", () => { const schema = joi.object().keys({ a: joi.object().pattern(/[a-z]+/, joi.string()) }) await expectError(() => validateSchema(obj, schema), { - contains: 'key "123" is not allowed at path .a', + contains: '"123" is not allowed at path a', }) }) @@ -453,8 +453,12 @@ describe("joi.customObject", () => { it("should give validation error if object doesn't match specified JSON Schema", async () => { const joiSchema = joi.object().jsonSchema(jsonSchema) await expectError(() => validateSchema({ numberProperty: "oops", blarg: "blorg" }, joiSchema), { - contains: - "Validation error: value at . must have required property 'stringProperty', value at . must NOT have additional properties, value at ./numberProperty must be integer", + contains: [ + "Validation error", + "value at . must have required property 'stringProperty'", + "value at . must NOT have additional properties", + "value at ./numberProperty must be integer", + ], }) }) @@ -471,32 +475,6 @@ describe("joi.customObject", () => { }) }) -describe("validateSchema", () => { - it("should format a basic object validation error", async () => { - const schema = joi.object().keys({ foo: joi.string() }) - const value = { foo: 123 } - await expectError(() => validateSchema(value, schema), { - contains: "Validation error: key .foo must be a string", - }) - }) - - it("should format a nested object validation error", async () => { - const schema = joi.object().keys({ foo: joi.object().keys({ bar: joi.string() }) }) - const value = { foo: { bar: 123 } } - await expectError(() => validateSchema(value, schema), { - contains: "Validation error: key .foo.bar must be a string", - }) - }) - - it("should format a nested pattern object validation error", async () => { - const schema = joi.object().keys({ foo: joi.object().pattern(/.+/, joi.string()) }) - const value = { foo: { bar: 123 } } - await expectError(() => validateSchema(value, schema), { - contains: "Validation error: key .foo[bar] must be a string", - }) - }) -}) - describe("allowUnknown", () => { it("allows unknown fields on an object schema", () => { const schema = joi.object().keys({ key: joi.number() }).unknown(false) diff --git a/core/test/unit/src/config/project.ts b/core/test/unit/src/config/project.ts index e48b7c7c22..abf376b707 100644 --- a/core/test/unit/src/config/project.ts +++ b/core/test/unit/src/config/project.ts @@ -426,6 +426,9 @@ describe("resolveProjectConfig", () => { }) ).to.eql({ ...config, + internal: { + basePath: "/foo", + }, dotIgnoreFiles: [], environments: [ { @@ -1076,7 +1079,7 @@ describe("pickEnvironment", () => { secrets: {}, commandInfo, }), - { contains: "Error validating environment default: key .defaultNamespace must be a string" } + { contains: ["Error validating environment default", "defaultNamespace must be a string"] } ) }) diff --git a/core/test/unit/src/config/render-template.ts b/core/test/unit/src/config/render-template.ts index 3e8900b1d6..6de846ea68 100644 --- a/core/test/unit/src/config/render-template.ts +++ b/core/test/unit/src/config/render-template.ts @@ -77,7 +77,10 @@ describe("config templates", () => { foo: "bar", } await expectError(() => resolveConfigTemplate(garden, config), { - contains: 'Error validating ConfigTemplate (templates.garden.yml): key "foo" is not allowed at path [foo]', + contains: [ + "Error validating ConfigTemplate 'test' (templates.garden.yml)", + '"foo" is not allowed at path [foo]', + ], }) }) @@ -207,7 +210,7 @@ describe("config templates", () => { foo: "bar", } await expectError(() => renderConfigTemplate({ garden, log, config, templates }), { - contains: 'Error validating Render test (modules.garden.yml): key "foo" is not allowed', + contains: ["Error validating Render test (modules.garden.yml)", '"foo" is not allowed'], }) }) @@ -367,7 +370,8 @@ describe("config templates", () => { await expectError(() => renderConfigTemplate({ garden, log, config, templates: _templates }), { contains: [ "ConfigTemplate test returned an invalid module (named foo) for templated module test", - "Error validating module (modules.garden.yml): key .type must be a string", + "Error validating module (modules.garden.yml)", + "type must be a string", ], }) }) @@ -388,8 +392,11 @@ describe("config templates", () => { ...defaults, } await expectError(() => renderConfigTemplate({ garden, log, config, templates: _templates }), { - contains: - "ConfigTemplate test returned an invalid module (named 123) for templated module test: Error validating module (modules.garden.yml): key .name must be a string", + contains: [ + "ConfigTemplate test returned an invalid module (named 123) for templated module test", + "Error validating module (modules.garden.yml)", + "name must be a string", + ], }) }) @@ -456,7 +463,9 @@ describe("config templates", () => { await expectError(() => renderConfigTemplate({ garden, log, config, templates: _templates }), { contains: [ - 'ConfigTemplate test returned an invalid module (named module-${modules.foo.version}-test) for templated module test: Error validating module (modules.garden.yml): key .name with value "module-${modules.foo.version}-test" fails to match the required pattern: /^(?!garden)(?=.{1,63}$)[a-z][a-z0-9]*(-[a-z0-9]+)*$/.', + "ConfigTemplate test returned an invalid module (named module-${modules.foo.version}-test) for templated module test", + "Error validating module (modules.garden.yml)", + 'name with value "module-${modules.foo.version}-test" fails to match the required pattern: /^(?!garden)(?=.{1,63}$)[a-z][a-z0-9]*(-[a-z0-9]+)*$/.', "Note that if a template string is used in the name of a module in a template, then the template string must be fully resolvable at the time of module scanning. This means that e.g. references to other modules or runtime outputs cannot be used.", ], }) diff --git a/core/test/unit/src/config/template-contexts/base.ts b/core/test/unit/src/config/template-contexts/base.ts index ed9172fea3..66d52627ae 100644 --- a/core/test/unit/src/config/template-contexts/base.ts +++ b/core/test/unit/src/config/template-contexts/base.ts @@ -317,7 +317,7 @@ describe("ScanContext", () => { a: "some ${templated.string}", b: "${more.stuff}", } - resolveTemplateStrings(obj, context) + resolveTemplateStrings({ value: obj, context, source: undefined }) expect(context.foundKeys.entries()).to.eql([ ["templated", "string"], ["more", "stuff"], @@ -330,7 +330,7 @@ describe("ScanContext", () => { a: "some ${templated['key.with.dots']}", b: "${more.stuff}", } - resolveTemplateStrings(obj, context) + resolveTemplateStrings({ value: obj, context, source: undefined }) expect(context.foundKeys.entries()).to.eql([ ["templated", "key.with.dots"], ["more", "stuff"], diff --git a/core/test/unit/src/config/template-contexts/project.ts b/core/test/unit/src/config/template-contexts/project.ts index 6877864b01..5903dab592 100644 --- a/core/test/unit/src/config/template-contexts/project.ts +++ b/core/test/unit/src/config/template-contexts/project.ts @@ -305,7 +305,10 @@ describe("ProjectConfigContext", () => { commandInfo: { name: "deploy", args: {}, opts: { sync: ["my-service"] } }, }) - let result = resolveTemplateString("${command.name == 'deploy' && (command.params.sync contains 'my-service')}", c) + let result = resolveTemplateString({ + string: "${command.name == 'deploy' && (command.params.sync contains 'my-service')}", + context: c, + }) expect(result).to.be.true }) @@ -322,10 +325,10 @@ describe("ProjectConfigContext", () => { commandInfo: { name: "test", args: {}, opts: {} }, }) - let result = resolveTemplateString( - "${command.params contains 'sync' && command.params.sync contains 'my-service'}", - c - ) + let result = resolveTemplateString({ + string: "${command.params contains 'sync' && command.params.sync contains 'my-service'}", + context: c, + }) expect(result).to.be.false }) }) diff --git a/core/test/unit/src/config/validation.ts b/core/test/unit/src/config/validation.ts new file mode 100644 index 0000000000..b1241b3348 --- /dev/null +++ b/core/test/unit/src/config/validation.ts @@ -0,0 +1,374 @@ +/* + * Copyright (C) 2018-2023 Garden Technologies, Inc. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { expect } from "chai" +import { joi } from "../../../../src/config/common" +import { validateSchema } from "../../../../src/config/validation" +import { expectError } from "../../../helpers" +import { + BaseGardenResource, + YamlDocumentWithSource, + baseInternalFieldsSchema, + loadAndValidateYaml, +} from "../../../../src/config/base" +import { GardenApiVersion } from "../../../../src/constants" +import { parseDocument } from "yaml" +import { dedent } from "../../../../src/util/string" +import stripAnsi from "strip-ansi" + +describe("validateSchema", () => { + it("returns validated config with default values set", () => { + const schema = joi.object().keys({ + apiVersion: joi.string(), + kind: joi.string(), + name: joi.string(), + internal: baseInternalFieldsSchema(), + foo: joi.string().default("bar"), + }) + + const config: BaseGardenResource = { + apiVersion: GardenApiVersion.v1, + kind: "Test", + name: "foo", + internal: { + basePath: "/foo", + }, + } + + const result = validateSchema(config, schema) + + expect(result).to.eql({ + ...config, + foo: "bar", + }) + }) + + it("should format a basic object validation error", async () => { + const schema = joi.object().keys({ foo: joi.string() }) + const value = { foo: 123 } + await expectError(() => validateSchema(value, schema), { + contains: ["Validation error", "foo must be a string"], + }) + }) + + it("should format a nested object validation error", async () => { + const schema = joi.object().keys({ foo: joi.object().keys({ bar: joi.string() }) }) + const value = { foo: { bar: 123 } } + await expectError(() => validateSchema(value, schema), { + contains: ["Validation error", "foo.bar must be a string"], + }) + }) + + it("should format a nested pattern object validation error", async () => { + const schema = joi.object().keys({ foo: joi.object().pattern(/.+/, joi.string()) }) + const value = { foo: { bar: 123 } } + await expectError(() => validateSchema(value, schema), { + contains: ["Validation error", "foo[bar] must be a string"], + }) + }) + + it("shows available keys when unexpected key is found", () => { + const schema = joi.object().keys({ + foo: joi.string(), + }) + + const config = { bar: "bla" } + + void expectError( + () => validateSchema(config, schema, {}), + (err) => expect(err.message).to.include("Available keys: foo") + ) + }) + + it("doesn't show available keys if object field validation fails but key is expected", () => { + const schema = joi.object().keys({ + foo: joi.string(), + }) + + const config = { foo: 123 } + + void expectError( + () => validateSchema(config, schema, {}), + (err) => expect(err.message).to.not.include("Available keys:") + ) + }) + + it("shows correct position of error if yamlDoc is attached to config, when error is on first line", () => { + const schema = joi.object().keys({ + apiVersion: joi.string(), + kind: joi.string(), + name: joi.string(), + internal: baseInternalFieldsSchema(), + spec: joi.object().keys({ + foo: joi.string(), + }), + }) + + const yaml = dedent` + apiVersion: 123 + kind: Test + name: foo + spec: + foo: bar + ` + + const yamlDoc = parseDocument(yaml) as YamlDocumentWithSource + yamlDoc["source"] = yaml + + const config: any = { + ...yamlDoc.toJS(), + internal: { + basePath: "/foo", + yamlDoc, + }, + } + + void expectError( + () => validateSchema(config, schema, { source: { yamlDoc } }), + (err) => + expect(stripAnsi(err.message)).to.equal(dedent` + Validation error: + + 1 | apiVersion: 123 + -----------------^ + apiVersion must be a string + `) + ) + }) + + it("shows correct position of error if yamlDoc is attached to config", () => { + const schema = joi.object().keys({ + apiVersion: joi.string(), + kind: joi.string(), + name: joi.string(), + internal: baseInternalFieldsSchema(), + spec: joi.object().keys({ + foo: joi.string(), + }), + }) + + const yaml = dedent` + apiVersion: v1 + kind: Test + spec: + foo: 123 + name: foo + ` + + const yamlDoc = parseDocument(yaml) as YamlDocumentWithSource + yamlDoc["source"] = yaml + + const config: any = { + ...yamlDoc.toJS(), + internal: { + basePath: "/foo", + yamlDoc, + }, + } + + void expectError( + () => validateSchema(config, schema, { source: { yamlDoc } }), + (err) => + expect(stripAnsi(err.message)).to.equal(dedent` + Validation error: + + ... + 3 | spec: + 4 | foo: 123 + ------------^ + spec.foo must be a string + `) + ) + }) + + it("shows correct position of error in list item if yamlDoc is attached to config", () => { + const schema = joi.object().keys({ + apiVersion: joi.string(), + kind: joi.string(), + name: joi.string(), + internal: baseInternalFieldsSchema(), + spec: joi.object().keys({ + foo: joi.array().items(joi.string()), + }), + }) + + const yaml = dedent` + apiVersion: v1 + kind: Test + spec: + foo: + - fine + - 123 + name: foo + ` + + const yamlDoc = parseDocument(yaml) as YamlDocumentWithSource + yamlDoc["source"] = yaml + + const config: any = { + ...yamlDoc.toJS(), + internal: { + basePath: "/foo", + yamlDoc, + }, + } + + void expectError( + () => validateSchema(config, schema, { source: { yamlDoc } }), + (err) => + expect(stripAnsi(err.message)).to.equal(dedent` + Validation error: + + ... + 5 | - fine + 6 | - 123 + -----------^ + spec.foo[1] must be a string + `) + ) + }) + + it("shows correct positions of multiple errors if yamlDoc is attached", () => { + const schema = joi.object().keys({ + apiVersion: joi.string(), + kind: joi.string(), + name: joi.string(), + internal: baseInternalFieldsSchema(), + spec: joi.object().keys({ + foo: joi.string(), + }), + }) + + const yaml = dedent` + apiVersion: 123 + kind: Test + spec: + foo: 123 + name: foo + ` + + const yamlDoc = parseDocument(yaml) as YamlDocumentWithSource + yamlDoc["source"] = yaml + + const config: any = { + ...yamlDoc.toJS(), + internal: { + basePath: "/foo", + yamlDoc, + }, + } + + void expectError( + () => validateSchema(config, schema, { source: { yamlDoc } }), + (err) => + expect(stripAnsi(err.message)).to.equal(dedent` + Validation error: + + 1 | apiVersion: 123 + -----------------^ + apiVersion must be a string + + ... + 3 | spec: + 4 | foo: 123 + ------------^ + spec.foo must be a string + `) + ) + }) + + it("shows correct position of error if yamlDoc with multiple configs is attached to config", async () => { + const schema = joi.object().keys({ + apiVersion: joi.string(), + kind: joi.string(), + name: joi.string(), + internal: baseInternalFieldsSchema(), + spec: joi.object().keys({ + foo: joi.string(), + }), + }) + + const yaml = dedent` + apiVersion: v1 + kind: Test + spec: + foo: 123 + name: foo + --- + apiVersion: v1 + kind: Test + spec: + foo: 456 + name: bar + ` + + const yamlDocs = await loadAndValidateYaml(yaml, "/foo") + const yamlDoc = yamlDocs[1] + + const config: any = { + ...yamlDoc.toJS(), + internal: { + basePath: "/foo", + yamlDoc, + }, + } + + void expectError( + () => validateSchema(config, schema, { source: { yamlDoc } }), + (err) => + expect(stripAnsi(err.message)).to.equal(dedent` + Validation error: + + ... + 9 | spec: + 10 | foo: 456 + -------------^ + spec.foo must be a string + `) + ) + }) + + it("shows correct position of error if yamlDoc is attached to config and yamlDocBasePath is set", () => { + const schema = joi.object().keys({ + foo: joi.string(), + }) + + const yaml = dedent` + apiVersion: v1 + kind: Test + spec: + foo: 123 + name: foo + ` + + const yamlDoc = parseDocument(yaml) as YamlDocumentWithSource + yamlDoc["source"] = yaml + + const config: any = { + ...yamlDoc.toJS(), + internal: { + basePath: "/foo", + yamlDoc, + }, + } + + void expectError( + () => validateSchema(config.spec, schema, { source: { yamlDoc, basePath: ["spec"] } }), + (err) => + expect(stripAnsi(err.message)).to.equal(dedent` + Validation error: + + ... + 3 | spec: + 4 | foo: 123 + ------------^ + spec.foo must be a string + `) + ) + }) +}) diff --git a/core/test/unit/src/config/workflow.ts b/core/test/unit/src/config/workflow.ts index ad904d481b..c0f39ba407 100644 --- a/core/test/unit/src/config/workflow.ts +++ b/core/test/unit/src/config/workflow.ts @@ -21,6 +21,7 @@ import { import { EnvironmentConfig, defaultNamespace } from "../../../../src/config/project" import { join } from "path" import { GardenApiVersion } from "../../../../src/constants" +import { omit } from "lodash" describe("resolveWorkflowConfig", () => { let garden: TestGarden @@ -173,7 +174,7 @@ describe("resolveWorkflowConfig", () => { } await expectError(() => resolveWorkflowConfig(garden, configWithTemplateStringInName), { - contains: 'key .name with value "workflow-${secrets.foo}" fails to match the required pattern', + contains: 'name with value "workflow-${secrets.foo}" fails to match the required pattern', }) const configWithTemplateStringInTrigger: WorkflowConfig = { @@ -268,7 +269,7 @@ describe("resolveWorkflowConfig", () => { expect(workflow).to.exist expect(workflow.steps[0].script).to.equal('echo "${inputs.envName}"') // <- resolved later - expect(workflow.internal).to.eql(internal) + expect(omit(workflow.internal, "yamlDoc")).to.eql(internal) }) describe("populateNamespaceForTriggers", () => { diff --git a/core/test/unit/src/garden.ts b/core/test/unit/src/garden.ts index 43b6bd8af5..0d9e175b4a 100644 --- a/core/test/unit/src/garden.ts +++ b/core/test/unit/src/garden.ts @@ -238,7 +238,7 @@ describe("Garden", () => { it("should throw if project.environments is not an array", async () => { const projectRoot = getDataDir("test-project-malformed-environments") await expectError(async () => makeTestGarden(projectRoot), { - contains: "Error validating project environments: value must be an array", + contains: ["Error validating project environments", "must be an array"], }) }) @@ -250,7 +250,7 @@ describe("Garden", () => { }) config.environments = [] // <-- await expectError(async () => await TestGarden.factory(pathFoo, { config }), { - contains: "Error validating project environments: value must contain at least 1 items", + contains: ["Error validating project environments", "must contain at least 1 items"], }) }) @@ -264,7 +264,7 @@ describe("Garden", () => { config.environments = [] // this is omitted later to simulate a config where envs are not set config = omit(config, "environments") as any as ProjectConfig await expectError(async () => await TestGarden.factory(pathFoo, { config }), { - contains: "Error validating project environments: value is required", + contains: ["Error validating project environments", "environments is required"], }) }) @@ -470,6 +470,9 @@ describe("Garden", () => { kind: "Project", name: "test", path: pathFoo, + internal: { + basePath: pathFoo, + }, defaultEnvironment: "default", dotIgnoreFile: ".gitignore", environments: [{ name: "default", defaultNamespace: "foo", variables: {} }], @@ -492,6 +495,9 @@ describe("Garden", () => { kind: "Project", name: "test", path: pathFoo, + internal: { + basePath: pathFoo, + }, proxy: { hostname: "127.0.0.1", // <--- Proxy config is set here }, @@ -520,6 +526,9 @@ describe("Garden", () => { kind: "Project", name: "test", path: pathFoo, + internal: { + basePath: pathFoo, + }, defaultEnvironment: "default", dotIgnoreFile: ".gitignore", environments: [{ name: "default", defaultNamespace: "foo", variables: {} }], @@ -531,6 +540,9 @@ describe("Garden", () => { kind: "Project", name: "test", path: pathFoo, + internal: { + basePath: pathFoo, + }, proxy: { hostname: "127.0.0.1", // <--- This should be overwritten }, @@ -620,8 +632,9 @@ describe("Garden", () => { const garden = await makeTestGarden(projectRoot, { plugins }) await expectError(() => garden.getAllPlugins(), { contains: [ - `Unable to load plugin`, - `Error: Error validating plugin module \"${pluginPath}\": key .gardenPlugin must be of type object`, + "Unable to load plugin", + `Error validating plugin module "${pluginPath}"`, + "gardenPlugin must be of type object", ], }) }) @@ -633,8 +646,9 @@ describe("Garden", () => { const garden = await makeTestGarden(projectRoot, { plugins }) await expectError(() => garden.getAllPlugins(), { contains: [ - `Unable to load plugin`, - `Error: Error validating plugin module "${pluginPath}": key .gardenPlugin is required`, + "Unable to load plugin", + `Error validating plugin module "${pluginPath}"`, + "gardenPlugin is required", ], }) }) @@ -1707,7 +1721,7 @@ describe("Garden", () => { await expectError( () => garden.resolveProviders(garden.log), (err) => { - expectFuzzyMatch(err.message, ["Failed resolving one or more providers:", "- test"]) + expectFuzzyMatch(err.toString(), ["Failed resolving one or more providers:", "- test"]) } ) }) @@ -1933,7 +1947,12 @@ describe("Garden", () => { await expectError( () => garden.resolveProviders(garden.log), (err) => { - expectFuzzyMatch(err.message, ["Failed resolving one or more providers:", "- test"]) + expectFuzzyMatch(err.toString(), [ + "Failed resolving one or more providers:", + "- test", + "Error validating provider configuration", + "foo must be a string", + ]) } ) }) @@ -1962,7 +1981,12 @@ describe("Garden", () => { await expectError( () => garden.resolveProviders(garden.log), (err) => { - expectFuzzyMatch(err.message, ["Failed resolving one or more providers:", "- test"]) + expectFuzzyMatch(err.toString(), [ + "Failed resolving one or more providers:", + "- test", + "Error validating provider configuration", + "foo must be a string", + ]) } ) }) @@ -2165,7 +2189,12 @@ describe("Garden", () => { await expectError( () => garden.resolveProviders(garden.log), (err) => { - expectFuzzyMatch(err.message, ["Failed resolving one or more providers:", "- test"]) + expectFuzzyMatch(err.toString(), [ + "Failed resolving one or more providers:", + "- test", + "Error validating provider configuration", + "foo must be a string", + ]) } ) }) @@ -2200,7 +2229,13 @@ describe("Garden", () => { await expectError( () => garden.resolveProviders(garden.log), (err) => { - expectFuzzyMatch(err.message, ["Failed resolving one or more providers:", "- test"]) + expectFuzzyMatch(err.toString(), [ + "Failed resolving one or more providers:", + "- test", + "Error validating provider configuration", + "base schema from 'base' plugin", + "foo must be a string", + ]) } ) }) @@ -2254,7 +2289,7 @@ describe("Garden", () => { }) await expectError(() => garden.getProjectSources(), { - contains: "Error validating remote source: key [0][name] must be a string", + contains: ["Error validating remote source:", "[0][name] must be a string"], }) delete process.env.TEST_ENV_VAR @@ -2494,14 +2529,14 @@ describe("Garden", () => { expect(build.type).to.equal("test") expect(build.spec.command).to.eql(["${inputs.value}"]) // <- resolved later - expect(build.internal).to.eql(internal) + expect(omit(build.internal, "yamlDoc")).to.eql(internal) expect(deploy["build"]).to.equal("${parent.name}-${inputs.name}") // <- resolved later - expect(deploy.internal).to.eql(internal) + expect(omit(deploy.internal, "yamlDoc")).to.eql(internal) expect(test.dependencies).to.eql(["build.${parent.name}-${inputs.name}"]) // <- resolved later expect(test.spec.command).to.eql(["echo", "${inputs.envName}", "${inputs.providerKey}"]) // <- resolved later - expect(test.internal).to.eql(internal) + expect(omit(test.internal, "yamlDoc")).to.eql(internal) }) it("should resolve a workflow from a template", async () => { @@ -2523,7 +2558,7 @@ describe("Garden", () => { expect(workflow).to.exist expect(workflow.steps).to.eql([{ script: 'echo "${inputs.envName}"' }]) // <- resolved later - expect(workflow.internal).to.eql(internal) + expect(omit(workflow.internal, "yamlDoc")).to.eql(internal) }) it("should throw on duplicate config template names", async () => { @@ -3643,7 +3678,8 @@ describe("Garden", () => { await expectError(() => garden.resolveModules({ log: garden.log }), { contains: [ "Failed resolving one or more modules", - "foo: Error validating Module 'foo': key \"bla\" is not allowed at path [bla]", + "foo: Error validating Module 'foo'", + '"bla" is not allowed at path [bla]', ], }) }) @@ -3691,7 +3727,7 @@ describe("Garden", () => { await expectError(() => garden.resolveModules({ log: garden.log }), { contains: [ "Failed resolving one or more modules:", - "foo: Error validating outputs for module 'foo': key .foo must be a string", + "foo: Error validating outputs for module 'foo':\nfoo must be a string", ], }) }) @@ -3719,13 +3755,13 @@ describe("Garden", () => { } expect(build.type).to.equal("test") - expect(build.getInternal()).to.eql(internal) + expect(omit(build.getInternal(), "yamlDoc")).to.eql(internal) expect(deploy.getBuildAction()?.name).to.equal("foo-test") // <- should be resolved - expect(deploy.getInternal()).to.eql(internal) + expect(omit(deploy.getInternal(), "yamlDoc")).to.eql(internal) expect(test.getDependencies().map((a) => a.key())).to.eql(["build.foo-test"]) // <- should be resolved - expect(test.getInternal()).to.eql(internal) + expect(omit(test.getInternal(), "yamlDoc")).to.eql(internal) }) it("throws with helpful message if action type doesn't exist", async () => { @@ -3927,7 +3963,7 @@ describe("Garden", () => { await expectError(() => garden.resolveModules({ log: garden.log }), { contains: [ "Failed resolving one or more modules:", - "foo: Error validating configuration for module 'foo' (base schema from 'base' plugin): key .base is required", + "foo: Error validating configuration for module 'foo' (base schema from 'base' plugin):\nbase is required", ], }) }) @@ -3992,7 +4028,7 @@ describe("Garden", () => { await expectError(() => garden.resolveModules({ log: garden.log }), { contains: [ "Failed resolving one or more modules:", - "foo: Error validating outputs for module 'foo' (base schema from 'base' plugin): key .foo must be a string", + "foo: Error validating outputs for module 'foo' (base schema from 'base' plugin):\nfoo must be a string", ], }) }) @@ -4077,7 +4113,7 @@ describe("Garden", () => { await expectError(() => garden.resolveModules({ log: garden.log }), { contains: [ "Failed resolving one or more modules:", - "foo: Error validating configuration for module 'foo' (base schema from 'base-a' plugin): key .base is required", + "foo: Error validating configuration for module 'foo' (base schema from 'base-a' plugin):\nbase is required", ], }) }) @@ -4155,7 +4191,7 @@ describe("Garden", () => { await expectError(() => garden.resolveModules({ log: garden.log }), { contains: [ "Failed resolving one or more modules:", - "foo: Error validating outputs for module 'foo' (base schema from 'base-a' plugin): key .foo must be a string", + "foo: Error validating outputs for module 'foo' (base schema from 'base-a' plugin):\nfoo must be a string", ], }) }) diff --git a/core/test/unit/src/router/base.ts b/core/test/unit/src/router/base.ts index fdc0b443a1..4800d96b96 100644 --- a/core/test/unit/src/router/base.ts +++ b/core/test/unit/src/router/base.ts @@ -637,7 +637,7 @@ describe("BaseActionRouter", () => { await expectError( async () => await router.validateActionOutputs(resolvedBuildAction, "static", { staticKey: 123 }), { - contains: ["Error validating static action outputs from Build", "key .staticKey must be a string."], + contains: ["Error validating static action outputs from Build", "staticKey must be a string"], } ) }) @@ -646,7 +646,7 @@ describe("BaseActionRouter", () => { const { router } = await createTestRouter(testPlugins) await expectError(async () => await router.validateActionOutputs(resolvedBuildAction, "runtime", { foo: 123 }), { - contains: "Error validating runtime action outputs from Build 'module-a': key .foo must be a string.", + contains: ["Error validating runtime action outputs from Build 'module-a'", "foo must be a string"], }) }) @@ -670,7 +670,7 @@ describe("BaseActionRouter", () => { thisPropertyFromBaseMustBePresent: "this should be a number", }), { - contains: "key .thispropertyfrombasemustbepresent must be a number", + contains: "thispropertyfrombasemustbepresent must be a number", } ) }) diff --git a/core/test/unit/src/router/deploy.ts b/core/test/unit/src/router/deploy.ts index c926a082a8..f76d5ebd66 100644 --- a/core/test/unit/src/router/deploy.ts +++ b/core/test/unit/src/router/deploy.ts @@ -65,7 +65,7 @@ describe("deploy actions", () => { action: resolvedDeployAction, graph, }), - { contains: "Error validating runtime action outputs from Deploy 'service-a': key .foo must be a string." } + { contains: ["Error validating runtime action outputs from Deploy 'service-a'", "foo must be a string"] } ) }) }) @@ -95,7 +95,7 @@ describe("deploy actions", () => { graph, force: true, }), - { contains: "Error validating runtime action outputs from Deploy 'service-a': key .foo must be a string." } + { contains: ["Error validating runtime action outputs from Deploy 'service-a'", "foo must be a string"] } ) }) }) diff --git a/core/test/unit/src/router/run.ts b/core/test/unit/src/router/run.ts index 7a7a954f0d..4d966c6b95 100644 --- a/core/test/unit/src/router/run.ts +++ b/core/test/unit/src/router/run.ts @@ -75,7 +75,7 @@ describe("run actions", () => { it("should throw if the outputs don't match the task outputs schema of the plugin", async () => { resolvedRunAction._config[returnWrongOutputsCfgKey] = true await expectError(() => actionRouter.run.getResult({ log, action: resolvedRunAction, graph }), { - contains: "Error validating runtime action outputs from Run 'task-a': key .foo must be a string", + contains: ["Error validating runtime action outputs from Run 'task-a'", "foo must be a string"], }) }) }) @@ -101,7 +101,7 @@ describe("run actions", () => { interactive: true, graph, }), - { contains: "Error validating runtime action outputs from Run 'task-a': key .foo must be a string" } + { contains: ["Error validating runtime action outputs from Run 'task-a'", "foo must be a string"] } ) }) diff --git a/core/test/unit/src/template-string.ts b/core/test/unit/src/template-string.ts index 9debb11605..fa5e642e39 100644 --- a/core/test/unit/src/template-string.ts +++ b/core/test/unit/src/template-string.ts @@ -29,107 +29,128 @@ class TestContext extends ConfigContext { describe("resolveTemplateString", () => { it("should return a non-templated string unchanged", () => { - const res = resolveTemplateString("somestring", new TestContext({})) + const res = resolveTemplateString({ string: "somestring", context: new TestContext({}) }) expect(res).to.equal("somestring") }) it("should resolve a key with a dash in it", () => { - const res = resolveTemplateString("${some-key}", new TestContext({ "some-key": "value" })) + const res = resolveTemplateString({ string: "${some-key}", context: new TestContext({ "some-key": "value" }) }) expect(res).to.equal("value") }) it("should resolve a nested key with a dash in it", () => { - const res = resolveTemplateString("${ctx.some-key}", new TestContext({ ctx: { "some-key": "value" } })) + const res = resolveTemplateString({ + string: "${ctx.some-key}", + context: new TestContext({ ctx: { "some-key": "value" } }), + }) expect(res).to.equal("value") }) it("should correctly resolve if ? suffix is present but value exists", () => { - const res = resolveTemplateString("${foo}?", new TestContext({ foo: "bar" })) + const res = resolveTemplateString({ string: "${foo}?", context: new TestContext({ foo: "bar" }) }) expect(res).to.equal("bar") }) it("should allow undefined values if ? suffix is present", () => { - const res = resolveTemplateString("${foo}?", new TestContext({})) + const res = resolveTemplateString({ string: "${foo}?", context: new TestContext({}) }) expect(res).to.equal(undefined) }) it("should pass optional string through if allowPartial=true", () => { - const res = resolveTemplateString("${foo}?", new TestContext({}), { allowPartial: true }) + const res = resolveTemplateString({ + string: "${foo}?", + context: new TestContext({}), + contextOpts: { allowPartial: true }, + }) expect(res).to.equal("${foo}?") }) it("should support a string literal in a template string as a means to escape it", () => { - const res = resolveTemplateString("${'$'}{bar}", new TestContext({})) + const res = resolveTemplateString({ string: "${'$'}{bar}", context: new TestContext({}) }) expect(res).to.equal("${bar}") }) it("should pass through a template string with a double $$ prefix", () => { - const res = resolveTemplateString("$${bar}", new TestContext({})) + const res = resolveTemplateString({ string: "$${bar}", context: new TestContext({}) }) expect(res).to.equal("$${bar}") }) it("should allow unescaping a template string with a double $$ prefix", () => { - const res = resolveTemplateString("$${bar}", new TestContext({}), { unescape: true }) + const res = resolveTemplateString({ + string: "$${bar}", + context: new TestContext({}), + contextOpts: { unescape: true }, + }) expect(res).to.equal("${bar}") }) it("should allow mixing normal and escaped strings", () => { - const res = resolveTemplateString("${foo}-and-$${var.nope}", new TestContext({ foo: "yes" }), { unescape: true }) + const res = resolveTemplateString({ + string: "${foo}-and-$${var.nope}", + context: new TestContext({ foo: "yes" }), + contextOpts: { unescape: true }, + }) expect(res).to.equal("yes-and-${var.nope}") }) it("should interpolate a format string with a prefix", () => { - const res = resolveTemplateString("prefix-${some}", new TestContext({ some: "value" })) + const res = resolveTemplateString({ string: "prefix-${some}", context: new TestContext({ some: "value" }) }) expect(res).to.equal("prefix-value") }) it("should interpolate a format string with a suffix", () => { - const res = resolveTemplateString("${some}-suffix", new TestContext({ some: "value" })) + const res = resolveTemplateString({ string: "${some}-suffix", context: new TestContext({ some: "value" }) }) expect(res).to.equal("value-suffix") }) it("should interpolate a format string with a prefix and a suffix", () => { - const res = resolveTemplateString("prefix-${some}-suffix", new TestContext({ some: "value" })) + const res = resolveTemplateString({ string: "prefix-${some}-suffix", context: new TestContext({ some: "value" }) }) expect(res).to.equal("prefix-value-suffix") }) it("should interpolate an optional format string with a prefix and a suffix", () => { - const res = resolveTemplateString("prefix-${some}?-suffix", new TestContext({})) + const res = resolveTemplateString({ string: "prefix-${some}?-suffix", context: new TestContext({}) }) expect(res).to.equal("prefix--suffix") }) it("should interpolate a format string with a prefix with whitespace", () => { - const res = resolveTemplateString("prefix ${some}", new TestContext({ some: "value" })) + const res = resolveTemplateString({ string: "prefix ${some}", context: new TestContext({ some: "value" }) }) expect(res).to.equal("prefix value") }) it("should interpolate a format string with a suffix with whitespace", () => { - const res = resolveTemplateString("${some} suffix", new TestContext({ some: "value" })) + const res = resolveTemplateString({ string: "${some} suffix", context: new TestContext({ some: "value" }) }) expect(res).to.equal("value suffix") }) it("should correctly interpolate a format string with surrounding whitespace", () => { - const res = resolveTemplateString("prefix ${some} suffix", new TestContext({ some: "value" })) + const res = resolveTemplateString({ string: "prefix ${some} suffix", context: new TestContext({ some: "value" }) }) expect(res).to.equal("prefix value suffix") }) it("should handle a nested key", () => { - const res = resolveTemplateString("${some.nested}", new TestContext({ some: { nested: "value" } })) + const res = resolveTemplateString({ + string: "${some.nested}", + context: new TestContext({ some: { nested: "value" } }), + }) expect(res).to.equal("value") }) it("should handle multiple format strings", () => { - const res = resolveTemplateString("prefix-${a}-${b}-suffix", new TestContext({ a: "value", b: "other" })) + const res = resolveTemplateString({ + string: "prefix-${a}-${b}-suffix", + context: new TestContext({ a: "value", b: "other" }), + }) expect(res).to.equal("prefix-value-other-suffix") }) it("should handle consecutive format strings", () => { - const res = resolveTemplateString("${a}${b}", new TestContext({ a: "value", b: "other" })) + const res = resolveTemplateString({ string: "${a}${b}", context: new TestContext({ a: "value", b: "other" }) }) expect(res).to.equal("valueother") }) it("should throw when a key is not found", () => { - void expectError(() => resolveTemplateString("${some}", new TestContext({})), { + void expectError(() => resolveTemplateString({ string: "${some}", context: new TestContext({}) }), { contains: "Invalid template string (${some}): Could not find key some.", }) }) @@ -137,29 +158,32 @@ describe("resolveTemplateString", () => { it("should trim long template string in error messages", () => { void expectError( () => - resolveTemplateString( - "${some} very very very very very long long long long long template string", - new TestContext({}) - ), + resolveTemplateString({ + string: "${some} very very very very very long long long long long template string", + context: new TestContext({}), + }), { contains: "Invalid template string (${some} very very very very very l…): Could not find key some." } ) }) it("should replace line breaks in template strings in error messages", () => { - void expectError(() => resolveTemplateString("${some}\nmulti\nline\nstring", new TestContext({})), { - contains: "Invalid template string (${some}\\nmulti\\nline\\nstring): Could not find key some.", - }) + void expectError( + () => resolveTemplateString({ string: "${some}\nmulti\nline\nstring", context: new TestContext({}) }), + { + contains: "Invalid template string (${some}\\nmulti\\nline\\nstring): Could not find key some.", + } + ) }) it("should throw when a nested key is not found", () => { - void expectError(() => resolveTemplateString("${some.other}", new TestContext({ some: {} })), { + void expectError(() => resolveTemplateString({ string: "${some.other}", context: new TestContext({ some: {} }) }), { contains: "Invalid template string (${some.other}): Could not find key other under some.", }) }) it("should throw with an incomplete template string", () => { try { - resolveTemplateString("${some", new TestContext({ some: {} })) + resolveTemplateString({ string: "${some", context: new TestContext({ some: {} }) }) } catch (err) { if (!(err instanceof TemplateStringError)) { expect.fail("Expected TemplateStringError") @@ -174,493 +198,543 @@ describe("resolveTemplateString", () => { }) it("should throw on nested format strings", () => { - void expectError(() => resolveTemplateString("${resol${part}ed}", new TestContext({})), { + void expectError(() => resolveTemplateString({ string: "${resol${part}ed}", context: new TestContext({}) }), { contains: "Invalid template string (${resol${part}ed}): Unable to parse as valid template string.", }) }) it("should handle a single-quoted string", () => { - const res = resolveTemplateString("${'foo'}", new TestContext({})) + const res = resolveTemplateString({ string: "${'foo'}", context: new TestContext({}) }) expect(res).to.equal("foo") }) it("should handle a numeric literal and return it directly", () => { - const res = resolveTemplateString("${123}", new TestContext({})) + const res = resolveTemplateString({ string: "${123}", context: new TestContext({}) }) expect(res).to.equal(123) }) it("should handle a boolean true literal and return it directly", () => { - const res = resolveTemplateString("${true}", new TestContext({})) + const res = resolveTemplateString({ string: "${true}", context: new TestContext({}) }) expect(res).to.equal(true) }) it("should handle a boolean false literal and return it directly", () => { - const res = resolveTemplateString("${false}", new TestContext({})) + const res = resolveTemplateString({ string: "${false}", context: new TestContext({}) }) expect(res).to.equal(false) }) it("should handle a null literal and return it directly", () => { - const res = resolveTemplateString("${null}", new TestContext({})) + const res = resolveTemplateString({ string: "${null}", context: new TestContext({}) }) expect(res).to.equal(null) }) it("should handle a numeric literal in a logical OR and return it directly", () => { - const res = resolveTemplateString("${a || 123}", new TestContext({})) + const res = resolveTemplateString({ string: "${a || 123}", context: new TestContext({}) }) expect(res).to.equal(123) }) it("should handle a boolean true literal in a logical OR and return it directly", () => { - const res = resolveTemplateString("${a || true}", new TestContext({})) + const res = resolveTemplateString({ string: "${a || true}", context: new TestContext({}) }) expect(res).to.equal(true) }) it("should handle a boolean false literal in a logical OR and return it directly", () => { - const res = resolveTemplateString("${a || false}", new TestContext({})) + const res = resolveTemplateString({ string: "${a || false}", context: new TestContext({}) }) expect(res).to.equal(false) }) it("should handle a null literal in a logical OR and return it directly", () => { - const res = resolveTemplateString("${a || null}", new TestContext({})) + const res = resolveTemplateString({ string: "${a || null}", context: new TestContext({}) }) expect(res).to.equal(null) }) it("should handle a double-quoted string", () => { - const res = resolveTemplateString('${"foo"}', new TestContext({})) + const res = resolveTemplateString({ string: '${"foo"}', context: new TestContext({}) }) expect(res).to.equal("foo") }) it("should throw on invalid single-quoted string", () => { - void expectError(() => resolveTemplateString("${'foo}", new TestContext({})), { + void expectError(() => resolveTemplateString({ string: "${'foo}", context: new TestContext({}) }), { contains: "Invalid template string (${'foo}): Unable to parse as valid template string.", }) }) it("should throw on invalid double-quoted string", () => { - void expectError(() => resolveTemplateString('${"foo}', new TestContext({})), { + void expectError(() => resolveTemplateString({ string: '${"foo}', context: new TestContext({}) }), { contains: 'Invalid template string (${"foo}): Unable to parse as valid template string.', }) }) it("should handle a logical OR between two identifiers", () => { - const res = resolveTemplateString("${a || b}", new TestContext({ a: undefined, b: "abc" })) + const res = resolveTemplateString({ string: "${a || b}", context: new TestContext({ a: undefined, b: "abc" }) }) expect(res).to.equal("abc") }) it("should handle a logical OR between two nested identifiers", () => { - const res = resolveTemplateString( - "${a.b || c.d}", - new TestContext({ + const res = resolveTemplateString({ + string: "${a.b || c.d}", + context: new TestContext({ a: { b: undefined }, c: { d: "abc" }, - }) - ) + }), + }) expect(res).to.equal("abc") }) it("should handle a logical OR between two nested identifiers where the first resolves", () => { - const res = resolveTemplateString( - "${a.b || c.d}", - new TestContext({ + const res = resolveTemplateString({ + string: "${a.b || c.d}", + context: new TestContext({ a: { b: "abc" }, c: { d: undefined }, - }) - ) + }), + }) expect(res).to.equal("abc") }) it("should handle a logical OR between two identifiers without spaces with first value undefined", () => { - const res = resolveTemplateString("${a||b}", new TestContext({ a: undefined, b: "abc" })) + const res = resolveTemplateString({ string: "${a||b}", context: new TestContext({ a: undefined, b: "abc" }) }) expect(res).to.equal("abc") }) it("should handle a logical OR between two identifiers with first value undefined and string fallback", () => { - const res = resolveTemplateString('${a || "foo"}', new TestContext({ a: undefined })) + const res = resolveTemplateString({ string: '${a || "foo"}', context: new TestContext({ a: undefined }) }) expect(res).to.equal("foo") }) it("should handle a logical OR with undefined nested value and string fallback", () => { - const res = resolveTemplateString("${a.b || 'foo'}", new TestContext({ a: {} })) + const res = resolveTemplateString({ string: "${a.b || 'foo'}", context: new TestContext({ a: {} }) }) expect(res).to.equal("foo") }) it("should handle chained logical OR with string fallback", () => { - const res = resolveTemplateString("${a.b || c.d || e.f || 'foo'}", new TestContext({ a: {}, c: {}, e: {} })) + const res = resolveTemplateString({ + string: "${a.b || c.d || e.f || 'foo'}", + context: new TestContext({ a: {}, c: {}, e: {} }), + }) expect(res).to.equal("foo") }) it("should handle a logical OR between two identifiers without spaces with first value set", () => { - const res = resolveTemplateString("${a||b}", new TestContext({ a: "abc", b: undefined })) + const res = resolveTemplateString({ string: "${a||b}", context: new TestContext({ a: "abc", b: undefined }) }) expect(res).to.equal("abc") }) it("should throw if neither key in logical OR is valid", () => { - void expectError(() => resolveTemplateString("${a || b}", new TestContext({})), { + void expectError(() => resolveTemplateString({ string: "${a || b}", context: new TestContext({}) }), { contains: "Invalid template string (${a || b}): Could not find key b.", }) }) it("should throw on invalid logical OR string", () => { - void expectError(() => resolveTemplateString("${a || 'b}", new TestContext({})), { + void expectError(() => resolveTemplateString({ string: "${a || 'b}", context: new TestContext({}) }), { contains: "Invalid template string (${a || 'b}): Unable to parse as valid template string.", }) }) it("should handle a logical OR between a string and a string", () => { - const res = resolveTemplateString("${'a' || 'b'}", new TestContext({ a: undefined })) + const res = resolveTemplateString({ string: "${'a' || 'b'}", context: new TestContext({ a: undefined }) }) expect(res).to.equal("a") }) it("should handle a logical OR between an empty string and a string", () => { - const res = resolveTemplateString("${a || 'b'}", new TestContext({ a: "" })) + const res = resolveTemplateString({ string: "${a || 'b'}", context: new TestContext({ a: "" }) }) expect(res).to.equal("b") }) context("logical AND (&& operator)", () => { it("true literal and true variable reference", () => { - const res = resolveTemplateString("${true && a}", new TestContext({ a: true })) + const res = resolveTemplateString({ string: "${true && a}", context: new TestContext({ a: true }) }) expect(res).to.equal(true) }) it("two true variable references", () => { - const res = resolveTemplateString("${var.a && var.b}", new TestContext({ var: { a: true, b: true } })) + const res = resolveTemplateString({ + string: "${var.a && var.b}", + context: new TestContext({ var: { a: true, b: true } }), + }) expect(res).to.equal(true) }) it("first part is false but the second part is not resolvable", () => { // i.e. the 2nd clause should not need to be evaluated - const res = resolveTemplateString("${false && a}", new TestContext({})) + const res = resolveTemplateString({ string: "${false && a}", context: new TestContext({}) }) expect(res).to.equal(false) }) it("an empty string as the first clause", () => { - const res = resolveTemplateString("${'' && true}", new TestContext({})) + const res = resolveTemplateString({ string: "${'' && true}", context: new TestContext({}) }) expect(res).to.equal("") }) it("an empty string as the second clause", () => { - const res = resolveTemplateString("${true && ''}", new TestContext({})) + const res = resolveTemplateString({ string: "${true && ''}", context: new TestContext({}) }) expect(res).to.equal("") }) it("a missing reference as the first clause", () => { - const res = resolveTemplateString("${var.foo && 'a'}", new TestContext({ var: {} })) + const res = resolveTemplateString({ string: "${var.foo && 'a'}", context: new TestContext({ var: {} }) }) expect(res).to.equal(false) }) it("a missing reference as the second clause", () => { - const res = resolveTemplateString("${'a' && var.foo}", new TestContext({ var: {} })) + const res = resolveTemplateString({ string: "${'a' && var.foo}", context: new TestContext({ var: {} }) }) expect(res).to.equal(false) }) context("partial resolution", () => { it("a missing reference as the first clause returns the original template", () => { - const res = resolveTemplateString("${var.foo && 'a'}", new TestContext({ var: {} }), { allowPartial: true }) + const res = resolveTemplateString({ + string: "${var.foo && 'a'}", + context: new TestContext({ var: {} }), + contextOpts: { allowPartial: true }, + }) expect(res).to.equal("${var.foo && 'a'}") }) it("a missing reference as the second clause returns the original template", () => { - const res = resolveTemplateString("${'a' && var.foo}", new TestContext({ var: {} }), { allowPartial: true }) + const res = resolveTemplateString({ + string: "${'a' && var.foo}", + context: new TestContext({ var: {} }), + contextOpts: { allowPartial: true }, + }) expect(res).to.equal("${'a' && var.foo}") }) }) }) it("should handle a positive equality comparison between equal resolved values", () => { - const res = resolveTemplateString("${a == b}", new TestContext({ a: "a", b: "a" })) + const res = resolveTemplateString({ string: "${a == b}", context: new TestContext({ a: "a", b: "a" }) }) expect(res).to.equal(true) }) it("should handle a positive equality comparison between equal string literals", () => { - const res = resolveTemplateString("${'a' == 'a'}", new TestContext({})) + const res = resolveTemplateString({ string: "${'a' == 'a'}", context: new TestContext({}) }) expect(res).to.equal(true) }) it("should handle a positive equality comparison between equal numeric literals", () => { - const res = resolveTemplateString("${123 == 123}", new TestContext({})) + const res = resolveTemplateString({ string: "${123 == 123}", context: new TestContext({}) }) expect(res).to.equal(true) }) it("should handle a positive equality comparison between equal boolean literals", () => { - const res = resolveTemplateString("${true == true}", new TestContext({})) + const res = resolveTemplateString({ string: "${true == true}", context: new TestContext({}) }) expect(res).to.equal(true) }) it("should handle a positive equality comparison between different resolved values", () => { - const res = resolveTemplateString("${a == b}", new TestContext({ a: "a", b: "b" })) + const res = resolveTemplateString({ string: "${a == b}", context: new TestContext({ a: "a", b: "b" }) }) expect(res).to.equal(false) }) it("should handle a positive equality comparison between different string literals", () => { - const res = resolveTemplateString("${'a' == 'b'}", new TestContext({})) + const res = resolveTemplateString({ string: "${'a' == 'b'}", context: new TestContext({}) }) expect(res).to.equal(false) }) it("should handle a positive equality comparison between different numeric literals", () => { - const res = resolveTemplateString("${123 == 456}", new TestContext({})) + const res = resolveTemplateString({ string: "${123 == 456}", context: new TestContext({}) }) expect(res).to.equal(false) }) it("should handle a positive equality comparison between different boolean literals", () => { - const res = resolveTemplateString("${true == false}", new TestContext({})) + const res = resolveTemplateString({ string: "${true == false}", context: new TestContext({}) }) expect(res).to.equal(false) }) it("should handle a negative equality comparison between equal resolved values", () => { - const res = resolveTemplateString("${a != b}", new TestContext({ a: "a", b: "a" })) + const res = resolveTemplateString({ string: "${a != b}", context: new TestContext({ a: "a", b: "a" }) }) expect(res).to.equal(false) }) it("should handle a negative equality comparison between equal string literals", () => { - const res = resolveTemplateString("${'a' != 'a'}", new TestContext({})) + const res = resolveTemplateString({ string: "${'a' != 'a'}", context: new TestContext({}) }) expect(res).to.equal(false) }) it("should handle a negative equality comparison between equal numeric literals", () => { - const res = resolveTemplateString("${123 != 123}", new TestContext({})) + const res = resolveTemplateString({ string: "${123 != 123}", context: new TestContext({}) }) expect(res).to.equal(false) }) it("should handle a negative equality comparison between equal boolean literals", () => { - const res = resolveTemplateString("${false != false}", new TestContext({})) + const res = resolveTemplateString({ string: "${false != false}", context: new TestContext({}) }) expect(res).to.equal(false) }) it("should handle a negative equality comparison between different resolved values", () => { - const res = resolveTemplateString("${a != b}", new TestContext({ a: "a", b: "b" })) + const res = resolveTemplateString({ string: "${a != b}", context: new TestContext({ a: "a", b: "b" }) }) expect(res).to.equal(true) }) it("should handle a negative equality comparison between different string literals", () => { - const res = resolveTemplateString("${'a' != 'b'}", new TestContext({})) + const res = resolveTemplateString({ string: "${'a' != 'b'}", context: new TestContext({}) }) expect(res).to.equal(true) }) it("should handle a negative equality comparison between different numeric literals", () => { - const res = resolveTemplateString("${123 != 456}", new TestContext({})) + const res = resolveTemplateString({ string: "${123 != 456}", context: new TestContext({}) }) expect(res).to.equal(true) }) it("should handle a negative equality comparison between different boolean literals", () => { - const res = resolveTemplateString("${true != false}", new TestContext({})) + const res = resolveTemplateString({ string: "${true != false}", context: new TestContext({}) }) expect(res).to.equal(true) }) it("should handle a positive equality comparison between different value types", () => { - const res = resolveTemplateString("${true == 'foo'}", new TestContext({})) + const res = resolveTemplateString({ string: "${true == 'foo'}", context: new TestContext({}) }) expect(res).to.equal(false) }) it("should handle a negative equality comparison between different value types", () => { - const res = resolveTemplateString("${123 != false}", new TestContext({})) + const res = resolveTemplateString({ string: "${123 != false}", context: new TestContext({}) }) expect(res).to.equal(true) }) it("should handle negations on booleans", () => { - const res = resolveTemplateString("${!true}", new TestContext({})) + const res = resolveTemplateString({ string: "${!true}", context: new TestContext({}) }) expect(res).to.equal(false) }) it("should handle negations on nulls", () => { - const res = resolveTemplateString("${!null}", new TestContext({})) + const res = resolveTemplateString({ string: "${!null}", context: new TestContext({}) }) expect(res).to.equal(true) }) it("should handle negations on empty strings", () => { - const res = resolveTemplateString("${!''}", new TestContext({})) + const res = resolveTemplateString({ string: "${!''}", context: new TestContext({}) }) expect(res).to.equal(true) }) it("should handle negations on resolved keys", () => { - const res = resolveTemplateString("${!a}", new TestContext({ a: false })) + const res = resolveTemplateString({ string: "${!a}", context: new TestContext({ a: false }) }) expect(res).to.equal(true) }) it("should handle the typeof operator for resolved booleans", () => { - const res = resolveTemplateString("${typeof a}", new TestContext({ a: false })) + const res = resolveTemplateString({ string: "${typeof a}", context: new TestContext({ a: false }) }) expect(res).to.equal("boolean") }) it("should handle the typeof operator for resolved numbers", () => { - const res = resolveTemplateString("${typeof foo}", new TestContext({ foo: 1234 })) + const res = resolveTemplateString({ string: "${typeof foo}", context: new TestContext({ foo: 1234 }) }) expect(res).to.equal("number") }) it("should handle the typeof operator for strings", () => { - const res = resolveTemplateString("${typeof 'foo'}", new TestContext({})) + const res = resolveTemplateString({ string: "${typeof 'foo'}", context: new TestContext({}) }) expect(res).to.equal("string") }) it("should throw when using comparison operators on missing keys", () => { - void expectError(() => resolveTemplateString("${a >= b}", new TestContext({ a: 123 })), { + void expectError(() => resolveTemplateString({ string: "${a >= b}", context: new TestContext({ a: 123 }) }), { contains: "Invalid template string (${a >= b}): Could not find key b. Available keys: a.", }) }) it("should concatenate two arrays", () => { - const res = resolveTemplateString("${a + b}", new TestContext({ a: [1], b: [2, 3] })) + const res = resolveTemplateString({ string: "${a + b}", context: new TestContext({ a: [1], b: [2, 3] }) }) expect(res).to.eql([1, 2, 3]) }) it("should concatenate two strings", () => { - const res = resolveTemplateString("${a + b}", new TestContext({ a: "foo", b: "bar" })) + const res = resolveTemplateString({ string: "${a + b}", context: new TestContext({ a: "foo", b: "bar" }) }) expect(res).to.eql("foobar") }) it("should add two numbers together", () => { - const res = resolveTemplateString("${1 + a}", new TestContext({ a: 2 })) + const res = resolveTemplateString({ string: "${1 + a}", context: new TestContext({ a: 2 }) }) expect(res).to.equal(3) }) it("should throw when using + on number and array", () => { - void expectError(() => resolveTemplateString("${a + b}", new TestContext({ a: 123, b: ["a"] })), { - contains: - "Invalid template string (${a + b}): Both terms need to be either arrays or strings or numbers for + operator (got number and object).", - }) + void expectError( + () => resolveTemplateString({ string: "${a + b}", context: new TestContext({ a: 123, b: ["a"] }) }), + { + contains: + "Invalid template string (${a + b}): Both terms need to be either arrays or strings or numbers for + operator (got number and object).", + } + ) }) it("should correctly evaluate clauses in parentheses", () => { - const res = resolveTemplateString("${(1 + 2) * (3 + 4)}", new TestContext({})) + const res = resolveTemplateString({ string: "${(1 + 2) * (3 + 4)}", context: new TestContext({}) }) expect(res).to.equal(21) }) it("should handle member lookup with bracket notation", () => { - const res = resolveTemplateString("${foo['bar']}", new TestContext({ foo: { bar: true } })) + const res = resolveTemplateString({ string: "${foo['bar']}", context: new TestContext({ foo: { bar: true } }) }) expect(res).to.equal(true) }) it("should handle member lookup with bracket notation, single quotes and dot in key name", () => { - const res = resolveTemplateString("${foo['bar.baz']}", new TestContext({ foo: { "bar.baz": true } })) + const res = resolveTemplateString({ + string: "${foo['bar.baz']}", + context: new TestContext({ foo: { "bar.baz": true } }), + }) expect(res).to.equal(true) }) it("should handle member lookup with bracket notation, double quotes and dot in key name", () => { - const res = resolveTemplateString('${foo.bar["bla.ble"]}', new TestContext({ foo: { bar: { "bla.ble": 123 } } })) + const res = resolveTemplateString({ + string: '${foo.bar["bla.ble"]}', + context: new TestContext({ foo: { bar: { "bla.ble": 123 } } }), + }) expect(res).to.equal(123) }) it("should handle numeric member lookup with bracket notation", () => { - const res = resolveTemplateString("${foo[1]}", new TestContext({ foo: [false, true] })) + const res = resolveTemplateString({ string: "${foo[1]}", context: new TestContext({ foo: [false, true] }) }) expect(res).to.equal(true) }) it("should handle consecutive member lookups with bracket notation", () => { - const res = resolveTemplateString("${foo['bar']['baz']}", new TestContext({ foo: { bar: { baz: true } } })) + const res = resolveTemplateString({ + string: "${foo['bar']['baz']}", + context: new TestContext({ foo: { bar: { baz: true } } }), + }) expect(res).to.equal(true) }) it("should handle dot member after bracket member", () => { - const res = resolveTemplateString("${foo['bar'].baz}", new TestContext({ foo: { bar: { baz: true } } })) + const res = resolveTemplateString({ + string: "${foo['bar'].baz}", + context: new TestContext({ foo: { bar: { baz: true } } }), + }) expect(res).to.equal(true) }) it("should handle template expression within brackets", () => { - const res = resolveTemplateString( - "${foo['${bar}']}", - new TestContext({ + const res = resolveTemplateString({ + string: "${foo['${bar}']}", + context: new TestContext({ foo: { baz: true }, bar: "baz", - }) - ) + }), + }) expect(res).to.equal(true) }) it("should handle identifiers within brackets", () => { - const res = resolveTemplateString( - "${foo[bar]}", - new TestContext({ + const res = resolveTemplateString({ + string: "${foo[bar]}", + context: new TestContext({ foo: { baz: true }, bar: "baz", - }) - ) + }), + }) expect(res).to.equal(true) }) it("should handle nested identifiers within brackets", () => { - const res = resolveTemplateString( - "${foo[a.b]}", - new TestContext({ + const res = resolveTemplateString({ + string: "${foo[a.b]}", + context: new TestContext({ foo: { baz: true }, a: { b: "baz" }, - }) - ) + }), + }) expect(res).to.equal(true) }) it("should throw if bracket expression resolves to a non-primitive", () => { - void expectError(() => resolveTemplateString("${foo[bar]}", new TestContext({ foo: {}, bar: {} })), { - contains: - "Invalid template string (${foo[bar]}): Expression in bracket must resolve to a primitive (got object).", - }) + void expectError( + () => resolveTemplateString({ string: "${foo[bar]}", context: new TestContext({ foo: {}, bar: {} }) }), + { + contains: + "Invalid template string (${foo[bar]}): Expression in bracket must resolve to a primitive (got object).", + } + ) }) it("should throw if attempting to index a primitive with brackets", () => { - void expectError(() => resolveTemplateString("${foo[bar]}", new TestContext({ foo: 123, bar: "baz" })), { - contains: 'Invalid template string (${foo[bar]}): Attempted to look up key "baz" on a number.', - }) + void expectError( + () => resolveTemplateString({ string: "${foo[bar]}", context: new TestContext({ foo: 123, bar: "baz" }) }), + { + contains: 'Invalid template string (${foo[bar]}): Attempted to look up key "baz" on a number.', + } + ) }) it("should throw when using >= on non-numeric terms", () => { - void expectError(() => resolveTemplateString("${a >= b}", new TestContext({ a: 123, b: "foo" })), { - contains: - "Invalid template string (${a >= b}): Both terms need to be numbers for >= operator (got number and string).", - }) + void expectError( + () => resolveTemplateString({ string: "${a >= b}", context: new TestContext({ a: 123, b: "foo" }) }), + { + contains: + "Invalid template string (${a >= b}): Both terms need to be numbers for >= operator (got number and string).", + } + ) }) it("should handle a positive ternary expression", () => { - const res = resolveTemplateString("${foo ? true : false}", new TestContext({ foo: true })) + const res = resolveTemplateString({ string: "${foo ? true : false}", context: new TestContext({ foo: true }) }) expect(res).to.equal(true) }) it("should handle a negative ternary expression", () => { - const res = resolveTemplateString("${foo ? true : false}", new TestContext({ foo: false })) + const res = resolveTemplateString({ string: "${foo ? true : false}", context: new TestContext({ foo: false }) }) expect(res).to.equal(false) }) it("should handle a ternary expression with an expression as a test", () => { - const res = resolveTemplateString("${foo == 'bar' ? a : b}", new TestContext({ foo: "bar", a: true, b: false })) + const res = resolveTemplateString({ + string: "${foo == 'bar' ? a : b}", + context: new TestContext({ foo: "bar", a: true, b: false }), + }) expect(res).to.equal(true) }) it("should ignore errors in a value not returned by a ternary", () => { - const res = resolveTemplateString("${var.foo ? replace(var.foo, ' ', ',') : null}", new TestContext({ var: {} })) + const res = resolveTemplateString({ + string: "${var.foo ? replace(var.foo, ' ', ',') : null}", + context: new TestContext({ var: {} }), + }) expect(res).to.equal(null) }) it("should handle a ternary expression with an object as a test", () => { - const res = resolveTemplateString("${a ? a.value : b}", new TestContext({ a: { value: true }, b: false })) + const res = resolveTemplateString({ + string: "${a ? a.value : b}", + context: new TestContext({ a: { value: true }, b: false }), + }) expect(res).to.equal(true) }) it("should handle a ternary expression with template key values", () => { - const res = resolveTemplateString( - "${foo == 'bar' ? '=${foo}' : b}", - new TestContext({ foo: "bar", a: true, b: false }) - ) + const res = resolveTemplateString({ + string: "${foo == 'bar' ? '=${foo}' : b}", + context: new TestContext({ foo: "bar", a: true, b: false }), + }) expect(res).to.equal("=bar") }) it("should handle an expression in parentheses", () => { - const res = resolveTemplateString("${foo || (a > 5)}", new TestContext({ foo: false, a: 10 })) + const res = resolveTemplateString({ string: "${foo || (a > 5)}", context: new TestContext({ foo: false, a: 10 }) }) expect(res).to.equal(true) }) it("should handle numeric indices on arrays", () => { - const res = resolveTemplateString("${foo.1}", new TestContext({ foo: [false, true] })) + const res = resolveTemplateString({ string: "${foo.1}", context: new TestContext({ foo: [false, true] }) }) expect(res).to.equal(true) }) it("should resolve keys on objects in arrays", () => { - const res = resolveTemplateString("${foo.1.bar}", new TestContext({ foo: [{}, { bar: true }] })) + const res = resolveTemplateString({ + string: "${foo.1.bar}", + context: new TestContext({ foo: [{}, { bar: true }] }), + }) expect(res).to.equal(true) }) it("should correctly propagate errors from nested contexts", () => { void expectError( () => - resolveTemplateString( - "${nested.missing}", - new TestContext({ nested: new TestContext({ foo: 123, bar: 456, baz: 789 }) }) - ), + resolveTemplateString({ + string: "${nested.missing}", + context: new TestContext({ nested: new TestContext({ foo: 123, bar: 456, baz: 789 }) }), + }), { contains: "Invalid template string (${nested.missing}): Could not find key missing under nested. Available keys: bar, baz and foo.", @@ -670,7 +744,11 @@ describe("resolveTemplateString", () => { it("should correctly propagate errors from nested objects", () => { void expectError( - () => resolveTemplateString("${nested.missing}", new TestContext({ nested: { foo: 123, bar: 456 } })), + () => + resolveTemplateString({ + string: "${nested.missing}", + context: new TestContext({ nested: { foo: 123, bar: 456 } }), + }), { contains: "Invalid template string (${nested.missing}): Could not find key missing under nested. Available keys: bar and foo.", @@ -681,7 +759,7 @@ describe("resolveTemplateString", () => { it("should correctly propagate errors when resolving key on object in nested context", () => { const c = new TestContext({ nested: new TestContext({ deeper: {} }) }) - void expectError(() => resolveTemplateString("${nested.deeper.missing}", c), { + void expectError(() => resolveTemplateString({ string: "${nested.deeper.missing}", context: c }), { contains: "Invalid template string (${nested.deeper.missing}): Could not find key missing under nested.deeper.", }) }) @@ -689,90 +767,110 @@ describe("resolveTemplateString", () => { it("should correctly propagate errors from deeply nested contexts", () => { const c = new TestContext({ nested: new TestContext({ deeper: new TestContext({}) }) }) - void expectError(() => resolveTemplateString("${nested.deeper.missing}", c), { + void expectError(() => resolveTemplateString({ string: "${nested.deeper.missing}", context: c }), { contains: "Invalid template string (${nested.deeper.missing}): Could not find key missing under nested.deeper.", }) }) context("allowPartial=true", () => { it("passes through template strings with missing key", () => { - const res = resolveTemplateString("${a}", new TestContext({}), { allowPartial: true }) + const res = resolveTemplateString({ + string: "${a}", + context: new TestContext({}), + contextOpts: { allowPartial: true }, + }) expect(res).to.equal("${a}") }) it("passes through a template string with a missing key in an optional clause", () => { - const res = resolveTemplateString("${a || b}", new TestContext({ b: 123 }), { allowPartial: true }) + const res = resolveTemplateString({ + string: "${a || b}", + context: new TestContext({ b: 123 }), + contextOpts: { allowPartial: true }, + }) expect(res).to.equal("${a || b}") }) it("passes through a template string with a missing key in a ternary", () => { - const res = resolveTemplateString("${a ? b : 123}", new TestContext({ b: 123 }), { allowPartial: true }) + const res = resolveTemplateString({ + string: "${a ? b : 123}", + context: new TestContext({ b: 123 }), + contextOpts: { allowPartial: true }, + }) expect(res).to.equal("${a ? b : 123}") }) }) context("when the template string is the full input string", () => { it("should return a resolved number directly", () => { - const res = resolveTemplateString("${a}", new TestContext({ a: 100 })) + const res = resolveTemplateString({ string: "${a}", context: new TestContext({ a: 100 }) }) expect(res).to.equal(100) }) it("should return a resolved boolean true directly", () => { - const res = resolveTemplateString("${a}", new TestContext({ a: true })) + const res = resolveTemplateString({ string: "${a}", context: new TestContext({ a: true }) }) expect(res).to.equal(true) }) it("should return a resolved boolean false directly", () => { - const res = resolveTemplateString("${a}", new TestContext({ a: false })) + const res = resolveTemplateString({ string: "${a}", context: new TestContext({ a: false }) }) expect(res).to.equal(false) }) it("should return a resolved null directly", () => { - const res = resolveTemplateString("${a}", new TestContext({ a: null })) + const res = resolveTemplateString({ string: "${a}", context: new TestContext({ a: null }) }) expect(res).to.equal(null) }) it("should return a resolved object directly", () => { - const res = resolveTemplateString("${a}", new TestContext({ a: { b: 123 } })) + const res = resolveTemplateString({ string: "${a}", context: new TestContext({ a: { b: 123 } }) }) expect(res).to.eql({ b: 123 }) }) it("should return a resolved array directly", () => { - const res = resolveTemplateString("${a}", new TestContext({ a: [123] })) + const res = resolveTemplateString({ string: "${a}", context: new TestContext({ a: [123] }) }) expect(res).to.eql([123]) }) }) context("when the template string is a part of a string", () => { it("should format a resolved number into the string", () => { - const res = resolveTemplateString("foo-${a}", new TestContext({ a: 100 })) + const res = resolveTemplateString({ string: "foo-${a}", context: new TestContext({ a: 100 }) }) expect(res).to.equal("foo-100") }) it("should format a resolved boolean true into the string", () => { - const res = resolveTemplateString("foo-${a}", new TestContext({ a: true })) + const res = resolveTemplateString({ string: "foo-${a}", context: new TestContext({ a: true }) }) expect(res).to.equal("foo-true") }) it("should format a resolved boolean false into the string", () => { - const res = resolveTemplateString("foo-${a}", new TestContext({ a: false })) + const res = resolveTemplateString({ string: "foo-${a}", context: new TestContext({ a: false }) }) expect(res).to.equal("foo-false") }) it("should format a resolved null into the string", () => { - const res = resolveTemplateString("foo-${a}", new TestContext({ a: null })) + const res = resolveTemplateString({ string: "foo-${a}", context: new TestContext({ a: null }) }) expect(res).to.equal("foo-null") }) context("allowPartial=true", () => { it("passes through template strings with missing key", () => { - const res = resolveTemplateString("${a}-${b}", new TestContext({ b: "foo" }), { allowPartial: true }) + const res = resolveTemplateString({ + string: "${a}-${b}", + context: new TestContext({ b: "foo" }), + contextOpts: { allowPartial: true }, + }) expect(res).to.equal("${a}-foo") }) it("passes through a template string with a missing key in an optional clause", () => { - const res = resolveTemplateString("${a || b}-${c}", new TestContext({ b: 123, c: "foo" }), { - allowPartial: true, + const res = resolveTemplateString({ + string: "${a || b}-${c}", + context: new TestContext({ b: 123, c: "foo" }), + contextOpts: { + allowPartial: true, + }, }) expect(res).to.equal("${a || b}-foo") }) @@ -783,7 +881,7 @@ describe("resolveTemplateString", () => { it("should throw when right-hand side is not a primitive", () => { const c = new TestContext({ a: [1, 2], b: [3, 4] }) - void expectError(() => resolveTemplateString("${a contains b}", c), { + void expectError(() => resolveTemplateString({ string: "${a contains b}", context: c }), { contains: "Invalid template string (${a contains b}): The right-hand side of a 'contains' operator must be a string, number, boolean or null (got object).", }) @@ -792,117 +890,144 @@ describe("resolveTemplateString", () => { it("should throw when left-hand side is not a string, array or object", () => { const c = new TestContext({ a: "foo", b: null }) - void expectError(() => resolveTemplateString("${b contains a}", c), { + void expectError(() => resolveTemplateString({ string: "${b contains a}", context: c }), { contains: "Invalid template string (${b contains a}): The left-hand side of a 'contains' operator must be a string, array or object (got null).", }) }) it("positive string literal contains string literal", () => { - const res = resolveTemplateString("${'foobar' contains 'foo'}", new TestContext({})) + const res = resolveTemplateString({ string: "${'foobar' contains 'foo'}", context: new TestContext({}) }) expect(res).to.equal(true) }) it("string literal contains string literal (negative)", () => { - const res = resolveTemplateString("${'blorg' contains 'blarg'}", new TestContext({})) + const res = resolveTemplateString({ string: "${'blorg' contains 'blarg'}", context: new TestContext({}) }) expect(res).to.equal(false) }) it("string literal contains string reference", () => { - const res = resolveTemplateString("${a contains 'foo'}", new TestContext({ a: "foobar" })) + const res = resolveTemplateString({ string: "${a contains 'foo'}", context: new TestContext({ a: "foobar" }) }) expect(res).to.equal(true) }) it("string reference contains string literal (negative)", () => { - const res = resolveTemplateString("${a contains 'blarg'}", new TestContext({ a: "foobar" })) + const res = resolveTemplateString({ string: "${a contains 'blarg'}", context: new TestContext({ a: "foobar" }) }) expect(res).to.equal(false) }) it("string contains number", () => { - const res = resolveTemplateString("${a contains 0}", new TestContext({ a: "hmm-0" })) + const res = resolveTemplateString({ string: "${a contains 0}", context: new TestContext({ a: "hmm-0" }) }) expect(res).to.equal(true) }) it("object contains string literal", () => { - const res = resolveTemplateString("${a contains 'foo'}", new TestContext({ a: { foo: 123 } })) + const res = resolveTemplateString({ + string: "${a contains 'foo'}", + context: new TestContext({ a: { foo: 123 } }), + }) expect(res).to.equal(true) }) it("object contains string literal (negative)", () => { - const res = resolveTemplateString("${a contains 'bar'}", new TestContext({ a: { foo: 123 } })) + const res = resolveTemplateString({ + string: "${a contains 'bar'}", + context: new TestContext({ a: { foo: 123 } }), + }) expect(res).to.equal(false) }) it("object contains string reference", () => { - const res = resolveTemplateString("${a contains b}", new TestContext({ a: { foo: 123 }, b: "foo" })) + const res = resolveTemplateString({ + string: "${a contains b}", + context: new TestContext({ a: { foo: 123 }, b: "foo" }), + }) expect(res).to.equal(true) }) it("object contains number reference", () => { - const res = resolveTemplateString("${a contains b}", new TestContext({ a: { 123: 456 }, b: 123 })) + const res = resolveTemplateString({ + string: "${a contains b}", + context: new TestContext({ a: { 123: 456 }, b: 123 }), + }) expect(res).to.equal(true) }) it("object contains number literal", () => { - const res = resolveTemplateString("${a contains 123}", new TestContext({ a: { 123: 456 } })) + const res = resolveTemplateString({ string: "${a contains 123}", context: new TestContext({ a: { 123: 456 } }) }) expect(res).to.equal(true) }) it("array contains string reference", () => { - const res = resolveTemplateString("${a contains b}", new TestContext({ a: ["foo"], b: "foo" })) + const res = resolveTemplateString({ + string: "${a contains b}", + context: new TestContext({ a: ["foo"], b: "foo" }), + }) expect(res).to.equal(true) }) it("array contains string reference (negative)", () => { - const res = resolveTemplateString("${a contains b}", new TestContext({ a: ["foo"], b: "bar" })) + const res = resolveTemplateString({ + string: "${a contains b}", + context: new TestContext({ a: ["foo"], b: "bar" }), + }) expect(res).to.equal(false) }) it("array contains string literal", () => { - const res = resolveTemplateString("${a contains 'foo'}", new TestContext({ a: ["foo"] })) + const res = resolveTemplateString({ string: "${a contains 'foo'}", context: new TestContext({ a: ["foo"] }) }) expect(res).to.equal(true) }) it("array contains number", () => { - const res = resolveTemplateString("${a contains 1}", new TestContext({ a: [0, 1] })) + const res = resolveTemplateString({ string: "${a contains 1}", context: new TestContext({ a: [0, 1] }) }) expect(res).to.equal(true) }) it("array contains numeric index (negative)", () => { - const res = resolveTemplateString("${a contains 1}", new TestContext({ a: [0] })) + const res = resolveTemplateString({ string: "${a contains 1}", context: new TestContext({ a: [0] }) }) expect(res).to.equal(false) }) }) context("conditional string blocks", () => { it("single-line if block (positive)", () => { - const res = resolveTemplateString("prefix ${if a}content ${endif}suffix", new TestContext({ a: true })) + const res = resolveTemplateString({ + string: "prefix ${if a}content ${endif}suffix", + context: new TestContext({ a: true }), + }) expect(res).to.equal("prefix content suffix") }) it("single-line if block (negative)", () => { - const res = resolveTemplateString("prefix ${if a}content ${endif}suffix", new TestContext({ a: false })) + const res = resolveTemplateString({ + string: "prefix ${if a}content ${endif}suffix", + context: new TestContext({ a: false }), + }) expect(res).to.equal("prefix suffix") }) it("single-line if/else statement (positive)", () => { - const res = resolveTemplateString( - "prefix ${if a == 123}content ${else}other ${endif}suffix", - new TestContext({ a: 123 }) - ) + const res = resolveTemplateString({ + string: "prefix ${if a == 123}content ${else}other ${endif}suffix", + context: new TestContext({ a: 123 }), + }) expect(res).to.equal("prefix content suffix") }) it("single-line if/else statement (negative)", () => { - const res = resolveTemplateString( - "prefix ${if a}content ${else}other ${endif}suffix", - new TestContext({ a: false }) - ) + const res = resolveTemplateString({ + string: "prefix ${if a}content ${else}other ${endif}suffix", + context: new TestContext({ a: false }), + }) expect(res).to.equal("prefix other suffix") }) it("multi-line if block (positive)", () => { - const res = resolveTemplateString("prefix\n${if a}content\n${endif}suffix", new TestContext({ a: true })) + const res = resolveTemplateString({ + string: "prefix\n${if a}content\n${endif}suffix", + context: new TestContext({ a: true }), + }) expect(res).to.equal(dedent` prefix content @@ -911,10 +1036,10 @@ describe("resolveTemplateString", () => { }) it("template string within if block", () => { - const res = resolveTemplateString( - "prefix\n${if a}templated: ${b}\n${endif}suffix", - new TestContext({ a: true, b: "content" }) - ) + const res = resolveTemplateString({ + string: "prefix\n${if a}templated: ${b}\n${endif}suffix", + context: new TestContext({ a: true, b: "content" }), + }) expect(res).to.equal(dedent` prefix templated: content @@ -923,10 +1048,10 @@ describe("resolveTemplateString", () => { }) it("nested if block (both positive)", () => { - const res = resolveTemplateString( - "prefix\n${if a}some ${if b}content\n${endif}${endif}suffix", - new TestContext({ a: true, b: true }) - ) + const res = resolveTemplateString({ + string: "prefix\n${if a}some ${if b}content\n${endif}${endif}suffix", + context: new TestContext({ a: true, b: true }), + }) expect(res).to.equal(dedent` prefix some content @@ -935,10 +1060,10 @@ describe("resolveTemplateString", () => { }) it("nested if block (outer negative)", () => { - const res = resolveTemplateString( - "prefix\n${if a}some ${if b}content\n${endif}${endif}suffix", - new TestContext({ a: false, b: true }) - ) + const res = resolveTemplateString({ + string: "prefix\n${if a}some ${if b}content\n${endif}${endif}suffix", + context: new TestContext({ a: false, b: true }), + }) expect(res).to.equal(dedent` prefix suffix @@ -946,10 +1071,10 @@ describe("resolveTemplateString", () => { }) it("nested if block (inner negative)", () => { - const res = resolveTemplateString( - "prefix\n${if a}some\n${if b}content\n${endif}${endif}suffix", - new TestContext({ a: true, b: false }) - ) + const res = resolveTemplateString({ + string: "prefix\n${if a}some\n${if b}content\n${endif}${endif}suffix", + context: new TestContext({ a: true, b: false }), + }) expect(res).to.equal(dedent` prefix some @@ -958,10 +1083,10 @@ describe("resolveTemplateString", () => { }) it("if/else statement inside if block", () => { - const res = resolveTemplateString( - "prefix\n${if a}some\n${if b}nope${else}content\n${endif}${endif}suffix", - new TestContext({ a: true, b: false }) - ) + const res = resolveTemplateString({ + string: "prefix\n${if a}some\n${if b}nope${else}content\n${endif}${endif}suffix", + context: new TestContext({ a: true, b: false }), + }) expect(res).to.equal(dedent` prefix some @@ -971,10 +1096,10 @@ describe("resolveTemplateString", () => { }) it("if block inside if/else statement", () => { - const res = resolveTemplateString( - "prefix\n${if a}some\n${if b}content\n${endif}${else}nope ${endif}suffix", - new TestContext({ a: true, b: false }) - ) + const res = resolveTemplateString({ + string: "prefix\n${if a}some\n${if b}content\n${endif}${else}nope ${endif}suffix", + context: new TestContext({ a: true, b: false }), + }) expect(res).to.equal(dedent` prefix some @@ -983,90 +1108,107 @@ describe("resolveTemplateString", () => { }) it("throws if an if block has an optional suffix", () => { - void expectError(() => resolveTemplateString("prefix ${if a}?content ${endif}", new TestContext({ a: true })), { - contains: - "Invalid template string (prefix ${if a}?content ${endif}): Cannot specify optional suffix in if-block.", - }) + void expectError( + () => + resolveTemplateString({ string: "prefix ${if a}?content ${endif}", context: new TestContext({ a: true }) }), + { + contains: + "Invalid template string (prefix ${if a}?content ${endif}): Cannot specify optional suffix in if-block.", + } + ) }) it("throws if an if block doesn't have a matching endif", () => { - void expectError(() => resolveTemplateString("prefix ${if a}content", new TestContext({ a: true })), { - contains: "Invalid template string (prefix ${if a}content): Missing ${endif} after ${if ...} block.", - }) + void expectError( + () => resolveTemplateString({ string: "prefix ${if a}content", context: new TestContext({ a: true }) }), + { + contains: "Invalid template string (prefix ${if a}content): Missing ${endif} after ${if ...} block.", + } + ) }) it("throws if an endif block doesn't have a matching if", () => { - void expectError(() => resolveTemplateString("prefix content ${endif}", new TestContext({ a: true })), { - contains: - "Invalid template string (prefix content ${endif}): Found ${endif} block without a preceding ${if...} block.", - }) + void expectError( + () => resolveTemplateString({ string: "prefix content ${endif}", context: new TestContext({ a: true }) }), + { + contains: + "Invalid template string (prefix content ${endif}): Found ${endif} block without a preceding ${if...} block.", + } + ) }) }) context("helper functions", () => { it("resolves a helper function with a string literal", () => { - const res = resolveTemplateString("${base64Encode('foo')}", new TestContext({})) + const res = resolveTemplateString({ string: "${base64Encode('foo')}", context: new TestContext({}) }) expect(res).to.equal("Zm9v") }) it("resolves a template string in a helper argument", () => { - const res = resolveTemplateString("${base64Encode('${a}')}", new TestContext({ a: "foo" })) + const res = resolveTemplateString({ string: "${base64Encode('${a}')}", context: new TestContext({ a: "foo" }) }) expect(res).to.equal("Zm9v") }) it("resolves a helper function with multiple arguments", () => { - const res = resolveTemplateString("${split('a,b,c', ',')}", new TestContext({})) + const res = resolveTemplateString({ string: "${split('a,b,c', ',')}", context: new TestContext({}) }) expect(res).to.eql(["a", "b", "c"]) }) it("resolves a helper function with a template key reference", () => { - const res = resolveTemplateString("${base64Encode(a)}", new TestContext({ a: "foo" })) + const res = resolveTemplateString({ string: "${base64Encode(a)}", context: new TestContext({ a: "foo" }) }) expect(res).to.equal("Zm9v") }) it("generates a correct hash with a string literal from the sha256 helper function", () => { - const res = resolveTemplateString("${sha256('This Is A Test String')}", new TestContext({})) + const res = resolveTemplateString({ string: "${sha256('This Is A Test String')}", context: new TestContext({}) }) expect(res).to.equal("9a058284378d1cc6b4348aacb6ba847918376054b094bbe06eb5302defc52685") }) it("throws if an argument is missing", () => { - void expectError(() => resolveTemplateString("${base64Decode()}", new TestContext({})), { + void expectError(() => resolveTemplateString({ string: "${base64Decode()}", context: new TestContext({}) }), { contains: "Invalid template string (${base64Decode()}): Missing argument 'string' (at index 0) for base64Decode helper function.", }) }) it("throws if a wrong argument type is passed", () => { - void expectError(() => resolveTemplateString("${base64Decode(a)}", new TestContext({ a: 1234 })), { - contains: - "Invalid template string (${base64Decode(a)}): Error validating argument 'string' for base64Decode helper function: value must be a string", - }) + void expectError( + () => resolveTemplateString({ string: "${base64Decode(a)}", context: new TestContext({ a: 1234 }) }), + { + contains: + "Invalid template string (${base64Decode(a)}): Error validating argument 'string' for base64Decode helper function:\nvalue must be a string", + } + ) }) it("throws if the function can't be found", () => { - void expectError(() => resolveTemplateString("${floop('blop')}", new TestContext({})), { + void expectError(() => resolveTemplateString({ string: "${floop('blop')}", context: new TestContext({}) }), { contains: "Invalid template string (${floop('blop')}): Could not find helper function 'floop'. Available helper functions:", }) }) it("throws if the function fails", () => { - void expectError(() => resolveTemplateString("${jsonDecode('{]}')}", new TestContext({})), { + void expectError(() => resolveTemplateString({ string: "${jsonDecode('{]}')}", context: new TestContext({}) }), { contains: "Invalid template string (${jsonDecode('{]}')}): Error from helper function jsonDecode: SyntaxError: Unexpected token ] in JSON at position 1", }) }) it("does not apply helper function on unresolved template string and returns string as-is, when allowPartial=true", () => { - const res = resolveTemplateString("${base64Encode('${environment.namespace}')}", new TestContext({}), { - allowPartial: true, + const res = resolveTemplateString({ + string: "${base64Encode('${environment.namespace}')}", + context: new TestContext({}), + contextOpts: { + allowPartial: true, + }, }) expect(res).to.equal("${base64Encode('${environment.namespace}')}") }) context("concat", () => { it("allows empty strings", () => { - const res = resolveTemplateString("${concat('', '')}", new TestContext({})) + const res = resolveTemplateString({ string: "${concat('', '')}", context: new TestContext({}) }) expect(res).to.equal("") }) @@ -1080,9 +1222,12 @@ describe("resolveTemplateString", () => { testContextVars?: object errorMessage: string }) { - void expectError(() => resolveTemplateString(template, new TestContext(testContextVars)), { - contains: `Invalid template string (\${concat(a, b)}): ${errorMessage}`, - }) + void expectError( + () => resolveTemplateString({ string: template, context: new TestContext(testContextVars) }), + { + contains: `Invalid template string (\${concat(a, b)}): ${errorMessage}`, + } + ) } it("using on incompatible argument types (string and object)", () => { @@ -1105,7 +1250,7 @@ describe("resolveTemplateString", () => { b: ["a"], }, errorMessage: - "Error validating argument 'arg1' for concat helper function: value must be one of [array, string]", + "Error validating argument 'arg1' for concat helper function:\nvalue must be one of [array, string]", }) }) }) @@ -1114,24 +1259,24 @@ describe("resolveTemplateString", () => { context("isEmpty", () => { context("allows nulls", () => { it("resolves null as 'true'", () => { - const res = resolveTemplateString("${isEmpty(null)}", new TestContext({})) + const res = resolveTemplateString({ string: "${isEmpty(null)}", context: new TestContext({}) }) expect(res).to.be.true }) it("resolves references to null as 'true'", () => { - const res = resolveTemplateString("${isEmpty(a)}", new TestContext({ a: null })) + const res = resolveTemplateString({ string: "${isEmpty(a)}", context: new TestContext({ a: null }) }) expect(res).to.be.true }) }) context("allows empty strings", () => { it("resolves an empty string as 'true'", () => { - const res = resolveTemplateString("${isEmpty('')}", new TestContext({})) + const res = resolveTemplateString({ string: "${isEmpty('')}", context: new TestContext({}) }) expect(res).to.be.true }) it("resolves a reference to an empty string as 'true'", () => { - const res = resolveTemplateString("${isEmpty(a)}", new TestContext({ a: "" })) + const res = resolveTemplateString({ string: "${isEmpty(a)}", context: new TestContext({ a: "" }) }) expect(res).to.be.true }) }) @@ -1139,62 +1284,80 @@ describe("resolveTemplateString", () => { context("slice", () => { it("allows numeric indices", () => { - const res = resolveTemplateString("${slice(foo, 0, 3)}", new TestContext({ foo: "abcdef" })) + const res = resolveTemplateString({ + string: "${slice(foo, 0, 3)}", + context: new TestContext({ foo: "abcdef" }), + }) expect(res).to.equal("abc") }) it("allows numeric strings as indices", () => { - const res = resolveTemplateString("${slice(foo, '0', '3')}", new TestContext({ foo: "abcdef" })) + const res = resolveTemplateString({ + string: "${slice(foo, '0', '3')}", + context: new TestContext({ foo: "abcdef" }), + }) expect(res).to.equal("abc") }) it("throws on invalid string in the start index", () => { - void expectError(() => resolveTemplateString("${slice(foo, 'a', 3)}", new TestContext({ foo: "abcdef" })), { - contains: `Invalid template string (\${slice(foo, 'a', 3)}): Error from helper function slice: Error: start index must be a number or a numeric string (got "a")`, - }) + void expectError( + () => resolveTemplateString({ string: "${slice(foo, 'a', 3)}", context: new TestContext({ foo: "abcdef" }) }), + { + contains: `Invalid template string (\${slice(foo, 'a', 3)}): Error from helper function slice: Error: start index must be a number or a numeric string (got "a")`, + } + ) }) it("throws on invalid string in the end index", () => { - void expectError(() => resolveTemplateString("${slice(foo, 0, 'b')}", new TestContext({ foo: "abcdef" })), { - contains: `Invalid template string (\${slice(foo, 0, 'b')}): Error from helper function slice: Error: end index must be a number or a numeric string (got "b")`, - }) + void expectError( + () => resolveTemplateString({ string: "${slice(foo, 0, 'b')}", context: new TestContext({ foo: "abcdef" }) }), + { + contains: `Invalid template string (\${slice(foo, 0, 'b')}): Error from helper function slice: Error: end index must be a number or a numeric string (got "b")`, + } + ) }) }) }) context("array literals", () => { it("returns an empty array literal back", () => { - const res = resolveTemplateString("${[]}", new TestContext({})) + const res = resolveTemplateString({ string: "${[]}", context: new TestContext({}) }) expect(res).to.eql([]) }) it("returns an array literal of literals back", () => { - const res = resolveTemplateString("${['foo', \"bar\", 123, true, false]}", new TestContext({})) + const res = resolveTemplateString({ + string: "${['foo', \"bar\", 123, true, false]}", + context: new TestContext({}), + }) expect(res).to.eql(["foo", "bar", 123, true, false]) }) it("resolves a key in an array literal", () => { - const res = resolveTemplateString("${[foo]}", new TestContext({ foo: "bar" })) + const res = resolveTemplateString({ string: "${[foo]}", context: new TestContext({ foo: "bar" }) }) expect(res).to.eql(["bar"]) }) it("resolves a nested key in an array literal", () => { - const res = resolveTemplateString("${[foo.bar]}", new TestContext({ foo: { bar: "baz" } })) + const res = resolveTemplateString({ string: "${[foo.bar]}", context: new TestContext({ foo: { bar: "baz" } }) }) expect(res).to.eql(["baz"]) }) it("calls a helper in an array literal", () => { - const res = resolveTemplateString("${[foo, base64Encode('foo')]}", new TestContext({ foo: "bar" })) + const res = resolveTemplateString({ + string: "${[foo, base64Encode('foo')]}", + context: new TestContext({ foo: "bar" }), + }) expect(res).to.eql(["bar", "Zm9v"]) }) it("calls a helper with an array literal argument", () => { - const res = resolveTemplateString("${join(['foo', 'bar'], ',')}", new TestContext({})) + const res = resolveTemplateString({ string: "${join(['foo', 'bar'], ',')}", context: new TestContext({}) }) expect(res).to.eql("foo,bar") }) it("allows empty string separator in join helper function", () => { - const res = resolveTemplateString("${join(['foo', 'bar'], '')}", new TestContext({})) + const res = resolveTemplateString({ string: "${join(['foo', 'bar'], '')}", context: new TestContext({}) }) expect(res).to.eql("foobar") }) }) @@ -1214,7 +1377,7 @@ describe("resolveTemplateStrings", () => { something: "else", }) - const result = resolveTemplateStrings(obj, templateContext) + const result = resolveTemplateStrings({ source: undefined, value: obj, context: templateContext }) expect(result).to.eql({ some: "value", @@ -1234,7 +1397,7 @@ describe("resolveTemplateStrings", () => { key: "value", }) - const result = resolveTemplateStrings(obj, templateContext) + const result = resolveTemplateStrings({ source: undefined, value: obj, context: templateContext }) expect(result).to.eql({ some: "value", @@ -1250,7 +1413,7 @@ describe("resolveTemplateStrings", () => { } const templateContext = new TestContext({}) - const result = resolveTemplateStrings(obj, templateContext) + const result = resolveTemplateStrings({ source: undefined, value: obj, context: templateContext }) expect(result).to.eql({ a: "a", @@ -1267,7 +1430,7 @@ describe("resolveTemplateStrings", () => { } const templateContext = new TestContext({}) - const result = resolveTemplateStrings(obj, templateContext) + const result = resolveTemplateStrings({ source: undefined, value: obj, context: templateContext }) expect(result).to.eql({ a: "a", @@ -1284,7 +1447,7 @@ describe("resolveTemplateStrings", () => { } const templateContext = new TestContext({ obj: { a: "a", b: "b" } }) - const result = resolveTemplateStrings(obj, templateContext) + const result = resolveTemplateStrings({ source: undefined, value: obj, context: templateContext }) expect(result).to.eql({ a: "a", @@ -1304,7 +1467,7 @@ describe("resolveTemplateStrings", () => { } const templateContext = new TestContext({ obj: { b: "b" } }) - const result = resolveTemplateStrings(obj, templateContext) + const result = resolveTemplateStrings({ source: undefined, value: obj, context: templateContext }) expect(result).to.eql({ a: "a", @@ -1320,7 +1483,7 @@ describe("resolveTemplateStrings", () => { } const templateContext = new TestContext({ var: { obj: { a: "a", b: "b" } } }) - const result = resolveTemplateStrings(obj, templateContext) + const result = resolveTemplateStrings({ value: obj, context: templateContext, source: undefined }) expect(result).to.eql({ a: "a", @@ -1353,7 +1516,12 @@ describe("resolveTemplateStrings", () => { }, }) - const result = resolveTemplateStrings(obj, templateContext, { allowPartial: true }) + const result = resolveTemplateStrings({ + value: obj, + context: templateContext, + contextOpts: { allowPartial: true }, + source: undefined, + }) expect(result).to.eql({ "key-value-array": { @@ -1390,7 +1558,7 @@ describe("resolveTemplateStrings", () => { }, }) - const result = resolveTemplateStrings(obj, templateContext) + const result = resolveTemplateStrings({ value: obj, context: templateContext, source: undefined }) expect(result).to.eql({ "key-value-array": [ @@ -1407,7 +1575,9 @@ describe("resolveTemplateStrings", () => { } const templateContext = new TestContext({ var: { obj: { a: "a", b: "b" } } }) - expect(() => resolveTemplateStrings(obj, templateContext)).to.throw("Invalid template string") + expect(() => resolveTemplateStrings({ value: obj, context: templateContext, source: undefined })).to.throw( + "Invalid template string" + ) }) context("$concat", () => { @@ -1415,7 +1585,7 @@ describe("resolveTemplateStrings", () => { const obj = { foo: ["a", { $concat: ["b", "c"] }, "d"], } - const res = resolveTemplateStrings(obj, new TestContext({})) + const res = resolveTemplateStrings({ source: undefined, value: obj, context: new TestContext({}) }) expect(res).to.eql({ foo: ["a", "b", "c", "d"], }) @@ -1425,7 +1595,11 @@ describe("resolveTemplateStrings", () => { const obj = { foo: ["a", { $concat: "${foo}" }, "d"], } - const res = resolveTemplateStrings(obj, new TestContext({ foo: ["b", "c"] })) + const res = resolveTemplateStrings({ + source: undefined, + value: obj, + context: new TestContext({ foo: ["b", "c"] }), + }) expect(res).to.eql({ foo: ["a", "b", "c", "d"], }) @@ -1435,7 +1609,11 @@ describe("resolveTemplateStrings", () => { const obj = { foo: ["a", { $concat: { $forEach: ["B", "C"], $return: "${lower(item.value)}" } }, "d"], } - const res = resolveTemplateStrings(obj, new TestContext({ foo: ["b", "c"] })) + const res = resolveTemplateStrings({ + source: undefined, + value: obj, + context: new TestContext({ foo: ["b", "c"] }), + }) expect(res).to.eql({ foo: ["a", "b", "c", "d"], }) @@ -1446,7 +1624,7 @@ describe("resolveTemplateStrings", () => { foo: ["a", { $concat: "b" }, "d"], } - void expectError(() => resolveTemplateStrings(obj, new TestContext({})), { + void expectError(() => resolveTemplateStrings({ source: undefined, value: obj, context: new TestContext({}) }), { contains: "Value of $concat key must be (or resolve to) an array (got string)", }) }) @@ -1456,7 +1634,7 @@ describe("resolveTemplateStrings", () => { foo: ["a", { $concat: "b", nope: "nay", oops: "derp" }, "d"], } - void expectError(() => resolveTemplateStrings(obj, new TestContext({})), { + void expectError(() => resolveTemplateStrings({ source: undefined, value: obj, context: new TestContext({}) }), { contains: 'A list item with a $concat key cannot have any other keys (found "nope" and "oops")', }) }) @@ -1465,7 +1643,12 @@ describe("resolveTemplateStrings", () => { const obj = { foo: ["a", { $concat: "${foo}" }, "d"], } - const res = resolveTemplateStrings(obj, new TestContext({}), { allowPartial: true }) + const res = resolveTemplateStrings({ + source: undefined, + value: obj, + context: new TestContext({}), + contextOpts: { allowPartial: true }, + }) expect(res).to.eql({ foo: ["a", { $concat: "${foo}" }, "d"], }) @@ -1481,7 +1664,7 @@ describe("resolveTemplateStrings", () => { $else: 456, }, } - const res = resolveTemplateStrings(obj, new TestContext({ foo: 1 })) + const res = resolveTemplateStrings({ source: undefined, value: obj, context: new TestContext({ foo: 1 }) }) expect(res).to.eql({ bar: 123 }) }) @@ -1493,7 +1676,7 @@ describe("resolveTemplateStrings", () => { $else: 456, }, } - const res = resolveTemplateStrings(obj, new TestContext({ foo: 2 })) + const res = resolveTemplateStrings({ source: undefined, value: obj, context: new TestContext({ foo: 2 }) }) expect(res).to.eql({ bar: 456 }) }) @@ -1504,7 +1687,7 @@ describe("resolveTemplateStrings", () => { $then: 123, }, } - const res = resolveTemplateStrings(obj, new TestContext({ foo: 2 })) + const res = resolveTemplateStrings({ source: undefined, value: obj, context: new TestContext({ foo: 2 }) }) expect(res).to.eql({ bar: undefined }) }) @@ -1516,7 +1699,12 @@ describe("resolveTemplateStrings", () => { $else: 456, }, } - const res = resolveTemplateStrings(obj, new TestContext({ foo: 2 }), { allowPartial: true }) + const res = resolveTemplateStrings({ + source: undefined, + value: obj, + context: new TestContext({ foo: 2 }), + contextOpts: { allowPartial: true }, + }) expect(res).to.eql(obj) }) @@ -1528,9 +1716,12 @@ describe("resolveTemplateStrings", () => { }, } - void expectError(() => resolveTemplateStrings(obj, new TestContext({ foo: "bla" })), { - contains: "Value of $if key must be (or resolve to) a boolean (got string)", - }) + void expectError( + () => resolveTemplateStrings({ source: undefined, value: obj, context: new TestContext({ foo: "bla" }) }), + { + contains: "Value of $if key must be (or resolve to) a boolean (got string)", + } + ) }) it("throws if $then key is missing", () => { @@ -1540,9 +1731,12 @@ describe("resolveTemplateStrings", () => { }, } - void expectError(() => resolveTemplateStrings(obj, new TestContext({ foo: 1 })), { - contains: "Missing $then field next to $if field", - }) + void expectError( + () => resolveTemplateStrings({ source: undefined, value: obj, context: new TestContext({ foo: 1 }) }), + { + contains: "Missing $then field next to $if field", + } + ) }) it("throws if extra keys are found", () => { @@ -1554,9 +1748,12 @@ describe("resolveTemplateStrings", () => { }, } - void expectError(() => resolveTemplateStrings(obj, new TestContext({ foo: 1 })), { - contains: 'Found one or more unexpected keys on $if object: "foo"', - }) + void expectError( + () => resolveTemplateStrings({ source: undefined, value: obj, context: new TestContext({ foo: 1 }) }), + { + contains: 'Found one or more unexpected keys on $if object: "foo"', + } + ) }) }) @@ -1568,7 +1765,7 @@ describe("resolveTemplateStrings", () => { $return: "foo", }, } - const res = resolveTemplateStrings(obj, new TestContext({})) + const res = resolveTemplateStrings({ source: undefined, value: obj, context: new TestContext({}) }) expect(res).to.eql({ foo: ["foo", "foo", "foo"], }) @@ -1585,7 +1782,7 @@ describe("resolveTemplateStrings", () => { $return: "${item.key}: ${item.value}", }, } - const res = resolveTemplateStrings(obj, new TestContext({})) + const res = resolveTemplateStrings({ source: undefined, value: obj, context: new TestContext({}) }) expect(res).to.eql({ foo: ["a: 1", "b: 2", "c: 3"], }) @@ -1599,7 +1796,7 @@ describe("resolveTemplateStrings", () => { }, } - void expectError(() => resolveTemplateStrings(obj, new TestContext({})), { + void expectError(() => resolveTemplateStrings({ source: undefined, value: obj, context: new TestContext({}) }), { contains: "Value of $forEach key must be (or resolve to) an array or mapping object (got string)", }) }) @@ -1611,7 +1808,12 @@ describe("resolveTemplateStrings", () => { $return: "foo", }, } - const res = resolveTemplateStrings(obj, new TestContext({}), { allowPartial: true }) + const res = resolveTemplateStrings({ + source: undefined, + value: obj, + context: new TestContext({}), + contextOpts: { allowPartial: true }, + }) expect(res).to.eql(obj) }) @@ -1622,7 +1824,7 @@ describe("resolveTemplateStrings", () => { }, } - void expectError(() => resolveTemplateStrings(obj, new TestContext({})), { + void expectError(() => resolveTemplateStrings({ source: undefined, value: obj, context: new TestContext({}) }), { contains: "Missing $return field next to $forEach field.", }) }) @@ -1637,7 +1839,7 @@ describe("resolveTemplateStrings", () => { }, } - void expectError(() => resolveTemplateStrings(obj, new TestContext({})), { + void expectError(() => resolveTemplateStrings({ source: undefined, value: obj, context: new TestContext({}) }), { contains: 'Found one or more unexpected keys on $forEach object: "$concat" and "foo"', }) }) @@ -1649,7 +1851,11 @@ describe("resolveTemplateStrings", () => { $return: "${item.key}: ${item.value}", }, } - const res = resolveTemplateStrings(obj, new TestContext({ foo: ["a", "b", "c"] })) + const res = resolveTemplateStrings({ + source: undefined, + value: obj, + context: new TestContext({ foo: ["a", "b", "c"] }), + }) expect(res).to.eql({ foo: ["0: a", "1: b", "2: c"], }) @@ -1662,7 +1868,11 @@ describe("resolveTemplateStrings", () => { $return: "${item.value}", }, } - const res = resolveTemplateStrings(obj, new TestContext({ foo: ["a", "b", "c"] })) + const res = resolveTemplateStrings({ + source: undefined, + value: obj, + context: new TestContext({ foo: ["a", "b", "c"] }), + }) expect(res).to.eql({ foo: ["a", "b", "c"], }) @@ -1676,7 +1886,11 @@ describe("resolveTemplateStrings", () => { $return: "${item.value}", }, } - const res = resolveTemplateStrings(obj, new TestContext({ foo: ["a", "b", "c"] })) + const res = resolveTemplateStrings({ + source: undefined, + value: obj, + context: new TestContext({ foo: ["a", "b", "c"] }), + }) expect(res).to.eql({ foo: ["a", "c"], }) @@ -1691,7 +1905,7 @@ describe("resolveTemplateStrings", () => { }, } - void expectError(() => resolveTemplateStrings(obj, new TestContext({})), { + void expectError(() => resolveTemplateStrings({ source: undefined, value: obj, context: new TestContext({}) }), { contains: "$filter clause in $forEach loop must resolve to a boolean value (got object)", }) }) @@ -1705,7 +1919,7 @@ describe("resolveTemplateStrings", () => { }, }, } - const res = resolveTemplateStrings(obj, new TestContext({})) + const res = resolveTemplateStrings({ source: undefined, value: obj, context: new TestContext({}) }) expect(res).to.eql({ foo: ["a-1", "a-2", "b-1", "b-2", "c-1", "c-2"], }) @@ -1724,7 +1938,7 @@ describe("resolveTemplateStrings", () => { }, }, } - const res = resolveTemplateStrings(obj, new TestContext({})) + const res = resolveTemplateStrings({ source: undefined, value: obj, context: new TestContext({}) }) expect(res).to.eql({ foo: [ ["A1", "A2"], @@ -1740,7 +1954,7 @@ describe("resolveTemplateStrings", () => { $return: "foo", }, } - const res = resolveTemplateStrings(obj, new TestContext({})) + const res = resolveTemplateStrings({ source: undefined, value: obj, context: new TestContext({}) }) expect(res).to.eql({ foo: [], }) @@ -1778,7 +1992,7 @@ describe("resolveTemplateStrings", () => { }, } - const res = resolveTemplateStrings(obj, new TestContext({ services })) + const res = resolveTemplateStrings({ source: undefined, value: obj, context: new TestContext({ services }) }) expect(res).to.eql({ services: [ { diff --git a/package-lock.json b/package-lock.json index 2efcd99a13..6ed836081a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3488,16 +3488,15 @@ "eslint-plugin-no-null": "^1.0.2", "eslint-plugin-react": "^7.32.2", "finalhandler": "^1.2.0", - "gulp": "^4.0.2", - "gulp-pegjs": "^0.2.0", "is-subset": "^0.1.1", "md5": "^2.3.0", "mocha": "^10.2.0", "nock": "^12.0.3", "node-fetch": "^2.7.0", + "nodemon": "^2.0.15", "nyc": "^15.1.0", "p-event": "^4.2.0", - "pegjs": "^0.10.0", + "peggy": "^3.0.2", "prettier": "3.0.0", "ps-tree": "^1.2.0", "replace-in-file": "^6.3.5", @@ -11371,344 +11370,6 @@ "node": ">=0.10.0" } }, - "core/node_modules/gulp-pegjs": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/gulp-pegjs/-/gulp-pegjs-0.2.0.tgz", - "integrity": "sha512-fyRAt+zKPaOSCd9xo8qHpgJ0vgeojQh8zjpULUWn2wqUDrB+8EflFWIaPd4X1hEnSBIqutgF1MEgKPfOpvjvWQ==", - "dev": true, - "dependencies": { - "object-assign": "^4.0.1", - "pegjs": "^0.10.0", - "plugin-error": "^1.0.1", - "replace-ext": "^1.0.0", - "through2": "^2.0.1", - "vinyl": "^2.2.0" - } - }, - "core/node_modules/gulp-pegjs/node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "core/node_modules/gulp-pegjs/node_modules/plugin-error": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/plugin-error/-/plugin-error-1.0.1.tgz", - "integrity": "sha512-L1zP0dk7vGweZME2i+EeakvUNqSrdiI3F91TwEoYiGrAfUXmVv6fJIq4g82PAXxNsWOp0J7ZqQy/3Szz0ajTxA==", - "dev": true, - "dependencies": { - "ansi-colors": "^1.0.1", - "arr-diff": "^4.0.0", - "arr-union": "^3.1.0", - "extend-shallow": "^3.0.2" - }, - "engines": { - "node": ">= 0.10" - } - }, - "core/node_modules/gulp-pegjs/node_modules/plugin-error/node_modules/ansi-colors": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-1.1.0.tgz", - "integrity": "sha512-SFKX67auSNoVR38N3L+nvsPjOE0bybKTYbkf5tRvushrAPQ9V75huw0ZxBkKVeRU9kqH3d6HA4xTckbwZ4ixmA==", - "dev": true, - "dependencies": { - "ansi-wrap": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "core/node_modules/gulp-pegjs/node_modules/plugin-error/node_modules/ansi-colors/node_modules/ansi-wrap": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/ansi-wrap/-/ansi-wrap-0.1.0.tgz", - "integrity": "sha512-ZyznvL8k/FZeQHr2T6LzcJ/+vBApDnMNZvfVFy3At0knswWd6rJ3/0Hhmpu8oqa6C92npmozs890sX9Dl6q+Qw==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "core/node_modules/gulp-pegjs/node_modules/plugin-error/node_modules/arr-diff": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", - "integrity": "sha512-YVIQ82gZPGBebQV/a8dar4AitzCQs0jjXwMPZllpXMaGjXPYVUawSxQrRsjhjupyVxEvbHgUmIhKVlND+j02kA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "core/node_modules/gulp-pegjs/node_modules/plugin-error/node_modules/arr-union": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", - "integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "core/node_modules/gulp-pegjs/node_modules/plugin-error/node_modules/extend-shallow": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", - "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==", - "dev": true, - "dependencies": { - "assign-symbols": "^1.0.0", - "is-extendable": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "core/node_modules/gulp-pegjs/node_modules/plugin-error/node_modules/extend-shallow/node_modules/assign-symbols": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", - "integrity": "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "core/node_modules/gulp-pegjs/node_modules/plugin-error/node_modules/extend-shallow/node_modules/is-extendable": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", - "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", - "dev": true, - "dependencies": { - "is-plain-object": "^2.0.4" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "core/node_modules/gulp-pegjs/node_modules/plugin-error/node_modules/extend-shallow/node_modules/is-extendable/node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dev": true, - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "core/node_modules/gulp-pegjs/node_modules/plugin-error/node_modules/extend-shallow/node_modules/is-extendable/node_modules/is-plain-object/node_modules/isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "core/node_modules/gulp-pegjs/node_modules/replace-ext": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/replace-ext/-/replace-ext-1.0.1.tgz", - "integrity": "sha512-yD5BHCe7quCgBph4rMQ+0KkIRKwWCrHDOX1p1Gp6HwjPM5kVoCdKGNhN7ydqqsX6lJEnQDKZ/tFMiEdQ1dvPEw==", - "dev": true, - "engines": { - "node": ">= 0.10" - } - }, - "core/node_modules/gulp-pegjs/node_modules/through2": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", - "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", - "dev": true, - "dependencies": { - "readable-stream": "~2.3.6", - "xtend": "~4.0.1" - } - }, - "core/node_modules/gulp-pegjs/node_modules/through2/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "core/node_modules/gulp-pegjs/node_modules/through2/node_modules/readable-stream/node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true - }, - "core/node_modules/gulp-pegjs/node_modules/through2/node_modules/readable-stream/node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "core/node_modules/gulp-pegjs/node_modules/through2/node_modules/readable-stream/node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true - }, - "core/node_modules/gulp-pegjs/node_modules/through2/node_modules/readable-stream/node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true - }, - "core/node_modules/gulp-pegjs/node_modules/through2/node_modules/readable-stream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true - }, - "core/node_modules/gulp-pegjs/node_modules/through2/node_modules/readable-stream/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "core/node_modules/gulp-pegjs/node_modules/through2/node_modules/readable-stream/node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true - }, - "core/node_modules/gulp-pegjs/node_modules/through2/node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "dev": true, - "engines": { - "node": ">=0.4" - } - }, - "core/node_modules/gulp-pegjs/node_modules/vinyl": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-2.2.1.tgz", - "integrity": "sha512-LII3bXRFBZLlezoG5FfZVcXflZgWP/4dCwKtxd5ky9+LOtM4CS3bIRQsmR1KMnMW07jpE8fqR2lcxPZ+8sJIcw==", - "dev": true, - "dependencies": { - "clone": "^2.1.1", - "clone-buffer": "^1.0.0", - "clone-stats": "^1.0.0", - "cloneable-readable": "^1.0.0", - "remove-trailing-separator": "^1.0.1", - "replace-ext": "^1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, - "core/node_modules/gulp-pegjs/node_modules/vinyl/node_modules/clone": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", - "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", - "dev": true, - "engines": { - "node": ">=0.8" - } - }, - "core/node_modules/gulp-pegjs/node_modules/vinyl/node_modules/clone-buffer": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/clone-buffer/-/clone-buffer-1.0.0.tgz", - "integrity": "sha512-KLLTJWrvwIP+OPfMn0x2PheDEP20RPUcGXj/ERegTgdmPEZylALQldygiqrPPu8P45uNuPs7ckmReLY6v/iA5g==", - "dev": true, - "engines": { - "node": ">= 0.10" - } - }, - "core/node_modules/gulp-pegjs/node_modules/vinyl/node_modules/clone-stats": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/clone-stats/-/clone-stats-1.0.0.tgz", - "integrity": "sha512-au6ydSpg6nsrigcZ4m8Bc9hxjeW+GJ8xh5G3BJCMt4WXe1H10UNaVOamqQTmrx1kjVuxAHIQSNU6hY4Nsn9/ag==", - "dev": true - }, - "core/node_modules/gulp-pegjs/node_modules/vinyl/node_modules/cloneable-readable": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/cloneable-readable/-/cloneable-readable-1.1.3.tgz", - "integrity": "sha512-2EF8zTQOxYq70Y4XKtorQupqF0m49MBz2/yf5Bj+MHjvpG3Hy7sImifnqD6UA+TKYxeSV+u6qqQPawN5UvnpKQ==", - "dev": true, - "dependencies": { - "inherits": "^2.0.1", - "process-nextick-args": "^2.0.0", - "readable-stream": "^2.3.5" - } - }, - "core/node_modules/gulp-pegjs/node_modules/vinyl/node_modules/cloneable-readable/node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "core/node_modules/gulp-pegjs/node_modules/vinyl/node_modules/cloneable-readable/node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true - }, - "core/node_modules/gulp-pegjs/node_modules/vinyl/node_modules/cloneable-readable/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "core/node_modules/gulp-pegjs/node_modules/vinyl/node_modules/cloneable-readable/node_modules/readable-stream/node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true - }, - "core/node_modules/gulp-pegjs/node_modules/vinyl/node_modules/cloneable-readable/node_modules/readable-stream/node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true - }, - "core/node_modules/gulp-pegjs/node_modules/vinyl/node_modules/cloneable-readable/node_modules/readable-stream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true - }, - "core/node_modules/gulp-pegjs/node_modules/vinyl/node_modules/cloneable-readable/node_modules/readable-stream/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "core/node_modules/gulp-pegjs/node_modules/vinyl/node_modules/cloneable-readable/node_modules/readable-stream/node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true - }, - "core/node_modules/gulp-pegjs/node_modules/vinyl/node_modules/remove-trailing-separator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", - "integrity": "sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw==", - "dev": true - }, "core/node_modules/has-ansi": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-4.0.1.tgz", @@ -15136,6 +14797,152 @@ } ] }, + "core/node_modules/nodemon": { + "version": "2.0.22", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.22.tgz", + "integrity": "sha512-B8YqaKMmyuCO7BowF1Z1/mkPqLk6cs/l63Ojtd6otKjMx47Dq1utxfRxcavH1I7VSaL8n5BUaoutadnsX3AAVQ==", + "dev": true, + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^3.2.7", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^5.7.1", + "simple-update-notifier": "^1.0.7", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=8.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "core/node_modules/nodemon/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "core/node_modules/nodemon/node_modules/debug/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "core/node_modules/nodemon/node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true + }, + "core/node_modules/nodemon/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "core/node_modules/nodemon/node_modules/minimatch/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "core/node_modules/nodemon/node_modules/minimatch/node_modules/brace-expansion/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "core/node_modules/nodemon/node_modules/minimatch/node_modules/brace-expansion/node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "core/node_modules/nodemon/node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true + }, + "core/node_modules/nodemon/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "core/node_modules/nodemon/node_modules/simple-update-notifier": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-1.1.0.tgz", + "integrity": "sha512-VpsrsJSUcJEseSbMHkrsrAVSdvVS5I96Qo1QAQ4FxQ9wXFcB+pjj7FB7/us9+GcgfW4ziHtYMc1J0PLczb55mg==", + "dev": true, + "dependencies": { + "semver": "~7.0.0" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "core/node_modules/nodemon/node_modules/simple-update-notifier/node_modules/semver": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz", + "integrity": "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "core/node_modules/nodemon/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "core/node_modules/nodemon/node_modules/supports-color/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "core/node_modules/nodemon/node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true + }, "core/node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -17210,16 +17017,38 @@ "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", "integrity": "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==" }, - "core/node_modules/pegjs": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/pegjs/-/pegjs-0.10.0.tgz", - "integrity": "sha512-qI5+oFNEGi3L5HAxDwN2LA4Gg7irF70Zs25edhjld9QemOgp0CbvMtbFcMvFtEo1OityPrcCzkQFB8JP/hxgow==", + "core/node_modules/peggy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/peggy/-/peggy-3.0.2.tgz", + "integrity": "sha512-n7chtCbEoGYRwZZ0i/O3t1cPr6o+d9Xx4Zwy2LYfzv0vjchMBU0tO+qYYyvZloBPcgRgzYvALzGWHe609JjEpg==", "dev": true, + "dependencies": { + "commander": "^10.0.0", + "source-map-generator": "0.8.0" + }, "bin": { - "pegjs": "bin/pegjs" + "peggy": "bin/peggy.js" }, "engines": { - "node": ">=0.10" + "node": ">=14" + } + }, + "core/node_modules/peggy/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "engines": { + "node": ">=14" + } + }, + "core/node_modules/peggy/node_modules/source-map-generator": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/source-map-generator/-/source-map-generator-0.8.0.tgz", + "integrity": "sha512-psgxdGMwl5MZM9S3FWee4EgsEaIjahYV5AzGnwUvPhWeITz/j6rKpysQHlQ4USdxvINlb8lKfWGIXwfkrgtqkA==", + "dev": true, + "engines": { + "node": ">= 10" } }, "core/node_modules/pluralize": { diff --git a/plugins/conftest-container/test/conftest-container.ts b/plugins/conftest-container/test/conftest-container.ts index 9a4f8ffa80..0248785b9d 100644 --- a/plugins/conftest-container/test/conftest-container.ts +++ b/plugins/conftest-container/test/conftest-container.ts @@ -27,6 +27,9 @@ describe.skip("conftest-container provider", () => { kind: "Project", name: "test", path: projectRoot, + internal: { + basePath: projectRoot, + }, defaultEnvironment: "default", dotIgnoreFile: defaultDotIgnoreFile, environments: [{ name: "default", defaultNamespace, variables: {} }], diff --git a/plugins/conftest/test/conftest.ts b/plugins/conftest/test/conftest.ts index 0c4039f5d0..0ccfe44d52 100644 --- a/plugins/conftest/test/conftest.ts +++ b/plugins/conftest/test/conftest.ts @@ -28,6 +28,9 @@ describe("conftest provider", () => { kind: "Project", name: "test", path: projectRoot, + internal: { + basePath: projectRoot, + }, defaultEnvironment: "default", dotIgnoreFile: defaultDotIgnoreFile, environments: [{ name: "default", defaultNamespace, variables: {} }], diff --git a/plugins/pulumi/src/helpers.ts b/plugins/pulumi/src/helpers.ts index 19ab87c3fb..b58f02ab08 100644 --- a/plugins/pulumi/src/helpers.ts +++ b/plugins/pulumi/src/helpers.ts @@ -256,7 +256,7 @@ export async function applyConfig(params: PulumiParams & { previewDirPath?: stri let stackConfigFileExists: boolean try { const fileData = await readFile(stackConfigPath) - stackConfig = (await loadAndValidateYaml(fileData.toString(), stackConfigPath))[0] + stackConfig = (await loadAndValidateYaml(fileData.toString(), stackConfigPath))[0].toJS() stackConfigFileExists = true } catch (err) { log.debug(`No pulumi stack configuration file for action ${action.name} found at ${stackConfigPath}`)