diff --git a/deno.jsonc b/deno.jsonc index d15fbde..ca6242d 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -1,6 +1,7 @@ { "tasks": { - "start": "deno run -A --watch=src src/index.ts", + "dev": "deno run -A --watch=src src/index.ts", + "test": "deno test -A --watch", "run": "deno run -A src/index.ts", "install": "deno install -A --force --global --name runreal src/index.ts", "compile-win": "deno compile -A --target x86_64-pc-windows-msvc --output build/runreal-win-x64 src/index.ts", @@ -9,7 +10,7 @@ "generate-schema": "deno run -A src/generate-schema.ts" }, "lint": { - "include": ["src/"], + "include": ["src/", "tests/"], "rules": { "tags": ["recommended"], "include": ["ban-untagged-todo"], @@ -17,7 +18,7 @@ } }, "fmt": { - "include": ["src/"], + "include": ["src/", "tests/"], "useTabs": true, "lineWidth": 120, "indentWidth": 2, diff --git a/src/commands/debug/debug-config.ts b/src/commands/debug/debug-config.ts new file mode 100644 index 0000000..10cea8f --- /dev/null +++ b/src/commands/debug/debug-config.ts @@ -0,0 +1,22 @@ +import { Command } from '../../deps.ts' +import { config } from '../../lib/config.ts' +import { CliOptions, GlobalOptions } from '../../lib/types.ts' + +export type DebugOptions = typeof debugConfig extends Command ? Options + : never + +export const debugConfig = new Command() + .option('-r, --render', 'Render the config with substitutions') + .description('debug config') + .action((options) => { + const { render } = options as DebugOptions & GlobalOptions + const cfg = config.get(options as CliOptions) + + if (render) { + const rendered = config.renderConfig(cfg) + console.dir(rendered, { depth: null }) + return + } + + console.dir(cfg, { depth: null }) + }) diff --git a/src/commands/debug/index.ts b/src/commands/debug/index.ts new file mode 100644 index 0000000..8b5a153 --- /dev/null +++ b/src/commands/debug/index.ts @@ -0,0 +1,8 @@ +import { Command } from '../../deps.ts' +import { GlobalOptions } from '../../lib/types.ts' + +import { debugConfig } from './debug-config.ts' + +export const debug = new Command() + .description('debug') + .command('config', debugConfig) diff --git a/src/index.ts b/src/index.ts index 7ab13a8..7863837 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ import { VERSION } from './version.ts' -import { debug } from './commands/debug.ts' +import { debug } from './commands/debug/index.ts' import { build } from './commands/build.ts' import { engine } from './commands/engine/index.ts' import { init } from './commands/init.ts' diff --git a/src/lib/config.ts b/src/lib/config.ts index 048d0d1..ebf9391 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -3,10 +3,11 @@ import { CliOptions, RunrealConfig } from '../lib/types.ts' import { execSync } from '../lib/utils.ts' import { ConfigSchema, InternalSchema } from '../lib/schema.ts' import { Source } from './source.ts' +import { renderConfig } from './template.ts' const env = (key: string) => Deno.env.get(key) || '' -class Config { +export class Config { private config: Partial = { engine: { path: '', @@ -19,7 +20,7 @@ class Config { }, build: { path: '', - id: '', + id: env('RUNREAL_BUILD_ID') || '', branch: '', branchSafe: '', commit: '', @@ -32,6 +33,9 @@ class Config { buildCheckoutPath: env('BUILDKITE_BUILD_CHECKOUT_PATH') || Deno.cwd(), buildPipelineSlug: env('BUILDKITE_PIPELINE_SLUG') || '', }, + metadata: { + test: env('RUNREAL_BUILD_ID') || '', + }, workflows: [], } private cliOptionToConfigMap = { @@ -66,6 +70,11 @@ class Config { return this.config as RunrealConfig } + renderConfig(cfg: RunrealConfig): RunrealConfig { + const rendered = renderConfig(cfg) + return rendered + } + async mergeConfig(configPath: string) { const cfg = await this.readConfigFile(configPath) if (!cfg) return diff --git a/src/lib/schema.ts b/src/lib/schema.ts index ddf7769..eeba9b3 100644 --- a/src/lib/schema.ts +++ b/src/lib/schema.ts @@ -2,12 +2,15 @@ import { z } from '../deps.ts' export const InternalSchema = z.object({ buildkite: z.object({ - branch: z.string().describe('Buildkite branch name'), - checkout: z.string().describe('Buildkite commit hash'), - buildNumber: z.string().describe('Buildkite build number'), - buildCheckoutPath: z.string().describe('Buildkite build checkout path'), - buildPipelineSlug: z.string().describe('Buildkite pipeline slug'), - }), + branch: z.string().describe('Buildkite branch name').optional(), + checkout: z.string().describe('Buildkite commit hash').optional(), + buildNumber: z.string().describe('Buildkite build number').optional(), + buildCheckoutPath: z.string().describe('Buildkite build checkout path').optional(), + buildPipelineSlug: z.string().describe('Buildkite pipeline slug').optional(), + }).optional(), + metadata: z.object({ + test: z.string().describe('Build id '), + }).optional(), }) export const ConfigSchema = z.object({ @@ -56,7 +59,6 @@ export const ConfigSchema = z.object({ args: z.array(z.string()).optional().describe('Command arguments'), }), ), - }) - .optional(), - ), + }), + ).optional(), }) diff --git a/src/lib/source.ts b/src/lib/source.ts index 7e338c5..c87aff2 100644 --- a/src/lib/source.ts +++ b/src/lib/source.ts @@ -20,20 +20,33 @@ abstract class Base { abstract clean(): void safeRef(): string { - return this.ref().replace(/\/\//g, '/').replace(/\//g, '-') + return this.ref().replace(/\/\//g, '/').replace(/\//g, '-').toLowerCase() } } export class Perforce extends Base { executable: string = 'p4' + stream(): string { + return execSync(this.executable, [ + '-F', + '%Stream%', + '-ztag', + 'client', + '-o', + ], { cwd: this.cwd, quiet: true }).output.trim() + } + changelist(): string { + return execSync(this.executable, [ + '-F', + '%change%', + 'changes', + '-m1', + ], { cwd: this.cwd, quiet: true }).output.trim().replace('Change ', '') + } ref(): string { - const stream = execSync(this.executable, ['p4', '-F', '"%Stream%"', '-ztag', 'client', '-o'], { - cwd: this.cwd, - quiet: false, - }).output.trim() - const change = execSync(this.executable, ['-F', '%change%', 'changes', '-m1'], { cwd: this.cwd, quiet: false }) - .output.trim().replace('Change ', '') const parts: string[] = [] + const stream = this.stream() + const change = this.changelist() if (stream) { parts.push(stream) } @@ -42,11 +55,9 @@ export class Perforce extends Base { } return parts.join('/') } - safeRef(): string { - return this.ref().split('//').filter(Boolean).join('-').replace(/\//g, '-') + return this.ref().split('//').filter(Boolean).join('-').replace(/\//g, '-').toLowerCase() } - clone({ source, destination, @@ -74,12 +85,15 @@ export class Perforce extends Base { export class Git extends Base { executable: string = 'git' - private branch(): string { - return execSync(this.executable, ['branch', '--show-current'], { cwd: this.cwd, quiet: false }).output.trim() + branch(): string { + return execSync(this.executable, ['branch', '--show-current'], { cwd: this.cwd, quiet: true }).output.trim() + } + commit(): string { + return execSync(this.executable, ['rev-parse', 'HEAD'], { cwd: this.cwd, quiet: true }).output.trim() } ref(): string { const branch = this.branch() - const commit = execSync(this.executable, ['rev-parse', 'HEAD'], { cwd: this.cwd, quiet: false }).output.trim() + const commit = this.commit() const parts: string[] = [] if (branch) { parts.push(branch) @@ -89,7 +103,6 @@ export class Git extends Base { } return parts.join('/') } - clone(opts: CloneOpts): string { const { source, destination, branch, dryRun } = opts const cmd = branch ? ['clone', '-b', branch, source, destination] : ['clone', source, destination] diff --git a/src/lib/template.ts b/src/lib/template.ts index 2bd2b74..0e98d7c 100644 --- a/src/lib/template.ts +++ b/src/lib/template.ts @@ -1,21 +1,10 @@ import { RunrealConfig } from './types.ts' -// This helper function will take a command string with placeholders and a substitutions object -// It will replace all placeholders in the command with their corresponding values -// If the key is not found in substitutions, keep the original placeholder -export function render(input: string[], cfg: RunrealConfig) { - // This regular expression matches all occurrences of ${placeholder} - const substitutions: Record = getSubstitutions(cfg) - - const placeholderRegex = /\$\{([^}]+)\}/g - return input.map((arg) => - arg.replace(placeholderRegex, (_, key: string) => { - return key in substitutions ? substitutions[key] || key : _ - }) - ) -} - -// Object containing the allowed substitutions +/** + * Get the substitutions object with values from the config. + * @param {RunrealConfig} cfg + * @returns {Record} the substitutions object + */ export const getSubstitutions = (cfg: RunrealConfig): Record => ({ 'engine.path': cfg.engine?.path, 'project.path': cfg.project?.path, @@ -26,3 +15,60 @@ export const getSubstitutions = (cfg: RunrealConfig): Record = getSubstitutions(cfg) + return input.map((arg) => + arg.replace(placeholderRegex, (_, key: string) => { + return key in substitutions ? substitutions[key] || key : _ + }) + ) +} + +/** + * Replace all ${placeholders} in the items with values from the substitutions object. + * If a placeholder is not found in the substitutions object, it will be kept as is. + * @param {any} item + * @param {Record} substitutions + * @returns {any} the rendered items + */ +function renderItems(item: any, substitutions: Record): any { + if (typeof item === 'string') { + // Replace placeholders in strings + return item.replace(/\$\{([^}]+)\}/g, (_, key: string) => substitutions[key] || _) + } else if (Array.isArray(item)) { + // Recursively process each item in an array + return item.map((subItem) => renderItems(subItem, substitutions)) + } else if (typeof item === 'object' && item !== null) { + // Recursively process each property in an object + const result: Record = {} + for (const key of Object.keys(item)) { + result[key] = renderItems(item[key], substitutions) + } + return result + } + // Return the item as is if it's not a string, array, or object + return item +} + +/** + * Render the config by replacing all ${placeholders} with values from the substitutions object. + * @param {RunrealConfig} cfg + * @returns {RunrealConfig} rendered RunrealConfig + */ +export function renderConfig(cfg: RunrealConfig) { + const substitutions: Record = getSubstitutions(cfg) + return renderItems(cfg, substitutions) as RunrealConfig +} diff --git a/src/lib/template_test.ts b/src/lib/template_test.ts deleted file mode 100644 index 742b926..0000000 --- a/src/lib/template_test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { assertEquals } from 'https://deno.land/std/assert/mod.ts' -import { render } from '../lib/template.ts' -import { RunrealConfig } from '../lib/types.ts' - -Deno.test('template tests', () => { - const tmpl = - '{"name": "${project.name}", "engine": "${engine.path}\\BuildGraph\\Build.xml", "project": "${project.path}"}' - const cfg = { project: { name: 'Deno' }, engine: { path: 'C:\\Program Files\\V8' } } as RunrealConfig - - const result = render([tmpl], cfg) - assertEquals(result, [ - '{"name": "Deno", "engine": "C:\\Program Files\\V8\\BuildGraph\\Build.xml", "project": "project.path"}', - ]) -}) diff --git a/tests/config.test.ts b/tests/config.test.ts new file mode 100644 index 0000000..885e142 --- /dev/null +++ b/tests/config.test.ts @@ -0,0 +1,61 @@ +import { assert, assertEquals } from 'https://deno.land/std/assert/mod.ts' +import { Config } from '../src/lib/config.ts' +import { ulid } from '../src/deps.ts' +import { CliOptions } from '../src/lib/types.ts' + +Deno.test('Config.create should initialize with default values', async () => { + const config = await Config.create() + const id = ulid() + config.determineBuildId = () => id + const expected = { + engine: { + path: '', + repoType: 'git', + }, + project: { + name: '', + path: '', + repoType: 'git', + }, + build: { + id, + path: '', + branch: '', + branchSafe: '', + commit: '', + commitShort: '', + }, + buildkite: { + branch: '', + checkout: '', + buildNumber: '0', + buildCheckoutPath: Deno.cwd(), + buildPipelineSlug: '', + }, + metadata: { + test: '', + }, + workflows: [], + } + assertEquals(config.get(), expected) +}) + +Deno.test('Config.create should load environment variables', async () => { + Deno.env.set('RUNREAL_BUILD_ID', 'test-id') + const config = await Config.create() + assertEquals(config.get().build.id, 'test-id') + Deno.env.delete('RUNREAL_BUILD_ID') +}) + +Deno.test('Config.get should apply CLI options', async () => { + const config = await Config.create() + const id = ulid() + config.determineBuildId = () => id + const cliOptions: CliOptions = { + enginePath: '/path/to/engine' as any, + projectPath: '/path/to/project' as any, + } + const result = config.get(cliOptions) + assert(result.engine.path.includes('/path/to/engine')) + assert(result.project.path.includes('/path/to/project')) +}) diff --git a/tests/fixtures/test.config.json b/tests/fixtures/test.config.json new file mode 100644 index 0000000..51284d8 --- /dev/null +++ b/tests/fixtures/test.config.json @@ -0,0 +1,101 @@ +{ + "project": { + "name": "MinimalProject", + "repoType": "perforce" + }, + "engine": { + "repoType": "perforce" + }, + "metadata": { + "test": "${build.id}-${project.name}" + }, + "workflows": [ + { + "name": "compile client", + "steps": [ + { + "command": "runreal buildgraph run", + "args": [ + "${project.path}\\Build\\MinimalProject.xml", + "-set:BuildId=${build.id}", + "-set:ProjectName=${project.name}", + "-set:ProjectPath=${project.path}", + "-set:OutputPath=${build.path}", + "-Target=Build Clients" + ] + } + ] + }, + { + "name": "compile server", + "steps": [ + { + "command": "runreal buildgraph run", + "args": [ + "${project.path}\\Build\\MinimalProject.xml", + "-set:BuildId=${build.id}", + "-set:ProjectName=${project.name}", + "-set:ProjectPath=${project.path}", + "-set:OutputPath=${build.path}", + "-Target=Build Servers" + ] + } + ] + }, + { + "name": "cook all", + "steps": [ + { + "command": "runreal buildgraph run", + "args": [ + "${project.path}\\Build\\MinimalProject.xml", + "-set:BuildId=${build.id}", + "-set:ProjectName=${project.name}", + "-set:ProjectPath=${project.path}", + "-set:OutputPath=${build.path}", + "-Target=Cook All" + ] + } + ] + }, + { + "name": "package client", + "steps": [ + { + "command": "runreal buildgraph run", + "args": [ + "${project.path}\\Build\\MinimalProject.xml", + "-set:BuildId=${build.id}-${buildkite.buildNumber}", + "-set:ProjectName=${project.name}", + "-set:ProjectPath=${project.path}", + "-set:OutputPath=${build.path}", + "-set:ClientTargetName=${project.name}Game", + "-set:ServerTargetName=${project.name}Server", + "-set:ClientConfigurations=Development", + "-Target=Package Clients", + "-ListOnly" + ] + } + ] + }, + { + "name": "package server", + "steps": [ + { + "command": "runreal buildgraph run", + "args": [ + "${project.path}\\Build\\MinimalProject.xml", + "-set:BuildId=${build.id}-${buildkite.buildNumber}", + "-set:ProjectName=${project.name}", + "-set:ProjectPath=${project.path}", + "-set:OutputPath=${build.path}", + "-set:ClientTargetName=${project.name}Game", + "-set:ServerTargetName=${project.name}Server", + "-Target=Package Servers", + "-Export=export.json" + ] + } + ] + } + ] +} diff --git a/src/lib/source_test.ts b/tests/source.test.ts similarity index 53% rename from src/lib/source_test.ts rename to tests/source.test.ts index c737b99..fe3770d 100644 --- a/src/lib/source_test.ts +++ b/tests/source.test.ts @@ -1,23 +1,37 @@ -import { assertEquals } from 'https://deno.land/std@0.221.0/assert/mod.ts' -import { Source } from './source.ts' +import { assertEquals } from 'https://deno.land/std/assert/mod.ts' +import { Perforce, Source } from '../src/lib/source.ts' -Deno.test('source', () => { +Deno.test('source git', () => { const source = Source('cwd', 'git') assertEquals(source.executable, 'git') source.ref = () => 'branch/commit' assertEquals(source.safeRef(), 'branch-commit') + source.ref = () => 'branch//commit' assertEquals(source.safeRef(), 'branch-commit') +}) +Deno.test('source perforce - safeRef test', () => { const psource = Source('cwd', 'perforce') assertEquals(psource.executable, 'p4') psource.ref = () => 'main/1' assertEquals(psource.safeRef(), 'main-1') + psource.ref = () => 'main//1' assertEquals(psource.safeRef(), 'main-1') psource.ref = () => '//main//1' assertEquals(psource.safeRef(), 'main-1') + + psource.ref = () => '//Main//1' + assertEquals(psource.safeRef(), 'main-1') + + const psource2 = new Perforce('cwd') + assertEquals(psource2.executable, 'p4') + + psource2.stream = () => '//Stream/Main' + psource2.changelist = () => '50' + assertEquals(psource2.safeRef(), 'stream-main-50') }) diff --git a/tests/template.test.ts b/tests/template.test.ts new file mode 100644 index 0000000..24f22b0 --- /dev/null +++ b/tests/template.test.ts @@ -0,0 +1,102 @@ +import { assertEquals } from 'https://deno.land/std/assert/mod.ts' +import { getSubstitutions, render, renderConfig } from '../src/lib/template.ts' +import { RunrealConfig } from '../src/lib/types.ts' + +Deno.test('template tests', () => { + const tmpl = + '{"name": "${project.name}", "engine": "${engine.path}\\BuildGraph\\Build.xml", "project": "${project.path}"}' + const cfg = { project: { name: 'Deno' }, engine: { path: 'C:\\Program Files\\V8' } } as RunrealConfig + + const result = render([tmpl], cfg) + assertEquals(result, [ + '{"name": "Deno", "engine": "C:\\Program Files\\V8\\BuildGraph\\Build.xml", "project": "project.path"}', + ]) +}) + +Deno.test('getSubstitutions should correctly extract values from config', () => { + const cfg: RunrealConfig = { + project: { name: 'Project', path: '/projects/project', repoType: 'git' }, + engine: { path: '/engines/5.1', repoType: 'git' }, + build: { id: '1234', path: '/builds/1234', branchSafe: 'main', commitShort: 'abcd' }, + buildkite: { buildNumber: '5678' }, + } + const expected = { + 'engine.path': '/engines/5.1', + 'project.path': '/projects/project', + 'project.name': 'Project', + 'build.id': '1234', + 'build.path': '/builds/1234', + 'build.branch': 'main', + 'build.commit': 'abcd', + 'buildkite.buildNumber': '5678', + } + const result = getSubstitutions(cfg) + assertEquals(result, expected) +}) + +Deno.test('render should replace placeholders with correct values', () => { + const input = [ + '${project.name} uses ${engine.path}', + 'Build ID: ${build.id}', + 'Non-existent: ${non.existent}', + ] + const cfg: RunrealConfig = { + project: { name: 'Project', path: '/projects/project', repoType: 'git' }, + engine: { path: '/engines/5.1', repoType: 'git' }, + build: { id: '1234', path: '/builds/1234' }, + } + const expected = [ + 'Project uses /engines/5.1', + 'Build ID: 1234', + 'Non-existent: ${non.existent}', + ] + const result = render(input, cfg) + assertEquals(result, expected) +}) + +Deno.test('renderConfig should deeply replace all placeholders in config object', () => { + const cfg: RunrealConfig = { + project: { name: 'Project', path: '/projects/project', repoType: 'git' }, + engine: { path: '/engines/5.0', repoType: 'git' }, + build: { id: '1234', path: '/builds/1234' }, + metadata: { test: '${build.id}-${project.name}' }, + workflows: [ + { + name: 'compile', + steps: [ + { + command: 'build', + args: [ + '${project.path}\\Build\\Build.xml', + '-set:BuildId=${build.id}', + '-set:ProjectName=${project.name}', + ], + }, + ], + }, + ], + } + const expected: RunrealConfig = { + project: { name: 'Project', path: '/projects/project', repoType: 'git' }, + engine: { path: '/engines/5.0', repoType: 'git' }, + build: { id: '1234', path: '/builds/1234' }, + metadata: { test: '1234-Project' }, + workflows: [ + { + name: 'compile', + steps: [ + { + command: 'build', + args: [ + '/projects/project\\Build\\Build.xml', + '-set:BuildId=1234', + '-set:ProjectName=Project', + ], + }, + ], + }, + ], + } + const result = renderConfig(cfg) + assertEquals(result, expected) +})