This commit is contained in:
sokol
2026-02-18 11:38:25 +03:00
commit 83a5bb87c1
40 changed files with 5803 additions and 0 deletions

0
src/App.css Normal file
View File

93
src/App.tsx Normal file
View File

@@ -0,0 +1,93 @@
import { useState } from 'react'
import './App.css'
import 'bootstrap/dist/css/bootstrap.css'
import { Env } from './models/Env'
import Environment from "./componets/env"
import Content from './componets/content'
import { FileChooser } from './componets/FileChooser'
import { Config } from "./models/Config"
import logo from './assets/cgg.png'
class AppState {
private constructor(
public config: Config = new Config(),
public envs: Env[] = [
],
) { }
static readonly Instance = new AppState();
public loadConfig(cfg: Config) {
this.envs = [...cfg.envs];
this.config = cfg;
}
public async saveEnv(env: Env): Promise<number> {
// Create a promise that resolves after 1 second
return await new Promise<number>((resolve) => {
setTimeout(() => {
let idx = this.envs.findIndex(x => x.id === env.id);
if (idx > -1) {
this.envs[idx] = env;
console.log("UPDATED envs", this.envs);
}
resolve(idx); // Resolve the promise after updating
}, 1000);
});
}
}
function App() {
const [envs, setEnvs] = useState(AppState.Instance.envs);
const [selectedEnv, setSelectedEnv] = useState(0);
const [config, setConfig] = useState(AppState.Instance.config);
async function handleEnvChanged(env: Env) {
let idx = await AppState.Instance.saveEnv(env);
if (idx > -1) {
setEnvs([...AppState.Instance.envs]);
setSelectedEnv(idx);
}
}
return (
<>
<main className="container-fluid m-2">
<div className="row mb-2">
<FileChooser onSelected={x => {
AppState.Instance.loadConfig(x);
setEnvs(AppState.Instance.envs);
setConfig(AppState.Instance.config);
}} />
</div>
{envs.length > 0 ?
(<div className="row">
<section id="env" className='col-4 me-1'>
<Environment
envs={envs}
onChanged={async (e) => await handleEnvChanged(e)}
onSelected={x => setSelectedEnv(x)} />
</section>
<section id="content" className="col-8 col-xl-7 border-start ms-1">
<Content env={envs[selectedEnv]} config={config} />
</section>
</div>)
:
(
<div className="row justify-content-center pt-5" >
<div className="col-1 pt-5">
<img src={logo} alt="" style={{ opacity: 0.2, transform: 'scale(1.8)' }} />
</div>
</div>
)}
</main>
</>
)
}
export default App

BIN
src/assets/cgg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -0,0 +1,62 @@
import { IBuilder } from ".";
import { Env } from "../models/Env";
export class EnvBuilder implements IBuilder<Env> {
readonly ident = " ";
readonly newLine = "\r\n";
private stack: string[] = [];
private _src!: Env;
get src(): Env {
return this._src;
}
set src(v: Env) {
this._src = v;
}
build(): string {
return this
.open()
.params()
.close()
.toString();
}
private params(): this {
const tag = `<parameter name="{name}" value="{val}" />`;
for (let p of this.src.params) {
this.stack.push(this.ident);
this.stack.push(tag
.replace("{name}", p.name ?? "!ERR!")
.replace("{val}", p.sanitize(p.value) ?? "!ERR!")
);
this.stack.push(this.newLine);
}
return this;
}
private open(): EnvBuilder {
const tag = `<environment name="${this.src.name}">`;
this.stack.push(tag);
this.stack.push(this.newLine);
return this;
}
private close(): EnvBuilder {
const tag = `</environment>`;
this.stack.push(tag);
return this;
}
toString(): string {
let res = "";
while (this.stack.length > 0) {
res = this.stack.pop() + res;
}
return res;
}
}

26
src/builders/index.ts Normal file
View File

@@ -0,0 +1,26 @@
import { Env } from "../models/Env";
import { EnvBuilder } from "./EnvBuilder";
export interface IBuilder<T> {
get src(): T;
set src(v: T);
build(): string;
}
export class Builder {
public static getEnv(env: Env): IBuilder<Env> {
let b = new EnvBuilder();
b.src = env;
return b;
};
public static getEnvs(envs: Env[]): string {
return envs.map(x => Builder.getEnv(x).build()).join("\r\n");
}
}

