Repository: firecow/gitlab-ci-local (ts)

Issue:
Implement include[].component syntax
**Is your feature request related to a problem? Please describe.**
I cannot follow examples on gitlab.com, because components isn't implemented

**Describe the solution you'd like**
Implement include[].component syntax


Pull request:
feat: implement include component
closes #1188 

Patch:
diff --git a/README.md b/README.md
index 1159b30..0afa48f 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,7 @@ Get rid of all those dev specific shell scripts and make files.
     * [docker-compose-nodejs](./examples/docker-compose-nodejs)
     * [docker-swarm-php](./examples/docker-swarm-php)
     * [docker-in-docker-build](./examples/docker-in-docker-build)
+    * [docker-in-docker with a local registry](./examples/docker-in-docker-build-with-local-registry)
 * [Installation](#installation)
 * [Convenience](#convenience)
     * [CLI options via shell](#cli-options-via-shell)
diff --git a/examples/docker-in-docker-build-with-local-registry/.gitignore b/examples/docker-in-docker-build-with-local-registry/.gitignore
new file mode 100644
index 0000000..5e2d7ba
--- /dev/null
+++ b/examples/docker-in-docker-build-with-local-registry/.gitignore
@@ -0,0 +1,1 @@
+.gitlab-ci-local/
diff --git a/examples/docker-in-docker-build-with-local-registry/.gitlab-ci-local-env b/examples/docker-in-docker-build-with-local-registry/.gitlab-ci-local-env
new file mode 100644
index 0000000..29cca56
--- /dev/null
+++ b/examples/docker-in-docker-build-with-local-registry/.gitlab-ci-local-env
@@ -0,0 +1,4 @@
+PRIVILEGED=true
+ULIMIT=8000:16000
+VOLUME=certs:/certs/client
+VARIABLE="DOCKER_TLS_CERTDIR=/certs"
diff --git a/examples/docker-in-docker-build-with-local-registry/.gitlab-ci-local-variables.yml b/examples/docker-in-docker-build-with-local-registry/.gitlab-ci-local-variables.yml
new file mode 100644
index 0000000..ba937fc
--- /dev/null
+++ b/examples/docker-in-docker-build-with-local-registry/.gitlab-ci-local-variables.yml
@@ -0,0 +1,2 @@
+---
+CI_REGISTRY_IMAGE: "10.11.22.33:5000/docker-in-docker-build-with-local-registry"
diff --git a/examples/docker-in-docker-build-with-local-registry/.gitlab-ci.yml b/examples/docker-in-docker-build-with-local-registry/.gitlab-ci.yml
new file mode 100644
index 0000000..db7813b
--- /dev/null
+++ b/examples/docker-in-docker-build-with-local-registry/.gitlab-ci.yml
@@ -0,0 +1,19 @@
+---
+# @Description Build alpine
+alpine-image:
+  services:
+    - name: docker:dind
+      command:
+        - --insecure-registry=10.11.22.33:5000
+  needs: []
+  image: docker:latest
+  stage: build
+  script:
+    - docker pull $CI_REGISTRY_IMAGE:latest || true
+    - docker build
+      --cache-from $CI_REGISTRY_IMAGE:latest
+      --tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
+      --tag $CI_REGISTRY_IMAGE:latest
+      .
+    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
+    - docker push $CI_REGISTRY_IMAGE:latest
diff --git a/examples/docker-in-docker-build-with-local-registry/Dockerfile b/examples/docker-in-docker-build-with-local-registry/Dockerfile
new file mode 100644
index 0000000..233bf8e
--- /dev/null
+++ b/examples/docker-in-docker-build-with-local-registry/Dockerfile
@@ -0,0 +1,3 @@
+FROM alpine
+
+RUN apk add nano
diff --git a/examples/docker-in-docker-build-with-local-registry/README.md b/examples/docker-in-docker-build-with-local-registry/README.md
new file mode 100644
index 0000000..481c5f4
--- /dev/null
+++ b/examples/docker-in-docker-build-with-local-registry/README.md
@@ -0,0 +1,15 @@
+# Building Docker in Docker with a local registry example
+
+#### !!! Important !!!
+See how the project is configured in [.gitlab-ci-local-env](.gitlab-ci-local-env)
+
+#### Before you can run if you don't have any local registry.
+```bash
+docker network create gitlab-ci-local-registry --subnet=10.11.22.0/24
+docker run -d -p 5000:5000 --network gitlab-ci-local-registry --ip=10.11.22.33 --name gitlab-ci-local-registry registry
+```
+
+#### Start by calling.
+```bash
+gitlab-ci-local --cwd examples/docker-in-docker-build-with-local-registry/
+```
diff --git a/package-lock.json b/package-lock.json
index 412f66d..a001ba1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "gitlab-ci-local",
-  "version": "4.48.1",
+  "version": "4.48.2",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "gitlab-ci-local",
-      "version": "4.48.1",
+      "version": "4.48.2",
       "license": "MIT",
       "dependencies": {
         "ajv": "^8.12.0",
diff --git a/package.json b/package.json
index 04bc044..c1d63b6 100644
--- a/package.json
+++ b/package.json
@@ -3,7 +3,7 @@
   "description": "Tired of pushing to test your .gitlab-ci.yml?",
   "main": "src/index.js",
   "bin": "src/index.js",
-  "version": "4.48.1",
+  "version": "4.48.2",
   "scripts": {
     "pkg-linux": "pkg src/index.js --public -t node18-linux-x64 -o bin/linux/gitlab-ci-local && chmod +x bin/linux/gitlab-ci-local && gzip -c bin/linux/gitlab-ci-local > bin/linux.gz",
     "pkg-macos": "pkg src/index.js --public -t node18-macos-x64 -o bin/macos/gitlab-ci-local && gzip -c bin/macos/gitlab-ci-local > bin/macos.gz",
@@ -162,5 +162,9 @@
     "untracked",
     "uncomitted",
     "gitlab-ci"
+  ],
+  "files":[
+    "src/**/*.js",
+    "src/**/*.json"
   ]
 }
diff --git a/src/job.ts b/src/job.ts
index 436a024..d6081db 100644
--- a/src/job.ts
+++ b/src/job.ts
@@ -94,6 +94,7 @@ export class Job {
     readonly gitData: GitData;
 
     private readonly _variables: {[key: string]: string} = {};
+    private _dotenvVariables: {[key: string]: string} = {};
     private _prescriptsExitCode: number | null = null;
     private _afterScriptsExitCode = 0;
     private _coveragePercent: string | null = null;
@@ -304,7 +305,7 @@ export class Job {
         if (!this.jobData["services"]) return [];
 
         for (const service of Object.values<any>(this.jobData["services"])) {
-            const expanded = Utils.expandVariables({...this._variables, ...service["variables"]});
+            const expanded = Utils.expandVariables({...this._variables, ...this._dotenvVariables, ...service["variables"]});
             let serviceName = Utils.expandText(service["name"], expanded);
             serviceName = serviceName.includes(":") ? serviceName : `${serviceName}:latest`;
             services.push({
@@ -425,8 +426,8 @@ export class Job {
         const argv = this.argv;
         this._startTime = process.hrtime();
         const writeStreams = this.writeStreams;
-        const reportsDotenvVariables = await this.initProducerReportsDotenvVariables(writeStreams, Utils.expandVariables(this._variables));
-        const expanded = Utils.unscape$$Variables(Utils.expandVariables({...this._variables, ...reportsDotenvVariables}));
+        this._dotenvVariables = await this.initProducerReportsDotenvVariables(writeStreams, Utils.expandVariables(this._variables));
+        const expanded = Utils.unscape$$Variables(Utils.expandVariables({...this._variables, ...this._dotenvVariables}));
         const imageName = this.imageName(expanded);
         const safeJobName = this.safeJobName;
 
@@ -984,11 +985,16 @@ export class Job {
         }
         cpCmd += `${artifactsPath}/${safeJobName}/. || true\n`;
         const reportDotenv = Utils.expandText(this.artifacts.reports?.dotenv ?? null, expanded);
-        if (reportDotenv != null) {
-            cpCmd += `mkdir -p ${artifactsPath}/${safeJobName}/.gitlab-ci-reports/dotenv\n`;
-            cpCmd += `if [ -f ${reportDotenv} ]; then\n`;
-            cpCmd += `  rsync -Ra ${reportDotenv} ${artifactsPath}/${safeJobName}/.gitlab-ci-reports/dotenv/.\n`;
-            cpCmd += "fi\n";
+        const reportDotenvs: string[] | null = (typeof reportDotenv === "string") // normalize to string[] for easier handling
+            ? [reportDotenv]
+            : reportDotenv;
+        if (reportDotenvs != null) {
+            reportDotenvs.forEach((reportDotenv) => {
+                cpCmd += `mkdir -p ${artifactsPath}/${safeJobName}/.gitlab-ci-reports/dotenv\n`;
+                cpCmd += `if [ -f ${reportDotenv} ]; then\n`;
+                cpCmd += `  rsync -Ra ${reportDotenv} ${artifactsPath}/${safeJobName}/.gitlab-ci-reports/dotenv/.\n`;
+                cpCmd += "fi\n";
+            });
         }
 
         time = process.hrtime();
@@ -996,8 +1002,12 @@ export class Job {
         await this.copyOut(cpCmd, stateDir, "artifacts", dockerCmdExtras);
         endTime = process.hrtime(time);
 
-        if (reportDotenv != null && !await fs.pathExists(`${cwd}/${stateDir}/artifacts/${safeJobName}/.gitlab-ci-reports/dotenv/${reportDotenv}`)) {
-            writeStreams.stderr(chalk`${this.formattedJobName} {yellow artifact reports dotenv '${reportDotenv}' could not be found}\n`);
+        if (reportDotenvs != null) {
+            reportDotenvs.forEach(async (reportDotenv) => {
+                if (!await fs.pathExists(`${cwd}/${stateDir}/artifacts/${safeJobName}/.gitlab-ci-reports/dotenv/${reportDotenv}`)) {
+                    writeStreams.stderr(chalk`${this.formattedJobName} {yellow artifact reports dotenv '${reportDotenv}' could not be found}\n`);
+                }
+            });
         }
 
         const readdir = await fs.readdir(`${cwd}/${stateDir}/artifacts/${safeJobName}`);
diff --git a/src/parser-includes.ts b/src/parser-includes.ts
index 0675293..911fef0 100644
--- a/src/parser-includes.ts
+++ b/src/parser-includes.ts
@@ -104,6 +104,23 @@ export class ParserIncludes {
 
                     includeDatas = includeDatas.concat(await this.init(fileDoc, opts));
                 }
+            } else if (value["component"]) {
+                const {domain, projectPath, componentName, ref} = this.parseIncludeComponent(value["component"]);
+                // converts component to project
+                const files = [`${componentName}.yml`, `${componentName}/template.yml`, null];
+                for (const f of files) {
+                    assert(f !== null, `This GitLab CI configuration is invalid: component: \`${value["component"]}\`. One of the file [${files}] must exists in \`${domain}/${projectPath}\``);
+                    if (!(await Utils.remoteFileExist(f, ref, domain, projectPath, gitData.remote.schema))) continue;
+                    const fileDoc = {
+                        include: {
+                            project: projectPath,
+                            file: f,
+                            ref: ref,
+                        },
+                    };
+                    includeDatas = includeDatas.concat(await this.init(fileDoc, opts));
+                    break;
+                }
             } else if (value["template"]) {
                 const {project, ref, file, domain} = this.covertTemplateToProjectFile(value["template"]);
                 const fsUrl = Utils.fsUrl(`https://${domain}/${project}/-/raw/${ref}/${file}`);
@@ -164,6 +181,21 @@ export class ParserIncludes {
         };
     }
 
+    static parseIncludeComponent (component: string): {domain: string; projectPath: string; componentName: string; ref: string} {
+        assert(!component.includes("://"), `This GitLab CI configuration is invalid: component: \`${component}\` should not contain protocol`);
+        // eslint-disable-next-line no-useless-escape
+        const pattern = /(?<domain>[^/\s]+)\/(?<projectPath>.+)\/(?<componentName>[^@]+)@(?<ref>.+)/; // regexr.com/7v7hm
+        const gitRemoteMatch = pattern.exec(component);
+
+        if (gitRemoteMatch?.groups == null) throw new Error(`This is a bug, please create a github issue if this is something you're expecting to work. input: ${component}`);
+        return {
+            domain: gitRemoteMatch.groups["domain"],
+            projectPath: gitRemoteMatch.groups["projectPath"],
+            componentName: `templates/${gitRemoteMatch.groups["componentName"]}`,
+            ref: gitRemoteMatch.groups["ref"],
+        };
+    }
+
     static async downloadIncludeRemote (cwd: string, stateDir: string, url: string, fetchIncludes: boolean): Promise<void> {
         const fsUrl = Utils.fsUrl(url);
         try {
diff --git a/src/utils.ts b/src/utils.ts
index 6fe2cd5..badf985 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -7,6 +7,7 @@ import execa from "execa";
 import assert from "assert";
 import {CICDVariable} from "./variables-from-files";
 import globby from "globby";
+import axios from "axios";
 
 type RuleResultOpt = {
     cwd: string;
@@ -259,4 +260,29 @@ export class Utils {
     static isObject (v: any) {
         return Object.getPrototypeOf(v) === Object.prototype;
     }
+
+    static async remoteFileExist (file: string, ref: string, domain: string, projectPath: string, protocol: string) {
+        switch (protocol) {
+            case "git":
+                try {
+                    await Utils.bash(`git archive --remote=ssh://git@${domain}/${projectPath}.git ${ref} ${file} > /dev/null`);
+                    return true;
+                } catch (e: any) {
+                    if (!e.stderr.includes(`remote: fatal: pathspec '${file}' did not match any files`)) throw new Error(e);
+                    return false;
+                }
+
+            case "http":
+            case "https": {
+                try {
+                    const {status} = await axios.get(`${protocol}://${domain}/${projectPath}/-/raw/${ref}/${file}`);
+                    return (status === 200);
+                } catch (e) {
+                    return false;
+                }
+            }
+            default:
+                throw new Error(`${protocol} not supported!`);
+        }
+    }
 }
diff --git a/src/validator.ts b/src/validator.ts
index fd32d9e..2d5013a 100644
--- a/src/validator.ts
+++ b/src/validator.ts
@@ -19,10 +19,10 @@ export class Validator {
         const validate = ajv.compile(schema);
         const valid = validate(data);
         assert(valid,
-            chalk`
-    Invalid gitlab-ci configuration! It have failed the json schema validation. Dump the following to the pipeline editor to debug:
-    ${yaml.dump(data)}
-    `);
+            chalk`Invalid gitlab-ci configuration! It have failed the json schema validation. Dump the following to the pipeline editor to debug:
+
+${yaml.dump(data)}
+`);
     }
 
     private static needs (jobs: ReadonlyArray<Job>, stages: readonly string[]): string[] {


Test patch:
diff --git a/tests/mocks/utils.mock.ts b/tests/mocks/utils.mock.ts
index 8078a4c..c68835e 100644
--- a/tests/mocks/utils.mock.ts
+++ b/tests/mocks/utils.mock.ts
@@ -1,11 +1,12 @@
 import {when} from "jest-when";
 import {Utils} from "../../src/utils";
 
-export function initBashSpyReject (spyMocks: {cmd: string; rejection: string}[]) {
+export function initBashSpyReject (spyMocks: {cmd: string; rejection: any}[]) {
     const spyOn = jest.spyOn(Utils, "bash");
 
     for (const spyMock of spyMocks) {
-        when(spyOn).calledWith(spyMock.cmd, expect.any(String)).mockRejectedValue(new Error(spyMock.rejection));
+        when(spyOn).calledWith(spyMock.cmd, expect.any(String)).mockRejectedValue(spyMock.rejection);
+        when(spyOn).calledWith(spyMock.cmd).mockRejectedValue(spyMock.rejection);
     }
 }
 
@@ -14,6 +15,7 @@ export function initBashSpy (spyMocks: {cmd: string; returnValue: any}[]) {
 
     for (const spyMock of spyMocks) {
         when(spyOn).calledWith(spyMock.cmd, expect.any(String)).mockResolvedValue(spyMock.returnValue);
+        when(spyOn).calledWith(spyMock.cmd).mockResolvedValue(spyMock.returnValue);
     }
 }
 
@@ -22,5 +24,6 @@ export function initSpawnSpy (spyMocks: {cmdArgs: string[]; returnValue: any}[])
 
     for (const spyMock of spyMocks) {
         when(spyOn).calledWith(spyMock.cmdArgs, expect.any(String)).mockResolvedValue(spyMock.returnValue);
+        when(spyOn).calledWith(spyMock.cmdArgs).mockResolvedValue(spyMock.returnValue);
     }
 }
diff --git a/tests/mocks/when-statics.ts b/tests/mocks/when-statics.ts
index 1436e8c..574aa5a 100644
--- a/tests/mocks/when-statics.ts
+++ b/tests/mocks/when-statics.ts
@@ -44,4 +44,11 @@ export class WhenStatics {
         WhenStatics.mockGitCommitShaShort,
     ];
 
+    public static readonly mockGitRemoteHttp = {
+        cmdArgs: ["git", "remote", "-v"],
+        returnValue: {
+            stdout: `origin https://gitlab.com/gcl/test-project.git (fetch)
+origin https://gitlab.com/gcl/test-project.git (push)`,
+        },
+    };
 }
diff --git a/tests/test-cases/artifacts-dotenv/.gitlab-ci.yml b/tests/test-cases/artifacts-dotenv/.gitlab-ci.yml
index 04ee407..41718d7 100644
--- a/tests/test-cases/artifacts-dotenv/.gitlab-ci.yml
+++ b/tests/test-cases/artifacts-dotenv/.gitlab-ci.yml
@@ -4,6 +4,8 @@ build-image-ref:
   needs: []
   script:
     - echo "BUILD_IMAGE_REF=alpine" > build.env
+    - echo "SERVICE_IMAGE_REF=docker.io/library/redis" >> build.env
+    - echo "SERVICE_IMAGE_ALIAS=redis" >> build.env
   artifacts:
     reports: { dotenv: build.env }
 
@@ -13,3 +15,13 @@ use-image-ref:
   image: ${BUILD_IMAGE_REF}
   script:
     - if [ "$BUILD_IMAGE_REF" = "" ]; then exit 1; else cat /etc/os-release; fi
+
+use-service-ref:
+  stage: deploy
+  needs: [build-image-ref]
+  services:
+    - name: $SERVICE_IMAGE_REF
+      alias: $SERVICE_IMAGE_ALIAS
+  image: docker.io/tutum/dnsutils
+  script:
+    - host $SERVICE_IMAGE_ALIAS
diff --git a/tests/test-cases/artifacts-dotenv/integration.artifacts-dotenv.test.ts b/tests/test-cases/artifacts-dotenv/integration.artifacts-dotenv.test.ts
index ef174d1..f206af6 100644
--- a/tests/test-cases/artifacts-dotenv/integration.artifacts-dotenv.test.ts
+++ b/tests/test-cases/artifacts-dotenv/integration.artifacts-dotenv.test.ts
@@ -17,3 +17,14 @@ test("artifacts-dotenv <use-image-ref> --needs", async () => {
 
     expect(writeStreams.stderrLines.join("\n")).not.toMatch(/FAIL/);
 });
+
+test("artifacts-dotenv <use-service-ref> --needs", async () => {
+    const writeStreams = new WriteStreamsMock();
+    await handler({
+        cwd: "tests/test-cases/artifacts-dotenv",
+        job: ["use-service-ref"],
+        needs: true,
+    }, writeStreams);
+
+    expect(writeStreams.stderrLines.join("\n")).not.toMatch(/FAIL/);
+});
diff --git a/tests/test-cases/artifacts-reports-dotenv/.gitlab-ci-issue-1160.yml b/tests/test-cases/artifacts-reports-dotenv/.gitlab-ci-issue-1160.yml
new file mode 100644
index 0000000..f0cb77d
--- /dev/null
+++ b/tests/test-cases/artifacts-reports-dotenv/.gitlab-ci-issue-1160.yml
@@ -0,0 +1,35 @@
+---
+multi-dotenv:
+  script:
+    - echo "TEST_MULTI_1=true" > multi1.env
+    - echo "TEST_MULTI_2=true" > multi2.env
+  artifacts:
+    reports:
+      dotenv:
+        - multi1.env
+        - multi2.env
+test-multi-dotenv:
+  needs:
+    - multi-dotenv
+  script:
+    - set -u
+    - echo $TEST_MULTI_1
+    - echo $TEST_MULTI_2
+
+
+multi-dotenv-with-missing-file:
+  script:
+    - echo "TEST_MULTI_1=123" > multi3.env
+  artifacts:
+    reports:
+      dotenv:
+        - multi3.env
+        - multi4.env
+
+test-multi-dotenv-with-missing-file:
+  needs:
+    - multi-dotenv-with-missing-file
+  script:
+    - set -u
+    - echo $TEST_MULTI_1
+    - echo $TEST_MULTI_2
diff --git a/tests/test-cases/artifacts-reports-dotenv/integration.artifacts-reports-dotenv.test.ts b/tests/test-cases/artifacts-reports-dotenv/integration.artifacts-reports-dotenv.test.ts
index 2a1bc7e..3de2360 100644
--- a/tests/test-cases/artifacts-reports-dotenv/integration.artifacts-reports-dotenv.test.ts
+++ b/tests/test-cases/artifacts-reports-dotenv/integration.artifacts-reports-dotenv.test.ts
@@ -41,3 +41,28 @@ test("artifacts-reports-dotenv <deploy-shell-noiso> --needs", async () => {
 
     expect(writeStreams.stderrLines.join("\n")).not.toMatch(/FAIL/);
 });
+
+test("artifacts-reports-dotenv <test-multi-dotenv> --needs", async () => {
+    const writeStreams = new WriteStreamsMock();
+    await handler({
+        cwd: "tests/test-cases/artifacts-reports-dotenv",
+        job: ["test-multi-dotenv"],
+        needs: true,
+        file: ".gitlab-ci-issue-1160.yml",
+    }, writeStreams);
+
+    expect(writeStreams.stderrLines.join("\n")).not.toMatch(/FAIL/);
+});
+
+test("artifacts-reports-dotenv <test-multi-dotenv-with-missing-file> --needs", async () => {
+    const writeStreams = new WriteStreamsMock();
+    await handler({
+        cwd: "tests/test-cases/artifacts-reports-dotenv",
+        job: ["test-multi-dotenv-with-missing-file"],
+        needs: true,
+        file: ".gitlab-ci-issue-1160.yml",
+    }, writeStreams);
+
+    expect(writeStreams.stderrLines.join("\n")).toContain("artifact reports dotenv 'multi4.env' could not be found");
+    expect(writeStreams.stderrLines.join("\n")).toContain("TEST_MULTI_2: unbound variable");
+});
diff --git a/tests/test-cases/include-component/component/.gitlab-ci.yml b/tests/test-cases/include-component/component/.gitlab-ci.yml
new file mode 100644
index 0000000..b34abff
--- /dev/null
+++ b/tests/test-cases/include-component/component/.gitlab-ci.yml
@@ -0,0 +1,14 @@
+---
+include:
+  - component: gitlab.com/components/go/full-pipeline@0.3.1
+    inputs:
+      stage_format: format
+      stage_build: build
+      stage_test: test
+      go_version: latest
+
+stages:
+  - format
+  - build
+  - test
+  - latest
diff --git a/tests/test-cases/include-component/integration.include-component.test.ts b/tests/test-cases/include-component/integration.include-component.test.ts
new file mode 100644
index 0000000..f832d42
--- /dev/null
+++ b/tests/test-cases/include-component/integration.include-component.test.ts
@@ -0,0 +1,70 @@
+import {WriteStreamsMock} from "../../../src/write-streams";
+import {handler} from "../../../src/handler";
+import assert, {AssertionError} from "assert";
+import {initSpawnSpy} from "../../mocks/utils.mock";
+import {WhenStatics} from "../../mocks/when-statics";
+
+beforeAll(() => {
+    initSpawnSpy(WhenStatics.all);
+});
+
+test.concurrent("include-component no component template file (protocol: https)", async () => {
+    initSpawnSpy([WhenStatics.mockGitRemoteHttp]);
+
+    const writeStreams = new WriteStreamsMock();
+    try {
+        await handler({
+            cwd: "tests/test-cases/include-component/no-component-template-file",
+            preview: true,
+        }, writeStreams);
+        expect(true).toBe(false);
+    } catch (e: any) {
+        assert(e instanceof AssertionError, `Unexpected error thrown:\n ${e}`);
+        expect(e.message).toBe("This GitLab CI configuration is invalid: component: `gitlab.com/components/go/potato@0.3.1`. One of the file [templates/potato.yml,templates/potato/template.yml,] must exists in `gitlab.com/components/go`");
+    }
+});
+
+test.concurrent("include-component component (protocol: https)", async () => {
+    initSpawnSpy([WhenStatics.mockGitRemoteHttp]);
+
+    const writeStreams = new WriteStreamsMock();
+    await handler({
+        cwd: "tests/test-cases/include-component/component",
+        preview: true,
+    }, writeStreams);
+
+
+    const expected = `---
+stages:
+  - .pre
+  - format
+  - build
+  - test
+  - latest
+  - .post
+format-latest:
+  image:
+    name: golang:latest
+  stage: format
+  script:
+    - go fmt $(go list ./... | grep -v /vendor/)
+    - go vet $(go list ./... | grep -v /vendor/)
+build-latest:
+  image:
+    name: golang:latest
+  stage: build
+  script:
+    - mkdir -p mybinaries
+    - go build -o mybinaries ./...
+  artifacts:
+    paths:
+      - mybinaries
+test-latest:
+  image:
+    name: golang:latest
+  stage: test
+  script:
+    - go test -race $(go list ./... | grep -v /vendor/)`;
+
+    expect(writeStreams.stdoutLines[0]).toEqual(expected);
+});
diff --git a/tests/test-cases/include-component/no-component-template-file/.gitlab-ci.yml b/tests/test-cases/include-component/no-component-template-file/.gitlab-ci.yml
new file mode 100644
index 0000000..edfcff2
--- /dev/null
+++ b/tests/test-cases/include-component/no-component-template-file/.gitlab-ci.yml
@@ -0,0 +1,5 @@
+---
+include:
+  - component: gitlab.com/components/go/potato@0.3.1
+stages:
+  - format
diff --git a/tests/utils.test.ts b/tests/utils.test.ts
new file mode 100644
index 0000000..23f6c36
--- /dev/null
+++ b/tests/utils.test.ts
@@ -0,0 +1,45 @@
+import {Utils} from "../src/utils";
+import {initBashSpyReject, initBashSpy} from "./mocks/utils.mock";
+
+import {isSshDirFound} from "./utils";
+
+test("remoteFileExist protocol: git", async () => {
+    const file = "templates/test.yml";
+    const ref = "0.3.1";
+    const domain = "gitlab.com";
+    const projectPath = "components/go";
+
+    // NOTE: Only mocks git archive command if `~/.ssh` ssh dir is not found
+    if (!isSshDirFound()) {
+        const spyGitArchive = {
+            cmd: `git archive --remote=ssh://git@${domain}/${projectPath}.git ${ref} ${file} > /dev/null`,
+            returnValue: {output: ""},
+        };
+        initBashSpy([spyGitArchive]);
+    }
+
+    const fileExist = await Utils.remoteFileExist(file, ref, domain, projectPath, "git");
+    expect(fileExist).toBe(true);
+});
+
+test("remoteFileDoesNotExist protocol: git", async () => {
+    const file = "templates/potato.yml";
+    const ref = "0.3.1";
+    const domain = "gitlab.com";
+    const projectPath = "components/go";
+
+    // NOTE: Only mocks git archive command if `~/.ssh` ssh dir is not found
+    if (!isSshDirFound()) {
+        const spyGitArchive = {
+            cmd: `git archive --remote=ssh://git@${domain}/${projectPath}.git ${ref} ${file} > /dev/null`,
+            rejection: {
+                stderr: `fatal: sent error to the client: git upload-archive: archiver died with error
+remote: fatal: pathspec 'templates/potato.yml' did not match any files
+remote: git upload-archive: archiver died with error`,
+            },
+        };
+        initBashSpyReject([spyGitArchive]);
+    }
+    const fileExist = await Utils.remoteFileExist(file, ref, domain, projectPath, "git");
+    expect(fileExist).toBe(false);
+});
diff --git a/tests/utils.ts b/tests/utils.ts
new file mode 100644
index 0000000..fcb7dc3
--- /dev/null
+++ b/tests/utils.ts
@@ -0,0 +1,10 @@
+import * as fs from "fs-extra";
+
+export function isSshDirFound () {
+    try {
+        fs.statSync(`${process.env.HOME}/.ssh`);
+        return true;
+    } catch {
+        return false;
+    }
+}