diff --git a/src/app.ts b/src/app.ts index 118e126..2089882 100644 --- a/src/app.ts +++ b/src/app.ts @@ -78,7 +78,7 @@ export default class App { static async setup(config: StaxConfig, options: SetupOptions = {}): Promise { options = { cache: true, ...options } const staxfile = new Staxfile(config) - const composeFile = await staxfile.compile({ force: true, excludes: options.rebuild ? [ 'prompts' ] : [] }) + const composeFile = await staxfile.compile({ force: true, excludes: options.rebuild ? [ 'prompt' ] : [] }) if (!composeFile) return exit(1, { message: `👿 Couldn't setup a container for '${staxfile.source}'` }) diff --git a/src/staxfile/evaluator.ts b/src/staxfile/evaluator.ts new file mode 100644 index 0000000..8aa0a8e --- /dev/null +++ b/src/staxfile/evaluator.ts @@ -0,0 +1,75 @@ +import { dasherize, dig, resolve } from '~/utils' +import { ExpressionWarning } from '~/yamler' +import icons from '~/icons' +import Staxfile from '.' +import inquirer from 'inquirer' + +export default class Evaluator { + public staxfile: Staxfile + + constructor(staxfile: Staxfile) { + this.staxfile = staxfile + } + + async evaluate(baseDir: string, attributes: Record, path: string, name: string, args: string[]) { + args = args.map(arg => typeof arg === 'string' && arg.startsWith('stax.') ? this.fetch(attributes, arg) : arg) + + // TODO: remove the stax. prefix + if (name === 'stax.ssh_auth_sock' || name === 'ssh_auth_sock') return '/run/host-services/ssh-auth.sock' + + if (name.startsWith('stax.')) return this.fetch(attributes, name) + if (name === 'read') return this.read(args[0], args[1]) + if (name === 'mount_workspace') return this.mountWorkspace() + if (name === 'mount_ssh_auth_sock') return this.mountSshAuthSock() + if (name === 'resolve' || name === 'resolve_relative') return resolve(baseDir, args[0]) + if (name === 'user') return process.env.USER || '' + if (name === 'user_id') return process.getuid() + if (name === 'dasherize') return dasherize(args[0]) + if (name === 'prompt') return await this.prompt(args[0], args[1]) + if (name === 'requires?') return this.staxfile.config.requires.includes(args[0]) + + throw new ExpressionWarning(`Invalid template expression: ${name}`) + } + + fetch(attributes: Record, path: string): string { + return dig(attributes, path, { required: true }) + } + + platform(): string { + return process.platform + } + + mountSshAuthSock(): string { + return this.platform() === 'darwin' ? + '/run/host-services/ssh-auth.sock:/run/host-services/ssh-auth.sock' : + `${process.env.STAX_HOST_SERVICES}:/run/host-services` + } + + mountWorkspace(): string { + const src = this.staxfile.config.location.local ? this.staxfile.config.source : this.staxfile.config.workspace_volume + const dest = this.staxfile.config.workspace + return `${src}:${dest}` + } + + read(file: string, defaultValue: string=''): string { + try { + return this.staxfile.location.readSync(file)?.trim() || defaultValue + } catch (e) { + const url = this.staxfile.config.location?.baseUrl + console.warn(`${icons.warning} Couldn't read ${file} from ${url}: ${e.code}... using default value of '${defaultValue}'`) + return defaultValue + } + } + + async prompt(message: string, defaultValue: string): Promise { + const response = await inquirer.prompt([ + { + type: 'input', + name: 'result', + message, + default: defaultValue, + }, + ]) + return response.result + } +} diff --git a/src/staxfile/expressions.ts b/src/staxfile/expressions.ts deleted file mode 100644 index f4d0ce7..0000000 --- a/src/staxfile/expressions.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { dasherize, resolve } from '~/utils' -import icons from '~/icons' -import Staxfile from './index' -import inquirer from 'inquirer' - -export default class Expressions { - private staxfile: Staxfile - private static cache: Record = {} - - constructor(staxfile: Staxfile) { - this.staxfile = staxfile - } - - static clearCache() { - Expressions.cache = {} - } - - async evaluate(name: string, args: any[]): Promise { - const cacheKey = this.getCacheKey(name, args) - - if (!name.startsWith('stax.') && Expressions.cache[cacheKey] !== undefined) - return Expressions.cache[cacheKey] - - return Expressions.cache[cacheKey] = await this.evaluateUncached(name, args) - } - - private async evaluateUncached(name: string, args: any[]): Promise { - args = args.map(arg => typeof arg === 'string' && arg.startsWith('stax.') ? this.fetchConfigValue(arg) : arg) - - if (name.startsWith('stax.')) return this.fetchConfigValue(name) - if (name === 'read') return this.read(args[0], args[1]) - if (name === 'mount_workspace') return this.mountWorkspace() - if (name === 'mount_ssh_auth_sock') return this.mountSshAuthSock() - if (name === 'resolve') return resolve(args[0]) - if (name === 'user') return process.env.USER || '' - if (name === 'user_id') return process.getuid().toString() - if (name === 'dasherize') return dasherize(args[0]) - if (name === 'test') return this.test(args[0], args[1]).toString() - if (name === 'prompt') return await this.prompt(args[0], args[1]) - if (name === 'requires?') return this.staxfile.config.requires.includes(args[0]).toString() - - this.staxfile.warnings.add(`Invalid template expression: ${name}`) - } - - private getCacheKey(name: string, args: any[]): string { - return `${this.staxfile.context}:${this.staxfile.app}:${name}:${JSON.stringify(args)}` - } - - private async prompt(message: string, defaultValue: string): Promise { - const response = await inquirer.prompt([ - { - type: 'input', - name: 'result', - message, - default: defaultValue, - }, - ]) - return response.result - } - - private fetchConfigValue(name: string): string { - const key = name.slice(5) // strip 'stax.' prefix - - if (key === 'host_services') - return process.env.STAX_HOST_SERVICES || '' - - if (key === 'ssh_auth_sock') - return '/run/host-services/ssh-auth.sock' - - if (!this.staxfile.config.hasProperty(key)) { - if (name === 'config.workspace_volume' && !this.staxfile.location.local) - this.staxfile.warnings.add(`A '${name}' name must be defined when setting up from a remote source.`) - - this.staxfile.warnings.add(`Undefined reference to '${name}'`) - } - - return this.staxfile.config.fetch(key) - } - - private read(file: string, defaultValue: string=''): string { - try { - return this.staxfile.location.readSync(file)?.trim() || defaultValue - } catch (e) { - const url = this.staxfile.config.location?.baseUrl - console.warn(`${icons.warning} Couldn't read ${file} from ${url}: ${e.code}... using default value of '${defaultValue}'`) - return defaultValue - } - } - - private mountWorkspace(): string { - const src = this.staxfile.config.location.local ? this.staxfile.config.source : this.staxfile.config.workspace_volume - const dest = this.staxfile.config.workspace - return `${src}:${dest}` - } - - private platform(): string { - return process.platform - } - - private mountSshAuthSock(): string { - return this.platform() === 'darwin' ? - '${{ stax.ssh_auth_sock }}:${{ stax.ssh_auth_sock }}' : - '${{ stax.host_services }}:/run/host-services' - } - - private test(filename: string, pattern: string): boolean { - const content = this.read(filename, '') - - if (pattern.startsWith('/') && pattern.endsWith('/')) { - const regex = new RegExp(pattern.slice(1, -1)) - return regex.test(content) - } - - return content.includes(pattern) - } -} diff --git a/src/staxfile/index.ts b/src/staxfile/index.ts index 10ad1ad..51897e9 100644 --- a/src/staxfile/index.ts +++ b/src/staxfile/index.ts @@ -1,12 +1,11 @@ import { writeFileSync, existsSync, mkdirSync, statSync } from 'fs' -import { cacheDir as _cacheDir, exit, flattenObject, deepMap, verifyFile, resolve } from '~/utils' +import { cacheDir as _cacheDir, exit, flattenObject, deepMap, verifyFile, resolve, deepMapWithKeys } from '~/utils' import { StaxConfig, CompileOptions, DefaultCompileOptions } from '~/types' -import { renderTemplate, parseTemplateExpression } from './template' -import { dump, loadFile } from './yaml' +import YamlER, { dump } from '~/yamler' import yaml from 'js-yaml' import Config from './config' import DockerfileCompiler from './dockerfile_compiler' -import Expressions from './expressions' +import Evaluator from './evaluator' import Location from '~/location' import icons from '~/icons' import * as path from 'path' @@ -14,10 +13,8 @@ import * as path from 'path' export default class Staxfile { public config: Config public compose: Record - public warnings: Set public cacheDir: string private buildsCompiled: Record = {} - private expressions: Expressions constructor(config: StaxConfig, options: { cacheDir?: string } = {}) { let source = config.source @@ -27,8 +24,6 @@ export default class Staxfile { this.config = new Config({ ...config, source: source }) this.cacheDir = options.cacheDir || this.systemCacheDir - this.warnings = new Set() - this.expressions = new Expressions(this) } get staxfile(): string { return this.config.staxfile } @@ -84,65 +79,36 @@ export default class Staxfile { } private async load(options: CompileOptions = {}): Promise { + const evaluator = new Evaluator(this) + const yamler = new YamlER(this.staxfile, { expressionCallback: evaluator.evaluate.bind(evaluator) }) + options = { ...DefaultCompileOptions, ...options } - this.warnings = new Set() - this.compose = loadFile(this.staxfile) + this.compose = yamler.compile() + yamler.attributes.stax.app = this.config.app - if (options.excludes.includes('prompts')) - this.keepExistingPromptValues() + // if (options.excludes?.includes('prompt')) + // this.compose = yamler.attributes = this.keepExistingPromptValues() - // render the stax section first since we need to update this.config with the values there - // exclude read on this first render since it can be dependent on stax.source - this.compose.stax = await this.renderCompose(this.compose.stax, { excludes: [ 'read' ] }) this.config = new Config({ ...this.config, ...this.compose.stax }) - - this.compose = await this.renderCompose(this.compose) + this.compose = await yamler.load() + this.config = new Config({ ...this.config, ...this.compose.stax }) this.updateServices() - // need to re-render after updating services since template expressions may have been added - this.compose = await this.renderCompose(this.compose) - - if (this.generatedWarnings.length > 0) - return exit(1, { message: this.generatedWarnings.join('\n') }) + if (yamler.warnings.length > 0) + return exit(1, { message: yamler.warnings.join('\n') }) } // set all prompts to it's current config value or default if it is being excluded - private keepExistingPromptValues() { - deepMap(this.compose, (path, value) => { - if (typeof value === 'string' && value.includes('prompt')) { - const expression = parseTemplateExpression(value) + private keepExistingPromptValues(): Record { + return deepMapWithKeys(this.compose, (path, key, value) => { + if (value && typeof(value) === 'string' && value.includes('prompt')) + return [ key, this.config.fetch(path) ] - if (expression.funcName === 'prompt') - return this.config.fetch(path) || expression.args[expression.args.length - 1] - } - return value + return [ key, value ] }) } - private get generatedWarnings(): Array { - return [...this.warnings] - } - - private async renderCompose(attributes: Record, options: CompileOptions = { excludes: [] }): Promise> { - const renderedYaml = await this.render(dump(attributes), options) - return yaml.load(renderedYaml) - } - - private async render(text: string, options: CompileOptions = { excludes: [] }): Promise { - let matches = 0 - - text = await renderTemplate(text, async (name, args, originalMatch) => { - if (options.excludes.includes(name)) - return originalMatch - - matches += 1 - return await this.expressions.evaluate(name, args) - }) - - return matches > 0 ? await this.render(text, options) : text - } - private updateServices() { const services = {} let number = 0 diff --git a/src/staxfile/template.ts b/src/staxfile/template.ts deleted file mode 100644 index 362c32e..0000000 --- a/src/staxfile/template.ts +++ /dev/null @@ -1,88 +0,0 @@ -function parseToken(content: string): [string, string[]] { - const parts = content.trim().split(/\s+/) - if (parts.length === 0) return [content, []] - - const [name, ...args] = parts - return [name, args] -} - -/** - * Parses an array of template expression arguments, handling quoted strings. - * - * @param args - The array of arguments to parse. - * @returns An array of parsed arguments, with quoted strings combined. - */ -function parseTemplateExpressionArgs(args: string[]): string[] { - if (!Array.isArray(args)) return args - - const parsedArgs: string[] = [] - let currentArg = '' - let inQuotes = false - - for (const arg of args) { - if (!inQuotes) { - if (arg.startsWith("'") && arg.endsWith("'")) { - parsedArgs.push(arg.slice(1, -1).trim()) - } else if (arg.startsWith("'")) { - inQuotes = true - currentArg = arg.slice(1) - } else { - parsedArgs.push(arg.trim()) - } - } else { - if (arg.endsWith("'")) { - inQuotes = false - currentArg += ' ' + arg.slice(0, -1) - parsedArgs.push(currentArg.trim()) - currentArg = '' - } else { - currentArg += ' ' + arg - } - } - } - - if (currentArg) { - parsedArgs.push(currentArg.trim()) - } - - return parsedArgs -} - -/** - * Parses a template expression and returns the function name and arguments. - * - * @param expression - The template expression to parse. - * @returns An object containing the function name and parsed arguments. - */ -export function parseTemplateExpression(expression: string): { funcName: string; args: string[] } { - const [content] = expression.match(/\$\{\{\s*([^}]+)\s*\}\}/) || [] - if (!content) { - throw new Error('Invalid template expression') - } - - const [funcName, argString] = parseToken(content.slice(3, -2).trim()) - const args = parseTemplateExpressionArgs(argString) - - return { funcName, args } -} - -export async function renderTemplate(template: string, callback: (name: string, args: string[], originalMatch: string) => Promise): Promise { - const regex = /\$\{\{\s*([^}]+)\s*\}\}/g - let result = '' - let lastIndex = 0 - let match - - while ((match = regex.exec(template)) !== null) { - const before = template.slice(lastIndex, match.index) - result += before - - const { funcName, args } = parseTemplateExpression(match[0]) - const replacement = await callback(funcName, args, match[0]) - - result += replacement - lastIndex = regex.lastIndex - } - - result += template.slice(lastIndex) - return result -} diff --git a/src/staxfile/yaml.ts b/src/staxfile/yaml.ts deleted file mode 100644 index e20f216..0000000 --- a/src/staxfile/yaml.ts +++ /dev/null @@ -1,144 +0,0 @@ -import * as fs from 'fs' -import * as path from 'path' -import yaml from 'js-yaml' -import icons from '~/icons' -import { deepRemoveKeys, dig, exit, resolve } from '~/utils' -import YamlER from '~/yamler' - -const dumpOptions = { lineWidth: -1, noRefs: true } -const sanitizeRegex = /[^a-zA-Z0-9_]/g -const importRegex = /^ *!import\s+(.+)\sas\s+(.+)$/gm -const extendsRegex = /^(\s*)(.+):\s*!extends\s+(.+)$/gm -const rootExtendsRegex = /^ *!extends\s+(.+)$/gm -const anchorNamePrefix = '_stax_import_' - -class Import { - public match: string - public name: string - public filePath: string - public yaml: Yaml - - constructor({ match, name, filePath, parentFile }: { match: string, name: string, filePath: string, parentFile: string }) { - this.match = match - this.name = name - this.filePath = filePath - this.yaml = new Yaml(filePath, parentFile) - } - - get anchorName(): string { - return this.buildAnchorName([ this.filePath, this.yaml.parentFile, this.name ]) - } - - buildAnchorName(parts: string | string[]): string { - if (typeof parts === 'string') parts = [ parts ] - return [ anchorNamePrefix, ...parts.map(part => part.replace(sanitizeRegex, '_')) ].join('_') - } -} - -class Yaml { - public filePath: string - public parentFile: string - public imports: Record - public content: string - public attributes: Record - - constructor(filePath: string, parentFile: string = undefined) { - this.filePath = resolve(path.dirname(parentFile || filePath), filePath) - this.parentFile = parentFile - } - - get baseDir(): string { - return path.dirname(this.filePath) - } - - compile(): Record { - this.content = this.readFile(this.filePath) - this.parseImports() - this.parseExtends() - this.parseResolveRelative() - return this.attributes = deepRemoveKeys(yaml.load(this.content), [ new RegExp(`^${anchorNamePrefix}`) ]) - } - - load(): Record { - return this.compile() - } - - dump(): string { - return dump(this.load()) - } - - private readFile(filePath: string): string { - try { - return fs.readFileSync(filePath, 'utf8') - } catch (error) { - let message = `Could not import ${filePath}` - if (this.parentFile) message += ` from ${this.parentFile}` - console.error(`${icons.error} ${message} - ${error.code}: ${error.message}`) - process.exit(1) - } - } - - private parseImports() { - this.imports = {} - this.content = this.content.replace(importRegex, (match, filePath, name) => { - const yamlImport = new Import({ name, match, filePath, parentFile: this.filePath }) - this.imports[yamlImport.name] = yamlImport - - const attrs: any = yamlImport.yaml.compile() - let text: string = dump({ [yamlImport.anchorName]: attrs }) - text = text.replace(`${yamlImport.anchorName}:`, `${yamlImport.anchorName}: &${yamlImport.name}`) - return `# ${match}\n${text}` - }) - } - - private parseExtends() { - const prepends = new Set() - - // root level !extends - this.content = this.content.replace(rootExtendsRegex, (_match, name) => `<<: *${name}`) - - // non-root level !extends - this.content = this.content.replace(extendsRegex, (_match, indent, key, name) => { - if (name.includes('.')) { - const imp = this.findImport(name) - const subKey = name.split('.').slice(1).join('.') - const extendedValue = dig(imp.yaml.attributes, subKey) - - if (extendedValue === undefined) - exit(1, { message: `${icons.error} Invalid !extends reference: '${name}' in file '${this.filePath}'. The referenced field does not exist.` }) - - let text = dump({ [name]: extendedValue }).replace(`${name}:`, `${imp.buildAnchorName(name)}: &${name}`) - prepends.add(text) - } - return `${indent}${key}:\n${indent} <<: *${name}` - }) - - if (prepends.size > 0) - this.content = Array.from(prepends).join('\n\n') + '\n\n' + this.content - } - - private findImport(name: string): Import { - const importName = name.split('.')[0] - const imp = this.imports[importName] || exit(1, { message: `${icons.error} Couldn't find import for '${importName}' referenced in '${this.filePath}'` }) - return dig(imp.yaml.attributes, name.split('.')[1]) ? imp : null - } - - // Need to handle resolve_relative here rather than in Expressions because we know - // the actual paths here when importing - private parseResolveRelative() { - this.content = this.content.replace(/\$\{\{ resolve_relative (.+?) \}\}/g, (_match, p1) => resolve(this.baseDir, p1)) - } -} - -function yamlClass() { - return process.env.STAX_YAMLER == "1" ? YamlER : Yaml -} - -export function loadFile(filePath: string): Record { - const klass = yamlClass() - return new klass(filePath).load() -} - -export function dump(obj: any): string { - return yaml.dump(obj, dumpOptions) -} diff --git a/tests/unit/staxfile/expressions.test.js b/tests/unit/staxfile/expressions.test.js deleted file mode 100644 index be918a3..0000000 --- a/tests/unit/staxfile/expressions.test.js +++ /dev/null @@ -1,139 +0,0 @@ -import { describe, it, expect, beforeEach, mock } from 'bun:test' -import { resolve } from '~/utils' -import Expressions from '~/staxfile/expressions' -import Staxfile from '~/staxfile' -import os from 'os' -import path from 'path' - -describe('Expressions', () => { - const cacheDir = path.join(__dirname, '../../tmp/tests-staxfile-cache') - let expression - let staxfile - - beforeEach(() => { - staxfile = new Staxfile({ source: './tests/fixtures', staxfile: './tests/fixtures/some_service.staxfile', workspace: '/workspaces/tests' }, { cacheDir }) - expression = new Expressions(staxfile) - }) - - it('evaluates undefined stax config values', async () => { - expect(await expression.evaluate('stax.app', [])).toBe('fixtures') - }) - - // it('evaluates read function', () => { - // const result = expression.evaluate('read', [__filename, 'default']) - // expect(result).toContain('import { describe, it, expect, beforeEach } from \'bun:test\'') - // }) - - it('evaluates mount_workspace function', async () => { - const result = await expression.evaluate('mount_workspace', []) - expect(result).toBe(`${resolve('./tests/fixtures')}:/workspaces/tests`) - }) - - it('evaluates mount_ssh_auth_sock function for Darwin', async () => { - expression.platform = mock(() => 'darwin') - expect(await expression.evaluate('mount_ssh_auth_sock', [])).toBe('${{ stax.ssh_auth_sock }}:${{ stax.ssh_auth_sock }}') - }) - - it('evaluates mount_ssh_auth_sock function for Linux', async () => { - expression.platform = mock(() => 'linux') - expect(await expression.evaluate('mount_ssh_auth_sock', [])).toBe('${{ stax.host_services }}:/run/host-services') - }) - - it('evaluates resolve function', async () => { - expect(await expression.evaluate('resolve', ['/test/path'])).toBe('/test/path') - }) - - it('evaluates user function', async () => { - expect(await expression.evaluate('user', [])).toBe(os.userInfo().username) - }) - - it('evaluates user_id function', async () => { - expect(await expression.evaluate('user_id', [])).toBe(process.getuid().toString()) - }) - - it('evaluates dasherize function', async () => { - expect(await expression.evaluate('dasherize', ['TestString'])).toBe('test-string') - }) - - it('evaluates dasherize function with stax.app', async () => { - staxfile.config.set('app', 'MyTestApp') - const result = await expression.evaluate('dasherize', ['stax.app']) - expect(result).toBe('my-test-app') - }) - - it('evaluates dasherize function with undefined stax config', async () => { - const result = await expression.evaluate('dasherize', ['stax.undefined_key']) - expect(result).toBeUndefined() - expect(staxfile.warnings).toContain("Undefined reference to 'stax.undefined_key'") - }) - - it('adds warning for invalid expression', async () => { - await expression.evaluate('invalid_expression', []) - expect(staxfile.warnings).toContain('Invalid template expression: invalid_expression') - }) - - it('adds warning for undefined stax config', async () => { - await expression.evaluate('stax.undefined_key', []) - expect(staxfile.warnings).toContain("Undefined reference to 'stax.undefined_key'") - }) - - describe('requires?', () => { - it('returns true when requirement is in staxfile config', async () => { - staxfile.config.requires = ['docker', 'node'] - expect(await expression.evaluate('requires?', ['docker'])).toBe('true') - }) - - it('returns false when requirement is not in staxfile config', async () => { - staxfile.config.requires = ['docker', 'node'] - expect(await expression.evaluate('requires?', ['python'])).toBe('false') - }) - - it('returns false when staxfile config has no requirements', async () => { - staxfile.config.requires = [] - expect(await expression.evaluate('requires?', ['docker'])).toBe('false') - }) - }) - - describe('test', () => { - let expressions - let mockStaxfile - - beforeEach(() => { - mockStaxfile = { - warnings: { add: mock(() => {}) }, - config: { - hasProperty: mock(() => true), - fetch: mock(() => 'mocked-value'), - }, - location: { - readSync: mock(() => 'file content'), - }, - } - expressions = new Expressions(mockStaxfile) - }) - - it('returns true when pattern is found in file', async () => { - mockStaxfile.location.readSync.mockImplementation(() => 'Hello, world!') - const result = await expressions.evaluate('test', ['test.txt', 'world']) - expect(result).toBe('true') - }) - - it('returns false when pattern is not found in file', async () => { - mockStaxfile.location.readSync.mockImplementation(() => 'Hello, world!') - const result = await expressions.evaluate('test', ['test.txt', 'foo']) - expect(result).toBe('false') - }) - - it('uses default value when file cannot be read', async () => { - mockStaxfile.location.readSync.mockImplementation(() => { throw new Error('File not found') }) - const result = await expressions.evaluate('test', ['nonexistent.txt', 'pattern']) - expect(result).toBe('false') - }) - - it('accepts regex patterns', async () => { - mockStaxfile.location.readSync.mockImplementation(() => 'Hello, world!') - const result = await expressions.evaluate('test', ['test.txt', '/world/']) - expect(result).toBe('true') - }) - }) -}) diff --git a/tests/unit/staxfile/template.test.js b/tests/unit/staxfile/template.test.js deleted file mode 100644 index bf8ea3a..0000000 --- a/tests/unit/staxfile/template.test.js +++ /dev/null @@ -1,82 +0,0 @@ -import { expect, it, describe } from 'bun:test' -import { renderTemplate } from '~/staxfile/template' - -describe('renderTemplate', () => { - it('replaces tokens with callback results', async () => { - const template = 'Hello, ${{ name John Doe }}! Your age is ${{ age 30 }}.' - const callback = async (name, args) => { - if (name === 'name') return args.join(' ') - if (name === 'age') return `${parseInt(args[0]) + 1}` - return '' - } - - const result = await renderTemplate(template, callback) - expect(result).toBe('Hello, John Doe! Your age is 31.') - }) - - it('handles tokens without arguments', async () => { - const template = 'The current year is ${{ year }}.' - const callback = async (name) => name === 'year' ? '2024' : '' - - const result = await renderTemplate(template, callback) - expect(result).toBe('The current year is 2024.') - }) - - it('ignores malformed tokens', async () => { - const template = 'This is a ${{ malformed token }} and this is correct ${{ correct token }}.' - const callback = async (name, args) => name === 'correct' ? 'CORRECT' : 'IGNORED' - - const result = await renderTemplate(template, callback) - expect(result).toBe('This is a IGNORED and this is correct CORRECT.') - }) - - it('handles multiple tokens in a single line', async () => { - const template = '${{ greeting Hello }} ${{ name John }}! Today is ${{ day Monday }}.' - const callback = async (name, args) => args.join(' ') - - const result = await renderTemplate(template, callback) - expect(result).toBe('Hello John! Today is Monday.') - }) - - it('handles simple arguments', async () => { - const input = "${{ test arg1 arg2 arg3 }}" - const result = await renderTemplate(input, async (name, args) => `${name}:${args.join(',')}`) - expect(result).toBe("test:arg1,arg2,arg3") - }) - - it('handles single-quoted arguments', async () => { - const input = "${{ test 'arg with spaces' normal_arg }}" - const result = await renderTemplate(input, async (name, args) => `${name}:${args.join(',')}`) - expect(result).toBe("test:arg with spaces,normal_arg") - }) - - it('handles multi-part quoted arguments', async () => { - const input = "${{ test 'arg with multiple parts' normal_arg }}" - const result = await renderTemplate(input, async (name, args) => `${name}:${args.join(',')}`) - expect(result).toBe("test:arg with multiple parts,normal_arg") - }) - - it('handles mixed quoted and unquoted arguments', async () => { - const input = "${{ test normal_arg 'quoted arg' another_normal 'multi part quoted arg' }}" - const result = await renderTemplate(input, async (name, args) => `${name}:${args.join(',')}`) - expect(result).toBe("test:normal_arg,quoted arg,another_normal,multi part quoted arg") - }) - - it('handles empty arguments', async () => { - const input = "${{ test }}" - const result = await renderTemplate(input, async (name, args) => `${name}:${args.length}`) - expect(result).toBe("test:0") - }) - - it('handles unclosed quotes', async () => { - const input = "${{ test 'unclosed quote argument }}" - const result = await renderTemplate(input, async (name, args) => `${name}:${args.join(',')}`) - expect(result).toBe("test:unclosed quote argument") - }) - - it('handles multiple template expressions', async () => { - const input = "Hello ${{ test arg1 }} world ${{ test2 'arg2 with spaces' }}" - const result = await renderTemplate(input, async (name, args) => `${name}:${args.join('|')}`) - expect(result).toBe("Hello test:arg1 world test2:arg2 with spaces") - }) -}) diff --git a/tests/unit/staxfile/yaml.test.js b/tests/unit/staxfile/yaml.test.js deleted file mode 100644 index cdbe827..0000000 --- a/tests/unit/staxfile/yaml.test.js +++ /dev/null @@ -1,27 +0,0 @@ -import { beforeEach, describe, it, expect } from 'bun:test' -import { loadFile, dump } from '~/staxfile/yaml' -import { resolve } from '~/utils' - -describe('loadFile', () => { - const fixturesDir = resolve(__dirname, '../../../tests/fixtures') - const composeYaml = resolve(fixturesDir, 'some_service.staxfile') - let yaml - - beforeEach(() => yaml = loadFile(composeYaml)) - - it('loads and processes a YAML file with imports', () => { - expect(yaml.stax.app).toBe('some_service') - expect(yaml.stax.vars.ruby_version).toBe('2.0.1') - expect(yaml.stax.vars.rails_server_port).toBe(3000) - expect(Object.keys(yaml.volumes).length).toBe(2) - }) - - it('parses resolve_relative', () => { - expect(yaml.services.web.build.context).toBe(resolve(fixturesDir, 'build')) - expect(yaml.services.web.build.dockerfile).toBe(resolve(fixturesDir, 'build/Dockerfile')) - }) - - it('strips _stax_import_ anchors', () => { - expect(dump(yaml)).not.toContain('_stax_import_') - }) -}) diff --git a/tests/unit/utils.test.js b/tests/unit/utils.test.js index 7208a53..9a2abe4 100644 --- a/tests/unit/utils.test.js +++ b/tests/unit/utils.test.js @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, mock } from 'bun:test' -import { csvKeyValuePairs, dasherize, deepRemoveKeys, dig, directoryExists, flattenObject, isFile, timeAgo, truthy, verifyDirectory, presence, deepMap, resolve, deepMapWithKeysAsync } from '~/utils' +import { csvKeyValuePairs, dasherize, deepRemoveKeys, dig, directoryExists, flattenObject, isFile, timeAgo, truthy, verifyDirectory, presence, resolve, deepMapWithKeysAsync } from '~/utils' import * as os from 'os' import * as path from 'path'