diff --git a/package.json b/package.json index a6bafff..5857efb 100644 --- a/package.json +++ b/package.json @@ -44,10 +44,10 @@ "axios": "^1.8.3", "fs-extra": "^11.2.0", "inversify": "^7.1.0", - "lodash": "^4.17.21", "js-yaml": "^4.0.0", "jsonc-parser": "^3.0.0", "jsonschema": "^1.4.1", + "lodash": "^4.17.21", "reflect-metadata": "^0.2.2" }, "devDependencies": { @@ -55,7 +55,7 @@ "eslint": "^9.5.0", "if-env": "^1.0.4", "jest": "^29.7.0", - "prettier": "^3.3.2", + "prettier": "^3.5.3", "rimraf": "^6.0.1", "rollup": "^4.18.0", "ts-jest": "^29.2.6", diff --git a/src/devcontainers/dev-containers-to-devfile-adapter.ts b/src/devcontainers/dev-containers-to-devfile-adapter.ts new file mode 100644 index 0000000..5cf967d --- /dev/null +++ b/src/devcontainers/dev-containers-to-devfile-adapter.ts @@ -0,0 +1,260 @@ +/********************************************************************** + * Copyright (c) 2022-2024 + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ***********************************************************************/ + +import yaml from 'js-yaml'; + +const DEFAULT_DEVFILE_CONTAINER_IMAGE = 'quay.io/devfile/universal-developer-image:ubi9-latest'; +const DEFAULT_DEVFILE_NAME = 'default-devfile'; +const DEFAULT_WORKSPACE_DIR = '/projects'; + +export function convertDevContainerToDevfile(devContainer: any): string { + const devfile: any = { + schemaVersion: '2.2.0', + metadata: { + name: (devContainer.name ?? DEFAULT_DEVFILE_NAME).toLowerCase().replace(/\s+/g, '-'), + description: devContainer.description ?? '', + }, + components: [], + commands: [], + events: {}, + }; + const workspaceFolder = devContainer.workspaceFolder ?? DEFAULT_WORKSPACE_DIR; + + const containerName = 'dev-container'; + const containerComponent: any = { + name: containerName, + container: { + image: devContainer.image ?? DEFAULT_DEVFILE_CONTAINER_IMAGE, + }, + }; + + if (Array.isArray(devContainer.forwardPorts)) { + containerComponent.container.endpoints = convertPortsToEndpoints(devContainer.forwardPorts); + } + + const remoteEnvMap = convertToDevfileEnv(devContainer.remoteEnv); + const containerEnvMap = convertToDevfileEnv(devContainer.containerEnv); + const combinedEnvMap = new Map(remoteEnvMap); + for (const [key, value] of containerEnvMap) { + combinedEnvMap.set(key, value); + } + containerComponent.container.env = Array.from(combinedEnvMap.entries()).map(([name, value]) => ({ + name, + value, + })); + + if (devContainer.overrideCommand) { + containerComponent.container.command = ['/bin/bash']; + containerComponent.container.args = ['-c', 'while true; do sleep 1000; done']; + } + + devfile.commands.postStart = []; + devfile.events.postStart = []; + const postStartCommands: { key: keyof typeof devContainer; id: string }[] = [ + { key: 'onCreateCommand', id: 'on-create-command' }, + { key: 'updateContentCommand', id: 'update-content-command' }, + { key: 'postCreateCommand', id: 'post-create-command' }, + { key: 'postStartCommand', id: 'post-start-command' }, + { key: 'postAttachCommand', id: 'post-attach-command' }, + ]; + + for (const { key, id } of postStartCommands) { + const commandValue = devContainer[key]; + if (commandValue) { + devfile.commands.push(createDevfileCommand(id, containerComponent.name, commandValue, workspaceFolder)); + devfile.events.postStart.push(id); + } + } + + if (devContainer.initializeCommand) { + const commandId = 'initialize-command'; + devfile.commands.push( + createDevfileCommand(commandId, containerComponent.name, devContainer.initializeCommand, workspaceFolder), + ); + devfile.events.preStart = [commandId]; + } + + if (devContainer.hostRequirements) { + if (devContainer.hostRequirements.cpus) { + containerComponent.container.cpuRequest = devContainer.hostRequirements.cpus; + } + if (devContainer.hostRequirements.memory) { + containerComponent.container.memoryRequest = convertMemoryToDevfileFormat(devContainer.hostRequirements.memory); + } + } + + if (devContainer.workspaceFolder) { + containerComponent.container.mountSources = true; + containerComponent.container.sourceMapping = devContainer.workspaceFolder; + } + + const volumeComponents: any[] = []; + const volumeMounts: any[] = []; + if (devContainer.mounts) { + devContainer.mounts.forEach((mount: string) => { + const convertedDevfileVolume = createDevfileVolumeMount(mount); + + if (convertedDevfileVolume) { + volumeComponents.push(convertedDevfileVolume.volumeComponent); + volumeMounts.push(convertedDevfileVolume.volumeMount); + } + }); + } + if (volumeMounts.length > 0) { + containerComponent.container.volumeMounts = volumeMounts; + } + + let imageComponent: any = null; + if (devContainer.build) { + imageComponent = createDevfileImageComponent(devContainer); + } + + if (imageComponent) { + devfile.components.push(imageComponent); + } else { + devfile.components.push(containerComponent); + } + devfile.components.push(...volumeComponents); + + return yaml.dump(devfile, { noRefs: true }); +} + +function convertMemoryToDevfileFormat(value: string): string { + const unitMap: Record = { tb: 'TiB', gb: 'GiB', mb: 'MiB', kb: 'KiB' }; + for (const [decUnit, binUnit] of Object.entries(unitMap)) { + if (value.toLowerCase().endsWith(decUnit)) { + value = value.toLowerCase().replace(decUnit, binUnit); + break; + } + } + return value; +} + +function convertToDevfileEnv(envObject: Record | undefined): Map { + const result = new Map(); + + if (!envObject || typeof envObject !== 'object') { + return result; + } + + for (const [key, value] of Object.entries(envObject)) { + result.set(key, String(value)); + } + + return result; +} + +function parsePortValue(port: number | string): number | null { + if (typeof port === 'number') return port; + + // Example: "db:5432" => extract 5432 + const match = RegExp(/.*:(\d+)$/).exec(port); + return match ? parseInt(match[1], 10) : null; +} + +function convertPortsToEndpoints(ports: (number | string)[]): { name: string; targetPort: number }[] { + return ports + .map(port => { + const targetPort = parsePortValue(port); + if (targetPort === null) return null; + + let portName = `port-${targetPort}`; + if (typeof port === 'string' && port.includes(':')) { + portName = port.split(':')[0]; + } + return { name: portName, targetPort }; + }) + .filter((ep): ep is { name: string; targetPort: number } => ep !== null); +} + +function createDevfileCommand( + id: string, + component: string, + commandLine: string | string[] | Record, + workingDir: string, +) { + let resolvedCommandLine: string; + if (typeof commandLine === 'string') { + resolvedCommandLine = commandLine; + } else if (Array.isArray(commandLine)) { + resolvedCommandLine = commandLine.join(' '); + } else if (typeof commandLine === 'object') { + const values = Object.values(commandLine).map(v => { + if (typeof v === 'string') { + return v.trim(); + } else if (Array.isArray(v)) { + return v.join(' '); + } + }); + resolvedCommandLine = values.join(' && '); + } + + return { + id: id, + exec: { + component: component, + commandLine: resolvedCommandLine, + workingDir: workingDir, + }, + }; +} + +function createDevfileVolumeMount(mount: string) { + // Format: source=devvolume,target=/data,type=volume + const parts = Object.fromEntries( + mount.split(',').map(segment => { + const [key, val] = segment.split('='); + return [key.trim(), val.trim()]; + }), + ); + + const { type, source, target } = parts; + + if (!source || !target || !type || type === 'bind') { + return null; + } + + const isEphemeral = type === 'tmpfs'; + + return { + volumeComponent: { + name: source, + volume: { + ephemeral: isEphemeral, + }, + }, + volumeMount: { + name: source, + path: target, + }, + }; +} + +function createDevfileImageComponent(devcontainer: Record | undefined) { + let imageComponent = { + imageName: '', + dockerfile: { + uri: '', + buildContext: '', + args: [], + }, + }; + imageComponent.imageName = (devcontainer.name ?? 'default-devfile-image').toLowerCase().replace(/\s+/g, '-'); + if (devcontainer.build.dockerfile) { + imageComponent.dockerfile.uri = devcontainer.build.dockerfile; + } + if (devcontainer.build.context) { + imageComponent.dockerfile.buildContext = devcontainer.build.context; + } + if (devcontainer.build.args) { + imageComponent.dockerfile.args = Object.entries(devcontainer.build.args).map(([key, value]) => `${key}=${value}`); + } + return imageComponent; +} diff --git a/src/main.ts b/src/main.ts index 6d6e441..c3d5c8c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -20,6 +20,7 @@ import { V1alpha2DevWorkspaceSpecTemplate } from '@devfile/api'; import { DevfileContext } from './api/devfile-context'; import { GitUrlResolver } from './resolve/git-url-resolver'; import { ValidatorResult } from 'jsonschema'; +import { convertDevContainerToDevfile } from './devcontainers/dev-containers-to-devfile-adapter'; export const DEVWORKSPACE_DEVFILE = 'che.eclipse.org/devfile'; export const DEVWORKSPACE_DEVFILE_SOURCE = 'che.eclipse.org/devfile-source'; @@ -37,6 +38,7 @@ export class Main { devfilePath?: string; devfileUrl?: string; devfileContent?: string; + devContainerJsonContent?: string; outputFile?: string; editorPath?: string; editorContent?: string; @@ -50,8 +52,8 @@ export class Main { if (!params.editorPath && !params.editorUrl && !params.editorContent) { throw new Error('missing editorPath or editorUrl or editorContent'); } - if (!params.devfilePath && !params.devfileUrl && !params.devfileContent) { - throw new Error('missing devfilePath or devfileUrl or devfileContent'); + if (!params.devfilePath && !params.devfileUrl && !params.devfileContent && !params.devContainerJsonContent) { + throw new Error('missing devfilePath or devfileUrl or devfileContent or devContainerJsonContent'); } const inversifyBinbding = new InversifyBinding(); @@ -102,8 +104,11 @@ export class Main { devfileContent = jsYaml.dump(devfileParsed); } else if (params.devfilePath) { devfileContent = await fs.readFile(params.devfilePath); - } else { + } else if (params.devfileContent) { devfileContent = params.devfileContent; + } else if (params.devContainerJsonContent) { + const devContainer = JSON.parse(params.devContainerJsonContent); + devfileContent = convertDevContainerToDevfile(devContainer); } const jsYamlDevfileContent = jsYaml.load(devfileContent); @@ -182,6 +187,7 @@ export class Main { let editorUrl: string | undefined; let injectDefaultComponent: string | undefined; let defaultComponentImage: string | undefined; + let devContainerJsonContent: string | undefined; const projects: { name: string; location: string }[] = []; const args = process.argv.slice(2); @@ -201,6 +207,9 @@ export class Main { if (arg.startsWith('--output-file:')) { outputFile = arg.substring('--output-file:'.length); } + if (arg.startsWith('--devcontainer-json:')) { + devContainerJsonContent = arg.substring('--devcontainer-json:'.length); + } if (arg.startsWith('--project.')) { const name = arg.substring('--project.'.length, arg.indexOf('=')); let location = arg.substring(arg.indexOf('=') + 1); @@ -220,8 +229,8 @@ export class Main { if (!editorPath && !editorUrl) { throw new Error('missing --editor-path: or --editor-url: parameter'); } - if (!devfilePath && !devfileUrl) { - throw new Error('missing --devfile-path: or --devfile-url: parameter'); + if (!devfilePath && !devfileUrl && !devContainerJsonContent) { + throw new Error('missing --devfile-path: or --devfile-url: parameter or --devcontainer-json: parameter'); } if (!outputFile) { throw new Error('missing --output-file: parameter'); @@ -231,6 +240,7 @@ export class Main { devfilePath, devfileUrl, editorPath, + devContainerJsonContent: devContainerJsonContent, outputFile, editorUrl, projects, diff --git a/tests/devcontainer/devcontainer-to-devfile-adapter.test.ts b/tests/devcontainer/devcontainer-to-devfile-adapter.test.ts new file mode 100644 index 0000000..127250d --- /dev/null +++ b/tests/devcontainer/devcontainer-to-devfile-adapter.test.ts @@ -0,0 +1,42 @@ +/********************************************************************** + * Copyright (c) 2022-2024 + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ***********************************************************************/ +import { convertDevContainerToDevfile } from '../../src/devcontainers/dev-containers-to-devfile-adapter'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as yaml from 'js-yaml'; + +describe('convertDevContainerToDevfile - integration test from files', () => { + const testCases = [ + 'basic-node', + 'minimal', + 'host-requirements', + 'override-command', + 'dockerfile', + 'lifecycle-scripts', + 'unsupported-fields', + ]; + + test.each(testCases)('test case: %s', async testCaseName => { + const baseDir = path.join(__dirname, 'testdata', testCaseName); + + const devContainerPath = path.join(baseDir, 'input-devcontainer.json'); + const expectedYamlPath = path.join(baseDir, 'expected.yaml'); + + const devContainerContent = await fs.readFile(devContainerPath, 'utf-8'); + const expectedYamlContent = await fs.readFile(expectedYamlPath, 'utf-8'); + + const devContainer = JSON.parse(devContainerContent); + const expectedDevfile = yaml.load(expectedYamlContent); + const actualDevfileYaml = convertDevContainerToDevfile(devContainer); + const actualDevfile = yaml.load(actualDevfileYaml); + + expect(actualDevfile).toMatchObject(expectedDevfile); + }); +}); diff --git a/tests/devcontainer/testdata/basic-node/expected.yaml b/tests/devcontainer/testdata/basic-node/expected.yaml new file mode 100644 index 0000000..35d4361 --- /dev/null +++ b/tests/devcontainer/testdata/basic-node/expected.yaml @@ -0,0 +1,28 @@ +schemaVersion: 2.2.0 +metadata: + name: node-app + description: Simple Node App description +components: + - name: dev-container + container: + image: node:18 + mountSources: true + sourceMapping: "/projects/my-app" + endpoints: + - name: port-3000 + targetPort: 3000 + - name: db + targetPort: 5329 + env: + - name: DEBUG + value: "true" + - name: NODE_ENV + value: "development" + - name: PORT + value: "3000" + volumeMounts: + - name: devcontainer-cache + path: /workspace/cache + - name: devcontainer-cache + volume: + ephemeral: true \ No newline at end of file diff --git a/tests/devcontainer/testdata/basic-node/input-devcontainer.json b/tests/devcontainer/testdata/basic-node/input-devcontainer.json new file mode 100644 index 0000000..5cf8fa7 --- /dev/null +++ b/tests/devcontainer/testdata/basic-node/input-devcontainer.json @@ -0,0 +1,18 @@ +{ + "name": "Node App", + "description": "Simple Node App description", + "image": "node:18", + "forwardPorts": [3000, "db:5329", "invalidport"], + "workspaceFolder": "/projects/my-app", + "mounts": [ + "source=${localWorkspaceFolder}/data,target=/workspace/data,type=bind", + "source=devcontainer-cache,target=/workspace/cache,type=tmpfs" + ], + "containerEnv": { + "PORT": 3000 + }, + "remoteEnv": { + "DEBUG": true, + "NODE_ENV": "development" + } +} \ No newline at end of file diff --git a/tests/devcontainer/testdata/dockerfile/expected.yaml b/tests/devcontainer/testdata/dockerfile/expected.yaml new file mode 100644 index 0000000..4490514 --- /dev/null +++ b/tests/devcontainer/testdata/dockerfile/expected.yaml @@ -0,0 +1,11 @@ +schemaVersion: 2.2.0 +metadata: + name: default-devfile +components: + - imageName: default-devfile-image + dockerfile: + uri: Dockerfile + buildContext: . + args: + - "ENV1=dev" + - "PORT=3000" \ No newline at end of file diff --git a/tests/devcontainer/testdata/dockerfile/input-devcontainer.json b/tests/devcontainer/testdata/dockerfile/input-devcontainer.json new file mode 100644 index 0000000..313d1b8 --- /dev/null +++ b/tests/devcontainer/testdata/dockerfile/input-devcontainer.json @@ -0,0 +1,10 @@ +{ + "build": { + "dockerfile": "Dockerfile", + "context": ".", + "args": { + "ENV1": "dev", + "PORT": "3000" + } + } +} \ No newline at end of file diff --git a/tests/devcontainer/testdata/host-requirements/expected.yaml b/tests/devcontainer/testdata/host-requirements/expected.yaml new file mode 100644 index 0000000..8c4d3d4 --- /dev/null +++ b/tests/devcontainer/testdata/host-requirements/expected.yaml @@ -0,0 +1,9 @@ +schemaVersion: 2.2.0 +metadata: + name: default-devfile +components: + - name: dev-container + container: + cpuRequest: 2 + image: quay.io/devfile/universal-developer-image:ubi9-latest + memoryRequest: "4GiB" \ No newline at end of file diff --git a/tests/devcontainer/testdata/host-requirements/input-devcontainer.json b/tests/devcontainer/testdata/host-requirements/input-devcontainer.json new file mode 100644 index 0000000..5234195 --- /dev/null +++ b/tests/devcontainer/testdata/host-requirements/input-devcontainer.json @@ -0,0 +1,6 @@ +{ + "hostRequirements": { + "cpus": 2, + "memory": "4gb" + } +} \ No newline at end of file diff --git a/tests/devcontainer/testdata/lifecycle-scripts/expected.yaml b/tests/devcontainer/testdata/lifecycle-scripts/expected.yaml new file mode 100644 index 0000000..fb5b15b --- /dev/null +++ b/tests/devcontainer/testdata/lifecycle-scripts/expected.yaml @@ -0,0 +1,47 @@ +schemaVersion: 2.2.0 +metadata: + name: default-devfile +components: + - name: dev-container + container: + image: quay.io/devfile/universal-developer-image:ubi9-latest +commands: + - id: on-create-command + exec: + component: dev-container + commandLine: "yarn install" + workingDir: /projects + - id: update-content-command + exec: + component: dev-container + commandLine: "echo foo='bar'" + workingDir: /projects + - id: post-create-command + exec: + component: dev-container + commandLine: "npm start && mysql -u root -p my-password" + workingDir: /projects + - id: post-start-command + exec: + component: dev-container + commandLine: "echo foo='bar'" + workingDir: /projects + - id: post-attach-command + exec: + component: dev-container + commandLine: "echo foo='bar'" + workingDir: /projects + - id: initialize-command + exec: + component: dev-container + commandLine: "yarn install" + workingDir: /projects +events: + postStart: + - on-create-command + - update-content-command + - post-create-command + - post-start-command + - post-attach-command + preStart: + - initialize-command diff --git a/tests/devcontainer/testdata/lifecycle-scripts/input-devcontainer.json b/tests/devcontainer/testdata/lifecycle-scripts/input-devcontainer.json new file mode 100644 index 0000000..8cf0874 --- /dev/null +++ b/tests/devcontainer/testdata/lifecycle-scripts/input-devcontainer.json @@ -0,0 +1,11 @@ +{ + "initializeCommand": "yarn install", + "onCreateCommand": ["yarn", "install"], + "updateContentCommand": "echo foo='bar'", + "postCreateCommand": { + "server": "npm start", + "db": ["mysql", "-u", "root", "-p", "my-password"] + }, + "postStartCommand": ["echo", "foo='bar'"], + "postAttachCommand": "echo foo='bar'" +} \ No newline at end of file diff --git a/tests/devcontainer/testdata/minimal/expected.yaml b/tests/devcontainer/testdata/minimal/expected.yaml new file mode 100644 index 0000000..28763cd --- /dev/null +++ b/tests/devcontainer/testdata/minimal/expected.yaml @@ -0,0 +1,7 @@ +schemaVersion: 2.2.0 +metadata: + name: default-devfile +components: + - name: dev-container + container: + image: quay.io/devfile/universal-developer-image:ubi9-latest \ No newline at end of file diff --git a/tests/devcontainer/testdata/minimal/input-devcontainer.json b/tests/devcontainer/testdata/minimal/input-devcontainer.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/tests/devcontainer/testdata/minimal/input-devcontainer.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/tests/devcontainer/testdata/override-command/expected.yaml b/tests/devcontainer/testdata/override-command/expected.yaml new file mode 100644 index 0000000..7a4339a --- /dev/null +++ b/tests/devcontainer/testdata/override-command/expected.yaml @@ -0,0 +1,9 @@ +schemaVersion: 2.2.0 +metadata: + name: default-devfile +components: + - name: dev-container + container: + image: quay.io/devfile/universal-developer-image:ubi9-latest + args: ["-c", "while true; do sleep 1000; done"] + command: ["/bin/bash"] \ No newline at end of file diff --git a/tests/devcontainer/testdata/override-command/input-devcontainer.json b/tests/devcontainer/testdata/override-command/input-devcontainer.json new file mode 100644 index 0000000..edb87a0 --- /dev/null +++ b/tests/devcontainer/testdata/override-command/input-devcontainer.json @@ -0,0 +1,3 @@ +{ + "overrideCommand": true +} \ No newline at end of file diff --git a/tests/devcontainer/testdata/unsupported-fields/expected.yaml b/tests/devcontainer/testdata/unsupported-fields/expected.yaml new file mode 100644 index 0000000..28763cd --- /dev/null +++ b/tests/devcontainer/testdata/unsupported-fields/expected.yaml @@ -0,0 +1,7 @@ +schemaVersion: 2.2.0 +metadata: + name: default-devfile +components: + - name: dev-container + container: + image: quay.io/devfile/universal-developer-image:ubi9-latest \ No newline at end of file diff --git a/tests/devcontainer/testdata/unsupported-fields/input-devcontainer.json b/tests/devcontainer/testdata/unsupported-fields/input-devcontainer.json new file mode 100644 index 0000000..e65b04f --- /dev/null +++ b/tests/devcontainer/testdata/unsupported-fields/input-devcontainer.json @@ -0,0 +1,25 @@ +{ + "capAdd": ["SYS_PTRACE"], + "shutdownAction": "none", + "runArgs": ["--device-cgroup-rule=my rule here"], + "securityOpt": [ "seccomp=unconfined" ], + "privileged": false, + "init": false, + "userEnvProbe": "none", + "remoteUser": "root", + "features": { + "ghcr.io/devcontainers/features/github-cli": {}, + "ghcr.io/devcontainers/features/common-utils": {} + }, + "overrideFeatureInstallОrder": [ "ghcr.io/devcontainers/features/common-utils", "ghcr.io/devcontainers/features/github-cli" ], + "customizations": { + "vscode": { + "settings": {}, + "extensions": [] + } + }, + "service": "app", + "runServices": ["db", "redis"], + "dockerComposeFile": ["docker-compose.yml", "docker-compose.override.yml"], + "waitFor": "updateContentCommand" +} \ No newline at end of file diff --git a/tests/main.spec.ts b/tests/main.spec.ts index 7536286..fe52e83 100644 --- a/tests/main.spec.ts +++ b/tests/main.spec.ts @@ -55,6 +55,7 @@ describe('Test Main with stubs', () => { devfilePath: string | undefined, devfileUrl: string | undefined, editorPath: string | undefined, + devContainerJson: string | undefined, editorUrl: string | undefined, outputFile: string | undefined, injectDefaultComponent: string | undefined, @@ -71,6 +72,9 @@ describe('Test Main with stubs', () => { if (editorUrl) { process.argv.push(`--editor-url:${editorUrl}`); } + if (devContainerJson) { + process.argv.push(`--devcontainer-json:${devContainerJson}`); + } if (editorPath) { process.argv.push(`--editor-path:${editorPath}`); } @@ -97,7 +101,16 @@ describe('Test Main with stubs', () => { describe('start', () => { beforeEach(() => { - initArgs(FAKE_DEVFILE_PATH, undefined, FAKE_EDITOR_PATH, undefined, FAKE_OUTPUT_FILE, undefined, undefined); + initArgs( + FAKE_DEVFILE_PATH, + undefined, + FAKE_EDITOR_PATH, + undefined, + undefined, + FAKE_OUTPUT_FILE, + undefined, + undefined, + ); jest.spyOn(fs, 'readFile').mockResolvedValue(''); spyInitBindings = jest.spyOn(InversifyBinding.prototype, 'initBindings'); @@ -134,7 +147,16 @@ describe('Test Main with stubs', () => { test('success with custom devfile Url', async () => { const main = new Main(); - initArgs(undefined, FAKE_DEVFILE_URL, undefined, FAKE_EDITOR_URL, FAKE_OUTPUT_FILE, 'true', 'my-image'); + initArgs( + undefined, + FAKE_DEVFILE_URL, + undefined, + undefined, + FAKE_EDITOR_URL, + FAKE_OUTPUT_FILE, + 'true', + 'my-image', + ); process.argv.push('--project.foo=bar'); containerGetMethod.mockReset(); const githubResolverResolveMethod = jest.fn(); @@ -221,7 +243,16 @@ describe('Test Main with stubs', () => { test('success with custom devfile Url (devfile includes attributes)', async () => { const main = new Main(); - initArgs(undefined, FAKE_DEVFILE_URL, undefined, FAKE_EDITOR_URL, FAKE_OUTPUT_FILE, 'true', 'my-image'); + initArgs( + undefined, + FAKE_DEVFILE_URL, + undefined, + undefined, + FAKE_EDITOR_URL, + FAKE_OUTPUT_FILE, + 'true', + 'my-image', + ); process.argv.push('--project.foo=bar'); containerGetMethod.mockReset(); const githubResolverResolveMethod = jest.fn(); @@ -307,9 +338,67 @@ describe('Test Main with stubs', () => { expect(generateMethod).toBeCalledWith(jsYaml.dump(result), "''\n", FAKE_OUTPUT_FILE, 'true', 'my-image'); }); + test('success with devcontainer.json', async () => { + // Given + const main = new Main(); + initArgs(undefined, undefined, undefined, '{}', FAKE_EDITOR_URL, FAKE_OUTPUT_FILE, 'true', 'my-image'); + containerGetMethod.mockReset(); + const validateDevfileMethod = jest.fn(); + const devfileSchemaValidatorMock = { + validateDevfile: validateDevfileMethod as any, + }; + validateDevfileMethod.mockReturnValueOnce({ valid: true }); + containerGetMethod.mockReturnValueOnce(devfileSchemaValidatorMock); + + const loadEditorMethod = jest.fn(); + const editorResolverMock = { + loadEditor: loadEditorMethod as any, + }; + loadEditorMethod.mockReturnValue(''); + containerGetMethod.mockReturnValueOnce(editorResolverMock); + containerGetMethod.mockReturnValueOnce(generateMock); + + // When + const returnCode = await main.start(); + + // Then + expect(mockedConsoleError).toBeCalledTimes(0); + expect(loadEditorMethod).toBeCalled(); + + expect(returnCode).toBeTruthy(); + + expect(generateMethod).toBeCalledWith( + 'schemaVersion: 2.2.0\n' + + 'metadata:\n' + + ' name: default-devfile\n' + + " description: ''\n" + + 'components:\n' + + ' - name: dev-container\n' + + ' container:\n' + + ' image: quay.io/devfile/universal-developer-image:ubi9-latest\n' + + ' env: []\n' + + 'commands: []\n' + + 'events:\n' + + ' postStart: []\n', + "''\n", + FAKE_OUTPUT_FILE, + 'true', + 'my-image', + ); + }); + test('missing devfile', async () => { const main = new Main(); - initArgs(undefined, undefined, FAKE_EDITOR_PATH, FAKE_DEVFILE_URL, FAKE_OUTPUT_FILE, 'false', undefined); + initArgs( + undefined, + undefined, + FAKE_EDITOR_PATH, + undefined, + FAKE_DEVFILE_URL, + FAKE_OUTPUT_FILE, + 'false', + undefined, + ); const returnCode = await main.start(); expect(mockedConsoleError).toBeCalled(); expect(mockedConsoleError.mock.calls[1][1].toString()).toContain('missing --devfile-path:'); @@ -319,7 +408,7 @@ describe('Test Main with stubs', () => { test('missing editor', async () => { const main = new Main(); - initArgs(FAKE_DEVFILE_PATH, undefined, undefined, undefined, FAKE_OUTPUT_FILE, 'false', undefined); + initArgs(FAKE_DEVFILE_PATH, undefined, undefined, undefined, undefined, FAKE_OUTPUT_FILE, 'false', undefined); const returnCode = await main.start(); expect(mockedConsoleError).toBeCalled(); @@ -330,7 +419,7 @@ describe('Test Main with stubs', () => { test('missing outputfile', async () => { const main = new Main(); - initArgs(FAKE_DEVFILE_PATH, undefined, FAKE_EDITOR_PATH, undefined, undefined, 'false', undefined); + initArgs(FAKE_DEVFILE_PATH, undefined, FAKE_EDITOR_PATH, undefined, undefined, undefined, 'false', undefined); const returnCode = await main.start(); expect(mockedConsoleError).toBeCalled(); expect(mockedConsoleError.mock.calls[1][1].toString()).toContain('missing --output-file: parameter'); @@ -396,7 +485,7 @@ describe('Test Main with stubs', () => { } catch (e) { message = e.message; } - expect(message).toEqual('missing devfilePath or devfileUrl or devfileContent'); + expect(message).toEqual('missing devfilePath or devfileUrl or devfileContent or devContainerJsonContent'); }); test('success with custom default image', async () => { diff --git a/yarn.lock b/yarn.lock index 6c4edcc..ab4a6ef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2884,7 +2884,7 @@ prelude-ls@^1.2.1: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== -prettier@^3.3.2: +prettier@^3.5.3: version "3.5.3" resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.5.3.tgz#4fc2ce0d657e7a02e602549f053b239cb7dfe1b5" integrity sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==