View File

@@ -0,0 +1,37 @@
import { Env } from "../models/Env";
import { ConfigReader } from "../models/ConfigReader";
import { Config } from "../models/Config";
export function FileChooser(props: { onSelected: (x: Config) => void }) {
async function handleFile(x: React.ChangeEvent<HTMLInputElement>) {
let file = x.target.files![0];
console.log(file.name, file.type, file.size, "supported:", ConfigReader.isSupportedFormat(file));
let reader = new ConfigReader();
let cfg = await reader.parseFromFile(file);
if (cfg !== null) {
props.onSelected(cfg);
}
}
function handleNew(){
let cfg = new Config();
cfg.addEnvs([new Env(0,"DEFAULT", [])]);
props.onSelected(cfg);
}
return (
<>
<div className="col-2">
<button className="btn btn-primary" onClick={handleNew} >Create new</button>
</div>
<div className="col-1">or</div>
<div className="col">
<input className="form-control" type="file" id="formFile" onChange={handleFile} />
</div >
</>
);
}

View File

@@ -0,0 +1,4 @@
.highlihgt-scrolled {
overflow-x: auto;
max-width: 90%;
}

View File

@@ -0,0 +1,162 @@
import { useState } from "react";
import { Env } from "../../models/Env";
import Highlight from 'react-highlight'
import 'highlight.js/styles/far.css'
import { Builder } from "../../builders";
import { Config } from "../../models/Config";
export function Content(props: { config: Config, env: Env }) {
const [selectTab, setTab] = useState(ContentType.Env);
return (
<>
<ContentTabs onSelected={(id) => setTab(id)} />
<div className="">
{selectTab == ContentType.Env ? (<ContentParams env={props.env} />) : ""}
{selectTab == ContentType.Json ? (<ContentTemplate env={props.env} config={props.config} />) : ""}
{selectTab == ContentType.Raw ? (<ContentRaw config={props.config} env={props.env} />) : ""}
{selectTab == ContentType.Test ? (<ContentTest config={props.config} env={props.env} />) : ""}
</div>
</>
);
}
enum ContentType {
Env = 0,
Json = 1,
Raw = 2,
Test = 3
}
function ContentTabs(props: { onSelected: (id: ContentType) => void }) {
const [selectTab, setSelect] = useState(ContentType.Env);
function clickHandler(type: ContentType) {
setSelect(type);
props.onSelected(type);
}
function isActive(type: ContentType): string {
return type == selectTab ? " active" : " ";
}
return (
<ul className="nav nav-pills nav-fill">
<li className="nav-item">
<a className={"nav-link" + isActive(ContentType.Env)} aria-current="page" href="#" onClick={() => clickHandler(ContentType.Env)}>Env</a>
</li>
<li className="nav-item">
<a className={"nav-link" + isActive(ContentType.Json)} href="#" onClick={() => clickHandler(ContentType.Json)} >Content Template</a>
</li>
<li className="nav-item">
<a className={"nav-link" + isActive(ContentType.Raw)} href="#" onClick={() => clickHandler(ContentType.Raw)}>Raw template</a>
</li>
<li className="nav-item">
<a className={"nav-link" + isActive(ContentType.Test)} href="#" onClick={() => clickHandler(ContentType.Test)}>Test-filled template</a>
</li>
</ul>
)
}
function ContentRaw(props: { config: Config, env: Env }) {
const envsXml = Builder.getEnvs(props.config.envs);
const templateContent = props.config.template.content;
const xml = `<engine>
${envsXml}
<template>
${templateContent}
</template>
</engine>`;
return (
<>
<Highlight className="language-xml">
{xml}
</Highlight>
</>
)
}
function ContentTest(props: { config: Config, env: Env }) {
const [selectedEnvId, setSelectedEnvId] = useState(props.env.id);
const selectedEnv = props.config.envs.find(e => e.id === selectedEnvId) ?? props.env;
const filledTemplate = fillTemplate(props.config, selectedEnv);
return (
<>
<div className="mb-2">
<label className="form-label">Select Environment:</label>
<select
className="form-select w-auto d-inline-block"
value={selectedEnvId}
onChange={(e) => setSelectedEnvId(Number(e.target.value))}
>
{props.config.envs.map(env => (
<option key={env.id} value={env.id}>{env.name}</option>
))}
</select>
</div>
<Highlight className="language-json">
{filledTemplate}
</Highlight>
</>
)
}
function fillTemplate(config: Config, env: Env): string {
const defaultEnv = config.envs.find(e => e.name === "DEFAULT");
const paramMap = new Map<string, string>();
// First, load DEFAULT values as fallback
if (defaultEnv) {
for (const param of defaultEnv.params) {
if (param.name && param.value !== undefined) {
paramMap.set(param.name, param.value);
}
}
}
// Then, override with selected environment values (precedence)
for (const param of env.params) {
if (param.name && param.value !== undefined) {
paramMap.set(param.name, param.value);
}
}
let filledTemplate = config.template.content;
const placeholderRegex = /@(\w+)@/g;
filledTemplate = filledTemplate.replace(placeholderRegex, (match, paramName) => {
if (paramName === Config.ENV_NAME_PARAM) {
return env.name ?? "--NO-VALUE--";
}
return paramMap.get(paramName) ?? "--NO-VALUE--";
});
return filledTemplate;
}
function ContentTemplate(props: { config: Config, env: Env }) {
let text = props.config.getTemplateAsJson() ?? "{//no content. at all. tottaly empty!!!\n}";
return (
<>
<Highlight className="language-json" >
{text ?? ""}
</Highlight>
</>
)
}
function ContentParams(props: { env: Env }) {
const bldr = Builder.getEnv(props.env);
return (
<Highlight className="language-xml">
{bldr.build()}
</Highlight>
)
}

