diff --git a/src/spec-configuration/containerFeaturesConfiguration.ts b/src/spec-configuration/containerFeaturesConfiguration.ts index b0dbfed41..393d99356 100644 --- a/src/spec-configuration/containerFeaturesConfiguration.ts +++ b/src/spec-configuration/containerFeaturesConfiguration.ts @@ -545,16 +545,26 @@ export async function processFeatureIdentifier(output: Log, env: NodeJS.ProcessE // remote tar file if (type === 'direct-tarball') { output.write(`Remote tar file found.`); - let input = userFeature.id.replace(/\/+$/, ''); - const featureIdDelimiter = input.lastIndexOf('#'); - const id = input.substring(featureIdDelimiter + 1); + const tarballUri = new URL.URL(userFeature.id); + + const fullPath = tarballUri.pathname; + const tarballName = fullPath.substring(fullPath.lastIndexOf('/') + 1); + output.write(`tarballName = ${tarballName}`, LogLevel.Trace); + + const regex = new RegExp('devcontainer-feature-(.*).tgz'); + const matches = regex.exec(tarballName); + + if (!matches || matches.length !== 2) { + output.write(`Expected tarball name to follow 'devcontainer-feature-.tgz' format. Received '${tarballName}'`, LogLevel.Error); + return undefined; + } + const id = matches[1]; if (id === '' || !allowedFeatureIdRegex.test(id)) { - output.write(`Parse error. Specify a feature id with alphanumeric, dash, or underscore characters. Provided: ${id}.`, LogLevel.Error); + output.write(`Parse error. Specify a feature id with alphanumeric, dash, or underscore characters. Received ${id}.`, LogLevel.Error); return undefined; } - const tarballUri = new URL.URL(input.substring(0, featureIdDelimiter)).toString(); let feat: Feature = { id: id, name: userFeature.id, @@ -565,7 +575,7 @@ export async function processFeatureIdentifier(output: Log, env: NodeJS.ProcessE let newFeaturesSet: FeatureSet = { sourceInformation: { type: 'direct-tarball', - tarballUri: tarballUri + tarballUri: tarballUri.toString() }, features: [feat], }; diff --git a/src/test/container-features/configs/image-with-v2-tarball/.devcontainer.json b/src/test/container-features/configs/image-with-v2-tarball/.devcontainer.json new file mode 100644 index 000000000..423bbfc93 --- /dev/null +++ b/src/test/container-features/configs/image-with-v2-tarball/.devcontainer.json @@ -0,0 +1,6 @@ +{ + "image": "mcr.microsoft.com/vscode/devcontainers/typescript-node:16", + "features": { + "https://github.com/codspace/features/releases/download/tarball02/devcontainer-feature-docker-in-docker.tgz": {} + } +} \ No newline at end of file diff --git a/src/test/container-features/containerFeaturesOCI.test.ts b/src/test/container-features/containerFeaturesOCI.test.ts index c0ba9a909..238d63349 100644 --- a/src/test/container-features/containerFeaturesOCI.test.ts +++ b/src/test/container-features/containerFeaturesOCI.test.ts @@ -5,7 +5,6 @@ import { createPlainLog, LogLevel, makeLog } from '../../spec-utils/log'; export const output = makeLog(createPlainLog(text => process.stdout.write(text), () => LogLevel.Trace)); describe('Test OCI Pull', () => { - it('Parse OCI identifier', async () => { const feat = getFeatureRef(output, 'ghcr.io/codspace/features/ruby:1'); output.write(`feat: ${JSON.stringify(feat)}`); @@ -19,8 +18,8 @@ describe('Test OCI Pull', () => { }); it('Get a manifest by tag', async () => { - const featureRef = getFeatureRef(output, 'ghcr.io/codspace/features/ruby:1.0.14'); - const manifest = await getFeatureManifest(output, process.env, 'https://ghcr.io/v2/codspace/features/ruby/manifests/1.0.14', featureRef); + const featureRef = getFeatureRef(output, 'ghcr.io/codspace/features/ruby:1.0.13'); + const manifest = await getFeatureManifest(output, process.env, 'https://ghcr.io/v2/codspace/features/ruby/manifests/1.0.13', featureRef); assert.isNotNull(manifest); assert.exists(manifest); @@ -37,12 +36,12 @@ describe('Test OCI Pull', () => { output.write(`Layer imageTitle: ${layer.annotations['org.opencontainers.image.title']}`); }); - assert.equal(manifest.layers[0].digest, 'sha256:c33008d0dc12d0e631734082401bec692da809eae2ac51e24f58c1cac68fc0c9'); + assert.equal(manifest.layers[0].digest, 'sha256:8f59630bd1ba6d9e78b485233a0280530b3d0a44338f472206090412ffbd3efb'); }); it('Download a feature', async () => { - const featureRef = getFeatureRef(output, 'ghcr.io/codspace/features/ruby:1.0.14'); - const result = await getFeatureBlob(output, process.env, 'https://ghcr.io/v2/codspace/features/ruby/blobs/sha256:c33008d0dc12d0e631734082401bec692da809eae2ac51e24f58c1cac68fc0c9', '/tmp', '/tmp/featureTest', featureRef); + const featureRef = getFeatureRef(output, 'ghcr.io/codspace/features/ruby:1.0.13'); + const result = await getFeatureBlob(output, process.env, 'https://ghcr.io/v2/codspace/features/ruby/blobs/sha256:8f59630bd1ba6d9e78b485233a0280530b3d0a44338f472206090412ffbd3efb', '/tmp', '/tmp/featureTest', featureRef); assert.isTrue(result); }); }); \ No newline at end of file diff --git a/src/test/container-features/e2e.test.ts b/src/test/container-features/e2e.test.ts index 22983f8db..e35bdfb6e 100644 --- a/src/test/container-features/e2e.test.ts +++ b/src/test/container-features/e2e.test.ts @@ -53,7 +53,6 @@ describe('Dev Container Features E2E (remote)', function () { }); }); - describe('v2 - Dockerfile feature Configs', () => { describe(`dockerfile-with-v2-oci-features`, () => { @@ -71,6 +70,24 @@ describe('Dev Container Features E2E (remote)', function () { }); }); }); + + describe('v2 - Image property feature Configs', () => { + + describe(`image-with-v2-tarball`, () => { + let containerId: string | null = null; + const testFolder = `${__dirname}/configs/image-with-v2-tarball`; + beforeEach(async () => containerId = (await devContainerUp(cli, testFolder, { 'logLevel': 'trace' })).containerId); + afterEach(async () => await devContainerDown({ containerId })); + it('should detect docker installed (--privileged flag implicitly passed)', async () => { + // NOTE: Doing a docker ps will ensure that the --privileged flag was set by the feature + const res = await shellExec(`${cli} exec --workspace-folder ${testFolder} docker ps`); + const response = JSON.parse(res.stdout); + console.log(res.stderr); + assert.equal(response.outcome, 'success'); + assert.match(res.stderr, /CONTAINER ID/); + }); + }); + }); }); diff --git a/src/test/container-features/featureHelpers.test.ts b/src/test/container-features/featureHelpers.test.ts new file mode 100644 index 000000000..458b7ea0c --- /dev/null +++ b/src/test/container-features/featureHelpers.test.ts @@ -0,0 +1,342 @@ +import { assert } from 'chai'; +import * as path from 'path'; +import { DevContainerFeature } from '../../spec-configuration/configuration'; +import { processFeatureIdentifier } from '../../spec-configuration/containerFeaturesConfiguration'; +import { OCIFeatureRef } from '../../spec-configuration/containerFeaturesOCI'; +import { getSafeId } from '../../spec-node/containerFeatures'; +import { createPlainLog, LogLevel, makeLog } from '../../spec-utils/log'; + +export const output = makeLog(createPlainLog(text => process.stdout.write(text), () => LogLevel.Trace)); + +describe('getIdSafe should return safe environment variable name', function () { + + it('should replace a "-" with "_"', function () { + const ex = 'option-name'; + assert.strictEqual(getSafeId(ex), 'OPTION_NAME'); + }); + + it('should replace all "-" with "_"', function () { + const ex = 'option1-name-with_dashes-'; + assert.strictEqual(getSafeId(ex), 'OPTION1_NAME_WITH_DASHES_'); + }); + + it('should only be capitalized if no special characters', function () { + const ex = 'myOptionName'; + assert.strictEqual(getSafeId(ex), 'MYOPTIONNAME'); + }); + + it('should delete a leading numbers and add a _', function () { + const ex = '1name'; + assert.strictEqual(getSafeId(ex), '_NAME'); + }); + + it('should delete all leading numbers and add a _', function () { + const ex = '12345_option-name'; + assert.strictEqual(getSafeId(ex), '_OPTION_NAME'); + }); +}); + +// A 'Feature' object's id should always be parsed to +// the individual feature's name (without any other 'sourceInfo' information) +const assertFeatureIdInvariant = (id: string) => { + const includesInvalidCharacter = id.includes('/') || id.includes(':') || id.includes('\\') || id.includes('.'); + assert.isFalse(includesInvalidCharacter, `Individual feature id '${id}' contains invalid characters`); +}; + +describe('validate processFeatureIdentifier', async function () { + // const VALID_TYPES = ['local-cache', 'github-repo', 'direct-tarball', 'file-path', 'oci']; + + // In the real implementation, the cwd is passed by the calling function with the value of `--workspace-folder`. + // See: https://github.com/devcontainers/cli/blob/45541ba21437bf6c16826762f084ab502157789b/src/spec-node/devContainersSpecCLI.ts#L152-L153 + const cwd = process.cwd(); + console.log(`cwd: ${cwd}`); + + describe('VALID processFeatureIdentifier examples', async function () { + + it('should process local-cache', async function () { + // Parsed out of a user's devcontainer.json + let userFeature: DevContainerFeature = { + id: 'docker-in-docker', + options: {} + }; + const featureSet = await processFeatureIdentifier(output, process.env, cwd, userFeature); + if (!featureSet) { + assert.fail('processFeatureIdentifier returned null'); + } + + assert.strictEqual(featureSet.features.length, 1); + + const featureId = featureSet.features[0].id; + assertFeatureIdInvariant(featureId); + + assert.strictEqual(featureId, 'docker-in-docker'); + assert.strictEqual(featureSet?.sourceInformation.type, 'local-cache'); + }); + + it('should process github-repo (without version)', async function () { + const feature: DevContainerFeature = { + id: 'octocat/myfeatures/helloworld', + options: {}, + }; + const featureSet = await processFeatureIdentifier(output, process.env, cwd, feature); + if (!featureSet) { + assert.fail('processFeatureIdentifier returned null'); + } + + assert.strictEqual(featureSet.features.length, 1); + + const featureId = featureSet.features[0].id; + assertFeatureIdInvariant(featureId); + + assert.strictEqual(featureSet?.features[0].id, 'helloworld'); + assert.deepEqual(featureSet?.sourceInformation, { + type: 'github-repo', + owner: 'octocat', + repo: 'myfeatures', + apiUri: 'https://api.github.com/repos/octocat/myfeatures/releases/latest', + unauthenticatedUri: 'https://github.com/octocat/myfeatures/releases/latest/download', + isLatest: true + }); + }); + + it('should process github-repo (with version)', async function () { + const feature: DevContainerFeature = { + id: 'octocat/myfeatures/helloworld@v0.0.4', + options: {}, + }; + const featureSet = await processFeatureIdentifier(output, process.env, cwd, feature); + if (!featureSet) { + assert.fail('processFeatureIdentifier returned null'); + } + + assert.strictEqual(featureSet.features.length, 1); + + const featureId = featureSet.features[0].id; + assertFeatureIdInvariant(featureId); + + assert.strictEqual(featureSet?.features[0].id, 'helloworld'); + assert.deepEqual(featureSet?.sourceInformation, { + type: 'github-repo', + owner: 'octocat', + repo: 'myfeatures', + tag: 'v0.0.4', + apiUri: 'https://api.github.com/repos/octocat/myfeatures/releases/tags/v0.0.4', + unauthenticatedUri: 'https://github.com/octocat/myfeatures/releases/download/v0.0.4', + isLatest: false, + }); + }); + + it('should process direct-tarball (v2 with direct tar download)', async function () { + const feature: DevContainerFeature = { + id: 'https://example.com/some/long/path/devcontainer-feature-ruby.tgz', + options: {}, + }; + + const featureSet = await processFeatureIdentifier(output, process.env, cwd, feature); + if (!featureSet) { + assert.fail('processFeatureIdentifier returned null'); + } + const featureId = featureSet.features[0].id; + assertFeatureIdInvariant(featureId); + + assert.exists(featureSet); + assert.strictEqual(featureSet?.features[0].id, 'ruby'); + assert.deepEqual(featureSet?.sourceInformation, { type: 'direct-tarball', tarballUri: 'https://example.com/some/long/path/devcontainer-feature-ruby.tgz' }); + }); + + it('should parse when provided a local-filesystem relative path', async function () { + const feature: DevContainerFeature = { + id: './some/long/path/to/helloworld', + options: {}, + }; + + const result = await processFeatureIdentifier(output, process.env, cwd, feature); + assert.exists(result); + assert.strictEqual(result?.features[0].id, 'helloworld'); + assert.deepEqual(result?.sourceInformation, { type: 'file-path', resolvedFilePath: path.join(cwd, '/some/long/path/to/helloworld') }); + }); + + + it('should parse when provided a local-filesystem relative path, starting with ../', async function () { + const feature: DevContainerFeature = { + id: '../some/long/path/to/helloworld', + options: {}, + }; + + const result = await processFeatureIdentifier(output, process.env, cwd, feature); + + assert.exists(result); + assert.strictEqual(result?.features[0].id, 'helloworld'); + assert.deepEqual(result?.sourceInformation, { type: 'file-path', resolvedFilePath: path.join(path.dirname(cwd), '/some/long/path/to/helloworld') }); + }); + + it('should parse when provided a local-filesystem absolute path', async function () { + const feature: DevContainerFeature = { + id: '/some/long/path/to/helloworld', + options: {}, + }; + const result = await processFeatureIdentifier(output, process.env, cwd, feature); + assert.exists(result); + assert.strictEqual(result?.features[0].id, 'helloworld'); + assert.deepEqual(result?.sourceInformation, { type: 'file-path', resolvedFilePath: '/some/long/path/to/helloworld' }); + }); + + it('should process oci registry (without tag)', async function () { + const feature: DevContainerFeature = { + id: 'ghcr.io/codspace/features/ruby', + options: {}, + }; + + const featureSet = await processFeatureIdentifier(output, process.env, cwd, feature); + if (!featureSet) { + assert.fail('processFeatureIdentifier returned null'); + } + const featureId = featureSet.features[0].id; + assertFeatureIdInvariant(featureId); + assert.strictEqual(featureSet?.features[0].id, 'ruby'); + + assert.exists(featureSet); + + const expectedFeatureRef: OCIFeatureRef = { + id: 'ruby', + owner: 'codspace', + namespace: 'codspace/features', + registry: 'ghcr.io', + version: 'latest', + resource: 'ghcr.io/codspace/features/ruby' + }; + + if (featureSet.sourceInformation.type === 'oci') { + assert.ok(featureSet.sourceInformation.type === 'oci'); + assert.deepEqual(featureSet.sourceInformation.featureRef, expectedFeatureRef); + } else { + assert.fail('sourceInformation.type is not oci'); + } + }); + + it('should process oci registry (with a tag)', async function () { + const feature: DevContainerFeature = { + id: 'ghcr.io/codspace/features/ruby:1.0.13', + options: {}, + }; + + const featureSet = await processFeatureIdentifier(output, process.env, cwd, feature); + if (!featureSet) { + assert.fail('processFeatureIdentifier returned null'); + } + const featureId = featureSet.features[0].id; + assertFeatureIdInvariant(featureId); + assert.strictEqual(featureSet?.features[0].id, 'ruby'); + + assert.exists(featureSet); + + const expectedFeatureRef: OCIFeatureRef = { + id: 'ruby', + owner: 'codspace', + namespace: 'codspace/features', + registry: 'ghcr.io', + version: '1.0.13', + resource: 'ghcr.io/codspace/features/ruby' + }; + + if (featureSet.sourceInformation.type === 'oci') { + assert.ok(featureSet.sourceInformation.type === 'oci'); + assert.deepEqual(featureSet.sourceInformation.featureRef, expectedFeatureRef); + } else { + assert.fail('sourceInformation.type is not oci'); + } + }); + }); + + describe('INVALID processFeatureIdentifier examples', async function () { + it('should fail parsing a generic tar with no feature and trailing slash', async function () { + const feature: DevContainerFeature = { + id: 'https://example.com/some/long/path/devcontainer-features.tgz/', + options: {}, + }; + + const result = await processFeatureIdentifier(output, process.env, cwd, feature); + assert.notExists(result); + }); + + it('should not parse gitHub without triple slash', async function () { + const feature: DevContainerFeature = { + id: 'octocat/myfeatures#helloworld', + options: {}, + }; + + const result = await processFeatureIdentifier(output, process.env, cwd, feature); + assert.notExists(result); + }); + + it('should fail parsing a generic tar with no feature and no trailing slash', async function () { + const feature: DevContainerFeature = { + id: 'https://example.com/some/long/path/devcontainer-features.tgz', + options: {}, + }; + + const result = await processFeatureIdentifier(output, process.env, cwd, feature); + assert.notExists(result); + }); + + it('should fail parsing a generic tar with a hash but no feature', async function () { + const feature: DevContainerFeature = { + id: 'https://example.com/some/long/path/devcontainer-features.tgz#', + options: {}, + }; + + const result = await processFeatureIdentifier(output, process.env, cwd, feature); + assert.notExists(result); + }); + + it('should fail parsing a marketplace shorthand with only two segments and a hash with no feature', async function () { + const feature: DevContainerFeature = { + id: 'octocat/myfeatures#', + options: {}, + }; + + const result = await processFeatureIdentifier(output, process.env, cwd, feature); + assert.notExists(result); + }); + + it('should fail parsing a marketplace shorthand with only two segments (no feature)', async function () { + const feature: DevContainerFeature = { + id: 'octocat/myfeatures', + options: {}, + }; + + const result = await processFeatureIdentifier(output, process.env, cwd, feature); + assert.notExists(result); + }); + + it('should fail parsing a marketplace shorthand with an invalid feature name (1)', async function () { + const feature: DevContainerFeature = { + id: 'octocat/myfeatures/@mycoolfeature', + options: {}, + }; + + const result = await processFeatureIdentifier(output, process.env, cwd, feature); + assert.notExists(result); + }); + + it('should fail parsing a marketplace shorthand with an invalid feature name (2)', async function () { + const feature: DevContainerFeature = { + id: 'octocat/myfeatures/MY_$UPER_COOL_FEATURE', + options: {}, + }; + + const result = await processFeatureIdentifier(output, process.env, cwd, feature); + assert.notExists(result); + }); + + it('should fail parsing a marketplace shorthand with only two segments, no hash, and with a version', async function () { + const feature: DevContainerFeature = { + id: 'octocat/myfeatures@v0.0.1', + options: {}, + }; + + const result = await processFeatureIdentifier(output, process.env, cwd, feature); + assert.notExists(result); + }); + }); +}); \ No newline at end of file diff --git a/src/test/container-features/generateFeaturesConfig.test.ts b/src/test/container-features/generateFeaturesConfig.test.ts index 6305bd21c..b9d31d41c 100644 --- a/src/test/container-features/generateFeaturesConfig.test.ts +++ b/src/test/container-features/generateFeaturesConfig.test.ts @@ -10,7 +10,7 @@ import { getLocalCacheFolder } from '../../spec-node/utils'; export const output = makeLog(createPlainLog(text => process.stdout.write(text), () => LogLevel.Trace)); // Test fetching/generating the devcontainer-features.json config -describe('validate (offline) generateFeaturesConfig()', function () { +describe('validate generateFeaturesConfig()', function () { // Setup const env = { 'SOME_KEY': 'SOME_VAL' }; @@ -67,7 +67,7 @@ describe('validate (offline) generateFeaturesConfig()', function () { // assert.strictEqual(actualEnvs, expectedEnvs); // getFeatureLayers - const actualLayers = await getFeatureLayers(featuresConfig); + const actualLayers = getFeatureLayers(featuresConfig); const expectedLayers = `RUN cd /tmp/build-features/first_1 \\ && chmod +x ./install.sh \\ && ./install.sh diff --git a/src/test/container-features/helpers.test.ts b/src/test/container-features/helpers.test.ts deleted file mode 100644 index cc20dbdfb..000000000 --- a/src/test/container-features/helpers.test.ts +++ /dev/null @@ -1,274 +0,0 @@ -import { assert } from 'chai'; -import path from 'path'; -import { DevContainerFeature } from '../../spec-configuration/configuration'; -import { getSourceInfoString, processFeatureIdentifier, SourceInformation } from '../../spec-configuration/containerFeaturesConfiguration'; -import { getSafeId } from '../../spec-node/containerFeatures'; -import { createPlainLog, LogLevel, makeLog } from '../../spec-utils/log'; -export const output = makeLog(createPlainLog(text => process.stdout.write(text), () => LogLevel.Trace)); - -describe('getIdSafe should return safe environment variable name', function () { - - it('should replace a "-" with "_"', function () { - const ex = 'option-name'; - assert.strictEqual(getSafeId(ex), 'OPTION_NAME'); - }); - - it('should replace all "-" with "_"', function () { - const ex = 'option1-name-with_dashes-'; - assert.strictEqual(getSafeId(ex), 'OPTION1_NAME_WITH_DASHES_'); - }); - - it('should only be capitalized if no special characters', function () { - const ex = 'myOptionName'; - assert.strictEqual(getSafeId(ex), 'MYOPTIONNAME'); - }); - - it('should delete a leading numbers and add a _', function () { - const ex = '1name'; - assert.strictEqual(getSafeId(ex), '_NAME'); - }); - - it('should delete all leading numbers and add a _', function () { - const ex = '12345_option-name'; - assert.strictEqual(getSafeId(ex), '_OPTION_NAME'); - }); -}); - -describe('validate function parseRemoteFeatureToDownloadUri', function () { - - // // -- Valid - - const cwd = process.cwd(); - console.log(`cwd: ${cwd}`); - - it('should parse local features and return an undefined tarballUrl', async function () { - const feature: DevContainerFeature = { - id: 'helloworld', - options: {}, - }; - - const result = await processFeatureIdentifier(output, process.env, cwd, feature); - assert.exists(result); - assert.strictEqual(result?.features[0].id, 'helloworld'); - assert.strictEqual(result?.sourceInformation.type, 'local-cache'); - }); - - it('should parse gitHub without version', async function () { - const feature: DevContainerFeature = { - id: 'octocat/myfeatures/helloworld', - options: {}, - }; - const result = await processFeatureIdentifier(output, process.env, cwd, feature); - assert.exists(result); - assert.strictEqual(result?.features[0].id, 'helloworld'); - assert.deepEqual(result?.sourceInformation, { - type: 'github-repo', - owner: 'octocat', - repo: 'myfeatures', - apiUri: 'https://api.github.com/repos/octocat/myfeatures/releases/latest', - unauthenticatedUri: 'https://github.com/octocat/myfeatures/releases/latest/download', - isLatest: true - }); - }); - - it('should parse gitHub with version', async function () { - const feature: DevContainerFeature = { - id: 'octocat/myfeatures/helloworld@v0.0.4', - options: {}, - }; - - const result = await processFeatureIdentifier(output, process.env, cwd, feature); - assert.exists(result); - assert.strictEqual(result?.features[0].id, 'helloworld'); - assert.deepEqual(result?.sourceInformation, { - type: 'github-repo', - owner: 'octocat', - repo: 'myfeatures', - tag: 'v0.0.4', - apiUri: 'https://api.github.com/repos/octocat/myfeatures/releases/tags/v0.0.4', - unauthenticatedUri: 'https://github.com/octocat/myfeatures/releases/download/v0.0.4', - isLatest: false - }); - }); - - it('should parse generic tar', async function () { - const feature: DevContainerFeature = { - id: 'https://example.com/some/long/path/devcontainer-features.tgz#helloworld', - options: {}, - }; - - const result = await processFeatureIdentifier(output, process.env, cwd, feature); - assert.exists(result); - assert.strictEqual(result?.features[0].id, 'helloworld'); - assert.deepEqual(result?.sourceInformation, { type: 'direct-tarball', tarballUri: 'https://example.com/some/long/path/devcontainer-features.tgz' }); - }); - - it('should parse when provided a local-filesystem relative path', async function () { - const feature: DevContainerFeature = { - id: './some/long/path/to/helloworld', - options: {}, - }; - - const result = await processFeatureIdentifier(output, process.env, cwd, feature); - assert.exists(result); - assert.strictEqual(result?.features[0].id, 'helloworld'); - assert.deepEqual(result?.sourceInformation, { type: 'file-path', resolvedFilePath: path.join(cwd, '/some/long/path/to/helloworld') }); - }); - - - it('should parse when provided a local-filesystem relative path, starting with ../', async function () { - const feature: DevContainerFeature = { - id: '../some/long/path/to/helloworld', - options: {}, - }; - - const result = await processFeatureIdentifier(output, process.env, cwd, feature); - - assert.exists(result); - assert.strictEqual(result?.features[0].id, 'helloworld'); - assert.deepEqual(result?.sourceInformation, { type: 'file-path', resolvedFilePath: path.join(path.dirname(cwd), '/some/long/path/to/helloworld') }); - }); - - it('should parse when provided a local-filesystem absolute path', async function () { - const feature: DevContainerFeature = { - id: '/some/long/path/to/helloworld', - options: {}, - }; - const result = await processFeatureIdentifier(output, process.env, cwd, feature); - assert.exists(result); - assert.strictEqual(result?.features[0].id, 'helloworld'); - assert.deepEqual(result?.sourceInformation, { type: 'file-path', resolvedFilePath: '/some/long/path/to/helloworld' }); - }); - - - // -- Invalid - - it('should fail parsing a generic tar with no feature and trailing slash', async function () { - const feature: DevContainerFeature = { - id: 'https://example.com/some/long/path/devcontainer-features.tgz/', - options: {}, - }; - - const result = await processFeatureIdentifier(output, process.env, cwd, feature); - assert.notExists(result); - }); - - it('should not parse gitHub without triple slash', async function () { - const feature: DevContainerFeature = { - id: 'octocat/myfeatures#helloworld', - options: {}, - }; - - const result = await processFeatureIdentifier(output, process.env, cwd, feature); - assert.notExists(result); - }); - - it('should fail parsing a generic tar with no feature and no trailing slash', async function () { - const feature: DevContainerFeature = { - id: 'https://example.com/some/long/path/devcontainer-features.tgz', - options: {}, - }; - - const result = await processFeatureIdentifier(output, process.env, cwd, feature); - assert.notExists(result); - }); - - it('should fail parsing a generic tar with a hash but no feature', async function () { - const feature: DevContainerFeature = { - id: 'https://example.com/some/long/path/devcontainer-features.tgz#', - options: {}, - }; - - const result = await processFeatureIdentifier(output, process.env, cwd, feature); - assert.notExists(result); - }); - - it('should fail parsing a marketplace shorthand with only two segments and a hash with no feature', async function () { - const feature: DevContainerFeature = { - id: 'octocat/myfeatures#', - options: {}, - }; - - const result = await processFeatureIdentifier(output, process.env, cwd, feature); - assert.notExists(result); - }); - - it('should fail parsing a marketplace shorthand with only two segments (no feature)', async function () { - const feature: DevContainerFeature = { - id: 'octocat/myfeatures', - options: {}, - }; - - const result = await processFeatureIdentifier(output, process.env, cwd, feature); - assert.notExists(result); - }); - - it('should fail parsing a marketplace shorthand with an invalid feature name (1)', async function () { - const feature: DevContainerFeature = { - id: 'octocat/myfeatures/@mycoolfeature', - options: {}, - }; - - const result = await processFeatureIdentifier(output, process.env, cwd, feature); - assert.notExists(result); - }); - - it('should fail parsing a marketplace shorthand with an invalid feature name (2)', async function () { - const feature: DevContainerFeature = { - id: 'octocat/myfeatures/MY_$UPER_COOL_FEATURE', - options: {}, - }; - - const result = await processFeatureIdentifier(output, process.env, cwd, feature); - assert.notExists(result); - }); - - it('should fail parsing a marketplace shorthand with only two segments, no hash, and with a version', async function () { - const feature: DevContainerFeature = { - id: 'octocat/myfeatures@v0.0.1', - options: {}, - }; - - const result = await processFeatureIdentifier(output, process.env, cwd, feature); - assert.notExists(result); - }); -}); - - -describe('validate function getSourceInfoString', function () { - - it('should work for local-cache', async function () { - const srcInfo: SourceInformation = { - type: 'local-cache', - }; - const output = getSourceInfoString(srcInfo); - assert.include(output, 'local-cache'); - }); - - it('should work for github-repo without a tag (implicit latest)', async function () { - const srcInfo: SourceInformation = { - type: 'github-repo', - owner: 'bob', - repo: 'mobileapp', - isLatest: true, - apiUri: 'https://api.github.com/repos/bob/mobileapp/releases/latest', - unauthenticatedUri: 'https://github.com/bob/mobileapp/releases/latest/download' - }; - const output = getSourceInfoString(srcInfo); - assert.include(output, 'github-bob-mobileapp-latest'); - }); - - it('should work for github-repo with a tag', async function () { - const srcInfo: SourceInformation = { - type: 'github-repo', - owner: 'bob', - repo: 'mobileapp', - tag: 'v0.0.4', - isLatest: false, - apiUri: 'https://api.github.com/repos/bob/mobileapp/releases/tags/v0.0.4', - unauthenticatedUri: 'https://github.com/bob/mobileapp/releases/download/v0.0.4' - }; - const output = getSourceInfoString(srcInfo); - assert.include(output, 'github-bob-mobileapp-v0.0.4'); - }); -}); \ No newline at end of file