From 3dbf0f2e4134b9a1e39ce0df2e2a735c281a69c5 Mon Sep 17 00:00:00 2001 From: Barrett <81570928+btlghrants@users.noreply.github.com> Date: Wed, 15 Jan 2025 15:12:02 -0600 Subject: [PATCH] test: add cli test to validate multiple manifest generation paths result in comparable output (#1642) ## Description Adds a new `npm run test:int` (int == "integration") script to invoke a new style of test -- integration tests -- where "integration" means something like "wider in scope than unit tests" but "not requiring a full K8s cluster" (general idea is to have a nice place for non-k8s-required tests to land (e.g. client-side `pepr` CLI commands, like init / build). ## Related Issue Fixes #1654 ## Type of change - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [x] Other (security config, docs update, etc) ## Checklist before merging - [x] Unit, [Journey](https://github.com/defenseunicorns/pepr/tree/main/journey), [E2E Tests](https://github.com/defenseunicorns/pepr-excellent-examples), [docs](https://github.com/defenseunicorns/pepr/tree/main/docs), [adr](https://github.com/defenseunicorns/pepr/tree/main/adr) added or updated as needed - [x] [Contributor Guide Steps](https://docs.pepr.dev/main/contribute/#submitting-a-pull-request) followed --------- Co-authored-by: Sam Mayer --- .eslintrc.json | 11 +- .github/workflows/cli-tests.yml | 127 -------------- .github/workflows/node.js.yml | 19 +++ integration/.gitignore | 2 + integration/cli/build.defaults.test.ts | 52 ++++++ integration/cli/build.help.test.ts | 31 ++++ integration/cli/build.noembed.test.ts | 88 ++++++++++ integration/cli/build.nonconflict.test.ts | 185 +++++++++++++++++++++ integration/cli/build.registryinfo.test.ts | 99 +++++++++++ integration/cli/build.version.test.ts | 59 +++++++ integration/cli/init.test.ts | 60 +++++++ integration/helpers/cmd.test.ts | 56 +++++++ integration/helpers/cmd.ts | 74 +++++++++ integration/helpers/pepr.test.ts | 65 ++++++++ integration/helpers/pepr.ts | 37 +++++ integration/helpers/resource.test.ts | 126 ++++++++++++++ integration/helpers/resource.ts | 58 +++++++ integration/helpers/time.test.ts | 64 +++++++ integration/helpers/time.ts | 85 ++++++++++ integration/helpers/workdir.test.ts | 127 ++++++++++++++ integration/helpers/workdir.ts | 43 +++++ integration/prep.sh | 12 ++ package.json | 3 + tsconfig.eslint.json | 4 + 24 files changed, 1358 insertions(+), 129 deletions(-) delete mode 100644 .github/workflows/cli-tests.yml create mode 100644 integration/.gitignore create mode 100644 integration/cli/build.defaults.test.ts create mode 100644 integration/cli/build.help.test.ts create mode 100644 integration/cli/build.noembed.test.ts create mode 100644 integration/cli/build.nonconflict.test.ts create mode 100644 integration/cli/build.registryinfo.test.ts create mode 100644 integration/cli/build.version.test.ts create mode 100644 integration/cli/init.test.ts create mode 100644 integration/helpers/cmd.test.ts create mode 100644 integration/helpers/cmd.ts create mode 100644 integration/helpers/pepr.test.ts create mode 100644 integration/helpers/pepr.ts create mode 100644 integration/helpers/resource.test.ts create mode 100644 integration/helpers/resource.ts create mode 100644 integration/helpers/time.test.ts create mode 100644 integration/helpers/time.ts create mode 100644 integration/helpers/workdir.test.ts create mode 100644 integration/helpers/workdir.ts create mode 100755 integration/prep.sh create mode 100644 tsconfig.eslint.json diff --git a/.eslintrc.json b/.eslintrc.json index 80f386771..f56f1546f 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -6,7 +6,7 @@ "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"], "parser": "@typescript-eslint/parser", "parserOptions": { - "project": ["tsconfig.json"], + "project": ["tsconfig.eslint.json"], "ecmaVersion": 2022 }, "rules": { @@ -22,6 +22,12 @@ "no-invalid-this": "warn" }, "overrides": [ + { + "files": ["*.test.ts"], + "rules": { + "max-nested-callbacks": ["warn", { "max": 8 }] + } + }, { "files": ["*.ts"], "excludedFiles": "*.test.ts", @@ -39,7 +45,8 @@ "pepr-test-module", "build.mjs", "journey", - "__mocks__" + "__mocks__", + "integration/testroot" ], "root": true } diff --git a/.github/workflows/cli-tests.yml b/.github/workflows/cli-tests.yml deleted file mode 100644 index 93670afbf..000000000 --- a/.github/workflows/cli-tests.yml +++ /dev/null @@ -1,127 +0,0 @@ -name: CLI tests - -permissions: read-all -on: - push: - branches: ["main"] - pull_request: - branches: ["main"] - merge_group: - paths-ignore: - - "**.md" - - "**.yml" - - "**.yaml" - - "**.toml" - - "docs/**" - - "hack/**" - - "journey/**" - - "LICENSE" - - "CODEOWNERS" - - "Dockerfile" - - "Dockerfile.controller" - - "Dockerfile.kfc" - -jobs: - pepr-build: - name: controller image - runs-on: ubuntu-latest - steps: - - name: Harden Runner - uses: step-security/harden-runner@c95a14d0e5bab51a9f56296a4eb0e416910cd350 # v2.10.3 - with: - egress-policy: audit - - - name: clone pepr - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - repository: defenseunicorns/pepr - path: pepr - - - name: "set env: PEPR" - run: echo "PEPR=${GITHUB_WORKSPACE}/pepr" >> "$GITHUB_ENV" - - - name: setup node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 - with: - node-version: 20 - cache-dependency-path: pepr - - - name: Prep for CLI tests - run: | - cd "$PEPR" - npm ci - - - name: pepr init - displays the help menu - run: | - cd "$PEPR" - npm run gen-data-json - npx ts-node src/cli.ts init --help > result.log - grep " \-\-name" result.log - grep " \-\-description" result.log - grep " \-\-errorBehavior" result.log - grep " \-\-confirm" result.log - - - name: pepr init - creates a module with input from flags - run: | - cd "$PEPR" - npm run gen-data-json - npx ts-node src/cli.ts init \ - --name my-flagged-module \ - --description "Set by flag" \ - --errorBehavior reject \ - --confirm \ - --skip-post-init - RESULT_FILE="my-flagged-module/package.json" - grep "my-flagged-module" $RESULT_FILE - grep "Set by flag" $RESULT_FILE - grep "reject" $RESULT_FILE - - - name: pepr init - creates a module with input from stdin - run: | - cd "$PEPR" - npm run gen-data-json - echo "stdin-module" | npx ts-node src/cli.ts init \ - --description "Set by flag" \ - --errorBehavior reject \ - --confirm \ - --skip-post-init - RESULT_FILE="stdin-module/package.json" - grep "stdin-module" $RESULT_FILE - grep "Set by flag" $RESULT_FILE - grep "reject" $RESULT_FILE - - - name: pepr build --custom-image - generates Kubernetes manifest with a custom image - run: | - cd "${GITHUB_WORKSPACE}" - npx pepr@latest init \ - --name=custom-image \ - --description="custom image test" \ - --errorBehavior=reject \ - --skip-post-init \ - --confirm - cd custom-image - npm i - npx ts-node ../pepr/src/cli.ts build --custom-image pepr:dev - UUID=$(cat package.json | jq -r .pepr.uuid) - count=$(cat dist/$UUID-chart/values.yaml | egrep "image: 'pepr:dev'" | wc -l) - if [ "$count" -eq 2 ]; then - echo "✅ Generated correct image for helm values." - else - echo "❌ Generated incorrect image for helm values." - exit 1 - fi - count=$(cat dist/pepr-module-$UUID.yaml | egrep "pepr:dev" | wc -l) - if [ "$count" -eq 2 ]; then - echo "✅ Generated correct image for Pepr manifest." - else - echo "❌ Generated incorrect image for Pepr manifest." - exit 1 - fi - count=$(cat dist/zarf.yaml | egrep "pepr:dev" | wc -l) - if [ "$count" -eq 1 ]; then - echo "✅ Generated correct image for Zarf manifest." - else - echo "❌ Generated incorrect image for Zarf manifest." - exit 1 - fi - diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index f22aced21..a1a4bb918 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -44,6 +44,25 @@ jobs: uses: codecov/codecov-action@1e68e06f1dbfde0e4cefc87efeba9e4643565303 # v5.1.2 env: CODECOV_TOKEN: ${{ secrets.CODECOV_ORG_TOKEN }} + + integration: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Use Node.js 22 + uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + with: + node-version: 22 + cache: "npm" + - name: Setup Helm + uses: azure/setup-helm@fe7b79cd5ee1e45176fcad797de68ecaf3ca4814 # v4.2.0 + with: + version: v3.3.4 + + - run: npm ci + - run: npm run test:integration + journey: runs-on: ubuntu-latest steps: diff --git a/integration/.gitignore b/integration/.gitignore new file mode 100644 index 000000000..66dd4c91a --- /dev/null +++ b/integration/.gitignore @@ -0,0 +1,2 @@ +testroot/ +.npm/ \ No newline at end of file diff --git a/integration/cli/build.defaults.test.ts b/integration/cli/build.defaults.test.ts new file mode 100644 index 000000000..7d6175d2d --- /dev/null +++ b/integration/cli/build.defaults.test.ts @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2023-Present The Pepr Authors + +import { beforeAll, describe, expect, it } from "@jest/globals"; +import * as path from "node:path"; +import * as fs from "node:fs/promises"; +import { Workdir } from "../helpers/workdir"; +import * as time from "../helpers/time"; +import * as pepr from "../helpers/pepr"; + +const FILE = path.basename(__filename); +const HERE = __dirname; + +describe("build", () => { + const workdir = new Workdir(`${FILE}`, `${HERE}/../testroot/cli`); + + beforeAll(async () => { + await workdir.recreate(); + }); + + describe("builds a module", () => { + const id = FILE.split(".").at(1); + const testModule = `${workdir.path()}/${id}`; + + beforeAll(async () => { + await fs.rm(testModule, { recursive: true, force: true }); + const argz = [ + `--name ${id}`, + `--description ${id}`, + `--errorBehavior reject`, + "--confirm", + "--skip-post-init", + ].join(" "); + await pepr.cli(workdir.path(), { cmd: `pepr init ${argz}` }); + await pepr.tgzifyModule(testModule); + await pepr.cli(testModule, { cmd: `npm install` }); + }, time.toMs("2m")); + + describe("using default build options", () => { + it( + "builds", + async () => { + const build = await pepr.cli(testModule, { cmd: `pepr build` }); + expect(build.exitcode).toBe(0); + expect(build.stderr.join("").trim()).toBe(""); + expect(build.stdout.join("").trim()).toContain("K8s resource for the module saved"); + }, + time.toMs("1m"), + ); + }); + }); +}); diff --git a/integration/cli/build.help.test.ts b/integration/cli/build.help.test.ts new file mode 100644 index 000000000..2824e24c1 --- /dev/null +++ b/integration/cli/build.help.test.ts @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2023-Present The Pepr Authors + +import { beforeAll, describe, expect, it } from "@jest/globals"; +import * as path from "node:path"; +import { Workdir } from "../helpers/workdir"; +import * as pepr from "../helpers/pepr"; +import * as time from "../helpers/time"; + +const FILE = path.basename(__filename); +const HERE = __dirname; + +describe("build", () => { + const workdir = new Workdir(`${FILE}`, `${HERE}/../testroot/cli`); + + beforeAll(async () => { + await workdir.recreate(); + }); + + it( + "gives command line help", + async () => { + const argz = "--help"; + const result = await pepr.cli(workdir.path(), { cmd: `pepr build ${argz}` }); + expect(result.exitcode).toBe(0); + expect(result.stderr.join("").trim()).toBe(""); + expect(result.stdout.at(0)).toMatch("Usage: pepr build"); + }, + time.toMs("30s"), + ); +}); diff --git a/integration/cli/build.noembed.test.ts b/integration/cli/build.noembed.test.ts new file mode 100644 index 000000000..15b51ca4c --- /dev/null +++ b/integration/cli/build.noembed.test.ts @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2023-Present The Pepr Authors + +import { beforeAll, describe, expect, it } from "@jest/globals"; +import * as path from "node:path"; +import * as fs from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { Workdir } from "../helpers/workdir"; +import * as time from "../helpers/time"; +import * as pepr from "../helpers/pepr"; +import * as resource from "../helpers/resource"; + +const FILE = path.basename(__filename); +const HERE = __dirname; + +describe("build", () => { + const workdir = new Workdir(`${FILE}`, `${HERE}/../testroot/cli`); + + beforeAll(async () => { + await workdir.recreate(); + }); + + describe("builds a module", () => { + const id = FILE.split(".").at(1); + const testModule = `${workdir.path()}/${id}`; + + beforeAll(async () => { + await fs.rm(testModule, { recursive: true, force: true }); + const argz = [ + `--name ${id}`, + `--description ${id}`, + `--errorBehavior reject`, + "--confirm", + "--skip-post-init", + ].join(" "); + await pepr.cli(workdir.path(), { cmd: `pepr init ${argz}` }); + await pepr.tgzifyModule(testModule); + await pepr.cli(testModule, { cmd: `npm install` }); + }, time.toMs("2m")); + + describe("for use as a library", () => { + let packageJson; + let uuid: string; + + it( + "builds", + async () => { + const argz = [`--no-embed`].join(" "); + const build = await pepr.cli(testModule, { cmd: `pepr build ${argz}` }); + expect(build.exitcode).toBe(0); + expect(build.stderr.join("").trim()).toContain("Error: Cannot find module"); + expect(build.stdout.join("").trim()).toContain(""); + + packageJson = await resource.fromFile(`${testModule}/package.json`); + uuid = packageJson.pepr.uuid; + }, + time.toMs("1m"), + ); + + it( + "outputs appropriate configuration", + async () => { + const missing = [ + `${testModule}/dist/pepr-${uuid}.js`, + `${testModule}/dist/pepr-${uuid}.js.map`, + `${testModule}/dist/pepr-${uuid}.js.LEGAL.txt`, + `${testModule}/dist/pepr-module-${uuid}.yaml`, + `${testModule}/dist/zarf.yaml`, + `${testModule}/dist/${uuid}-chart/`, + ]; + for (const path of missing) { + expect(existsSync(path)).toBe(false); + } + + const found = [ + `${testModule}/dist/pepr.js`, + `${testModule}/dist/pepr.js.map`, + `${testModule}/dist/pepr.js.LEGAL.txt`, + ]; + for (const path of found) { + expect(existsSync(path)).toBe(true); + } + }, + time.toMs("1m"), + ); + }); + }); +}); diff --git a/integration/cli/build.nonconflict.test.ts b/integration/cli/build.nonconflict.test.ts new file mode 100644 index 000000000..6be2dde9a --- /dev/null +++ b/integration/cli/build.nonconflict.test.ts @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2023-Present The Pepr Authors + +import { beforeAll, describe, expect, it } from "@jest/globals"; +import * as path from "node:path"; +import * as fs from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { kind } from "kubernetes-fluent-client"; +import { Workdir } from "../helpers/workdir"; +import * as time from "../helpers/time"; +import * as pepr from "../helpers/pepr"; +import * as resource from "../helpers/resource"; + +const FILE = path.basename(__filename); +const HERE = __dirname; + +describe("build", () => { + const workdir = new Workdir(`${FILE}`, `${HERE}/../testroot/cli`); + + beforeAll(async () => { + await workdir.recreate(); + }); + + describe("builds a module", () => { + const id = FILE.split(".").at(1); + const testModule = `${workdir.path()}/${id}`; + + beforeAll(async () => { + await fs.rm(testModule, { recursive: true, force: true }); + const argz = [ + `--name ${id}`, + `--description ${id}`, + `--errorBehavior reject`, + "--confirm", + "--skip-post-init", + ].join(" "); + await pepr.cli(workdir.path(), { cmd: `pepr init ${argz}` }); + await pepr.tgzifyModule(testModule); + await pepr.cli(testModule, { cmd: `npm install` }); + }, time.toMs("2m")); + + describe("using non-conflicting build override options", () => { + const entryPoint = "pepr2.ts"; + const customImage = "pepr:override"; + const outputDir = `${testModule}/out`; + const timeout = 11; + const withPullSecret = "shhh"; + const zarf = "chart"; + + let packageJson; + let uuid: string; + + it( + "builds", + async () => { + await fs.rename(`${testModule}/pepr.ts`, `${testModule}/${entryPoint}`); + + const argz = [ + `--entry-point ${entryPoint}`, + `--custom-image ${customImage}`, + `--output-dir ${outputDir}`, + `--timeout ${timeout}`, + `--withPullSecret ${withPullSecret}`, + `--zarf ${zarf}`, + ].join(" "); + const build = await pepr.cli(testModule, { cmd: `pepr build ${argz}` }); + + expect(build.exitcode).toBe(0); + expect(build.stderr.join("").trim()).toBe(""); + expect(build.stdout.join("").trim()).toContain("K8s resource for the module saved"); + + packageJson = await resource.fromFile(`${testModule}/package.json`); + uuid = packageJson.pepr.uuid; + }, + time.toMs("1m"), + ); + + const getDepConImg = (deploy: kind.Deployment, container: string): string => { + return deploy! + .spec!.template!.spec!.containers.filter(cont => cont.name === container) + .at(0)!.image!; + }; + + it("--entry-point, works", async () => { + // build would fail if given entrypoint didn't exist, so... no-op test! + }); + + it("--output-dir, works", async () => { + const dist = `${testModule}/dist`; + expect(existsSync(dist)).toBe(false); + + expect(existsSync(outputDir)).toBe(true); + }); + + it("--custom-image, works", async () => { + const moduleYaml = await resource.fromFile(`${outputDir}/pepr-module-${uuid}.yaml`); + { + const admission = resource.select(moduleYaml, kind.Deployment, `pepr-${uuid}`); + const admissionImage = getDepConImg(admission, "server"); + expect(admissionImage).toBe(customImage); + + const watcher = resource.select(moduleYaml, kind.Deployment, `pepr-${uuid}-watcher`); + const watcherImage = getDepConImg(watcher, "watcher"); + expect(watcherImage).toBe(customImage); + } + + const zarfYaml = await resource.fromFile(`${outputDir}/zarf.yaml`); + { + const componentImage = zarfYaml.components.at(0).images.at(0); + expect(componentImage).toBe(customImage); + } + + const valuesYaml = await resource.fromFile(`${outputDir}/${uuid}-chart/values.yaml`); + { + const admissionImage = valuesYaml.admission.image; + expect(admissionImage).toBe(customImage); + + const watcherImage = valuesYaml.watcher.image; + expect(watcherImage).toBe(customImage); + } + }); + + it("--timeout, works", async () => { + const moduleYaml = await resource.fromFile(`${outputDir}/pepr-module-${uuid}.yaml`); + { + const mwc = resource.select( + moduleYaml, + kind.MutatingWebhookConfiguration, + `pepr-${uuid}`, + ); + const webhook = mwc + .webhooks!.filter(hook => hook.name === `pepr-${uuid}.pepr.dev`) + .at(0)!; + expect(webhook.timeoutSeconds).toBe(timeout); + } + { + const mwc = resource.select( + moduleYaml, + kind.ValidatingWebhookConfiguration, + `pepr-${uuid}`, + ); + const webhook = mwc + .webhooks!.filter(hook => hook.name === `pepr-${uuid}.pepr.dev`) + .at(0)!; + expect(webhook.timeoutSeconds).toBe(timeout); + } + + const valuesYaml = await resource.fromFile(`${outputDir}/${uuid}-chart/values.yaml`); + expect(valuesYaml.admission.webhookTimeout).toBe(timeout); + }); + + it("--withPullSecret, works", async () => { + const getDepImgPull = (deploy: kind.Deployment): string[] => { + return deploy!.spec!.template!.spec!.imagePullSecrets!.map( + imagePullSecret => imagePullSecret.name!, + ); + }; + + const moduleYaml = await resource.fromFile(`${outputDir}/pepr-module-${uuid}.yaml`); + const admission = resource.select(moduleYaml, kind.Deployment, `pepr-${uuid}`); + const admissionSecrets = getDepImgPull(admission); + expect(admissionSecrets).toEqual([withPullSecret]); + + const watcher = resource.select(moduleYaml, kind.Deployment, `pepr-${uuid}-watcher`); + const watcherSecrets = getDepImgPull(watcher); + expect(watcherSecrets).toEqual([withPullSecret]); + }); + + it("--zarf, works", async () => { + const chart = { + name: "module", + namespace: "pepr-system", + version: "0.0.1", + localPath: `${uuid}-chart`, + }; + + const zarfYaml = await resource.fromFile(`${outputDir}/zarf.yaml`); + const component = zarfYaml.components + .filter((component: { name: string }) => component.name === "module") + .at(0); + expect(component.charts).toContainEqual(chart); + }); + }); + }); +}); diff --git a/integration/cli/build.registryinfo.test.ts b/integration/cli/build.registryinfo.test.ts new file mode 100644 index 000000000..9c1777b18 --- /dev/null +++ b/integration/cli/build.registryinfo.test.ts @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2023-Present The Pepr Authors + +import { beforeAll, describe, expect, it } from "@jest/globals"; +import * as path from "node:path"; +import * as fs from "node:fs/promises"; +import { kind } from "kubernetes-fluent-client"; +import { Workdir } from "../helpers/workdir"; +import * as time from "../helpers/time"; +import * as pepr from "../helpers/pepr"; +import * as resource from "../helpers/resource"; + +const FILE = path.basename(__filename); +const HERE = __dirname; + +describe("build", () => { + const workdir = new Workdir(`${FILE}`, `${HERE}/../testroot/cli`); + + beforeAll(async () => { + await workdir.recreate(); + }); + + describe("builds a module", () => { + const id = FILE.split(".").at(1); + const testModule = `${workdir.path()}/${id}`; + + let packageJson; + let uuid: string; + + beforeAll(async () => { + await fs.rm(testModule, { recursive: true, force: true }); + const argz = [ + `--name ${id}`, + `--description ${id}`, + `--errorBehavior reject`, + "--confirm", + "--skip-post-init", + ].join(" "); + await pepr.cli(workdir.path(), { cmd: `pepr init ${argz}` }); + await pepr.tgzifyModule(testModule); + await pepr.cli(testModule, { cmd: `npm install` }); + }, time.toMs("2m")); + + describe("using a custom registry", () => { + const registryInfo = "registry.io/username"; + + const getDepConImg = (deploy: kind.Deployment, container: string): string => { + return deploy! + .spec!.template!.spec!.containers.filter(cont => cont.name === container) + .at(0)!.image!; + }; + + it( + "builds", + async () => { + const argz = [`--registry-info ${registryInfo}`].join(" "); + const build = await pepr.cli(testModule, { cmd: `pepr build ${argz}` }); + expect(build.exitcode).toBe(0); + expect(build.stderr.join("").trim()).toBe(""); + expect(build.stdout.join("").trim()).toContain("K8s resource for the module saved"); + + packageJson = await resource.fromFile(`${testModule}/package.json`); + uuid = packageJson.pepr.uuid; + }, + time.toMs("1m"), + ); + + it("outputs appropriate configuration", async () => { + const image = `${registryInfo}/custom-pepr-controller:0.0.0-development`; + + { + const moduleYaml = await resource.fromFile(`${testModule}/dist/pepr-module-${uuid}.yaml`); + const admission = resource.select(moduleYaml, kind.Deployment, `pepr-${uuid}`); + const admissionImage = getDepConImg(admission, "server"); + expect(admissionImage).toBe(image); + + const watcher = resource.select(moduleYaml, kind.Deployment, `pepr-${uuid}-watcher`); + const watcherImage = getDepConImg(watcher, "watcher"); + expect(watcherImage).toBe(image); + } + { + const zarfYaml = await resource.fromFile(`${testModule}/dist/zarf.yaml`); + const componentImage = zarfYaml.components.at(0).images.at(0); + expect(componentImage).toBe(image); + } + { + const valuesYaml = await resource.fromFile( + `${testModule}/dist/${uuid}-chart/values.yaml`, + ); + const admissionImage = valuesYaml.admission.image; + expect(admissionImage).toBe(image); + + const watcherImage = valuesYaml.watcher.image; + expect(watcherImage).toBe(image); + } + }); + }); + }); +}); diff --git a/integration/cli/build.version.test.ts b/integration/cli/build.version.test.ts new file mode 100644 index 000000000..a63715e7b --- /dev/null +++ b/integration/cli/build.version.test.ts @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2023-Present The Pepr Authors + +import { beforeAll, describe, expect, it } from "@jest/globals"; +import * as path from "node:path"; +import * as fs from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { Workdir } from "../helpers/workdir"; +import * as time from "../helpers/time"; +import * as pepr from "../helpers/pepr"; + +const FILE = path.basename(__filename); +const HERE = __dirname; + +describe("build", () => { + const workdir = new Workdir(`${FILE}`, `${HERE}/../testroot/cli`); + + beforeAll(async () => { + await workdir.recreate(); + }); + + describe("builds a module", () => { + const id = FILE.split(".").at(1); + const testModule = `${workdir.path()}/${id}`; + + beforeAll(async () => { + await fs.rm(testModule, { recursive: true, force: true }); + const argz = [ + `--name ${id}`, + `--description ${id}`, + `--errorBehavior reject`, + "--confirm", + "--skip-post-init", + ].join(" "); + await pepr.cli(workdir.path(), { cmd: `pepr init ${argz}` }); + await pepr.tgzifyModule(testModule); + await pepr.cli(testModule, { cmd: `npm install` }); + }, time.toMs("2m")); + + describe("using a specified pepr version", () => { + const cliVersion = "0.0.0-development"; + const version = "1.2.3"; + + it( + "builds", + async () => { + const argz = [`--version ${version}`].join(" "); + const build = await pepr.cli(testModule, { cmd: `pepr build ${argz}` }); + expect(build.exitcode).toBe(0); + expect(build.stderr.join("").trim()).toBe(""); + expect(build.stdout.join("").trim()).toContain(cliVersion); + + expect(existsSync(`${testModule}/dist`)).toBe(false); + }, + time.toMs("1m"), + ); + }); + }); +}); diff --git a/integration/cli/init.test.ts b/integration/cli/init.test.ts new file mode 100644 index 000000000..ab80641ff --- /dev/null +++ b/integration/cli/init.test.ts @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2023-Present The Pepr Authors + +import { beforeAll, describe, expect, it } from "@jest/globals"; +import * as path from "node:path"; +import * as fs from "node:fs/promises"; +import { Workdir } from "../helpers/workdir"; +import * as time from "../helpers/time"; +import * as pepr from "../helpers/pepr"; + +const FILE = path.basename(__filename); +const HERE = __dirname; + +describe("init", () => { + const workdir = new Workdir(`${FILE}`, `${HERE}/../testroot/cli`); + + beforeAll(async () => { + await workdir.recreate(); + }); + + it( + "gives command line help", + async () => { + const argz = "--help"; + const result = await pepr.cli(workdir.path(), { cmd: `pepr init ${argz}` }); + expect(result.exitcode).toBe(0); + expect(result.stderr.join("").trim()).toBe(""); + expect(result.stdout.at(0)).toMatch("Usage: pepr init"); + }, + time.toMs("2m"), + ); + + it( + "creates new module using input args", + async () => { + const name = "flags-name"; + const desc = "flags-desc"; + const errorBehavior = "reject"; + const argz = [ + `--name ${name}`, + `--description ${desc}`, + `--errorBehavior ${errorBehavior}`, + "--confirm", + "--skip-post-init", + ].join(" "); + const result = await pepr.cli(workdir.path(), { cmd: `pepr init ${argz}` }); + expect(result.exitcode).toBe(0); + expect(result.stderr.join("").trim()).toBe(""); + expect(result.stdout.join("").trim()).toContain("New Pepr module created"); + + const packageJson = JSON.parse( + await fs.readFile(`${workdir.path()}/${name}/package.json`, { encoding: "utf8" }), + ); + expect(packageJson.name).toBe(name); + expect(packageJson.description).toBe(desc); + expect(packageJson.pepr.onError).toBe(errorBehavior); + }, + time.toMs("2m"), + ); +}); diff --git a/integration/helpers/cmd.test.ts b/integration/helpers/cmd.test.ts new file mode 100644 index 000000000..30fd43ab7 --- /dev/null +++ b/integration/helpers/cmd.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "@jest/globals"; +import { Cmd } from "./cmd"; + +describe("runRaw()", () => { + it("returns stdout", async () => { + const expected = "pong"; + const { stdout } = await new Cmd({ cmd: `echo "${expected}"` }).runRaw(); + expect(stdout.join("")).toBe(expected); + }); + + it("returns exit code", async () => { + const expected = 83; + const { exitcode } = await new Cmd({ cmd: `exit ${expected}` }).runRaw(); + expect(exitcode).toBe(expected); + }); + + it("returns stderr", async () => { + const expected = "oof"; + const { stderr } = await new Cmd({ cmd: `>&2 echo "${expected}" ` }).runRaw(); + expect(stderr.join("")).toBe(expected); + }); + + it("caches last result", async () => { + const cmd = new Cmd({ cmd: `echo "whatever"` }); + const result = await cmd.runRaw(); + expect(result).toBe(cmd.result); + }); + + it("accepts working directory", async () => { + const expected = "/"; + const { stdout } = await new Cmd({ cwd: expected, cmd: `pwd` }).runRaw(); + expect(stdout.join("")).toBe(expected); + }); + + it("accepts env var overrides", async () => { + const [key, val] = ["TESTVAR", "testcontent"]; + const { stdout } = await new Cmd({ env: { [key]: val }, cmd: `echo $${key}` }).runRaw(); + expect(stdout.join("")).toBe(val); + }); +}); + +describe("run()", () => { + it("on success, returns result", async () => { + const expected = "pong"; + const result = await new Cmd({ cmd: `echo "${expected}"` }).run(); + expect(result.stdout.join("")).toBe(expected); + expect(result.stderr.join("")).toBe(""); + expect(result.exitcode).toBe(0); + }); + + it("on failure, throws result", async () => { + const expected = { exitcode: 1, stderr: [], stdout: [] }; + const promise = new Cmd({ cmd: `exit ${expected.exitcode}` }).run(); + return expect(promise).rejects.toEqual(expected); + }); +}); diff --git a/integration/helpers/cmd.ts b/integration/helpers/cmd.ts new file mode 100644 index 000000000..2443ef066 --- /dev/null +++ b/integration/helpers/cmd.ts @@ -0,0 +1,74 @@ +import { spawn } from "child_process"; + +export interface Spec { + cmd: string; + stdin?: string[]; + cwd?: string; + env?: object; // object containing key-value pairs +} + +export interface Result { + stdout: string[]; + stderr: string[]; + exitcode: number; +} + +export class Cmd { + result?: Result; + cmd: string; + stdin: string[]; + cwd: string; + env: object; + + constructor(spec: Spec) { + this.cmd = spec.cmd; + this.stdin = spec.stdin || []; + this.cwd = spec.cwd || process.cwd(); + this.env = spec.env ? { ...process.env, ...spec.env } : process.env; + } + + runRaw(): Promise { + return new Promise((resolve, reject) => { + const proc = spawn(this.cmd, [], { + shell: true, + cwd: this.cwd, + env: this.env as NodeJS.ProcessEnv, + }); + + this.stdin.forEach(line => proc.stdin.write(`${line}\n`)); + proc.stdin.end(); + + let bufout: Buffer = Buffer.from(""); + proc.stdout.on("data", buf => { + bufout = Buffer.concat([bufout, buf]); + }); + + let buferr: Buffer = Buffer.from(""); + proc.stderr.on("data", buf => { + buferr = Buffer.concat([buferr, buf]); + }); + + proc.on("close", exitcode => { + const stdout = bufout.toString("utf8") === "" ? [] : bufout.toString("utf8").split(/[\r\n]+/); + + const stderr = buferr.toString("utf8") === "" ? [] : buferr.toString("utf8").split(/[\r\n]+/); + + this.result = { stdout, stderr, exitcode: exitcode || 0 }; + resolve(this.result); + }); + + proc.on("error", err => { + reject(err); + }); + }); + } + + run(): Promise { + return this.runRaw().then(result => { + if (result.exitcode > 0) { + throw result; + } + return result; + }); + } +} diff --git a/integration/helpers/pepr.test.ts b/integration/helpers/pepr.test.ts new file mode 100644 index 000000000..b8afb42a7 --- /dev/null +++ b/integration/helpers/pepr.test.ts @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2023-Present The Pepr Authors + +import { beforeAll, describe, expect, it } from "@jest/globals"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { Workdir } from "./workdir"; +import * as time from "./time"; +import * as sut from "./pepr"; + +const FILE = path.basename(__filename); +const HERE = __dirname; + +describe("pepr", () => { + describe("projectRoot", () => { + it("returns pepr project root directory", async () => { + const expected = path.resolve(HERE, "../.."); + const actual = await sut.projectRoot(); + expect(actual).toBe(expected); + }); + }); + + describe("tgzifyModule", () => { + const workdir = new Workdir(`${FILE}-tgzifyModule`, `${HERE}/../testroot/helpers`); + + beforeAll(async () => await workdir.recreate()); + + it("converts module source to install pepr from .tgz", async () => { + const modulePath = `${workdir.path()}/module`; + const packagePath = `${modulePath}/package.json`; + let packageJson = { + dependencies: { + pepr: "0.0.0-development", + }, + }; + await fs.mkdir(modulePath, { recursive: true }); + await fs.writeFile(packagePath, JSON.stringify(packageJson, null, 2)); + + await sut.tgzifyModule(modulePath); + + packageJson = JSON.parse(await fs.readFile(packagePath, { encoding: "utf8" })); + const root = await sut.projectRoot(); + expect(packageJson.dependencies.pepr).toBe(`file://${root}/pepr-0.0.0-development.tgz`); + }); + }); + + describe("cli", () => { + const workdir = new Workdir(`${FILE}-cli`, `${HERE}/../testroot/helpers`); + + beforeAll(async () => { + await workdir.recreate(); + }); + + it( + "can invoke pepr command via .tgz", + async () => { + const result = await sut.cli(workdir.path(), { cmd: "pepr --version" }); + expect(result.exitcode).toBe(0); + expect(result.stderr.join("").trim()).toBe(""); + expect(result.stdout.join("").trim()).toBe("0.0.0-development"); + }, + time.toMs("2m"), + ); + }); +}); diff --git a/integration/helpers/pepr.ts b/integration/helpers/pepr.ts new file mode 100644 index 000000000..3d57b3acc --- /dev/null +++ b/integration/helpers/pepr.ts @@ -0,0 +1,37 @@ +import { resolve } from "node:path"; +import { readFile, writeFile } from "node:fs/promises"; +import { Spec, Cmd, Result } from "./cmd"; +import { clone } from "ramda"; + +const HERE = __dirname; + +export async function projectRoot(): Promise { + const cmd = new Cmd({ cmd: `npm root`, cwd: HERE }); + const res = await cmd.run(); + const npmroot = res.stdout.join("").trim(); + return resolve(npmroot, ".."); +} + +export async function tgzifyModule(modulePath: string): Promise { + const packagePath = `${modulePath}/package.json`; + const packageJson = JSON.parse(await readFile(packagePath, { encoding: "utf8" })); + + const root = await projectRoot(); + packageJson.dependencies.pepr = `file://${root}/pepr-0.0.0-development.tgz`; + await writeFile(packagePath, JSON.stringify(packageJson, null, 2)); +} + +export async function cli(workdir: string, spec: Spec): Promise { + const root = await projectRoot(); + const tgz = `file://${root}/pepr-0.0.0-development.tgz`; + + const _spec = clone(spec); + _spec.cwd = workdir; + + const _cmd = _spec.cmd.trim().replace(/^pepr /, `npx --yes ${tgz} `); + _spec.cmd = _cmd; + _spec.env = { ..._spec.env, NPM_CONFIG_CACHE: `${root}/integration/testroot/.npm` }; + + const cmd = new Cmd(_spec); + return await cmd.runRaw(); +} diff --git a/integration/helpers/resource.test.ts b/integration/helpers/resource.test.ts new file mode 100644 index 000000000..53918f68d --- /dev/null +++ b/integration/helpers/resource.test.ts @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2023-Present The Pepr Authors + +import { beforeAll, describe, expect, it } from "@jest/globals"; +import * as path from "node:path"; +import * as fs from "node:fs/promises"; +import { Workdir } from "../helpers/workdir"; +import { heredoc } from "../../src/sdk/heredoc"; +import * as sut from "./resource"; +import { kind } from "kubernetes-fluent-client"; + +const FILE = path.basename(__filename); +const HERE = __dirname; +const workdir = new Workdir(`${FILE}`, `${HERE}/../testroot/helpers`); + +beforeAll(async () => { + await workdir.recreate(); +}); + +describe("fromFile", () => { + it("can load one resource from .json file", async () => { + const oneJson = `${workdir.path()}/one.json`; + await fs.writeFile( + oneJson, + heredoc` + { + "one": "json" + } + `, + ); + const result = await sut.fromFile(oneJson); + expect(result.one).toBe("json"); + }); + + it("can load one resource from .yaml file", async () => { + const oneYaml = `${workdir.path()}/one.yaml`; + await fs.writeFile( + oneYaml, + heredoc` + --- + one: yaml + `, + ); + const result = await sut.fromFile(oneYaml); + expect(result.one).toBe("yaml"); + }); +}); + +describe("fromFile", () => { + it("can load many resources from .json file", async () => { + const manyJson = `${workdir.path()}/many.json`; + await fs.writeFile( + manyJson, + heredoc` + [ + { + "one": "json" + }, + { + "two": "json" + }, + { + "three": "json" + } + ] + `, + ); + const result = await sut.fromFile(manyJson); + expect(result.at(0).one).toBe("json"); + expect(result.at(1).two).toBe("json"); + expect(result.at(2).three).toBe("json"); + }); + + it("can load many resources from .yaml file", async () => { + const manyYaml = `${workdir.path()}/many.yaml`; + await fs.writeFile( + manyYaml, + heredoc` + --- + one: yaml + --- + two: yaml + --- + three: yaml + `, + ); + const result = await sut.fromFile(manyYaml); + expect(result.at(0).one).toBe("yaml"); + expect(result.at(1).two).toBe("yaml"); + expect(result.at(2).three).toBe("yaml"); + }); +}); + +describe("select", () => { + it("returns typed resources, selected from list by name", async () => { + const manyYaml = `${workdir.path()}/select.yaml`; + await fs.writeFile( + manyYaml, + heredoc` + --- + apiVersion: v1 + kind: Secret + metadata: + name: sec + namespace: select + stringData: + top: secret + --- + apiVersion: v1 + kind: ConfigMap + metadata: + name: cm + namespace: select + data: + fake: news + `, + ); + const many = await sut.fromFile(manyYaml); + + const sec = sut.select(many, kind.Secret, "sec"); + const cm = sut.select(many, kind.ConfigMap, "cm"); + + expect(sec.stringData!.top).toBe("secret"); + expect(cm.data!.fake).toBe("news"); + }); +}); diff --git a/integration/helpers/resource.ts b/integration/helpers/resource.ts new file mode 100644 index 000000000..69612f9fc --- /dev/null +++ b/integration/helpers/resource.ts @@ -0,0 +1,58 @@ +import { readFile } from "node:fs/promises"; +import { kind, KubernetesObject } from "kubernetes-fluent-client"; +import { parseAllDocuments } from "yaml"; + +/** + * Read resources from a file and return them as JS objects. + * + * @param path Path to the file (supports JSON (*.json) or YAML (*.yaml)) + * @returns JS object or array of JS objects. + */ +/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ +export async function fromFile(path: string): Promise { + const extension = path.split(".").at(-1); + + let result: object | object[]; + const content = await readFile(path, { encoding: "utf8" }); + + switch (extension) { + case "json": { + const parsed = JSON.parse(content); + result = Array.isArray(parsed) ? parsed : [parsed]; + break; + } + case "yaml": { + const documents = parseAllDocuments(content).map(doc => doc.contents!.toJSON()); + result = documents.length === 1 ? documents[0] : documents; + break; + } + default: + throw new Error(`Unsupported file type ".${extension}"`); + } + + // If the result is an array with one element, return the single element + return Array.isArray(result) && result.length === 1 ? result[0] : result; +} + +/** + * Select & strongly-type resource from array of JS objects + * + * @param list Array of JS objects + * @param asKind Type of object to select (from kubernetes-fluent-client, e.g. kind.Secret) + * @param name Object.metadata.name to select + * @returns Strong-typed resource object + */ +export function select InstanceType>( + list: T[], + asKind: U, + name: string, +): InstanceType { + const kynd = Object.entries(kind) + .filter(f => f[1] === asKind) + .at(0)! + .at(0); + return list + .filter(f => f.kind === kynd) + .filter(f => f!.metadata!.name === name) + .at(0) as InstanceType; +} diff --git a/integration/helpers/time.test.ts b/integration/helpers/time.test.ts new file mode 100644 index 000000000..ccbe8866b --- /dev/null +++ b/integration/helpers/time.test.ts @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2023-Present The Pepr Authors + +import { describe, it, expect } from "@jest/globals"; +import * as sut from "./time"; + +describe("toHuman()", () => { + it.each([ + // simple + [1, "1ms"], + [1000, "1s"], + [60000, "1m"], + [3600000, "1h"], + [86400000, "1d"], + [604800000, "1w"], + [2592000000, "1mo"], + [31536000000, "1y"], + + // combined + [34822861001, "1y1mo1w1d1h1m1s1ms"], + ])("given ms '%s', returns '%s' duration", (ms, human) => { + const result = sut.toHuman(ms); + expect(result).toBe(human); + }); +}); + +describe("toMs()", () => { + it.each([ + // simple + ["1ms", 1], + ["1s", 1000], + ["60s", 60000], + ["1m", 60000], + ["60m", 3600000], + ["1h", 3600000], + ["24h", 86400000], + ["1d", 86400000], + ["7d", 604800000], + ["1w", 604800000], + ["30d", 2592000000], + ["1mo", 2592000000], + ["365d", 31536000000], + ["1y", 31536000000], + + // weird + ["0001s", 1000], + ["1 s ", 1000], + + // combined + ["1y1mo1w1d1h1m1s1ms", 34822861001], + ["1ms1s1m1h1d1w1mo1y", 34822861001], + ])("given duration '%s', returns '%s' ms", (human, ms) => { + const result = sut.toMs(human); + expect(result).toBe(ms); + }); + + it.each([ + // bad + ["h1m1s", /Unrecognized number .* while parsing/], + ["1z", /Unrecognized unit .* while parsing/], + ])("given duration '%s', throws error matching '%s'", (human, err) => { + expect(() => sut.toMs(human)).toThrow(err); + }); +}); diff --git a/integration/helpers/time.ts b/integration/helpers/time.ts new file mode 100644 index 000000000..d12c78591 --- /dev/null +++ b/integration/helpers/time.ts @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2023-Present The Pepr Authors + +const UNITS: Record = {}; +UNITS.ms = 1; +UNITS.s = UNITS.ms * 1000; +UNITS.m = UNITS.s * 60; +UNITS.h = UNITS.m * 60; +UNITS.d = UNITS.h * 24; +UNITS.w = UNITS.d * 7; +UNITS.mo = UNITS.d * 30; +UNITS.y = UNITS.d * 365; + +export async function nap(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export function toMs(human: string) { + const splits = human.split("").map(str => str.trim()); + + const groups: string[] = []; + splits.forEach(next => { + const tail = groups.at(-1) as string; + const sameKind = (tail: string, next: string) => /\d+/.test(tail) === /\d+/.test(next); + sameKind(tail, next) ? groups.splice(-1, 1, `${tail}${next}`) : groups.push(next); + }); + + const pairs = groups.reduce<[string, string][]>( + (acc, cur, idx, arr) => (idx % 2 === 0 ? [...acc, [arr[idx], arr[idx + 1]]] : acc), + [], + ); + + const parsed = pairs.map<[number, string]>(([num, unit]) => { + const parsedNum = parseInt(num); + if (isNaN(parsedNum)) { + throw `Unrecognized number "${num}" seen while parsing "${human}"`; + } + + const validUnits = Object.keys(UNITS); + if (!validUnits.includes(unit)) { + throw `Unrecognized unit "${unit}" seen while parsing "${human}"`; + } + + return [parsedNum, unit]; + }); + + const milliseconds = parsed.reduce((acc, [num, unit]) => acc + num * UNITS[unit], 0); + + return milliseconds; +} + +function reduceBy(unit: number, ms: number): [number, number] { + let remain = ms; + let result = 0; + while (remain >= unit) { + remain -= unit; + result += 1; + } + return [result, remain]; +} + +export function toHuman(ms: number) { + let [y, mo, w, d, h, m, s] = Array(7).fill(0); + let remain = ms; + + [y, remain] = reduceBy(UNITS.y, remain); + [mo, remain] = reduceBy(UNITS.mo, remain); + [w, remain] = reduceBy(UNITS.w, remain); + [d, remain] = reduceBy(UNITS.d, remain); + [h, remain] = reduceBy(UNITS.h, remain); + [m, remain] = reduceBy(UNITS.m, remain); + [s, remain] = reduceBy(UNITS.s, remain); + + let result = ""; + result = y > 0 ? `${result}${y}y` : result; + result = mo > 0 ? `${result}${mo}mo` : result; + result = w > 0 ? `${result}${w}w` : result; + result = d > 0 ? `${result}${d}d` : result; + result = h > 0 ? `${result}${h}h` : result; + result = m > 0 ? `${result}${m}m` : result; + result = s > 0 ? `${result}${s}s` : result; + result = remain > 0 ? `${result}${remain}ms` : result; + + return result; +} diff --git a/integration/helpers/workdir.test.ts b/integration/helpers/workdir.test.ts new file mode 100644 index 000000000..21dbb36ff --- /dev/null +++ b/integration/helpers/workdir.test.ts @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2023-Present The Pepr Authors + +import { beforeEach, describe, expect, it } from "@jest/globals"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { Workdir } from "../helpers/workdir"; + +describe("Workdir", () => { + const ROOT_DEFAULT = new Workdir("").root; + + describe("path", () => { + it.each([ + ["a", `/tmp`, `/tmp/a`], + ["b", `/tmp/c`, `/tmp/c/b`], + ["d", undefined, `${ROOT_DEFAULT}/d`], + ["e/f", undefined, `${ROOT_DEFAULT}/e/f`], + ])(`leaf "%s" and root "%s" gives "%s"`, async (leaf, root, expected) => { + const sut = new Workdir(leaf, root); + expect(sut.path()).toBe(expected); + }); + }); + + describe("create", () => { + const sut = new Workdir("create"); + + beforeEach(async () => { + await fs.rm(sut.path(), { recursive: true, force: true }); + }); + + it("creating a non-existant workdir succeeds", async () => { + const path = await sut.create(); + + expect(path).toBe(sut.path()); + await fs.access(path); + }); + + it("creating a pre-existing workdir also succeeds (idempotency, yay!)", async () => { + await sut.create(); + const path = await sut.create(); + + expect(path).toBe(sut.path()); + await fs.access(path); + }); + }); + + describe("exists", () => { + const sut = new Workdir("exists"); + + beforeEach(async () => { + await fs.rm(sut.path(), { recursive: true, force: true }); + }); + + it("returns false when workdir doesn't exist", async () => { + const exists = await sut.exists(); + expect(exists).toBe(false); + }); + + it("returns true when workdir does exist", async () => { + await sut.create(); + const exists = await sut.exists(); + expect(exists).toBe(true); + }); + }); + + describe("delete", () => { + const sut = new Workdir("delete"); + + beforeEach(async () => { + await fs.rm(sut.path(), { recursive: true, force: true }); + }); + + it("deleting a pre-existing workdir succeeds", async () => { + await sut.create(); + await sut.delete(); + expect(await sut.exists()).toBe(false); + }); + + it("deleting a non-existant workdir also succeeds (idempotency, yay!)", async () => { + await sut.delete(); + expect(await sut.exists()).toBe(false); + }); + }); + + describe("isEmpty", () => { + const sut = new Workdir("isEmpty"); + + beforeEach(async () => { + await fs.rm(sut.path(), { recursive: true, force: true }); + }); + + it("returns true when workdir is empty", async () => { + await sut.create(); + expect(await sut.isEmpty()).toBe(true); + }); + + it("returns false when workdir has content", async () => { + await sut.create(); + await fs.writeFile(path.join(sut.path(), "file.txt"), "exists"); + expect(await sut.isEmpty()).toBe(false); + }); + }); + + describe("recreate", () => { + const sut = new Workdir("recreate"); + + beforeEach(async () => { + await fs.rm(sut.path(), { recursive: true, force: true }); + }); + + it("recreating a pre-existing workdir succeeds", async () => { + const path = await sut.create(); + const stat = await fs.stat(path, { bigint: true }); + + const repath = await sut.recreate(); + const restat = await fs.stat(path, { bigint: true }); + + expect(path).toBe(repath); + expect(stat.birthtimeNs).toBeLessThan(restat.birthtimeNs); + }); + + it("recreating a non-existant workdir also succeeds (idempotency, yay!)", async () => { + await sut.recreate(); + expect(await sut.exists()).toBe(true); + }); + }); +}); diff --git a/integration/helpers/workdir.ts b/integration/helpers/workdir.ts new file mode 100644 index 000000000..fd3a49944 --- /dev/null +++ b/integration/helpers/workdir.ts @@ -0,0 +1,43 @@ +import * as os from "node:os"; +import * as path from "node:path"; +import * as fs from "node:fs/promises"; +import { existsSync } from "node:fs"; +import * as time from "./time"; + +export class Workdir { + root: string; + leaf: string; + + constructor(leaf: string, root: string = os.tmpdir()) { + this.leaf = leaf; + this.root = path.resolve(root); + } + + path() { + return path.join(this.root, this.leaf); + } + + async create(): Promise { + await fs.mkdir(this.path(), { recursive: true }); + return this.path(); + } + + async exists() { + return existsSync(this.path()); + } + + async isEmpty() { + const contents = await fs.readdir(this.path()); + return contents.length > 0 ? false : true; + } + + async delete(): Promise { + await fs.rm(this.path(), { recursive: true, force: true }); + } + + async recreate(): Promise { + await this.delete(); + await time.nap(100); + return await this.create(); + } +} diff --git a/integration/prep.sh b/integration/prep.sh new file mode 100755 index 000000000..d5ad2e5d3 --- /dev/null +++ b/integration/prep.sh @@ -0,0 +1,12 @@ +#!/bin/sh +# This script makes a version of the npm cache for local use to avoid installing test artifacts into the global npm cache. +# This isn't an issue in CI where environments are ephemeral, but is useful for local testing. +ME="$(readlink -f "$0")" +HERE="$(dirname "$ME")" +ROOT="$(dirname "$HERE")" + +export NPM_CONFIG_CACHE="${HERE}/testroot/.npm" +mkdir --parents "$NPM_CONFIG_CACHE" + +npm run build +npx --yes file://${ROOT}/pepr-0.0.0-development.tgz \ No newline at end of file diff --git a/package.json b/package.json index e2495604c..2b5c7fc19 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,9 @@ "build:image": "npm run build && docker buildx build --output type=docker --tag pepr:dev .", "test": "npm run test:unit && npm run test:journey", "test:unit": "npm run gen-data-json && jest src --coverage --detectOpenHandles --coverageDirectory=./coverage --testPathIgnorePatterns='cosign.e2e.test.ts'", + "test:integration": "npm run test:integration:prep && npm run test:integration:run", + "test:integration:prep": "./integration/prep.sh", + "test:integration:run": "jest --maxWorkers=4 integration", "test:journey": "npm run test:journey:k3d && npm run build && npm run test:journey:image && npm run test:journey:run", "test:journey:prep": "if [ ! -d ./pepr-upgrade-test ]; then git clone https://github.com/defenseunicorns/pepr-upgrade-test.git ; fi", "test:journey-wasm": "npm run test:journey:k3d && npm run build && npm run test:journey:image && npm run test:journey:run-wasm", diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json new file mode 100644 index 000000000..8e41fae78 --- /dev/null +++ b/tsconfig.eslint.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*.ts", "integration/**/*.ts"] +}