View File

@@ -0,0 +1,3 @@
import { Content } from "./Content";
export default Content;

72
src/componets/env/Environment.tsx vendored Normal file
View File

@@ -0,0 +1,72 @@
import { useState } from "react";
import { AddEvent, AppEvent, DelEvent, Env, UpdateEvent } from "../../models/Env";
import { EnvParam } from "../../models/EnvParam";
import { EnvironmentParam } from "./EnvironmentParam";
export function Environment(props: { envs: Env[], onChanged: (env: Env) => void, onSelected: (envId: number) => void }) {
const [currEnv, setCurrEnv] = useState(props.envs[0]);
function handleParamChanged(e: AppEvent<EnvParam>) {
let isChanged = false;
let env = currEnv;
if (e instanceof DelEvent) {
env = currEnv.delParam(e.payload);
isChanged = true;
}
if (e instanceof AddEvent) {
env = currEnv.addParams(e.payload);
isChanged = true;
}
if (e instanceof UpdateEvent) {
env = currEnv.updateParams(e.payload);
isChanged = true;
}
if (isChanged) {
let idx = props.envs.findIndex(x => x.id === env.id);
if (idx > -1) {
props.envs[idx] = env;
props.onChanged(props.envs[idx]);
setCurrEnv(env);
}
}
}
const selectOptions = props.envs.map((x) => <option key={x.id} value={x.id} >{x.name}</option>);
const paramCtrls = currEnv.params.map(x =>
<EnvironmentParam key={`${currEnv.id}-${x.id}`}
param={new EnvParam(x.id, x.name, x.value)}
onChanged={handleParamChanged}
isNew={false} />);
return (
<>
<div className="row">
<select
id="environments"
name="environments"
aria-label="Environments"
className="form-select"
onChange={x => {
let id = Number.parseInt(x.target.value);
setCurrEnv(props.envs[id]);
props.onSelected(id);
}}>
{selectOptions}
</select>
</div>
<div className="row">Params</div>
{paramCtrls}
<EnvironmentParam key={`${currEnv.id}-new`}
param={new EnvParam(-1, "", "")}
onChanged={handleParamChanged}
isNew={true}
/>
</>
);
}

68
src/componets/env/EnvironmentParam.tsx vendored Normal file
View File

