From e2e2406fc280c987cddf8bca539588c4f88f4721 Mon Sep 17 00:00:00 2001 From: Jens Pots Date: Mon, 8 Jul 2024 00:11:09 +0200 Subject: [PATCH] feat(runner/node): type safe arguments --- runners/nodejs/.eslintrc.json | 3 +- runners/nodejs/package-lock.json | 8 +- runners/nodejs/package.json | 3 +- runners/nodejs/src/error.ts | 32 ++- runners/nodejs/src/interfaces/processor.ts | 23 +- runners/nodejs/src/runtime/arguments.ts | 214 +++++++++++++++ runners/nodejs/src/runtime/constructor.ts | 1 - runners/nodejs/src/runtime/runner.ts | 243 ++++++++++++------ runners/nodejs/src/runtime/server.ts | 19 ++ runners/nodejs/src/runtime/util.ts | 22 ++ runners/nodejs/src/std/transparent.ts | 17 +- runners/nodejs/test/runtime/arguments.test.ts | 126 +++++++++ 12 files changed, 599 insertions(+), 112 deletions(-) create mode 100644 runners/nodejs/src/runtime/arguments.ts delete mode 100644 runners/nodejs/src/runtime/constructor.ts create mode 100644 runners/nodejs/src/runtime/util.ts create mode 100644 runners/nodejs/test/runtime/arguments.test.ts diff --git a/runners/nodejs/.eslintrc.json b/runners/nodejs/.eslintrc.json index 8252e0b..7e85fb7 100644 --- a/runners/nodejs/.eslintrc.json +++ b/runners/nodejs/.eslintrc.json @@ -20,6 +20,7 @@ "quotes": ["error", "double"], "semi": ["error", "always"], "@typescript-eslint/no-unused-vars": "warn", - "@typescript-eslint/ban-types": "off" + "@typescript-eslint/ban-types": "off", + "default-case": "error" } } diff --git a/runners/nodejs/package-lock.json b/runners/nodejs/package-lock.json index ea3b4bf..04af241 100644 --- a/runners/nodejs/package-lock.json +++ b/runners/nodejs/package-lock.json @@ -25,7 +25,7 @@ "prettier": "^3.3.2", "ts-proto": "^1.180.0", "tsc-alias": "^1.8.10", - "typescript": "^5.4.5", + "typescript": "^5.5.3", "vite": "^5.3.1", "vite-tsconfig-paths": "^4.3.2", "vitest": "^1.6.0" @@ -3511,9 +3511,9 @@ } }, "node_modules/typescript": { - "version": "5.4.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", - "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz", + "integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/runners/nodejs/package.json b/runners/nodejs/package.json index fb2c8a1..854b4b1 100644 --- a/runners/nodejs/package.json +++ b/runners/nodejs/package.json @@ -5,6 +5,7 @@ "scripts": { "run": "ts-node src/runtime/index.ts", "build": "tsc && tsc-alias", + "prepare": "ts-patch install -s", "test": "vitest run --coverage --coverage.include src", "format": "eslint --fix . && prettier --write ." }, @@ -36,7 +37,7 @@ "prettier": "^3.3.2", "ts-proto": "^1.180.0", "tsc-alias": "^1.8.10", - "typescript": "^5.4.5", + "typescript": "^5.5.3", "vite": "^5.3.1", "vite-tsconfig-paths": "^4.3.2", "vitest": "^1.6.0" diff --git a/runners/nodejs/src/error.ts b/runners/nodejs/src/error.ts index 26a7bf8..e1b7a4a 100644 --- a/runners/nodejs/src/error.ts +++ b/runners/nodejs/src/error.ts @@ -1,22 +1,38 @@ export class RunnerError extends Error { - constructor(message: string) { + private constructor(message: string) { super(message); - this.name = "JVMRunnerError"; + this.name = "RunnerError"; } - static missingArgument(key: string): RunnerError { - return new RunnerError(`Missing argument: ${key}`); + static inconsistency(message: string | null = null): never { + let msg = "An error occurred while parsing incoming data."; + if (message) { + msg += "\n" + message; + } + throw new RunnerError(msg); } - static missingImplementation(): RunnerError { - return new RunnerError("Not implemented"); + static missingArgument(key: string): never { + throw new RunnerError(`Missing argument: ${key}`); + } + + static incorrectType(key: string, type: string): never { + throw new RunnerError(`Incorrect type '${type}' for argument: ${key}`); + } + + static nonExhaustiveSwitch(): never { + throw new RunnerError("Non-exhaustive switch statement"); + } + + static missingImplementation(): never { + throw new RunnerError("Not implemented"); } static channelError(): RunnerError { return new RunnerError("Channel error"); } - static unexpectedBehaviour(): RunnerError { - return new RunnerError("Unexpected behaviour"); + static unexpectedBehaviour(): never { + throw new RunnerError("Unexpected behaviour"); } } diff --git a/runners/nodejs/src/interfaces/processor.ts b/runners/nodejs/src/interfaces/processor.ts index 7ddd7cd..6b4fdaa 100644 --- a/runners/nodejs/src/interfaces/processor.ts +++ b/runners/nodejs/src/interfaces/processor.ts @@ -1,11 +1,12 @@ import { RunnerError } from "../error"; +import { Arguments } from "../runtime/arguments"; -export class Processor { +export abstract class Processor { /* Retrieve a processor definition by its resource name. */ - private args: Map; + protected args: Arguments; /* Parse the incoming arguments. */ - constructor(args: Map) { + constructor(args: Arguments) { this.args = args; } @@ -13,20 +14,4 @@ export class Processor { public exec(): void { throw RunnerError.missingImplementation(); } - - /* Retrieve an argument. */ - public getArgument(key: string): T { - const result = this.args.get(key); - - if (!result) { - throw RunnerError.missingArgument(key); - } - - return result as T; - } - - /* Retrieve an optional argument. */ - public getOptionalArgument(key: string): T | null { - return (this.args.get(key) ?? null) as T | null; - } } diff --git a/runners/nodejs/src/runtime/arguments.ts b/runners/nodejs/src/runtime/arguments.ts new file mode 100644 index 0000000..509989d --- /dev/null +++ b/runners/nodejs/src/runtime/arguments.ts @@ -0,0 +1,214 @@ +/** + * Warning: this file contains magic! We use advanced TypeScript features to + * provide a type-safe way to handle arguments in the runtime. We heavily rely + * on conditional types for this, as well as literal types, since those do not + * get erased during compilation. + * + * Unfortunately, this means that adding new types to the runner requires a bit + * of work, and the corresponding types should be added in multiple places here. + * + * Preferably, we should find a way to use reified types, such as in Kotlin, so + * the `Options` object is not needed. However, there doesn't seem to be a good + * solution for that. + */ + +import { Writer } from "../interfaces/writer"; +import { Reader } from "../interfaces/reader"; +import { RunnerError } from "../error"; + +/** + * Argument types supported by RDF-Connect. These are enumerated as strings, in + * order to support usage at runtime through literals. + */ +type Type = + | "boolean" + | "byte" + | "date" + | "double" + | "float" + | "int" + | "long" + | "string" + | "writer" + | "reader" + | "map"; + +/** + * Map a type to its native Node.js type. + */ +type GetType = T extends "boolean" + ? boolean + : T extends "byte" + ? number + : T extends "date" + ? Date + : T extends "double" + ? number + : T extends "float" + ? number + : T extends "int" + ? number + : T extends "long" + ? number + : T extends "string" + ? string + : T extends "writer" + ? Writer + : T extends "reader" + ? Reader + : T extends "map" + ? Arguments + : never; + +/** + * Check if a given value conforms to a given type. + * @param value The value to check. + * @param type The abstract type to check against. + */ +function conforms(value: unknown, type: Type): boolean { + switch (type) { + case "boolean": + return typeof value === "boolean"; + case "byte": + return typeof value === "number"; + case "date": + return value instanceof Date; + case "double": + return typeof value === "number"; + case "float": + return typeof value === "number"; + case "int": + return typeof value === "number"; + case "long": + return typeof value === "number"; + case "string": + return typeof value === "string"; + case "writer": + return value instanceof Writer; + case "reader": + return value instanceof Reader; + case "map": + return value instanceof Arguments; + default: + RunnerError.nonExhaustiveSwitch(); + } +} + +/** + * Literal type which indicates if the requested type is a singleton or a list. + */ +type List = "true" | "false"; + +/** + * Literal type which indicate if the requested type is a nullable or not. + */ +type Nullable = "true" | "false"; + +/** + * Given a type `T`, return either `T` or `T[]` based on a `List` value. + */ +type GetList = L extends undefined + ? T + : L extends "true" + ? T[] + : T; + +/** + * Given a type `T`, return either `T` or `T?` depending on a `Nullable` value. + */ +type GetNullable = N extends undefined + ? T + : N extends "true" + ? T | null + : T; + +/** + * Describes the return type of a returned argument function. + */ +type Options< + T extends Type, + L extends List | undefined, + N extends Nullable | undefined, +> = { + type: T; + list: L | undefined; + nullable: N | undefined; +}; + +/** + * Parse an `Options` type into a single concrete type. + */ +type Returns< + T extends Type, + L extends List | undefined, + N extends Nullable | undefined, +> = GetNullable, L>, N>; + +/** + * Wrapper class for processor arguments, which holds a string-to-any map and + * provides a runtime-safe getter function. + */ +export class Arguments { + // The actual arguments, parsed by the runner beforehand. + private readonly args: Map; + + constructor(args: Map) { + this.args = args; + + // Map all instances of a map into an `Arguments` object. + for (const [key, values] of this.args) { + const newValues = values.map((value) => { + if (value instanceof Map) { + return new Arguments(value); + } else { + return value; + } + }); + + this.args.set(key, newValues); + } + } + + /** + * Retrieve an argument in a type-safe manner using the provided options. + * @param name The name of the argument. + * @param options The options to use for parsing the argument regarding the + * type, count, and presence. + */ + get< + T extends Type, + L extends List | undefined, + N extends Nullable | undefined, + >(name: string, options: Options): Returns { + const values = this.args.get(name); + + // If no value is found, handle accordingly. + if (!values) { + if (options.nullable === "true") { + return null as Returns; + } else { + RunnerError.missingArgument(name); + } + } + + // Cast the value to the correct type. + values.forEach((value) => { + if (!conforms(value, options.type)) { + RunnerError.incorrectType(name, options.type); + } + }); + + // If the value is a list, return it as such. + if (options.list === "true") { + return values as Returns; + } + + // Check if there is only one value present. + if (values.length != 1) { + RunnerError.inconsistency(); + } + + // Return the value. + return values[0] as Returns; + } +} diff --git a/runners/nodejs/src/runtime/constructor.ts b/runners/nodejs/src/runtime/constructor.ts deleted file mode 100644 index 933e435..0000000 --- a/runners/nodejs/src/runtime/constructor.ts +++ /dev/null @@ -1 +0,0 @@ -export type Constructor = new (...args: unknown[]) => T; diff --git a/runners/nodejs/src/runtime/runner.ts b/runners/nodejs/src/runtime/runner.ts index 41f663c..454b5b8 100644 --- a/runners/nodejs/src/runtime/runner.ts +++ b/runners/nodejs/src/runtime/runner.ts @@ -1,8 +1,6 @@ import { IRArgument, IRParameter, - IRParameterCount, - IRParameterPresence, IRParameterType, IRStage, } from "../proto/intermediate"; @@ -12,127 +10,220 @@ import { Processor } from "../interfaces/processor"; import * as path from "node:path"; import { Reader } from "../interfaces/reader"; import { Writer } from "../interfaces/writer"; - +import { RunnerError } from "../error"; +import { asMap, tryOrPanic } from "./util"; +import { Arguments } from "./arguments"; + +/** + * The actual implementation of the runner, and the core of the program. It is + * responsible for loading and executing stages, and managing the communication + * between them. It is also responsible for parsing the arguments and binding + * the reader and writer interfaces to the actual implementation. + * + * We provide a singleton as the static `Runner.shared` field, which is required + * by the gRPC server since it is stateless. + */ export class Runner { - /** Channels. */ + // The incoming channel is bound to by an external object. Whenever data is + // written to it, it is handled by the runner as an incoming message. public incoming = new Subject(); + + // All writers are bound to the outgoing channel, and after it is written to, + // the runner will delegate the messages to the server implementation. public outgoing = new Subject(); + + // The handler for incoming message. Note that this value is not used, but + // kept as a reference to ensure the subscription is not dropped. private incomingSubscription: Subscription; + + // Maps the URIs of channels to their corresponding readers. We use this map + // to route incoming messages to their correct receiver. private readers: Map> = new Map(); - /** Runtime config. */ + // We keep track of the stages that are loaded into the runner by URI. These + // are instantiated beforehand and can be executed or interrupted. private stages: Map = new Map(); + // The constructor binds the handler to the incoming message stream. constructor() { - this.incomingSubscription = this.incoming.subscribe((payload) => { - const reader = this.readers.get(payload.destinationUri); - if (!reader) { - throw new Error( - `Reader not found for payload ${payload.destinationUri}`, - ); - } - reader.next(payload.data); + this.incomingSubscription = this.incoming.subscribe((x) => + this.handleMessage(x), + ); + } + + /** + * Handle an incoming message by routing it to the correct reader. This is + * done by looking up the destination URI in the readers map and calling the + * next method on the corresponding subject. + * @param payload The incoming message. + */ + handleMessage(payload: ChannelData): void { + const reader = this.readers.get(payload.destinationUri); + if (!reader) { + throw new Error(`Reader not found for payload ${payload.destinationUri}`); + } + reader.next(payload.data); + } + + /** + * Create a new writer for a specific channel URI. This writer is bound to the + * outgoing channel, and whenever data is written to it, it is propagated to + * the server implementation. + * @param channelURI The channel to write to as a URI. + * @private + */ + private createWriter(channelURI: string): Writer { + const subject = new Subject(); + subject.subscribe((data) => { + this.outgoing.next({ + destinationUri: channelURI, + data: data, + }); }); + return new Writer(subject); } - parseArgumentSimple(arg: IRParameterType, value: string): unknown { - if (arg == IRParameterType.STRING) { - return value; - } else if (arg == IRParameterType.INT) { + /** + * Create a new reader for a specific channel URI. This reader is bound to the + * incoming channel, and whenever data is written to it, it is propagated to + * the resulting reader. + * @param channelURI The channel to read from as a URI. + * @private + */ + private createReader(channelURI: string): Reader { + const subject = new Subject(); + this.readers.set(channelURI, subject); + return new Reader(subject); + } + + /** + * Parse a simple argument into its native Node.js representation. This is + * done simply be exhaustively checking the type of the argument and parsing + * the value accordingly. + * @param type The type of the argument. + * @param value The value of the argument. + * @private + */ + private parseSimpleArgument(type: IRParameterType, value: string): unknown { + if (type == IRParameterType.BOOLEAN) { + return value == "true"; + } else if (type == IRParameterType.BYTE) { return Number.parseInt(value); - } else if (arg == IRParameterType.FLOAT) { + } else if (type == IRParameterType.DATE) { + return new Date(value); + } else if (type == IRParameterType.DOUBLE) { return Number.parseFloat(value); - } else if (arg == IRParameterType.BOOLEAN) { - return value == "true"; - } else if (arg == IRParameterType.READER) { - const subject = new Subject(); - this.readers.set(value, subject); - return new Reader(subject); - } else if (arg == IRParameterType.WRITER) { - const subject = new Subject(); - subject.subscribe((data) => { - this.outgoing.next({ - destinationUri: value, - data: data, - }); - }); - return new Writer(subject); + } else if (type == IRParameterType.FLOAT) { + return Number.parseFloat(value); + } else if (type == IRParameterType.INT) { + return Number.parseInt(value); + } else if (type == IRParameterType.LONG) { + return Number.parseInt(value); + } else if (type == IRParameterType.STRING) { + return value; + } else if (type == IRParameterType.WRITER) { + return this.createWriter(value); + } else if (type == IRParameterType.READER) { + return this.createReader(value); } else { - throw new Error("Invalid argument type"); + RunnerError.nonExhaustiveSwitch(); } } - parseArgument(arg: IRArgument, param: IRParameter): unknown[] { + /** + * Parse a single parameter, either simple or complex, into its native Node.js + * representation. + * @param arg The arguments to parse. + * @param param The parameter to parse. + */ + private parseArgument(arg: IRArgument, param: IRParameter): unknown[] { + // If the argument is complex, we need to recursively parse the arguments. if (arg.complex && param.complex) { - const params: Map = new Map( - Object.entries(param.complex.parameters), - ); + const params = asMap(param.complex.parameters); + + // Recursively call for each value. return arg.complex.value.map((map) => { - const args: Map = new Map( - Object.entries(map.arguments), - ); - return this.parseArguments(args, params); + const args = asMap(map.arguments); + return this.parseComplexArgument(args, params); }); } + // If the argument is a single value, we can parse it directly. if (arg.simple && param.simple) { + const params = param.simple ?? RunnerError.inconsistency(); + + // Recursively call for each value. return arg.simple.value.map((value) => - this.parseArgumentSimple(param.simple!, value), + this.parseSimpleArgument(params, value), ); } - throw new Error("Invalid argument type"); + // If the argument is not simple or complex, we throw an error. + RunnerError.inconsistency(); } - parseArguments( + /** + * Parse incoming intermediate arguments into their native Node.js + * representation. This is done with the help of the parameter map. Note that + * we do not check for correctness, since the SHACL validator will already + * have asserted that arguments are valid. + * @param args The argument mapping. + * @param params The parameter mapping. + */ + private parseComplexArgument( args: Map, params: Map, - ): Map { - const result = new Map(); + ): Map { + // We gather the result into a new untyped map. + const result = new Map(); + // Simply go over all arguments and instantiate them, recursively + // if required. for (const [name, arg] of args) { - const param = params.get(name)!; + const param = params.get(name) ?? RunnerError.inconsistency(); const parsed = this.parseArgument(arg, param); - if (param.count == IRParameterCount.SINGLE) { - if (parsed.length > 1) { - throw new Error(`Too many arguments for ${name}`); - } - - result.set(name, parsed[0]); - } else { - result.set(name, parsed); - } - } - - for (const [name, param] of params) { - if (param.presence == IRParameterPresence.REQUIRED && !result.has(name)) { - throw new Error(`Missing required argument ${name}`); - } + // Set the argument. + result.set(name, parsed); } return result; } + /** + * Load a stage into the runner. It's processor will be instantiated with the + * arguments provided in the stage, and the instance will be stored in the + * stages map. Note that the execute function is not called here. + * @param stage The stage to be instantiated. + */ async load(stage: IRStage): Promise { - /** Load the processor into Node.js */ + // Load the processor into Node.js. const absolutePath = path.resolve(stage.processor!.metadata.import); const processor = await import(absolutePath); const constructor = processor.default; - /** Parse the stage's arguments. */ - const args = new Map(Object.entries(stage.arguments)); - const params = new Map(Object.entries(stage.processor!.parameters!)); - const parsedArguments = this.parseArguments(args, params); + // Parse the stage's arguments. + const params = stage.processor?.parameters ?? RunnerError.inconsistency(); + const rawArguments = asMap(stage.arguments); + const parsedArguments = this.parseComplexArgument( + rawArguments, + asMap(params), + ); + + // Instantiate the processor with the parsed arguments. + const instance = tryOrPanic(() => { + return new constructor(new Arguments(parsedArguments)); + }); - try { - const processorInstance = new constructor(parsedArguments); - this.stages.set(stage.uri, processorInstance); - } catch (e) { - console.error(e); - } + // Keep track of it in the stages map. + this.stages.set(stage.uri, instance); } + /** + * Execute all stages in the runner in parallel by calling the `exec` function + * on all implementations. This function is asynchronous and will return once + * all executions have been started. + */ async exec(): Promise { console.log("Executing stages."); this.stages.forEach((stage) => { @@ -147,5 +238,9 @@ export class Runner { }); } + /** + * A shared runner instance, mainly used for stateless servers such as gRPC + * which require the runner to be globally defined. + */ static shared = new Runner(); } diff --git a/runners/nodejs/src/runtime/server.ts b/runners/nodejs/src/runtime/server.ts index 1039ace..eaf012e 100644 --- a/runners/nodejs/src/runtime/server.ts +++ b/runners/nodejs/src/runtime/server.ts @@ -9,9 +9,20 @@ import { IRStage } from "../proto/intermediate"; import { Empty } from "../proto/empty"; import { Runner } from "./runner"; +/** + * The implementation of the gRPC server. This class binds the incoming server + * requests to the actual implementation of the Runner. It should not contain + * any specific runner logic, in order to maintain flexibility in terms of which + * communication protocol is used. + */ export class ServerImplementation implements RunnerServer { [name: string]: UntypedHandleCall; + /** + * A duplex stream in which channel data can be written and read. These are + * implemented by gRPC as callbacks, but can be easily bound to the runners + * internal handlers. + */ channel(call: ServerDuplexStream): void { // On incoming data, call the appropriate reader. call.on("data", function (payload: ChannelData) { @@ -26,6 +37,10 @@ export class ServerImplementation implements RunnerServer { }); } + /** + * Load a specific stage into the runner, without executing it. Once again, we + * simply bind to the runners internal implementation. + */ load( call: ServerUnaryCall, callback: sendUnaryData, @@ -40,6 +55,10 @@ export class ServerImplementation implements RunnerServer { }); } + /** + * Execute all stages in the runner by calling the `exec` function on all + * implementations. + */ exec( call: ServerUnaryCall, callback: sendUnaryData, diff --git a/runners/nodejs/src/runtime/util.ts b/runners/nodejs/src/runtime/util.ts new file mode 100644 index 0000000..d2aad9d --- /dev/null +++ b/runners/nodejs/src/runtime/util.ts @@ -0,0 +1,22 @@ +import { RunnerError } from "../error"; + +/** + * Convert an object to a Map of strings to a generic type. + * @param object The object to convert. + */ +export function asMap(object: { [key: string]: T }): Map { + return new Map(Object.entries(object)); +} + +/** + * Attempt to execute a function, and return its result. If the function fails + * the program should panic. + * @param func The function to execute. + */ +export function tryOrPanic(func: () => T): T | never { + try { + return func(); + } catch (e) { + RunnerError.unexpectedBehaviour(); + } +} diff --git a/runners/nodejs/src/std/transparent.ts b/runners/nodejs/src/std/transparent.ts index 860f22c..d4837dc 100644 --- a/runners/nodejs/src/std/transparent.ts +++ b/runners/nodejs/src/std/transparent.ts @@ -1,14 +1,23 @@ import { Processor } from "../interfaces/processor"; -import { Reader } from "../interfaces/reader"; -import { Writer } from "../interfaces/writer"; /** * The Transparent processor reads data and transmits it directly to it's output, while also logging the data to the * console. This processor is only used for debugging purposes. */ export default class Transparent extends Processor { - private readonly input = this.getArgument("input"); - private readonly output = this.getArgument("output"); + // The channel to read from. + private readonly input = this.args.get("input", { + type: "reader", + list: "false", + nullable: "false", + }); + + // The channel to write to. + private readonly output = this.args.get("output", { + type: "writer", + list: "false", + nullable: "false", + }); async exec(): Promise { // eslint-disable-next-line no-constant-condition diff --git a/runners/nodejs/test/runtime/arguments.test.ts b/runners/nodejs/test/runtime/arguments.test.ts new file mode 100644 index 0000000..b98b1bf --- /dev/null +++ b/runners/nodejs/test/runtime/arguments.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, test } from "vitest"; +import { Arguments } from "../../src/runtime/arguments"; + +describe("Arguments", () => { + test("nullable", () => { + expect.assertions(1); + + const input = new Map(); + const args = new Arguments(input); + + const result = args.get("name", { + type: "boolean", + list: "false", + nullable: "true", + }); + + expect(result).toBeNull(); + }); + + test("boolean - single as single", () => { + expect.assertions(1); + + const input = new Map(); + input.set("key", [true]); + const args = new Arguments(input); + + const result = args.get("key", { + type: "boolean", + list: "false", + nullable: "false", + }); + + expect(result).toBeTruthy(); + }); + + test("boolean - single as list", () => { + expect.assertions(2); + + const input = new Map(); + input.set("key", [true]); + const args = new Arguments(input); + + const result = args.get("key", { + type: "boolean", + list: "true", + nullable: "false", + }); + + expect(result).toHaveLength(1); + expect(result.at(0) ?? false).toBeTruthy(); + }); + + test("boolean - many as list", () => { + expect.assertions(3); + + const input = new Map(); + input.set("key", [true, true]); + const args = new Arguments(input); + + const result = args.get("key", { + type: "boolean", + list: "true", + nullable: "false", + }); + + expect(result).toHaveLength(2); + expect(result.at(0) ?? false).toBeTruthy(); + expect(result.at(1) ?? false).toBeTruthy(); + }); + + test("boolean - many as single", () => { + expect.assertions(1); + + const input = new Map(); + input.set("key", [true, true]); + const args = new Arguments(input); + + expect(() => { + args.get("key", { + type: "boolean", + list: "false", + nullable: "false", + }); + }).toThrowError(); + }); + + test("boolean - error from string", () => { + expect.assertions(1); + + const input = new Map(); + input.set("key", ["Hello, World!"]); + const args = new Arguments(input); + + expect(() => { + args.get("key", { + type: "boolean", + list: "false", + nullable: "false", + }); + }).toThrowError(); + }); + + test("nested", () => { + expect.assertions(1); + + const input = new Map(); + const innerInput = new Map(); + innerInput.set("inner", [true]); + input.set("outer", [innerInput]); + + const args = new Arguments(input); + const innerArgs = args.get("outer", { + type: "map", + list: "false", + nullable: "false", + }); + + const result = innerArgs.get("inner", { + type: "boolean", + list: "false", + nullable: "false", + }); + + expect(result).toBeTruthy(); + }); +});