From bee8bb555faab4d1efd3cd3568c30225628e381f Mon Sep 17 00:00:00 2001 From: Erin Millard Date: Tue, 26 Mar 2024 21:26:51 +1000 Subject: [PATCH] WIP --- src/declaration/kubernetes-address.ts | 11 ++++- src/environment.ts | 13 +++++- src/error.ts | 9 +++++ src/summary.ts | 11 +++-- src/validation.ts | 45 ++++++++++++++++----- src/variable.ts | 6 +++ test/fixture/summary/invalid-composite.ansi | 4 ++ test/suite/summary.spec.ts | 15 +++++++ 8 files changed, 99 insertions(+), 15 deletions(-) create mode 100644 test/fixture/summary/invalid-composite.ansi diff --git a/src/declaration/kubernetes-address.ts b/src/declaration/kubernetes-address.ts index 8c27e3f..01460b4 100644 --- a/src/declaration/kubernetes-address.ts +++ b/src/declaration/kubernetes-address.ts @@ -7,7 +7,7 @@ import { defaultFromOptions, type ExactOptions, } from "../declaration.js"; -import { registerVariable } from "../environment.js"; +import { registerComposite, registerVariable } from "../environment.js"; import { SpecError, normalize } from "../error.js"; import { resolveExamples, type Example } from "../example.js"; import { Maybe, map, resolve } from "../maybe.js"; @@ -51,7 +51,8 @@ export function kubernetesAddress( const portVar = registerPort(name, portExamples, isSensitive, def, portName); const pName = portVar.spec.name; - return { + const composite = registerComposite({ + variables: [hostVar, portVar], value() { const host = resolve(hostVar.nativeValue()); const port = resolve(portVar.nativeValue()); @@ -63,6 +64,12 @@ export function kubernetesAddress( return undefined as Value; }, + }); + + return { + value() { + return composite.value(); + }, }; } diff --git a/src/environment.ts b/src/environment.ts index 9c57043..1cb815d 100644 --- a/src/environment.ts +++ b/src/environment.ts @@ -6,6 +6,7 @@ import { Variable, VariableSpec, create as createVariable, + type VariableComposite, } from "./variable.js"; let state: State = createInitialState(); @@ -22,7 +23,7 @@ export function initialize(options: InitializeOptions = {}): void { process.exit(0); } else { const { onInvalid = defaultOnInvalid } = options; - const [isValid, results] = validate(variablesByName()); + const [isValid, results] = validate(variablesByName(), state.composites); if (!isValid) { onInvalid({ @@ -42,6 +43,14 @@ export function registerVariable(spec: VariableSpec): Variable { return variable; } +export function registerComposite( + composite: VariableComposite, +): VariableComposite { + state.composites.push(composite); + + return composite; +} + export function readVariable(spec: VariableSpec): string { return process.env[spec.name] ?? ""; } @@ -54,11 +63,13 @@ type State = { // TODO: WTF TypeScript? Why can't I use unknown here? // eslint-disable-next-line @typescript-eslint/no-explicit-any readonly variables: Record>; + readonly composites: VariableComposite[]; }; function createInitialState(): State { return { variables: {}, + composites: [], }; } diff --git a/src/error.ts b/src/error.ts index cc6d7ca..1f16de0 100644 --- a/src/error.ts +++ b/src/error.ts @@ -26,6 +26,15 @@ export class ValueError extends Error { } } +export class CompositeError extends Error { + constructor( + public readonly name: string, + public readonly cause: Error, + ) { + super(`${name} is invalid: ${cause.message}`); + } +} + export class NotSetError extends Error { constructor(public readonly name: string) { super(`${name} is not set and does not have a default value`); diff --git a/src/summary.ts b/src/summary.ts index e5fb5d5..d9e8763 100644 --- a/src/summary.ts +++ b/src/summary.ts @@ -1,4 +1,4 @@ -import { ValueError } from "./error.js"; +import { CompositeError, ValueError } from "./error.js"; import { Visitor } from "./schema.js"; import { quote } from "./shell.js"; import { create as createTable } from "./table.js"; @@ -84,9 +84,14 @@ function renderResult( } function describeError(isSensitive: boolean, error: Error) { - if (!(error instanceof ValueError)) return "not set"; + if (error instanceof ValueError) { + return ( + `set to ${quoteAndSuppress(isSensitive, error.value)}, ` + + `${error.cause.message}` + ); + } - return `set to ${quoteAndSuppress(isSensitive, error.value)}, ${error.cause.message}`; + return error instanceof CompositeError ? error.message : "not set"; } function quoteAndSuppress(isSensitive: boolean, value: string) { diff --git a/src/validation.ts b/src/validation.ts index 6d2eaca..2951756 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -1,23 +1,50 @@ -import { normalize } from "./error.js"; +import { CompositeError, normalize } from "./error.js"; import { Maybe } from "./maybe.js"; -import { Value, Variable } from "./variable.js"; +import { Value, Variable, type VariableComposite } from "./variable.js"; -export function validate(variables: Variable[]): [boolean, Results] { - const results: Results = []; +export function validate( + variables: Variable[], + composites: VariableComposite[], +): [boolean, Results] { + const resultMap = new Map, Result>(); let isValid = true; for (const variable of variables) { try { - results.push({ - variable, - result: { maybe: variable.value() }, - }); + resultMap.set(variable, { maybe: variable.value() }); } catch (error) { isValid = false; - results.push({ variable, result: { error: normalize(error) } }); + resultMap.set(variable, { error: normalize(error) }); } } + for (const composite of composites) { + const shouldSkip = composite.variables.some( + (variable) => resultMap.get(variable)?.error, + ); + + if (shouldSkip) continue; + + try { + composite.value(); + } catch (error) { + isValid = false; + + for (const variable of composite.variables) { + resultMap.set(variable, { + error: new CompositeError(variable.spec.name, normalize(error)), + }); + } + } + } + + const results: Results = []; + + for (const variable of variables) { + const result = resultMap.get(variable); + if (result) results.push({ variable, result }); + } + return [isValid, results]; } diff --git a/src/variable.ts b/src/variable.ts index 32dcaa0..c55501a 100644 --- a/src/variable.ts +++ b/src/variable.ts @@ -22,6 +22,12 @@ export type Variable = { readonly unmarshal: (value: string) => T; }; +export type VariableComposite = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + readonly variables: Variable[]; + readonly value: () => T; +}; + export type Value = { readonly verbatim: string; readonly canonical: string; diff --git a/test/fixture/summary/invalid-composite.ansi b/test/fixture/summary/invalid-composite.ansi new file mode 100644 index 0000000..5194af0 --- /dev/null +++ b/test/fixture/summary/invalid-composite.ansi @@ -0,0 +1,4 @@ +Environment Variables: + +❯ AUSTENITE_SVC_SERVICE_HOST kubernetes `austenite-svc` service host [ ] ✗ AUSTENITE_SVC_SERVICE_HOST is invalid: AUSTENITE_SVC_SERVICE_HOST is defined but AUSTENITE_SVC_SERVICE_PORT is not, define both or neither +❯ AUSTENITE_SVC_SERVICE_PORT kubernetes `austenite-svc` service port [ ] ✗ AUSTENITE_SVC_SERVICE_PORT is invalid: AUSTENITE_SVC_SERVICE_HOST is defined but AUSTENITE_SVC_SERVICE_PORT is not, define both or neither diff --git a/test/suite/summary.spec.ts b/test/suite/summary.spec.ts index 393df99..8925e98 100644 --- a/test/suite/summary.spec.ts +++ b/test/suite/summary.spec.ts @@ -311,6 +311,21 @@ describe("Validation summary", () => { expect(exitCode).toBeGreaterThan(0); }); + it("summarizes invalid composites", async () => { + Object.assign(process.env, { + AUSTENITE_SVC_SERVICE_HOST: "host.example.org", + }); + + kubernetesAddress("austenite-svc", { default: undefined }); + + initialize(); + + await expect(mockConsole.readStderr()).toMatchFileSnapshot( + fixturePath("invalid-composite"), + ); + expect(exitCode).toBeGreaterThan(0); + }); + it("summarizes variables that violate constraints", async () => { Object.assign(process.env, { AUSTENITE_STRING: "hello, world!",