@@ -0,0 +1,68 @@
import { useState } from "react";
import { EnvParam } from "../../models/EnvParam";
import { AppEvent } from "../../models/Env";
export function EnvironmentParam(props: { param: EnvParam; onChanged: (e: AppEvent<EnvParam>) => void, isNew: boolean }) {
const [param, setParam] = useState(props.param);
const [isFocused, setIsFocused] = useState(false);
function doSet(x: string, act: (x: string) => void) {
act(x);
setParam(param.Changed(true));
}
function handleChange() {
if (!param.isChanged)
return;
let newParam = param.Changed(false);
if (!props.isNew) {
props.onChanged(AppEvent.update(newParam));
}
setParam(newParam);
}
function handleAdd() {
props.onChanged(AppEvent.add(param));
setParam(new EnvParam(0, "", ""));
}
function handleKeyUp(x: React.KeyboardEvent<HTMLInputElement>) {
if (x.key === "Enter") { handleChange(); }
}
return (
<div className={"row px-0" + (param.isChanged ? "border border-warning" : "")}
style={isFocused ? { backgroundColor: "lightskyblue", padding: "1px 0" } : { padding: "1px 0" }}>
<div className="col-4 mx-0 px-0">
<input type="text"
className="form-control"
style={{ backgroundColor: "rgba(170, 170, 247, 0.16)" }}
value={param.name}
onChange={x => doSet(x.target.value, (v) => param.name = v)}
onBlur={() => { handleChange(); setIsFocused(false); }}
onFocus={() => setIsFocused(true)}
onKeyUp={handleKeyUp}
placeholder="name"
aria-label="name" />
</div>
<div className="col mx-0 px-0">
<input type="text"
className="form-control"
value={param.value}
onChange={x => doSet(x.target.value, v => param.value = v)}
onBlur={() => { handleChange(); setIsFocused(false); }}
onFocus={() => setIsFocused(true)}
onKeyUp={handleKeyUp}
placeholder="value"
aria-label="value" />
</div>
<div className="col-1 mx-0 px-0" >
<button className="btn btn-success" hidden={!props.isNew} onClick={handleAdd}></button>
<button className="btn btn-warning" hidden={props.isNew} onClick={() => props.onChanged(AppEvent.del(param))} tabIndex={-1}></button>
</div>
</div>
);
}

3
src/componets/env/index.tsx vendored Normal file
View File

@@ -0,0 +1,3 @@
import { Environment } from "./Environment";
export default Environment;

0
src/index.css Normal file
View File

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

74
src/models/Config.tsx Normal file
View File

@@ -0,0 +1,74 @@
import { Env } from "./Env";
export class ConfigTemplate {
public static Empty: ConfigTemplate = new ConfigTemplate();
constructor(text: string = "") {
this._contentText = text;
this.extractParams();
}
private _contentText: string = "";
private _params: string[] = [];
public get content(): string {
return this._contentText;
}
public get Params(): string[] {
return [...this._params];
}
private extractParams() {
let regex = /@(\w+)@/g;
let matches;
let paramsSet = new Set<string>();
while ((matches = regex.exec(this._contentText)) !== null) {
if (matches.length > 1) {
paramsSet.add(matches[1]);
}
}
this._params = Array.from(paramsSet);
}
}
export class Config {
public static get ENV_NAME_PARAM(): string { return "env_name" };
public envs: Env[] = [];
public template: ConfigTemplate = ConfigTemplate.Empty;
addEnvs(envs: Env[]) {
this.envs = envs;
}
addTemplate(text: string) {
this.template = new ConfigTemplate(text);
}
getTemplateAsJson(): string {
try {
return this.template.content ;
} catch (error) {
console.error("Error converting template content to JSON:", error);
return "{}";
}
}
validateParams(): string[] {
const envKeys = this.envs.map(env => env.params.map(param => param.name)).flat();
const missingParams = this.template.Params.filter(param => param != Config.ENV_NAME_PARAM && !envKeys.includes(param));
if (missingParams.length > 0) {
console.error("Template: missing parameters in environments:", missingParams);
}
return missingParams;
}
}

135
src/models/ConfigReader.tsx Normal file
View File

