diff --git a/backend-deno/Dockerfile b/backend-deno/Dockerfile new file mode 100644 index 00000000..4402a81a --- /dev/null +++ b/backend-deno/Dockerfile @@ -0,0 +1,13 @@ +FROM denoland/deno:alpine-2.1.4 + +WORKDIR /app + +COPY deno.json deno.lock /app/ + +RUN deno install --frozen + +COPY . . + +RUN deno cache mod.ts + +ENTRYPOINT [ "deno", "run", "-A", "testbed.e2e.ts" ] diff --git a/backend-deno/cucumber/README.md b/backend-deno/cucumber/README.md deleted file mode 100644 index 99071945..00000000 --- a/backend-deno/cucumber/README.md +++ /dev/null @@ -1,47 +0,0 @@ -# Cucumber Testing Implementation - -Since we are using Deno, there is no official Cucumber implementation for Deno. -However, it's pretty easy to parse the Gherkin files and execute the steps. -This folder contains a custom implementation of Cucumber for Deno. - -## How to run the tests - -Run the following command to run the tests: - -```bash -docker compose up -``` - -In the background, this uses `deno test` with the [`test.ts`](test.ts) file as entrypoint. - -## Feature file location - -Since the feature files specify the general behavior of services, they are independent of the implementation (here: -Deno). -Therefore, the feature files are located in the repo's [`/backend-features`](../../backend-features) folder. - -## Setting up VSCode - -1. Open both folders (`/backend-deno` and `/backend-features`) in VSCode in the same workspace. -2. Install recommended extensions. -3. Open the workspace settings and add the following: - -```json -{ - "cucumberautocomplete.steps": ["backend-deno/**/*.ts"], - "cucumberautocomplete.syncfeatures": "backend-features/**/*.feature" -} -``` - -Now, you should have autocompletion for the step definitions and the feature files. - -## Unsupported Gherkin features - -As of right now, the following Gherkin features are not supported: - -- Doc Strings -- Data Tables -- Backgrounds -- Tags -- Scenario Outlines -- Examples diff --git a/backend-deno/cucumber/step-registry.ts b/backend-deno/cucumber/step-registry.ts deleted file mode 100644 index 7270749f..00000000 --- a/backend-deno/cucumber/step-registry.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { resolve } from "@std/path"; - -/** - * A step definition - * - * @see https://cucumber.io/docs/cucumber/step-definitions/?lang=javascript - */ -interface StepDefinition { - /** - * The name of the step definition that gets matched against the step name - */ - name: string; - /** - * Check if the step matches the step definition - * @param step The step name - * @returns `false` if the step does not match, otherwise an array of parameters - */ - matches: (step: string) => string[] | false; - /** - * The action to perform when the step is executed - * @param ctx A context object that can be used to share state between steps - * @param params Parameters extracted from the step name - * @returns potentially a promise that resolves when the step is done - */ - action: ( - ctx: Record, - ...params: string[] - ) => void | Promise; -} - -/** - * A registry of parameters that can be used in step definition names - */ -const paramRegistry: Record = { - "{string}": { - regex: /^"([^"]+)"$/, - }, -}; - -/** - * A registry of step definitions - */ -const stepRegistry: StepDefinition[] = []; - -/** - * Register a step definition to be used in scenarios - * @param name the name of the step definition - * @param action the action to perform when the step is executed - */ -function registerStep(name: string, action: StepDefinition["action"]) { - stepRegistry.push({ - name, - action, - matches: (step: string) => { - let regex = "^" + escapeRegExp(name) + "$"; - for (const param in paramRegistry) { - let paramRegex = paramRegistry[param].regex.source; - if (paramRegex.startsWith("^")) { - paramRegex = paramRegex.slice(1); - } - if (paramRegex.endsWith("$")) { - paramRegex = paramRegex.slice(0, -1); - } - regex = regex.replaceAll(escapeRegExp(param), paramRegex); - } - - const match = step.match(new RegExp(regex)); - - if (match) { - return match.slice(1); - } - - return false; - }, - }); -} - -/** - * Escape special characters in a string to be used in a regular expression - * @param string input - * @returns `input` with all special characters escaped - * - * @see https://stackoverflow.com/a/6969486/9276604 by user coolaj86 (CC BY-SA 4.0) - */ -function escapeRegExp(string: string) { - return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string -} - -/** - * Import all step definitions from a folder - * @param stepsFolderPath the path to the folder containing the step definitions - */ -export async function importStepDefinitions(stepsFolderPath: string) { - const files = Deno.readDirSync(stepsFolderPath); - - for (const file of files) { - const filePath = resolve(stepsFolderPath, file.name); - - if (file.isDirectory || !file.name.endsWith(".ts")) { - continue; - } - - console.debug(`Importing step file: ${filePath}`); - await import(filePath); - } - - console.debug("Steps imported"); -} - -/** - * Retrieve the action to perform when a step is executed - * @param name the name of the step - * @returns the `StepDefinition.action` function if a step definition matches the step name, otherwise `undefined` - */ -export function getStep(name: string): StepDefinition["action"] | undefined { - const step = stepRegistry.find((step) => step.matches(name)); - return step - ? (ctx) => step.action(ctx, ...step.matches(name) as string[]) - : undefined; -} - -/** - * Register a step definition to be used in scenarios - * @param name the name of the step definition. Can contain parameters. - * @param action the action to perform when the step is executed - */ -export function Given(name: string, action: StepDefinition["action"]) { - registerStep(name, action); -} - -/** - * Register a step definition to be used in scenarios - * @param name the name of the step definition. Can contain parameters. - * @param action the action to perform when the step is executed - */ -export function When(name: string, action: StepDefinition["action"]) { - registerStep(name, action); -} - -/** - * Register a step definition to be used in scenarios - * @param name the name of the step definition. Can contain parameters. - * @param action the action to perform when the step is executed - */ -export function Then(name: string, action: StepDefinition["action"]) { - registerStep(name, action); -} diff --git a/backend-deno/cucumber/steps/config.ts b/backend-deno/cucumber/steps/config.ts deleted file mode 100644 index 2aac067a..00000000 --- a/backend-deno/cucumber/steps/config.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { startService } from "jsr:@wuespace/telestion"; -import { Given, Then } from "../step-registry.ts"; -import { assertEquals } from "@std/assert"; - -Given('I have an environment variable named {string} with value {string}', (_ctx, key, value) => { - Deno.env.set(key, value); -}); - -Given('I have the basic service configuration', () => { - Object.keys(Deno.env.toObject()).forEach((key) => { - Deno.env.delete(key); - }); - Deno.env.set('NATS_URL', 'localhost:4222'); - Deno.env.set('DATA_DIR', '/tmp/deno-gherkin'); - Deno.env.set('SERVICE_NAME', 'deno-gherkin'); -}); - -Then('the service should be configured with {string} set to {string}', (ctx, key, shouldBe) => { - const theService = ctx.service as Awaited>; - - const value = theService.config[key]; - - assertEquals((value ?? 'undefined').toString(), shouldBe); -}); - -Given('I have no service configuration', () => { - Object.keys(Deno.env.toObject()).forEach((key) => { - Deno.env.delete(key); - }); -}) diff --git a/backend-deno/cucumber/steps/nats.ts b/backend-deno/cucumber/steps/nats.ts deleted file mode 100644 index 724471d1..00000000 --- a/backend-deno/cucumber/steps/nats.ts +++ /dev/null @@ -1,140 +0,0 @@ -import type { ConnectionOptions, NatsConnection } from "@nats-io/nats-core"; -import { Given, Then } from "../step-registry.ts"; -import { assert, assertEquals } from "@std/assert"; - -/** - * A mock NATS client that can be used to test services that use NATS. - */ -export class NatsMock { - /** - * A list of subscriptions that have been made. - */ - subscriptions: unknown[][] = []; - /** - * Whether the client is connected to NATS. - */ - public isConnected = false; - /** - * Whether the client should fail to connect to NATS. - */ - public isOffline = false; - /** - * Whether the server requires authentication. - */ - public requiresAuth = false; - /** - * A map of usernames to passwords. - */ - public users = new Map(); - - /** - * Create a new mock NATS client. - * @param server The server that the client can connect to. - */ - constructor(public server = "localhost:4222") { - } - - /** - * Register a user with a password. This user can then be used to connect to NATS when `requiresAuth` is true. - * @param username valid username - * @param password valid password for the username - * - * @see {@link requiresAuth} - */ - public registerUser(username: string, password: string) { - this.users.set(username, password); - } - - /** - * A mock NATS connection that can be used to test services that use NATS. - * - * Gets returned by {@link connect} upon successful connection. - * - * Can be used to assert that the service received the correct NATS connection object. - */ - public readonly connection = { - subscribe: (...args: unknown[]) => { - this.subscriptions.push(args); - }, - }; - - connect(options: ConnectionOptions) { - if (this.server !== options.servers) { - return Promise.reject(new Error("Invalid server")); - } - - if (this.isOffline) { - return Promise.reject(new Error("NATS is offline")); - } - - if (this.requiresAuth && (!options.user || !options.pass)) { - return Promise.reject(new Error("NATS requires authentication")); - } - - if (this.requiresAuth && this.users.get(options.user!) !== options.pass) { - return Promise.reject(new Error("Invalid credentials")); - } - - this.isConnected = true; - - return Promise.resolve(this.connection); - } -} - -Given( - "I have a NATS server running on {string}", - (ctx, url) => { - ctx.nats = new NatsMock(url); - }, -); - -Given( - "{string} is a NATS user with password {string}", - (ctx, username, password) => { - if (!ctx.nats) { - throw new Error("No NATS mock available"); - } - - const nats = ctx.nats as NatsMock; - nats.registerUser(username, password); - }, -); - -Given("the NATS server requires authentication", (ctx) => { - if (!ctx.nats) { - throw new Error("No NATS mock available"); - } - - const nats = ctx.nats as NatsMock; - nats.requiresAuth = true; -}); - -Given("the NATS server is offline", (ctx) => { - if (!ctx.nats) { - throw new Error("No NATS mock available"); - } - - const nats = ctx.nats as NatsMock; - nats.isOffline = true; -}); - -Then("the service should connect to NATS", (ctx) => { - const nats = ctx.nats as NatsMock; - assert(nats); - assert(nats.isConnected); -}); - -Then("the service should not connect to NATS", (ctx) => { - const nats = ctx.nats as NatsMock; - assert(nats); - assert(!nats.isConnected); -}); - -Then("the NATS connection API should be available to the service", (ctx) => { - const nats = ctx.nats as NatsMock; - assert(nats); - const service = ctx.service as { nc: unknown }; - assert(service); - assert(service.nc); - assertEquals(service.nc, nats.connection as NatsConnection); -}); diff --git a/backend-deno/cucumber/steps/start.ts b/backend-deno/cucumber/steps/start.ts deleted file mode 100644 index 62742f46..00000000 --- a/backend-deno/cucumber/steps/start.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { startService } from "jsr:@wuespace/telestion"; -import { Then, When } from "../step-registry.ts"; -import { assert, assertRejects } from "@std/assert"; - -When("I start the service", async (ctx) => { - if (!ctx.nats) { - throw new Error("No NATS mock available"); - } - - ctx.service = await startService({ - natsMock: ctx.nats - }); -}); - -When("I start the service with {string}", async (ctx, arg) => { - if (!ctx.nats) { - throw new Error("No NATS mock available"); - } - - ctx.service = await startService({ - natsMock: ctx.nats, - overwriteArgs: arg.split(/\s+/), - }); -}); - -When("I start the service without NATS", async (ctx) => { - ctx.service = await startService({ - nats: false, - }); -}); - -When('I start the service with {string} without NATS', async (ctx, arg) => { - // Write code here that turns the phrase above into concrete actions - ctx.service = await startService({ - nats: false, - overwriteArgs: arg.split(/\s+/), - }); -}) - -Then("the service should fail to start", async (ctx) => { - await assertRejects(() => - startService({ - nats: !!ctx.nats, - natsMock: ctx.nats, - }) - ); -}); - -Then("the service should start", (ctx) => { - assert(ctx.service); -}); diff --git a/backend-deno/cucumber/test.ts b/backend-deno/cucumber/test.ts deleted file mode 100644 index 58e5b439..00000000 --- a/backend-deno/cucumber/test.ts +++ /dev/null @@ -1,202 +0,0 @@ -/*! - * Cucumber Implementation for Deno - * - * Call using: `deno test -A cucumber/test.ts --features [feature folder] --steps [steps folder]` - * - * Arguments: - * --features [feature folder] The folder containing the feature files, relative to the current working directory - * --steps [steps folder] The folder containing the step definitions, relative to the current working directory - * - * Copyright (2023) WüSpace e. V. - * Author: Zuri Klaschka - * - * MIT License (MIT) - */ - -import { parseArgs } from "@std/cli"; -import { getStep, importStepDefinitions } from "./step-registry.ts"; -import { resolve } from "@std/path"; -import { AssertionError } from "@std/assert"; - -/// Determine steps and features folder from command line arguments - -const { steps, features } = parseArgs(Deno.args); -const stepsFolderPath = resolve(Deno.cwd(), steps); -const featuresFolderPath = resolve(Deno.cwd(), features); - -/// Import all features - -const featureDefinitions = []; - -for await (const potentialFeature of Deno.readDir(featuresFolderPath)) { - const isFeatureFile = potentialFeature.isFile && - potentialFeature.name.endsWith(".feature"); - if (isFeatureFile) { - const filePath = resolve(featuresFolderPath, potentialFeature.name); - featureDefinitions.push( - Deno.readTextFileSync( - filePath, - ), - ); - } -} - -/// Import all step definitions - -await importStepDefinitions(stepsFolderPath); - -/// Run all features - -for (const featureFile of featureDefinitions) { - const feature = parseFeature(featureFile); - - Deno.test(`Feature: ${feature.name}`, async (t) => { - for (const scenario of feature.scenarios) { - await t.step(`Scenario: ${scenario.name}`, async (t) => { - await runScenario(scenario, t); - }); - } - }); -} - -/** - * Run a scenario in a deno testing context - * @param scenario the scenario to run - * @param t the text context. Required to keep async steps in order - */ -async function runScenario(scenario: Scenario, t: Deno.TestContext) { - /** - * The context object passed to all steps - * - * This is used to share data between steps - */ - const ctx = {}; - for (const step of scenario.steps) { - const stepAction = getStep(step.name); - if (!stepAction) { - throw new AssertionError(`Step not found: ${step.name}`); - } - - await t.step(`${step.type} ${step.name}`, async () => { - await stepAction(ctx); - }); - } -} - -/** - * A Gherkin feature - */ -interface Feature { - /** - * The name of the feature - */ - name: string; - /** - * The scenarios in the feature - */ - scenarios: Scenario[]; -} - -/** - * A Gherkin scenario - */ -interface Scenario { - /** - * The name of the scenario - */ - name: string; - /** - * The steps in the scenario - */ - steps: Step[]; -} - -/** - * A Gherkin step - */ -interface Step { - /** - * The type of the step - */ - type: 'Given' | 'When' | 'Then'; - /** - * The name of the step - */ - name: string; -} - -/** - * Parse a Ghrekin feature - * @param featureCode The Ghrekin feature code - * @returns The parsed feature - */ -function parseFeature(featureCode: string): Feature { - const lines = extractLines(); - - let featureName = ""; - const scenarios: Scenario[] = []; - - for (const line of lines) { - if (line.startsWith("Feature:")) { - featureName = line.replace("Feature:", "").trim(); - continue; - } - - if (line.startsWith("Scenario:")) { - scenarios.push({ - name: line.replace("Scenario:", "").trim(), - steps: [], - }); - continue; - } - - const scenario = scenarios.at(-1); - if (!scenario) { - continue; - } - - for (const keyword of ["Given", "When", "Then"] satisfies Step['type'][]) { - if (line.startsWith(keyword + " ")) { - scenario.steps.push({ - type: keyword, - name: line.replace(keyword, "").trim(), - }); - continue; - } - } - - for (const keyword of ["And", "But", "*"]) { - if (line.startsWith(keyword + " ")) { - if (scenario.steps.length === 0) { - throw new Error( - `Step "${keyword}" is not allowed in the first step of a scenario.`, - ); - } - scenario.steps.push({ - type: scenario.steps[scenario.steps.length - 1].type, - name: line.replace("And", "").trim(), - }); - continue; - } - } - } - - return { - name: featureName, - scenarios, - }; - - function extractLines() { - featureCode = featureCode.replace(/\r\n/g, "\n") // Normalize line endings - .replace(/\r/g, "\n") // Normalize line endings - .replace(/ {2,}/g, " ") // Normalize whitespace - .replace(/\n{2,}/g, "\n") // Normalize multiple line endings - .replace(/\t/g, " ") // Normalize tabs - .replace(/* indented */ /^ {2,}/gm, ""); // Remove indentation - - const lines = featureCode.split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0); - return lines; - } -} diff --git a/backend-deno/docker-compose.yml b/backend-deno/docker-compose.yml deleted file mode 100644 index 8a930e8b..00000000 --- a/backend-deno/docker-compose.yml +++ /dev/null @@ -1,10 +0,0 @@ -services: - backend-deno: - image: denoland/deno:2.1.2 - working_dir: /app - command: [ "test", "--allow-all", "./cucumber/test.ts", "--", - "--features", "/features", - "--steps", "/app/cucumber/steps", ] - volumes: - - .:/app - - ../backend-features:/features diff --git a/backend-deno/testbed.e2e.ts b/backend-deno/testbed.e2e.ts new file mode 100644 index 00000000..b88366b9 --- /dev/null +++ b/backend-deno/testbed.e2e.ts @@ -0,0 +1,71 @@ +import { startService } from "jsr:@wuespace/telestion"; + +const disableNats = Deno.env.get("X_DISABLE_NATS") === "1"; + +const result: { + /** + * Whether the service has been started successfully. + */ + started: boolean; + /** + * Whether the NATS API is available. + */ + nats_api_available: boolean; + /** + * Whether the service is connected to the NATS server. + */ + nats_connected: boolean; + /** + * The configuration of the service. + */ + config?: Record; + /** + * Details about an error that occurred during startup. + */ + error?: string; + /** + * The environment variables of the service. + */ + env: Record; +} = { + started: false, + nats_api_available: false, + nats_connected: false, + env: Deno.env.toObject(), +}; + +try { + if (disableNats) { + const res = await startService({ nats: false }); + result.config = res.config; // We have a config + result.started = true; // We have started the service + try { + // This should throw an error as NATS is disabled + const nats = res.nc; + result.nats_api_available = true; + result.nats_connected = nats.isClosed() === false; + } catch { /**/ } + } else { + const res = await startService(); + result.started = true; // We have started the service + result.config = res.config; // We have a config + + try { + const nats = res.nc; // This should not throw an error – NATS is enabled + result.nats_api_available = true; + result.nats_connected = nats.isClosed() === false; + } catch { /**/ } + } +} catch (e) { + // An error occurred during startup. result.started is still false. + // Let's add some more details about the error in case it wasn't expected. + if (e instanceof Error) { + result.error = e.message; + } else { + result.error = "Unknown error"; + } +} finally { + // No matter what happens, the last printed line must be the JSON result string and the script must exit with code 0. + console.log(JSON.stringify(result)); + Deno.exit(); // Important – otherwise the script will keep running +}