@@ -0,0 +1,135 @@
import { Env } from "./Env";
import { EnvParam } from "./EnvParam";
import { Config } from "./Config";
/**
* A utility class for parsing XML configuration files into a structured Config object.
*
* Supports both string-based and file-based parsing, extracting environment definitions
* and their associated parameters. The expected XML format includes:
* - Root element with tag "engine"
* - Child elements "environment" with a "name" attribute
* - Nested "parameter" elements with "name" and "value" attributes
*
* Provides validation and error handling for missing attributes.
* Includes utility method to check if a file is in the supported XML format.
*/
export class ConfigReader {
private readonly rootTag = "engine";
private readonly envTag = "environment";
private readonly envNameAttr = "name";
private readonly paramTag = "parameter";
private readonly paramNameAttr = "name";
private readonly paramValAttr = "value";
private readonly templateTag = "template";
/**
* Parses an XML string into a Config object.
*
* @param xmlString - The XML content as a string
* @param fileType - The MIME type of the XML (default: 'application/xml')
* @returns A Config object containing parsed environments and parameters, or null if parsing fails
*/
public parseFromString(xmlString: string, fileType: DOMParserSupportedType = 'application/xml'): Config | null {
let parser = new DOMParser();
let xml = parser.parseFromString(xmlString, fileType);
this.checkTemplate(xml);
let config = new Config();
let envs = this.parseEnvs(xml.querySelectorAll(`${this.rootTag}>${this.envTag}`));
config.addEnvs(envs);
let tmplElement = xml.getElementsByTagName(this.templateTag)[0];
let tmplText = tmplElement?.textContent?.trim();
if (!tmplText) {
throw new Error(`Template content is missing or empty in <${this.templateTag}> element.`);
}
config.addTemplate(tmplText);
console.log("parsed from string res:", config);
return config;
}
/**
* Parses an XML file into a Config object asynchronously.
*
* @param file - The File object representing the XML file
* @returns A Promise resolving to a Config object or null if parsing fails
*/
public async parseFromFile(file: File): Promise<Config | null> {
let srcText = await file.text();
return this.parseFromString(srcText, file.type as DOMParserSupportedType);
}
private parseEnvs(xmlEnvs: NodeListOf<Element>): Env[] {
let res: Env[] = [];
let i = 0;
for (let xml of xmlEnvs) {
res.push(this.xmlToEnv(xml, i++));
}
return res;
}
private throwError(text: string): string {
throw new Error(text);
}
private xmlToEnv(xml: Element, id: number): Env {
let name = xml.getAttribute(this.envNameAttr) ?? this.throwError(`no attr '${this.envNameAttr}' in '${xml.tagName}'`);
let params = this.parseParams(xml);
return new Env(id, name, params);
}
private parseParams(xml: Element): EnvParam[] {
let paramElements = xml.getElementsByTagName(this.paramTag);
let params: EnvParam[] = [];
let id = 0;
for (let p of paramElements) {
params.push(this.xmlToParam(p, id++));
}
return params;
}
private xmlToParam(xmlParam: Element, id: number): EnvParam {
let name = xmlParam.getAttribute(this.paramNameAttr) ?? this.throwError(`no attr '${this.paramNameAttr}' in '${this.paramTag}'`);
let val = xmlParam.getAttribute(this.paramValAttr) ?? this.throwError(`no attr '${this.paramValAttr}' in '${this.paramTag}'`);
return new EnvParam(id, name, val);
}
/**
* Checks if the given file is in a supported format (text/xml).
*
* @param file - The File object to check
* @returns True if the file is of type 'text/xml', otherwise returns an error message string
*/
public static isSupportedFormat(file: File): (boolean | string) {
if (file.type !== "text/xml") {
return `file format ${file.type} not supported (or extension is't .xml)`;
}
return true;
}
public checkTemplate(xml: Document) {
const templateElements = xml.getElementsByTagName(this.templateTag);
if (templateElements.length === 0) {
this.throwError(`Missing required <${this.templateTag}> element in the XML.`);
}
if(templateElements.length > 1) {
this.throwError(`Multiple <${this.templateTag}> elements found. Only one is allowed.`);
}
}
}

60
src/models/Env.ts Normal file
View File

@@ -0,0 +1,60 @@
import { EnvParam } from "./EnvParam";
import { NamedId } from "./NamedId";
export class Env implements NamedId {
constructor(
public id?: number,
public name?: string,
public params: EnvParam[] = []
) { }
public isDefault() {
return this.name === "DEFAULT";
}
addParams(payload: EnvParam): Env {
payload.id = Math.random() * 10000;
this.params.push(payload);
return new Env(this.id, this.name, [...this.params]);
}
delParam(param: EnvParam): Env {
let idx = this.params.findIndex(el => el.id === param.id);
if (idx > -1) {
const newP = this.params.filter(el => el.id !== param.id);
return new Env(this.id, this.name, newP);
}
return this;
}
public updateParams(param: EnvParam): Env {
let idx = this.params.findIndex(el => el.id === param.id);
if (idx > -1) {
let newP = [...this.params];
newP[idx] = param;
return new Env(this.id, this.name, newP);
}
return this;
}
}
export class AppEvent<T> {
protected constructor(public payload: T) { }
public static add<T>(payload: T): AppEvent<T> {
return new AddEvent(payload);
}
public static del<T>(payload: T): AppEvent<T> {
return new DelEvent(payload);
}
public static update<T>(payload: T): AppEvent<T> {
return new UpdateEvent(payload);
}
}
export class AddEvent<T> extends AppEvent<T> { }
export class UpdateEvent<T> extends AppEvent<T> { }
export class DelEvent<T> extends AppEvent<T> { }

32
src/models/EnvParam.ts Normal file
View File

@@ -0,0 +1,32 @@
import { NamedId } from "./NamedId";
export class EnvParam implements NamedId {
constructor(
public id?: number,
public name?: string,
public value?: string,
public isChanged: boolean = false
) { }
public Changed(v: boolean = true): EnvParam {
return new EnvParam(
this.id,
this.name,
this.value,
v);
}
public sanitize(v?: string): string {
return v?.replace(/&/g, "&amp")
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
?? "";
}
public humanize(v?: string): string {
return v ?? "";
}
}

4
src/models/NamedId.ts Normal file
View File

@@ -0,0 +1,4 @@
export interface NamedId {
id?: number;
name?: string;
}

View File

@@ -0,0 +1,76 @@
import{test} from 'vitest'
import { ConfigReader } from '../models/ConfigReader';
const cfgTemplate = `
<engine>
<environment name="DEFAULT">
<parameter name = "host" value = "http://host1.xxx/api"/>
<parameter name="MessageBrokerHosts" value="[ &quot;smsk02ap432u:9096&quot; ]" />
</environment>
<environment name="env1">
<parameter name="port" value="60001"/>
</environment>
<environment name="env2">
<parameter name="port" value="60002"/>
<parameter name="MessageBrokerHosts" value="[&quot;smsk02ap430u:9096&quot; , &quot;smsk02ap433u:9096&quot; ]" />
</environment>
<template>
{
"Host": "@host@",
"Port": @port@,
"ApiPath":"@host@:@port@/v1/data",
"MessageBroker":{
"hosts":@MessageBrokerHosts@
},
"basePath":"./@env_name@/in",
"NoParam:"@no_param@"
}
</template>
</engine>
`
test("read from a file", async ({expect})=>{
let sut = new ConfigReader();
let file = new File([cfgTemplate],'cfg.json.xml',{type:'application/xml'});
// define a missing jsdom text() function,
// that presents in the real DOM
Object.defineProperty(file, 'text', {
value: () => Promise.resolve(cfgTemplate),
writable: true
});
let cfg = await sut.parseFromFile(file);
expect(cfg).not.toBeUndefined();
});
test("load environments and params", ({expect})=>{
let sut = new ConfigReader();
let cfg = sut.parseFromString(cfgTemplate);
expect(cfg?.envs).toHaveLength(3);
expect(cfg?.envs.map(x=>x.name))
.toEqual(expect.arrayContaining(["DEFAULT", "env1", "env2"]));
expect(cfg?.envs.flatMap(x=>x.params))
.toHaveLength(5);
});
test("load template", ({expect})=>{
let sut = new ConfigReader();
let cfg = sut.parseFromString(cfgTemplate);
expect(cfg?.template).toBeDefined();
expect(cfg?.template.content.length).toBeGreaterThan(20);
expect(cfg?.template.Params).toHaveLength(5)
expect(cfg?.getTemplateAsJson()).not.toBeUndefined();
expect(cfg?.validateParams()).does.has.length(1).and.contain("no_param");
});

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />